12 Commits
Author SHA1 Message Date
Mark Gregory 1340c53cd5 new scripts 2026-10-19 15:03:27 +01:00
Mark Gregory b81829012a new scripts 2026-10-19 15:02:33 +01:00
Mark Gregory ac0c5410c1 updates 2026-10-19 14:11:28 +01:00
Mark Gregory 26a2b448a5 new scripts 2026-10-19 14:11:01 +01:00
Mark Gregory 7cde037a4b cod consolidation and style updates 2026-09-28 16:24:16 +01:00
Mark Gregory f10ed942c0 new updates for paddel 2026-09-09 16:10:28 +01:00
Mark Gregory d541f492cf paddel setup new verifier 2026-09-09 14:40:18 +01:00
Mark Gregory e802dd8cf8 paddel setup new verifier 2026-09-09 12:36:34 +01:00
Mark Gregory a9bc0e5038 paddel setup new verifier 2026-09-09 12:33:45 +01:00
Mark Gregory 7fbbd42e79 host updates 2026-09-09 08:36:35 +01:00
Mark Gregory b4aa94a389 New updates for deployer 2026-09-09 08:33:05 +01:00
Mark Gregory 5b9c19c52f New updates 2026-09-09 08:23:43 +01:00
190 changed files with 17087 additions and 702 deletions
Regular → Executable
+252
View File
@@ -1,5 +1,257 @@
.idea
.DS_Store
build/
DerivedData/
*.xcuserstate
xcuserdata/
/releases/
/.build/
.DS_Store
.AppleDouble
.LSOverride
Icon?
._*
.Spotlight-V100
.Trashes
.fseventsd
.TemporaryItems
*.swp
*.swo
*~
# ------------------------------------------------------------
# Xcode Build Products
# ------------------------------------------------------------
build/
Build/
DerivedData/
.build/
*.app
*.dSYM
*.dSYM.zip
*.ipa
*.xcarchive
*.ipa
*.appex
*.o
*.a
*.dylib
*.swiftmodule
*.swiftdoc
*.swiftsourceinfo
# ------------------------------------------------------------
# Xcode User Settings
# ------------------------------------------------------------
xcuserdata/
*.xcuserstate
*.xcuserdatad/
*.xccheckout
*.xcscmblueprint
*.xcuserstate
*.mode1v3
*.mode2v3
*.perspectivev3
# ------------------------------------------------------------
# Xcode Temporary Files
# ------------------------------------------------------------
*.hmap
*.ipa
*.ipa.zip
*.xccrashpoint
*.xcactivitylog
*.profdata
*.profraw
*.gcda
*.gcno
# ------------------------------------------------------------
# Swift Package Manager
# ------------------------------------------------------------
.swiftpm/xcode/package.xcworkspace/xcuserdata/
.swiftpm/configuration/
Packages/
.checkouts/
.repositories/
# Keep Package.resolved tracked for reproducible builds.
# ------------------------------------------------------------
# CocoaPods / Carthage
# ------------------------------------------------------------
Pods/
Carthage/Build/
# ------------------------------------------------------------
# Temporary Files
# ------------------------------------------------------------
*.tmp
*.temp
*.bak
*.backup
*.orig
*.rej
*.swift.bak
*.swift.backup-*
*.pbxproj.bak
*.pbxproj.backup-*
*.log
logs/
tmp/
temp/
# ------------------------------------------------------------
# Local Environment / Secrets
# ------------------------------------------------------------
.env
.env.*
!.env.example
*.pem
*.p12
*.p8
*.key
*.mobileprovision
Secrets/
secrets/
credentials.json
credentials.plist
config.local.json
config.local.plist
LocalConfig.swift
Secrets.swift
# ------------------------------------------------------------
# MySQL / Database Files
# ------------------------------------------------------------
*.sql
*.sql.gz
*.sql.zip
*.sqlite
*.sqlite3
*.db
*.db-shm
*.db-wal
*.sqlite-shm
*.sqlite-wal
*.myd
*.myi
*.ibd
*.dump
*.backup.sql
# ------------------------------------------------------------
# Test / Coverage Results
# ------------------------------------------------------------
*.xcresult
*.xccovreport
*.xccovarchive
coverage/
.coverage/
# ------------------------------------------------------------
# Crash Reports
# ------------------------------------------------------------
*.crash
*.ips
*.spin
*.hang
# ------------------------------------------------------------
# Editor Settings
# ------------------------------------------------------------
.vscode/
.idea/
*.sublime-workspace
# ------------------------------------------------------------
# Python Helper Scripts
# ------------------------------------------------------------
__pycache__/
*.pyc
.venv/
venv/
# ------------------------------------------------------------
# Distribution / Packaging
# ------------------------------------------------------------
dist/
release/
releases/
*.dmg
*.pkg
*.zip
# ------------------------------------------------------------
# IMPORTANT: Keep these tracked
# ------------------------------------------------------------
# WorkbenchReplacement.xcodeproj/
# WorkbenchReplacement/*.swift
# WorkbenchReplacement/Resources/
# WorkbenchReplacement/Assets.xcassets/
# *.icns
# *.png
# Package.resolved
# README.md
# LICENSE
/build/
/test-results/
+8
View File
@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="EMPTY_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.build" />
<excludeFolder url="file://$MODULE_DIR$/.build/Build" />
<excludeFolder url="file://$MODULE_DIR$/.build/Logs/Build" />
<excludeFolder url="file://$MODULE_DIR$/Rsync Studio.xcodeproj/project.xcworkspace/xcuserdata" />
<excludeFolder url="file://$MODULE_DIR$/Rsync Studio.xcodeproj/xcuserdata" />
<excludeFolder url="file://$MODULE_DIR$/build" />
<excludeFolder url="file://$MODULE_DIR$/releases" />
<excludeFolder url="file://$MODULE_DIR$/rsync-studio-licensing/.idea" />
<excludeFolder url="file://$MODULE_DIR$/rsync-studio-licensing/public/build" />
<excludeFolder url="file://$MODULE_DIR$/rsync-studio-licensing/vendor" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/RsyncStudio.iml" filepath="$PROJECT_DIR$/.idea/RsyncStudio.iml" />
</modules>
</component>
</project>
Generated
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$/rsync-studio-licensing" vcs="Git" />
</component>
</project>
Regular → Executable
View File
View File

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 157 KiB

-36
View File
@@ -1,36 +0,0 @@
# Rsync Studio — Security / Release Audit
This project targets direct macOS distribution using Developer ID signing and Apple notarisation. App Sandbox is intentionally disabled because the product launches `rsync`/SSH and operates on arbitrary user-configured filesystem paths.
## Security controls present
- Hardened Runtime enabled for Debug and Release target configurations.
- No shell (`/bin/sh -c`) command construction; `Process` uses an absolute executable path and argument array.
- SSH host-key checking is not disabled.
- Automated SSH uses BatchMode, timeout and keepalive limits.
- SSH password authentication/storage is not supported; legacy stored password data is deleted.
- Remote host, username, port, key path and remote destination are validated.
- Remote paths reject shell metacharacters before rsync can invoke the remote shell.
- Advanced arguments reject transport replacement, remote-command, credential, arbitrary list/log/batch path and destructive overrides.
- Malformed Advanced argument quoting/trailing escapes are rejected.
- `--` terminates rsync option parsing before source/destination operands.
- Local source/destination overlap is rejected.
- File-watch and exclusion values reject control characters.
- Scheduled/watch delete requires explicit per-job authorisation.
- Run logs are ANSI-cleaned, likely credentials redacted and size-capped before persistence.
- Run history has bounded retention.
- Privacy manifest declares no tracking/no collected data and the app-specific UserDefaults required reason.
- Launch at login uses `SMAppService` and remains user-controlled.
- No analytics/telemetry or application HTTP client code is present.
## Required release verification on a Mac
Run:
```bash
./Scripts/release-preflight.sh
```
Then archive with Developer ID, submit with Apple `notarytool` (or Xcode Organizer), staple the ticket and run the signature/Gatekeeper checks in `Docs/RELEASE.md`.
A Linux source audit cannot substitute for Xcode's macOS SDK typecheck, code signing, notarisation or Gatekeeper assessment.
-88
View File
@@ -1,88 +0,0 @@
# Rsync Studio — Commercial Release Stage 1
This document freezes the commercial distribution identity and release-security baseline for Rsync Studio 1.0.0.
## Product identity
- Product: **Rsync Studio**
- Bundle identifier: **`net.absdev.RsyncStudio`**
- Marketing version: **`1.0.0`**
- Build number: **`1`**
- Minimum macOS: **14.0**
- Category: **Utilities**
- Distribution model: **Direct distribution outside the Mac App Store**
The bundle identifier is now treated as permanent product identity. Do not change it between commercial releases. Changing it creates a different application identity for macOS, Keychain access, preferences, update logic and future licensing.
## Apple signing baseline
Rsync Studio must ship with:
1. An active Apple Developer Program membership.
2. A **Developer ID Application** certificate belonging to the publisher's Apple Developer Team.
3. Hardened Runtime enabled.
4. No `get-task-allow` entitlement in a Release archive.
5. Successful Apple notarization.
6. A stapled notarization ticket before publication.
7. Successful Gatekeeper assessment of the exact artifact customers will download.
The Apple Team ID and certificate's full identity are deliberately not committed into the repository. Select the correct team in Xcode on the controlled release Mac. This avoids embedding developer-account-specific credentials into source control.
## App Sandbox
The App Sandbox remains intentionally disabled. Rsync Studio launches command-line synchronization/SSH tooling and must work with arbitrary user-selected filesystem locations. This is a direct-distribution architecture decision, not an accidental security relaxation.
Hardened Runtime remains mandatory.
## Versioning policy
Use semantic product versions:
- `1.0.0`, `1.0.1`, `1.1.0`, `2.0.0`
Increment `CURRENT_PROJECT_VERSION` for every build that may be distributed. Never publish two different binaries using the same version/build pair.
## Required Stage 1 checks
Run:
```bash
./Scripts/verify-release-config.sh
./Scripts/security-check.sh
./Scripts/release-preflight.sh
```
`release-preflight.sh` must be run on macOS with Xcode installed. It performs a clean Release build without signing so compiler/SDK failures are caught before creating the signed archive.
## First signed archive checklist
On the release Mac:
1. Open `Rsync Studio.xcodeproj` in Xcode.
2. Select the **Rsync Studio** target → **Signing & Capabilities**.
3. Select the publisher's Apple Developer Team.
4. Confirm Hardened Runtime is enabled.
5. Archive a **Release** build for macOS.
6. In Organizer choose direct/Developer ID distribution.
7. Confirm the archive is signed with **Developer ID Application**, not Apple Development.
8. Notarize the exported app/container with Apple.
9. Staple the ticket.
10. Verify the exact final artifact before uploading it anywhere.
## Signed artifact verification
For the exported application:
```bash
codesign --verify --deep --strict --verbose=2 "/path/to/Rsync Studio.app"
codesign -dv --verbose=4 "/path/to/Rsync Studio.app"
codesign -d --entitlements :- "/path/to/Rsync Studio.app"
spctl --assess --type execute --verbose=4 "/path/to/Rsync Studio.app"
xcrun stapler validate "/path/to/Rsync Studio.app"
```
The signing output must identify a Developer ID Application certificate belonging to the intended publisher. The entitlements must not contain `com.apple.security.get-task-allow`.
## Stage boundary
Stage 1 does **not** add customer licensing, payment handling, trial enforcement, DMG artwork, automatic installation of updates, or a licensing server. Those are intentionally separate stages so signing/distribution identity is stable before commercial logic is introduced.
-104
View File
@@ -1,104 +0,0 @@
# Rsync Studio — Commercial Release Stage 2
Stage 2 adds a reproducible, non-publishing direct-distribution release pipeline for macOS.
## What the pipeline does
`Scripts/release.sh` performs these steps in order:
1. Runs the existing release/security preflight.
2. Creates a clean Xcode Release archive.
3. Exports the archive using Apple's `developer-id` distribution method.
4. Verifies that the exported application is signed by **Developer ID Application** and has Hardened Runtime enabled.
5. Rejects `get-task-allow` and an unexpectedly sandboxed Release app.
6. Builds a compressed drag-to-Applications DMG using `hdiutil`.
7. Validates the configured `notarytool` Keychain profile.
8. Notarizes the exported app inside a temporary ZIP, staples its ticket directly to the app, validates it, then removes the temporary ZIP.
9. Builds the final DMG from that already-notarized/stapled app and validates the DMG container.
10. Submits the final DMG to Apple's notarization service and waits for the result.
11. Staples and validates the DMG notarization ticket.
12. Re-runs Gatekeeper/signature checks after notarization.
13. Produces a SHA-256 checksum and release metadata file.
14. Stops without uploading or publishing anything.
Existing release directories are immutable: the script refuses to overwrite `build/releases/<version>-<build>`. Increment the build number for a replacement build.
## One-time Mac setup
Install/sign into the current Xcode release and make sure a **Developer ID Application** certificate for your Apple Developer team is available in Keychain Access/Xcode.
Store notarization credentials once:
```bash
xcrun notarytool store-credentials "Rsync Studio Notary"
```
Apple will prompt for the required account/team credentials. The release script never stores those credentials in the source repository.
If Xcode cannot infer your team, run releases with your 10-character Apple Developer Team ID:
```bash
DEVELOPMENT_TEAM=YOURTEAMID ./Scripts/release.sh
```
Do not commit the Team ID solely because a local machine needs it; Xcode signing configuration can supply it.
## Produce a commercial build
From the repository root on the release Mac:
```bash
./Scripts/release.sh
```
To use another notarization Keychain profile:
```bash
./Scripts/release.sh --notary-profile MyNotaryProfile
```
For a signing/package smoke test without Apple notarization:
```bash
./Scripts/release.sh --skip-notarization
```
A skipped-notarization build is **not** suitable for customer distribution.
## Output
For version `1.0.0`, build `1`, the output is:
```text
build/releases/1.0.0-1/
├── Rsync Studio.xcarchive
├── export/
│ └── Rsync Studio.app
├── Rsync Studio-1.0.0.dmg
├── Rsync Studio-1.0.0.dmg.sha256
└── release-info.txt
```
The release directory deliberately includes the archive and exported application so a shipped artifact can be traced back to the exact signed archive.
## Mandatory clean-Mac test before publishing
Do not publish immediately after the script succeeds. Copy or download the final DMG onto a Mac/account that has not built Rsync Studio in Xcode, then:
1. Open the DMG.
2. Drag **Rsync Studio** into Applications.
3. Launch it from Applications.
4. Confirm Gatekeeper opens it normally with no unidentified-developer warning.
5. Create a harmless local dry-run job.
6. Verify SSH/local workflows required for the release.
7. Verify About reports the intended version/build.
8. Verify the app icon, Help menu, notifications and Launch at Login behaviour.
9. Eject/delete the DMG and repeat once from the actual hosted download before announcing the release.
## Release artifact policy
Never replace `Rsync Studio-1.0.0.dmg` with different bytes after it has been published. If the build changes, increment `CURRENT_PROJECT_VERSION` and create a new release artifact. Keep the published SHA-256 alongside the download.
## What Stage 2 intentionally does not do
It does not upload to a website/CDN, update the signed update manifest, publish release notes, create licences, take payments or activate customers. Those operations are deliberately separated so a successful local build cannot accidentally become a public release.
+51
View File
@@ -0,0 +1,51 @@
# Paddle sandbox checkout
Rsync Studio's licensing site now hosts a Paddle sandbox checkout at:
- `https://rsync.stage.absdev.net/buy`
The macOS app opens this page from the Licence and General settings screens.
## Stage environment
The stage `.env` must contain:
```dotenv
PADDLE_CLIENT_SIDE_TOKEN=test_...
PADDLE_ENVIRONMENT=sandbox
RSYNC_STUDIO_PURCHASE_URL=https://rsync.stage.absdev.net/buy
RSYNC_STUDIO_PORTAL_URL=https://rsync.stage.absdev.net/portal
RSYNC_STUDIO_SUPPORT_URL=https://rsync.stage.absdev.net/support
```
Keep `PADDLE_API_KEY` server-side. It must never be added to the macOS app or browser JavaScript.
The four Paddle catalog IDs remain configured through `PADDLE_PERSONAL_*` and `PADDLE_PROFESSIONAL_*` variables.
## Checkout behavior
- Paddle.js is initialized with the sandbox client-side token.
- Prices are localized with `Paddle.PricePreview()` when available, with the agreed GBP prices as a fallback.
- Each plan opens Paddle's overlay checkout using the configured price ID.
- All plans advertise the configured 7-day Paddle trial.
- A successful Paddle subscription webhook provisions the licence and creates the encrypted delivery outbox record.
- After webhook processing, pending licence keys are sent by email on a best-effort basis without rolling back the verified webhook if mail delivery is temporarily unavailable.
## macOS stage configuration
The app is configured for:
- API: `https://rsync.stage.absdev.net/api/`
- Buy: `https://rsync.stage.absdev.net/buy`
- Portal: `https://rsync.stage.absdev.net/portal`
- Support: `https://rsync.stage.absdev.net/support`
- The current stage Ed25519 licence public key is embedded in `Info.plist`.
The signing private seed remains server-only.
## Remote portal deactivation
Rsync Studio revalidates an installed licence against `POST /api/v1/validate` on launch/resume and every 24 hours while running. The validation endpoint reads only the local licensing database; it does **not** call Paddle and therefore does not add Paddle API usage or transaction charges.
A successful validation refreshes the signed licence token so subscription entitlement changes are picked up. If an installation was deactivated in the customer portal, the server returns HTTP 403 and the app removes its local token immediately. Temporary network/server failures retain the locally signed licence for up to three days; after that grace period the app requires an online validation.
-114
View File
@@ -1,114 +0,0 @@
# Rsync Studio — Direct Distribution Release
Rsync Studio is designed for direct macOS distribution rather than the Mac App Store because it intentionally launches `rsync`/`ssh` and accesses user-selected arbitrary paths.
## Release security baseline
- Developer ID Application signing.
- Hardened Runtime enabled.
- App Sandbox intentionally disabled for this product architecture.
- No Hardened Runtime exception entitlements.
- SSH host-key verification is never disabled.
- SSH uses BatchMode, connection timeout and keepalive limits.
- Password storage/authentication is not supported; use a private key or the macOS SSH agent.
- Advanced rsync arguments that can replace the SSH transport, run remote commands, load passwords, read/write arbitrary option/list/log/batch files or bypass controlled delete handling are rejected.
- Scheduled/file-watch `--delete` requires explicit per-job authorisation.
- Run logs are sanitised and capped before storage and expire according to Settings.
- `PrivacyInfo.xcprivacy` declares no tracking/no collected data and the UserDefaults required-reason API.
## Mandatory preflight
Before every release archive, run on the release Mac:
```bash
./Scripts/release-preflight.sh
```
This performs the static security policy checks, validates the privacy manifest, and performs a clean unsigned **Release** build with the installed macOS SDK so compiler/typecheck warnings or SDK incompatibilities are caught before signing.
## Archive
In Xcode select **Any Mac (Apple Silicon, Intel)**, choose **Product > Archive**, then distribute using **Developer ID**.
Alternatively:
```bash
xcodebuild -project "Rsync Studio.xcodeproj" -scheme "Rsync Studio" -configuration Release -archivePath "build/Rsync Studio.xcarchive" archive
```
Choose your Developer ID Application certificate/team in Xcode signing settings before archiving.
## Verify signature
```bash
codesign --verify --deep --strict --verbose=2 "/path/to/Rsync Studio.app"
codesign -d --entitlements :- "/path/to/Rsync Studio.app"
```
The Release build should show Hardened Runtime and should not contain `com.apple.security.get-task-allow`.
## Notarise
Store notarisation credentials in a Keychain profile once:
```bash
xcrun notarytool store-credentials "Rsync Studio Notary"
```
Submit a signed ZIP/DMG/PKG:
```bash
ditto -c -k --keepParent "/path/to/Rsync Studio.app" "Rsync Studio.zip"
xcrun notarytool submit "Rsync Studio.zip" --keychain-profile "Rsync Studio Notary" --wait
```
Staple and validate:
```bash
xcrun stapler staple "/path/to/Rsync Studio.app"
xcrun stapler validate "/path/to/Rsync Studio.app"
spctl --assess --type execute --verbose=4 "/path/to/Rsync Studio.app"
```
## Pre-release functional security tests
1. Local source nested inside destination is rejected.
2. Local destination nested inside source is rejected.
3. SSH host beginning with `-` is rejected.
4. SSH username containing spaces/control characters is rejected.
5. A `.pub` key cannot be selected as the effective private key.
6. Advanced arguments `-e`, `--rsh`, `--rsync-path`, `--password-file`, `--files-from`, `--log-file`, `--write-batch`, `--remove-source-files`, `--delete*` and `-M` are rejected, and malformed quoting is rejected.
7. Remote destination shell metacharacters are rejected before rsync is launched.
8. Scheduled/watch job using `--delete` cannot run until **Allow unattended delete** is explicitly enabled.
9. SSH to a previously unknown host does not silently bypass host-key checking.
10. Scheduled SSH job with unavailable authentication fails rather than prompting indefinitely.
11. History expiry removes old completed runs according to Settings.
12. A failed persistent SwiftData store opens Recovery Mode instead of immediately crashing.
13. `Scripts/security-check.sh` passes before every signed build.
## Signed update feed
The application contains a dependency-free signed update checker. It remains disabled until these generated Info.plist values are populated in the Release configuration:
- `RsyncStudioUpdateManifestURL`: HTTPS URL to the JSON manifest.
- `RsyncStudioUpdatePublicKey`: Base64 raw Ed25519/Curve25519.Signing public key.
Manifest schema:
```json
{
"version": "1.2.0",
"build": 120,
"downloadURL": "https://example.com/releases/Rsync Studio-1.2.0.dmg",
"signature": "BASE64_ED25519_SIGNATURE"
}
```
The exact signed bytes are UTF-8: `version + "\n" + build + "\n" + downloadURL`. The checker refuses HTTP feeds, unsigned manifests, invalid signatures, and non-HTTPS download URLs. It opens the verified signed download location; it does not silently install software. Keep the private signing key outside the source repository and build machine where practical.
## Automated commercial release pipeline
Stage 2 adds `Scripts/release.sh`, which automates archive/export, signed-app verification, drag-to-Applications DMG creation, Apple notarization, stapling, Gatekeeper verification, SHA-256 generation and release metadata. It intentionally does **not** publish artifacts.
See `Docs/COMMERCIAL_RELEASE_STAGE2.md` for first-time setup and usage.
Regular → Executable
+2 -2
View File
@@ -45,7 +45,7 @@ Background automation remains active while the application is running, including
This project targets Developer ID signed/notarised direct distribution, not the Mac App Store. Current hardening includes Hardened Runtime, validated rsync executables, protected advanced rsync arguments, non-interactive SSH with timeouts/keepalives, normal SSH host-key verification, private-key/agent authentication only, unattended `--delete` authorisation, local source/destination overlap protection, sanitised/capped run logs, configurable history retention, a privacy manifest, and persistent-store recovery mode.
See `Docs/RELEASE.md` and run `Scripts/security-check.sh` before producing a release archive.
See `Docs/RELEASE.md` and run `Scripts/old/security-check.sh` before producing a release archive.
## Professional reliability features
@@ -54,4 +54,4 @@ The production build includes job preflight health checks, destination-volume id
## Commercial direct-distribution build
On the release Mac, `Scripts/release.sh` creates the Developer ID signed, notarized and stapled DMG plus its SHA-256 checksum. The script never publishes automatically. See `Docs/COMMERCIAL_RELEASE_STAGE2.md`.
On the release Mac, `Scripts/old/release.sh` creates the Developer ID signed, notarized and stapled DMG plus its SHA-256 checksum. The script never publishes automatically. See `Docs/COMMERCIAL_RELEASE_STAGE2.md`.
+26 -4
View File
@@ -28,6 +28,9 @@
AC1000000000000000000001 /* Views/HelpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC2000000000000000000001 /* Views/HelpView.swift */; };
AC1000000000000000000002 /* Resources/Rsync Studio Help.md in Resources */ = {isa = PBXBuildFile; fileRef = AC2000000000000000000002 /* Resources/Rsync Studio Help.md */; };
AD1000000000000000000001 /* Resources/PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = AD2000000000000000000001 /* Resources/PrivacyInfo.xcprivacy */; };
AE1000000000000000000001 /* Services/LicensingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE2000000000000000000001 /* Services/LicensingManager.swift */; };
AE1000000000000000000002 /* Views/LicenseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE2000000000000000000002 /* Views/LicenseView.swift */; };
AE1000000000000000000003 /* Views/LicenseRequiredView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE2000000000000000000003 /* Views/LicenseRequiredView.swift */; };
C24299D89D285E985C089C0F /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 344231C687A1F4BDC84AE838 /* SettingsView.swift */; };
CC02106B9AF77855935487F6 /* Services/RsyncRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F173223A0A15A648115C2B6 /* Services/RsyncRunner.swift */; };
D2009B9542E292B66BB8A0D4 /* Views/Components/StatusBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA5919F7BA545585E4369602 /* Views/Components/StatusBadge.swift */; };
@@ -56,6 +59,9 @@
AC2000000000000000000001 /* Views/HelpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/HelpView.swift; sourceTree = "<group>"; };
AC2000000000000000000002 /* Resources/Rsync Studio Help.md */ = {isa = PBXFileReference; lastKnownFileType = text; path = "Resources/Rsync Studio Help.md"; sourceTree = "<group>"; };
AD2000000000000000000001 /* Resources/PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = Resources/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
AE2000000000000000000001 /* Services/LicensingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services/LicensingManager.swift; sourceTree = "<group>"; };
AE2000000000000000000002 /* Views/LicenseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/LicenseView.swift; sourceTree = "<group>"; };
AE2000000000000000000003 /* Views/LicenseRequiredView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/LicenseRequiredView.swift; sourceTree = "<group>"; };
BCA4F1E7C15E2FC1DCC85FEE /* Views/Jobs/JobEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/Jobs/JobEditorView.swift; sourceTree = "<group>"; };
BE0FFB276373DDA4C5A8E419 /* Services/RsyncLocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services/RsyncLocator.swift; sourceTree = "<group>"; };
C7A5B87A4971CCE37EF1B979 /* Models/SyncRun.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models/SyncRun.swift; sourceTree = "<group>"; };
@@ -100,6 +106,9 @@
BE0FFB276373DDA4C5A8E419 /* Services/RsyncLocator.swift */,
AB2000000000000000000002 /* Services/FileWatchService.swift */,
AB2000000000000000000003 /* Services/BackgroundSyncCoordinator.swift */,
AE2000000000000000000001 /* Services/LicensingManager.swift */,
AE2000000000000000000002 /* Views/LicenseView.swift */,
AE2000000000000000000003 /* Views/LicenseRequiredView.swift */,
8999F614BA12AF14ACCB5E3D /* Utilities/FilePicker.swift */,
CA5919F7BA545585E4369602 /* Views/Components/StatusBadge.swift */,
58B5F691C6DE4B3B7224BE4B /* Views/Components/PathField.swift */,
@@ -210,6 +219,9 @@
87F3C7BF9787C5EF1DFC58CE /* Services/RsyncLocator.swift in Sources */,
AB1000000000000000000002 /* Services/FileWatchService.swift in Sources */,
AB1000000000000000000003 /* Services/BackgroundSyncCoordinator.swift in Sources */,
AE1000000000000000000001 /* Services/LicensingManager.swift in Sources */,
AE1000000000000000000002 /* Views/LicenseView.swift in Sources */,
AE1000000000000000000003 /* Views/LicenseRequiredView.swift in Sources */,
0BBFC488F7DFF17E315AEEF2 /* Utilities/FilePicker.swift in Sources */,
D2009B9542E292B66BB8A0D4 /* Views/Components/StatusBadge.swift in Sources */,
3D5AB53FDA192FBBA66D2CEF /* Views/Components/PathField.swift in Sources */,
@@ -238,17 +250,22 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 39R6U3437D;
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_KEY_CFBundleDisplayName = "Rsync Studio";
INFOPLIST_KEY_CFBundleIconName = AppIcon;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_RsyncStudioLicenseAPIBaseURL = "https://rsync.vm/api/";
INFOPLIST_KEY_RsyncStudioLicensePublicKey = "8/rlCrS5mVEi4rGNXkw0Z22XkRpSH5+jPnD4f0GyV2k=";
INFOPLIST_KEY_RsyncStudioPortalURL = "";
INFOPLIST_KEY_RsyncStudioPurchaseURL = "";
INFOPLIST_KEY_RsyncStudioSupportURL = "";
INFOPLIST_KEY_RsyncStudioUpdateManifestURL = "";
INFOPLIST_KEY_RsyncStudioUpdatePublicKey = "";
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = net.absdev.RsyncStudio;
INFOPLIST_FILE = "Rsync Studio/Info.plist";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
@@ -346,17 +363,22 @@
COPY_PHASE_STRIP = YES;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 39R6U3437D;
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_KEY_CFBundleDisplayName = "Rsync Studio";
INFOPLIST_KEY_CFBundleIconName = AppIcon;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_RsyncStudioLicenseAPIBaseURL = "https://rsync.vm/api/";
INFOPLIST_KEY_RsyncStudioLicensePublicKey = "8/rlCrS5mVEi4rGNXkw0Z22XkRpSH5+jPnD4f0GyV2k=";
INFOPLIST_KEY_RsyncStudioPortalURL = "";
INFOPLIST_KEY_RsyncStudioPurchaseURL = "";
INFOPLIST_KEY_RsyncStudioSupportURL = "";
INFOPLIST_KEY_RsyncStudioUpdateManifestURL = "";
INFOPLIST_KEY_RsyncStudioUpdatePublicKey = "";
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = net.absdev.RsyncStudio;
INFOPLIST_FILE = "Rsync Studio/Info.plist";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
View File
View File
View File
View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

Before

Width:  |  Height:  |  Size: 596 B

After

Width:  |  Height:  |  Size: 596 B

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

View File

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 157 KiB

View File
Regular → Executable
+18 -1
View File
@@ -4,7 +4,9 @@ import SwiftData
struct ContentView: View {
let startupStorageError: String?
@EnvironmentObject private var automation: BackgroundSyncCoordinator
@EnvironmentObject private var licensing: LicensingManager
@Environment(\.openWindow) private var openWindow
@Environment(\.scenePhase) private var scenePhase
@State private var selectedJob: SyncJob?
@State private var section: AppSection = .jobs
@State private var showingNewJob = false
@@ -17,7 +19,10 @@ struct ContentView: View {
var body: some View {
ZStack {
mainContent
Group {
if licensing.canUseApp { mainContent }
else { LicenseRequiredView() }
}
.opacity(showingSplash ? 0 : 1)
if showingSplash {
@@ -38,6 +43,18 @@ struct ContentView: View {
try? await Task.sleep(for: .seconds(1.0))
withAnimation(.easeOut(duration: 0.28)) { showingSplash = false }
}
.task {
await licensing.revalidateIfNeeded()
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(LicensingManager.remoteValidationInterval))
if Task.isCancelled { return }
await licensing.revalidateIfNeeded()
}
}
.onChange(of: scenePhase) { _, phase in
guard phase == .active else { return }
Task { await licensing.revalidateIfNeeded(force: true) }
}
}
private var mainContent: some View {
+35
View File
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"\>
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>RsyncStudioLicenseAPIBaseURL</key>
<string>https://rsync.stage.absdev.net/api/</string>
<key>RsyncStudioLicensePublicKey</key>
<string>/QWCMp3he0bUM0VIYYmH9y1/BCncpUo4h/J8sM8gPKg=</string>
<key>RsyncStudioPurchaseURL</key>
<string>https://rsync.stage.absdev.net/buy</string>
<key>RsyncStudioPortalURL</key>
<string>https://rsync.stage.absdev.net/portal</string>
<key>RsyncStudioSupportURL</key>
<string>https://rsync.stage.absdev.net/support</string>
</dict>
</plist>
View File
View File
View File
View File
+1 -1
View File
@@ -548,7 +548,7 @@ The bundled privacy manifest declares no tracking and no collected data.
## Release verification
The project contains `Docs/RELEASE.md` and `Scripts/security-check.sh` with Developer ID, notarisation, signature and Gatekeeper verification steps for production builds.
The project contains `Docs/RELEASE.md` and `../../Scripts/old/security-check.sh` with Developer ID, notarisation, signature and Gatekeeper verification steps for production builds.
# Production Reliability
+11 -1
View File
@@ -8,6 +8,7 @@ struct RsyncStudioApp: App {
let startupStorageError: String?
@StateObject private var runner: RsyncRunner
@StateObject private var automation: BackgroundSyncCoordinator
@StateObject private var licensing: LicensingManager
init() {
let schema = Schema([
@@ -16,6 +17,7 @@ struct RsyncStudioApp: App {
])
let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
KeychainStore.delete("defaultSSHPassword")
_licensing = StateObject(wrappedValue: LicensingManager())
do {
let container = try ModelContainer(for: schema, configurations: [configuration])
@@ -45,7 +47,14 @@ struct RsyncStudioApp: App {
.frame(minWidth: 1040, minHeight: 680)
.environmentObject(runner)
.environmentObject(automation)
.onAppear { automation.start() }
.environmentObject(licensing)
.onAppear {
licensing.refresh()
if licensing.canUseApp { automation.start() } else { automation.stop() }
}
.onChange(of: licensing.canUseApp) { _, allowed in
if allowed { automation.start() } else { automation.stop() }
}
}
.modelContainer(sharedModelContainer)
.commands {
@@ -60,6 +69,7 @@ struct RsyncStudioApp: App {
Settings {
SettingsView()
.environmentObject(automation)
.environmentObject(licensing)
}
}
}
View File
View File
+485
View File
@@ -0,0 +1,485 @@
import Combine
import Security
import Foundation
import CryptoKit
@MainActor
final class LicensingManager: ObservableObject {
enum State: Equatable {
case licensed(LicensePayload)
case trial(daysRemaining: Int, expiresAt: Date)
case expired
case invalid(String)
var canUseApp: Bool {
switch self {
case .licensed, .trial: true
case .expired, .invalid: false
}
}
}
struct LicensePayload: Codable, Equatable {
let licenseID: String
let customerName: String?
let customerEmail: String?
let edition: String
let installationID: String
let issuedAt: Date
let expiresAt: Date?
let maxMajorVersion: Int?
var displayOwner: String {
if let customerName, !customerName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return customerName }
if let customerEmail, !customerEmail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return customerEmail }
return "Licensed User"
}
}
struct ActivationResponse: Decodable {
let licenseToken: String
}
private struct ActivationRequest: Encodable {
let licenseKey: String
let installationID: String
let appVersion: String
let platform: String
}
private struct DeactivationRequest: Encodable {
let licenseToken: String
let installationID: String
}
private struct ValidationRequest: Encodable {
let licenseToken: String
let installationID: String
let appVersion: String
let platform: String
}
private struct ValidationResponse: Decodable {
let active: Bool
let licenseToken: String
}
enum LicensingError: LocalizedError {
case configuration(String)
case invalidLicense(String)
case server(String)
case remoteInactive(String)
var errorDescription: String? {
switch self {
case .configuration(let message), .invalidLicense(let message), .server(let message), .remoteInactive(let message): message
}
}
}
static let trialLengthDays = 7
static let remoteValidationInterval: TimeInterval = 24 * 60 * 60
static let offlineGracePeriod: TimeInterval = 3 * 24 * 60 * 60
@Published private(set) var state: State = .trial(daysRemaining: trialLengthDays, expiresAt: Date().addingTimeInterval(TimeInterval(trialLengthDays * 86_400)))
@Published private(set) var installationID = ""
@Published private(set) var isWorking = false
@Published var lastMessage: String?
private let calendar = Calendar(identifier: .gregorian)
private let firstLaunchAccount = "licensing.trial.firstLaunch"
private let lastSeenAccount = "licensing.trial.lastSeen"
private let installationAccount = "licensing.installationID"
private let tokenAccount = "licensing.token"
private let lastRemoteValidationAccount = "licensing.remoteValidation.lastSuccess"
private let remoteBlockMessageAccount = "licensing.remoteValidation.blockMessage"
private var revalidationInFlight = false
init() {
refresh()
}
var canUseApp: Bool { state.canUseApp }
var statusTitle: String {
switch state {
case .licensed: "Licensed"
case .trial: "Free Trial"
case .expired: "Trial Expired"
case .invalid: "Licence Problem"
}
}
var statusDetail: String {
switch state {
case .licensed(let payload):
let expiry = payload.expiresAt.map { " · expires \($0.formatted(date: .abbreviated, time: .omitted))" } ?? " · perpetual"
return "\(payload.edition) · \(payload.displayOwner)\(expiry)"
case .trial(let days, let expiresAt):
return "\(days) day\(days == 1 ? "" : "s") remaining · ends \(expiresAt.formatted(date: .abbreviated, time: .omitted))"
case .expired:
return "The 7-day evaluation period has ended. Activate a licence to continue using sync jobs."
case .invalid(let message):
return message
}
}
func refresh(now: Date = Date()) {
installationID = loadOrCreateInstallationID()
if let token = storedLicenseToken, !token.isEmpty {
do {
let payload = try verify(token: token, now: now)
state = .licensed(payload)
recordLastSeen(now)
return
} catch {
state = .invalid(error.localizedDescription)
recordLastSeen(now)
return
}
}
let blockedMessage = KeychainStore.string(for: remoteBlockMessageAccount)
if !blockedMessage.isEmpty {
state = .invalid(blockedMessage)
return
}
state = trialState(now: now)
}
/// Revalidates only against the Rsync Studio licensing server. This endpoint reads
/// our own database and never calls Paddle, so routine validation has no Paddle API cost.
func revalidateIfNeeded(force: Bool = false, now: Date = Date()) async {
guard !revalidationInFlight else { return }
installationID = loadOrCreateInstallationID()
guard let token = storedLicenseToken, !token.isEmpty else {
refresh(now: now)
return
}
let localPayload: LicensePayload
do {
localPayload = try verify(token: token, now: now)
} catch {
state = .invalid(error.localizedDescription)
return
}
if !force,
let lastSuccess = Self.parseDate(KeychainStore.string(for: lastRemoteValidationAccount)),
now.timeIntervalSince(lastSuccess) < Self.remoteValidationInterval {
state = .licensed(localPayload)
return
}
guard let baseURL = configuredAPIBaseURL else {
applyOfflineGrace(localPayload: localPayload, now: now)
return
}
// Existing installations created before remote validation was added get a
// three-day migration grace period if the first network check cannot complete.
if Self.parseDate(KeychainStore.string(for: lastRemoteValidationAccount)) == nil {
_ = KeychainStore.set(Self.formatDate(now), for: lastRemoteValidationAccount)
}
revalidationInFlight = true
defer { revalidationInFlight = false }
do {
let response = try await validateRemotely(token: token, baseURL: baseURL)
guard response.active else {
throw LicensingError.remoteInactive("This Mac has been deactivated from the licence portal.")
}
let refreshedPayload = try verify(token: response.licenseToken, now: now)
guard KeychainStore.set(response.licenseToken, for: tokenAccount) == errSecSuccess else {
throw LicensingError.invalidLicense("Rsync Studio could not update the licence securely in Keychain.")
}
_ = KeychainStore.set(Self.formatDate(now), for: lastRemoteValidationAccount)
KeychainStore.delete(remoteBlockMessageAccount)
state = .licensed(refreshedPayload)
recordLastSeen(now)
} catch LicensingError.remoteInactive(let message) {
KeychainStore.delete(tokenAccount)
KeychainStore.delete(lastRemoteValidationAccount)
_ = KeychainStore.set(message, for: remoteBlockMessageAccount)
state = .invalid(message)
lastMessage = message
} catch {
applyOfflineGrace(localPayload: localPayload, now: now)
}
}
func installSignedToken(_ token: String) throws {
let clean = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !clean.isEmpty else { throw LicensingError.invalidLicense("Paste a signed Rsync Studio licence token.") }
let payload = try verify(token: clean, now: Date())
guard KeychainStore.set(clean, for: tokenAccount) == errSecSuccess else {
throw LicensingError.invalidLicense("Rsync Studio could not save the licence securely in Keychain.")
}
KeychainStore.delete(remoteBlockMessageAccount)
state = .licensed(payload)
lastMessage = "Licence installed successfully."
}
func activate(licenseKey: String) async throws {
let key = licenseKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { throw LicensingError.invalidLicense("Enter your licence key.") }
guard let baseURL = configuredAPIBaseURL else {
throw LicensingError.configuration("Online activation is not configured in this build. A signed licence token can still be installed manually.")
}
isWorking = true
defer { isWorking = false }
let requestBody = ActivationRequest(
licenseKey: key,
installationID: installationID,
appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown",
platform: "macOS"
)
let response: ActivationResponse = try await post(path: "v1/activate", body: requestBody, baseURL: baseURL)
try installSignedToken(response.licenseToken)
_ = KeychainStore.set(Self.formatDate(Date()), for: lastRemoteValidationAccount)
KeychainStore.delete(remoteBlockMessageAccount)
lastMessage = "Rsync Studio has been activated on this Mac."
}
func deactivate() async throws {
guard let token = storedLicenseToken, !token.isEmpty else {
KeychainStore.delete(tokenAccount)
refresh()
return
}
isWorking = true
defer { isWorking = false }
if let baseURL = configuredAPIBaseURL {
let body = DeactivationRequest(licenseToken: token, installationID: installationID)
let _: EmptyResponse = try await post(path: "v1/deactivate", body: body, baseURL: baseURL)
}
guard KeychainStore.delete(tokenAccount) == errSecSuccess else {
throw LicensingError.invalidLicense("Rsync Studio could not remove the local licence from Keychain.")
}
KeychainStore.delete(lastRemoteValidationAccount)
KeychainStore.delete(remoteBlockMessageAccount)
refresh()
lastMessage = "Licence removed from this Mac."
}
func removeInvalidLocalLicence() {
KeychainStore.delete(tokenAccount)
KeychainStore.delete(lastRemoteValidationAccount)
KeychainStore.delete(remoteBlockMessageAccount)
refresh()
lastMessage = "Invalid local licence removed."
}
private var storedLicenseToken: String? {
let token = KeychainStore.string(for: tokenAccount)
return token.isEmpty ? nil : token
}
private var configuredPublicKey: String? {
guard let value = Bundle.main.infoDictionary?["RsyncStudioLicensePublicKey"] as? String else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private var configuredAPIBaseURL: URL? {
guard let value = Bundle.main.infoDictionary?["RsyncStudioLicenseAPIBaseURL"] as? String else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let url = URL(string: trimmed), url.scheme?.lowercased() == "https" else { return nil }
return url
}
private func loadOrCreateInstallationID() -> String {
let existing = KeychainStore.string(for: installationAccount)
if UUID(uuidString: existing) != nil { return existing }
let value = UUID().uuidString.lowercased()
_ = KeychainStore.set(value, for: installationAccount)
return value
}
private func trialState(now: Date) -> State {
let firstLaunch: Date
let storedFirst = KeychainStore.string(for: firstLaunchAccount)
if let parsed = Self.parseDate(storedFirst) {
firstLaunch = parsed
} else {
firstLaunch = now
_ = KeychainStore.set(Self.formatDate(now), for: firstLaunchAccount)
}
if let lastSeen = Self.parseDate(KeychainStore.string(for: lastSeenAccount)), now < lastSeen.addingTimeInterval(-300) {
recordLastSeen(lastSeen)
return .invalid("The system clock appears to have moved backwards. Correct the date and time, then reopen Rsync Studio.")
}
recordLastSeen(now)
guard let expiresAt = calendar.date(byAdding: .day, value: Self.trialLengthDays, to: firstLaunch) else { return .expired }
if now >= expiresAt { return .expired }
let startOfToday = calendar.startOfDay(for: now)
let expiryDay = calendar.startOfDay(for: expiresAt)
let days = max(1, calendar.dateComponents([.day], from: startOfToday, to: expiryDay).day ?? 1)
return .trial(daysRemaining: days, expiresAt: expiresAt)
}
private func recordLastSeen(_ date: Date) {
let previous = Self.parseDate(KeychainStore.string(for: lastSeenAccount))
if previous == nil || date > previous! {
_ = KeychainStore.set(Self.formatDate(date), for: lastSeenAccount)
}
}
private func verify(token: String, now: Date) throws -> LicensePayload {
guard let publicKeyText = configuredPublicKey else {
throw LicensingError.configuration("Licence verification is not configured in this build. Add RsyncStudioLicensePublicKey to the signed app configuration.")
}
let parts = token.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count == 3, parts[0] == "RS3" else {
throw LicensingError.invalidLicense("The licence token format is invalid.")
}
guard let payloadData = Self.base64URLDecode(String(parts[1])),
let signature = Self.base64URLDecode(String(parts[2])),
let keyData = Data(base64Encoded: publicKeyText) ?? Self.base64URLDecode(publicKeyText) else {
throw LicensingError.invalidLicense("The licence token or embedded public key is not valid Base64.")
}
let publicKey: Curve25519.Signing.PublicKey
do { publicKey = try Curve25519.Signing.PublicKey(rawRepresentation: keyData) }
catch { throw LicensingError.configuration("The embedded Rsync Studio licence public key is invalid.") }
guard publicKey.isValidSignature(signature, for: payloadData) else {
throw LicensingError.invalidLicense("The licence signature is invalid.")
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let payload: LicensePayload
do { payload = try decoder.decode(LicensePayload.self, from: payloadData) }
catch { throw LicensingError.invalidLicense("The signed licence payload is invalid.") }
guard payload.installationID.caseInsensitiveCompare(installationID) == .orderedSame else {
throw LicensingError.invalidLicense("This licence was activated for a different Rsync Studio installation.")
}
if let expiresAt = payload.expiresAt, now >= expiresAt {
throw LicensingError.invalidLicense("This licence expired on \(expiresAt.formatted(date: .abbreviated, time: .omitted)).")
}
if let maxMajor = payload.maxMajorVersion,
let currentMajor = Self.currentMajorVersion,
currentMajor > maxMajor {
throw LicensingError.invalidLicense("This licence covers Rsync Studio through major version \(maxMajor).")
}
return payload
}
private func validateRemotely(token: String, baseURL: URL) async throws -> ValidationResponse {
let url = baseURL.appending(path: "v1/validate")
guard url.scheme?.lowercased() == "https" else {
throw LicensingError.configuration("Licence API must use HTTPS.")
}
let body = ValidationRequest(
licenseToken: token,
installationID: installationID,
appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown",
platform: "macOS"
)
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 20)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.httpBody = try JSONEncoder().encode(body)
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw LicensingError.server("The licence server returned an invalid response.")
}
if http.statusCode == 403 {
let message = (try? JSONDecoder().decode(ServerError.self, from: data).message)
?? "This licence is no longer active on this Mac."
throw LicensingError.remoteInactive(message)
}
guard (200..<300).contains(http.statusCode) else {
let message = (try? JSONDecoder().decode(ServerError.self, from: data).message)
?? "Licence server returned HTTP \(http.statusCode)."
throw LicensingError.server(message)
}
do {
return try JSONDecoder().decode(ValidationResponse.self, from: data)
} catch {
throw LicensingError.server("The licence server returned malformed validation data.")
}
} catch let error as LicensingError {
throw error
} catch {
throw LicensingError.server(error.localizedDescription)
}
}
private func applyOfflineGrace(localPayload: LicensePayload, now: Date) {
let lastSuccess = Self.parseDate(KeychainStore.string(for: lastRemoteValidationAccount))
if let lastSuccess, now.timeIntervalSince(lastSuccess) <= Self.offlineGracePeriod {
state = .licensed(localPayload)
return
}
state = .invalid("Rsync Studio has been unable to verify this licence for more than 3 days. Connect to the internet and reopen the app.")
}
private func post<Request: Encodable, Response: Decodable>(path: String, body: Request, baseURL: URL) async throws -> Response {
let url = baseURL.appending(path: path)
guard url.scheme?.lowercased() == "https" else { throw LicensingError.configuration("Licence API must use HTTPS.") }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.timeoutInterval = 20
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.httpBody = try JSONEncoder().encode(body)
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else { throw LicensingError.server("The licence server returned an invalid response.") }
guard (200..<300).contains(http.statusCode) else {
let serverMessage = (try? JSONDecoder().decode(ServerError.self, from: data).message) ?? "Activation server returned HTTP \(http.statusCode)."
throw LicensingError.server(serverMessage)
}
if Response.self == EmptyResponse.self, data.isEmpty {
return EmptyResponse() as! Response
}
do { return try JSONDecoder().decode(Response.self, from: data) }
catch { throw LicensingError.server("The licence server returned malformed data.") }
}
private struct ServerError: Decodable { let message: String }
private struct EmptyResponse: Codable { init() {} }
private static var currentMajorVersion: Int? {
guard let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String else { return nil }
return Int(version.split(separator: ".").first ?? "")
}
private static func formatDate(_ date: Date) -> String { ISO8601DateFormatter().string(from: date) }
private static func parseDate(_ value: String) -> Date? { value.isEmpty ? nil : ISO8601DateFormatter().date(from: value) }
private static func base64URLDecode(_ value: String) -> Data? {
var text = value.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
let remainder = text.count % 4
if remainder != 0 { text += String(repeating: "=", count: 4 - remainder) }
return Data(base64Encoded: text)
}
}
View File
View File
View File
Regular → Executable
+18 -1
View File
@@ -10,6 +10,7 @@ struct SettingsView: View {
@Query(sort: \SyncJob.name) private var jobs: [SyncJob]
@Query(sort: \SyncRun.startedAt, order: .reverse) private var runs: [SyncRun]
@EnvironmentObject private var automation: BackgroundSyncCoordinator
@EnvironmentObject private var licensing: LicensingManager
@AppStorage("defaultSSHUsername") private var defaultSSHUsername = NSUserName()
@AppStorage("defaultSSHKeyPath") private var defaultSSHKeyPath = ""
@AppStorage("defaultSSHPort") private var defaultSSHPort = 22
@@ -120,6 +121,9 @@ struct SettingsView: View {
}
Section("Support & Updates") {
Button("Buy Rsync Studio…") { openCommercialURL(infoKey: "RsyncStudioPurchaseURL") }
Button("Manage Licence Online…") { openCommercialURL(infoKey: "RsyncStudioPortalURL") }
Button("Support…") { openCommercialURL(infoKey: "RsyncStudioSupportURL") }
Button("Export Diagnostics…") { exportDiagnostics() }
Text("Diagnostics contain app/macOS/rsync versions, redacted job configuration and recent failures. Private keys and credentials are never exported.")
.font(.caption)
@@ -139,8 +143,12 @@ struct SettingsView: View {
}
.formStyle(.grouped)
.tabItem { Label("General", systemImage: "gearshape") }
LicenseView()
.environmentObject(licensing)
.tabItem { Label("Licence", systemImage: "key") }
}
.frame(width: 650, height: 455)
.frame(width: 680, height: 500)
.onAppear {
KeychainStore.delete("defaultSSHPassword")
launchAtLogin = SMAppService.mainApp.status == .enabled
@@ -173,6 +181,15 @@ struct SettingsView: View {
}
}
private func openCommercialURL(infoKey: String) {
guard let raw = Bundle.main.object(forInfoDictionaryKey: infoKey) as? String,
let url = URL(string: raw), url.scheme == "https" else {
updateStatus = "This commercial service URL has not been configured for this build."
return
}
NSWorkspace.shared.open(url)
}
private func checkForUpdates() {
isCheckingUpdates = true
Task {
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
+48
View File
@@ -0,0 +1,48 @@
import SwiftUI
import AppKit
struct LicenseRequiredView: View {
@EnvironmentObject private var licensing: LicensingManager
@Environment(\.openSettings) private var openSettings
var body: some View {
VStack(spacing: 20) {
Image(systemName: "lock.shield.fill")
.font(.system(size: 54, weight: .medium))
.foregroundStyle(Color.accentColor)
VStack(spacing: 7) {
Text(licensing.statusTitle)
.font(.title2.bold())
Text(licensing.statusDetail)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 520)
}
HStack(spacing: 12) {
Button("Buy Rsync Studio…") { openPurchasePage() }
.buttonStyle(.borderedProminent)
.controlSize(.large)
Button("Open Licence Settings") { openSettings() }
.controlSize(.large)
}
Text("Your jobs and history remain on this Mac. Rsync Studio does not delete local data when a trial expires or a licence is removed.")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 520)
}
.padding(48)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(nsColor: .windowBackgroundColor))
}
private func openPurchasePage() {
guard let raw = Bundle.main.object(forInfoDictionaryKey: "RsyncStudioPurchaseURL") as? String,
let url = URL(string: raw),
url.scheme?.lowercased() == "https" else { return }
NSWorkspace.shared.open(url)
}
}
+145
View File
@@ -0,0 +1,145 @@
import SwiftUI
import AppKit
struct LicenseView: View {
@EnvironmentObject private var licensing: LicensingManager
@State private var licenceKey = ""
@State private var signedToken = ""
@State private var errorMessage: String?
@State private var showingTokenEntry = false
var body: some View {
Form {
Section("Licence Status") {
HStack(spacing: 12) {
Image(systemName: statusIcon)
.font(.title2)
.foregroundStyle(licensing.canUseApp ? Color.green : Color.orange)
VStack(alignment: .leading, spacing: 3) {
Text(licensing.statusTitle).font(.headline)
Text(licensing.statusDetail).font(.caption).foregroundStyle(.secondary)
}
Spacer()
}
LabeledContent("Installation ID", value: licensing.installationID)
.textSelection(.enabled)
Text("The installation ID is a random UUID stored in macOS Keychain. Rsync Studio does not use your Mac serial number or other hardware identifiers.")
.font(.caption)
.foregroundStyle(.secondary)
}
if case .licensed(let payload) = licensing.state {
Section("Current Licence") {
LabeledContent("Edition", value: payload.edition)
LabeledContent("Licence ID", value: payload.licenseID)
LabeledContent("Licensed to", value: payload.displayOwner)
if let expiresAt = payload.expiresAt {
LabeledContent("Expires", value: expiresAt.formatted(date: .long, time: .omitted))
} else {
LabeledContent("Term", value: "Perpetual")
}
Button("Deactivate This Mac…", role: .destructive) {
Task { await deactivate() }
}
.disabled(licensing.isWorking)
}
} else {
Section("Activate Rsync Studio") {
TextField("Licence key", text: $licenceKey, prompt: Text("XXXX-XXXX-XXXX-XXXX"))
.textContentType(.password)
HStack {
Button {
Task { await activate() }
} label: {
if licensing.isWorking { ProgressView().controlSize(.small) }
else { Label("Activate", systemImage: "checkmark.seal") }
}
.buttonStyle(.borderedProminent)
.disabled(licenceKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || licensing.isWorking)
Button(showingTokenEntry ? "Hide Signed Token" : "Install Signed Token…") {
showingTokenEntry.toggle()
}
}
Button("Buy Rsync Studio…") { openPurchasePage() }
Text("Buy Personal or Professional online, then enter the licence key delivered to your email. Signed offline licence tokens can also be installed without contacting the activation server.")
.font(.caption)
.foregroundStyle(.secondary)
}
if showingTokenEntry {
Section("Signed Offline Licence") {
TextEditor(text: $signedToken)
.font(.system(.caption, design: .monospaced))
.frame(minHeight: 86)
Button("Install Signed Licence") { installToken() }
.disabled(signedToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
if case .invalid = licensing.state {
Section("Recovery") {
Button("Remove Invalid Local Licence") { licensing.removeInvalidLocalLicence() }
Text("This removes only the stored local licence token. Trial dates and the installation identity are not reset.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
Section("Privacy") {
Text("Licence activation sends only the licence key, random installation ID, app version and platform to the licensing service. Sync paths, filenames, SSH hosts, credentials, job history and file contents are never included.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.alert("Rsync Studio Licence", isPresented: Binding(
get: { errorMessage != nil || licensing.lastMessage != nil },
set: { if !$0 { errorMessage = nil; licensing.lastMessage = nil } }
)) {
Button("OK", role: .cancel) { errorMessage = nil; licensing.lastMessage = nil }
} message: {
Text(errorMessage ?? licensing.lastMessage ?? "")
}
}
private var statusIcon: String {
switch licensing.state {
case .licensed: "checkmark.seal.fill"
case .trial: "clock.badge.checkmark"
case .expired: "clock.badge.exclamationmark"
case .invalid: "exclamationmark.shield.fill"
}
}
private func activate() async {
do {
try await licensing.activate(licenseKey: licenceKey)
licenceKey = ""
} catch { errorMessage = error.localizedDescription }
}
private func deactivate() async {
do { try await licensing.deactivate() }
catch { errorMessage = error.localizedDescription }
}
private func installToken() {
do {
try licensing.installSignedToken(signedToken)
signedToken = ""
showingTokenEntry = false
} catch { errorMessage = error.localizedDescription }
}
private func openPurchasePage() {
guard let raw = Bundle.main.object(forInfoDictionaryKey: "RsyncStudioPurchaseURL") as? String,
let url = URL(string: raw),
url.scheme?.lowercased() == "https" else {
errorMessage = "The Rsync Studio purchase page is not configured in this build."
return
}
NSWorkspace.shared.open(url)
}
}
View File
View File
View File
+65
View File
@@ -0,0 +1,65 @@
#!/bin/bash
set -euo pipefail
# Resolve script location under Bash or Zsh.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$PROJECT_ROOT"
# Find the Xcode project.
PROJECT="$(find "$PROJECT_ROOT" -maxdepth 2 -name '*.xcodeproj' -type d -print -quit)"
if [[ -z "$PROJECT" ]]; then
echo "ERROR: No Xcode project found in $PROJECT_ROOT"
exit 1
fi
echo "Project: $PROJECT"
# Detect the application scheme.
SCHEME="$(xcodebuild -list -json -project "$PROJECT" | python3 -c '
import json
import sys
data = json.load(sys.stdin)
schemes = data.get("project", {}).get("schemes", [])
if not schemes:
sys.exit("ERROR: No Xcode schemes found")
print(schemes[0])
')"
if [[ -z "$SCHEME" ]]; then
echo "ERROR: Could not detect Xcode scheme"
exit 1
fi
echo "Scheme: $SCHEME"
DERIVED_DATA="$PROJECT_ROOT/.build"
echo "Building Rsync Studio..."
xcodebuild \
-project "$PROJECT" \
-scheme "$SCHEME" \
-configuration Debug \
-derivedDataPath "$DERIVED_DATA" \
CODE_SIGNING_ALLOWED=NO \
build
APP="$(find "$DERIVED_DATA/Build/Products/Debug" -maxdepth 1 -name '*.app' -type d -print -quit)"
if [[ -z "$APP" ]]; then
echo "ERROR: Built application not found"
exit 1
fi
echo "Build successful: $APP"
if [[ "${LAUNCH_AFTER_BUILD:-1}" == "1" ]]; then
echo "Launching application..."
open "$APP"
fi
-37
View File
@@ -1,37 +0,0 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
PROJECT="$ROOT/Rsync Studio.xcodeproj"
SCHEME="Rsync Studio"
if [[ "$(uname -s)" != "Darwin" ]]; then
echo "release-preflight.sh must run on macOS with Xcode installed." >&2
exit 2
fi
for tool in xcodebuild plutil codesign spctl; do
command -v "$tool" >/dev/null || { echo "Missing required tool: $tool" >&2; exit 2; }
done
"$SCRIPT_DIR/verify-release-config.sh"
"$SCRIPT_DIR/security-check.sh"
plutil -lint "$ROOT/Rsync Studio/Resources/PrivacyInfo.xcprivacy"
BUILD_DIR="$ROOT/build/preflight"
rm -rf "$BUILD_DIR"
xcodebuild \
-project "$PROJECT" \
-scheme "$SCHEME" \
-configuration Release \
-destination 'platform=macOS' \
-derivedDataPath "$BUILD_DIR" \
CODE_SIGNING_ALLOWED=NO \
clean build
APP="$BUILD_DIR/Build/Products/Release/Rsync Studio.app"
test -d "$APP" || { echo "Release app was not produced at $APP" >&2; exit 1; }
echo "Unsigned Release build passed: $APP"
echo "Next: Archive with Developer ID, notarise, staple, then run the signed-app checks in Docs/RELEASE.md."
+141 -187
View File
@@ -1,206 +1,160 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
PROJECT="$ROOT/Rsync Studio.xcodeproj"
# Rsync Studio direct distribution: Developer ID signed, notarized app and DMG.
# macOS/Xcode required. Install as Scripts/release.sh.
APP_NAME="Rsync Studio"
SCHEME="Rsync Studio"
CONFIGURATION="Release"
PRODUCT_NAME="Rsync Studio"
DISPLAY_NAME="Rsync Studio"
BUNDLE_ID="net.absdev.RsyncStudio"
NOTARY_PROFILE="${NOTARY_PROFILE:-Rsync Studio Notary}"
RELEASE_ROOT="${RELEASE_ROOT:-$ROOT/build/releases}"
SKIP_PREFLIGHT=0
SKIP_NOTARIZATION=0
PROJECT="Rsync Studio.xcodeproj"
TEAM_ID="${TEAM_ID:-39R6U3437D}"
SIGNING_IDENTITY="${SIGNING_IDENTITY:-B99D353C88996866C49E4389B375645F76870A3F}"
NOTARY_PROFILE="${NOTARY_PROFILE:-orion-notary}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [[ -d "$SCRIPT_DIR/$PROJECT" ]]; then ROOT_DIR="$SCRIPT_DIR"
elif [[ -d "$SCRIPT_DIR/../$PROJECT" ]]; then ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
else echo "ERROR: Cannot locate $PROJECT" >&2; exit 1; fi
BUILD_DIR="$ROOT_DIR/build/direct-distribution"
ARCHIVE_PATH="$BUILD_DIR/$APP_NAME.xcarchive"
EXPORT_DIR="$BUILD_DIR/export"
EXPORT_OPTIONS="$BUILD_DIR/ExportOptions.plist"
APP_PATH="$EXPORT_DIR/$APP_NAME.app"
APP_ZIP="$BUILD_DIR/$APP_NAME-notarization.zip"
DMG_STAGE="$BUILD_DIR/dmg-stage"
RW_DMG="$BUILD_DIR/$APP_NAME-rw.dmg"
TMP_DMG="$BUILD_DIR/$APP_NAME.dmg"
RELEASE_DIR="$ROOT_DIR/releases"
DMG_PATH="$RELEASE_DIR/$APP_NAME.dmg"
MOUNT_POINT="$BUILD_DIR/dmg-mount"
usage() {
cat <<USAGE
Usage: $(basename "$0") [options]
Builds a Developer ID archive, exports the signed app, creates a drag-to-Applications
DMG, notarizes/staples it, verifies Gatekeeper, and writes SHA-256 checksums.
Nothing is uploaded to your website or update feed.
Options:
--notary-profile NAME Keychain profile created by notarytool (default: $NOTARY_PROFILE)
--output DIR Release output root (default: build/releases)
--skip-preflight Skip Scripts/release-preflight.sh
--skip-notarization Build/sign/package only; do not submit to Apple
-h, --help Show this help
Environment:
DEVELOPMENT_TEAM Optional Apple Developer Team ID. Recommended for CI.
NOTARY_PROFILE Alternative to --notary-profile.
RELEASE_ROOT Alternative to --output.
First-time notarization setup:
xcrun notarytool store-credentials "$NOTARY_PROFILE"
USAGE
for tool in xcodebuild xcrun codesign spctl ditto hdiutil shasum security swift sips /usr/libexec/PlistBuddy; do
command -v "$tool" >/dev/null || { echo "ERROR: Missing $tool" >&2; exit 1; }
done
if ! security find-identity -v -p codesigning | grep -F "$SIGNING_IDENTITY" >/dev/null; then
echo "ERROR: Developer ID identity not found: $SIGNING_IDENTITY" >&2; exit 1
fi
xcrun notarytool history --keychain-profile "$NOTARY_PROFILE" >/dev/null || {
echo "ERROR: Notarytool profile '$NOTARY_PROFILE' unavailable" >&2; exit 1;
}
while [[ $# -gt 0 ]]; do
case "$1" in
--notary-profile) [[ $# -ge 2 ]] || { echo "Missing value for $1" >&2; exit 2; }; NOTARY_PROFILE="$2"; shift 2 ;;
--output) [[ $# -ge 2 ]] || { echo "Missing value for $1" >&2; exit 2; }; RELEASE_ROOT="$2"; shift 2 ;;
--skip-preflight) SKIP_PREFLIGHT=1; shift ;;
--skip-notarization) SKIP_NOTARIZATION=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
esac
done
[[ "$(uname -s)" == "Darwin" ]] || { echo "ERROR: release.sh must run on macOS." >&2; exit 2; }
for tool in xcodebuild xcrun hdiutil codesign spctl plutil shasum ditto; do
command -v "$tool" >/dev/null || { echo "ERROR: Missing required tool: $tool" >&2; exit 2; }
done
PBX="$PROJECT/project.pbxproj"
VERSION="$(awk -F' = ' '/MARKETING_VERSION = / {gsub(/;/,"",$2); print $2; exit}' "$PBX")"
BUILD="$(awk -F' = ' '/CURRENT_PROJECT_VERSION = / {gsub(/;/,"",$2); print $2; exit}' "$PBX")"
[[ -n "$VERSION" && -n "$BUILD" ]] || { echo "ERROR: Could not determine version/build from Xcode project." >&2; exit 1; }
RELEASE_DIR="$RELEASE_ROOT/$VERSION-$BUILD"
ARCHIVE="$RELEASE_DIR/$PRODUCT_NAME.xcarchive"
EXPORT_DIR="$RELEASE_DIR/export"
DMG_STAGING="$RELEASE_DIR/dmg-root"
DMG="$RELEASE_DIR/$PRODUCT_NAME-$VERSION.dmg"
CHECKSUM="$DMG.sha256"
METADATA="$RELEASE_DIR/release-info.txt"
EXPORT_OPTIONS="$RELEASE_DIR/ExportOptions.plist"
if [[ -e "$RELEASE_DIR" ]]; then
echo "ERROR: Release directory already exists: $RELEASE_DIR" >&2
echo "Refusing to overwrite an existing version/build artifact." >&2
exit 1
fi
mkdir -p "$RELEASE_DIR"
cleanup() {
rm -rf "$DMG_STAGING"
}
trap cleanup EXIT
if [[ "$SKIP_PREFLIGHT" -eq 0 ]]; then
"$SCRIPT_DIR/release-preflight.sh"
fi
cd "$ROOT_DIR"
rm -rf "$BUILD_DIR"
mkdir -p "$BUILD_DIR" "$EXPORT_DIR" "$DMG_STAGE" "$RELEASE_DIR" "$MOUNT_POINT"
cat > "$EXPORT_OPTIONS" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>developer-id</string>
<key>signingStyle</key>
<string>automatic</string>
<key>stripSwiftSymbols</key>
<true/>
</dict>
</plist>
<plist version="1.0"><dict>
<key>method</key><string>developer-id</string>
<key>signingStyle</key><string>automatic</string>
<key>teamID</key><string>$TEAM_ID</string>
</dict></plist>
PLIST
plutil -lint "$EXPORT_OPTIONS" >/dev/null
XCODE_SIGN_ARGS=()
if [[ -n "${DEVELOPMENT_TEAM:-}" ]]; then
XCODE_SIGN_ARGS+=("DEVELOPMENT_TEAM=$DEVELOPMENT_TEAM")
echo '==> Archiving Rsync Studio with hardened runtime...'
xcodebuild -project "$PROJECT" -scheme "$SCHEME" -configuration Release \
-destination 'generic/platform=macOS' -archivePath "$ARCHIVE_PATH" \
DEVELOPMENT_TEAM="$TEAM_ID" CODE_SIGN_STYLE=Automatic \
ENABLE_HARDENED_RUNTIME=YES \
clean archive
echo '==> Exporting Developer ID app...'
xcodebuild -exportArchive -archivePath "$ARCHIVE_PATH" \
-exportPath "$EXPORT_DIR" -exportOptionsPlist "$EXPORT_OPTIONS"
[[ -d "$APP_PATH" ]] || { echo "ERROR: Missing exported app" >&2; exit 1; }
# Xcode's generated Info.plist can omit CFBundleIconFile even when the
# build setting specifies it. Set it on the exported bundle BEFORE the
# final Developer ID signature, so the signed app includes the icon key.
ICON_PATH="$APP_PATH/Contents/Resources/AppIcon.icns"
if [[ ! -s "$ICON_PATH" ]]; then
ICON_PATH="$(find "$APP_PATH/Contents/Resources" -maxdepth 1 -name "*.icns" -type f -print -quit)"
fi
echo "== Archive $DISPLAY_NAME $VERSION ($BUILD) =="
xcodebuild \
-project "$PROJECT" \
-scheme "$SCHEME" \
-configuration "$CONFIGURATION" \
-destination 'generic/platform=macOS' \
-archivePath "$ARCHIVE" \
clean archive \
"${XCODE_SIGN_ARGS[@]}"
echo "== Export Developer ID app =="
xcodebuild \
-exportArchive \
-archivePath "$ARCHIVE" \
-exportPath "$EXPORT_DIR" \
-exportOptionsPlist "$EXPORT_OPTIONS" \
-allowProvisioningUpdates
APP="$EXPORT_DIR/$PRODUCT_NAME.app"
[[ -d "$APP" ]] || { echo "ERROR: Exported app not found: $APP" >&2; exit 1; }
ACTUAL_BUNDLE_ID="$(plutil -extract CFBundleIdentifier raw -o - "$APP/Contents/Info.plist" 2>/dev/null || true)"
[[ "$ACTUAL_BUNDLE_ID" == "$BUNDLE_ID" ]] || { echo "ERROR: Exported bundle id is '$ACTUAL_BUNDLE_ID', expected '$BUNDLE_ID'." >&2; exit 1; }
"$SCRIPT_DIR/verify-signed-app.sh" "$APP"
if [[ "$SKIP_NOTARIZATION" -eq 0 ]]; then
echo "== Validate notarization credentials =="
xcrun notarytool history --keychain-profile "$NOTARY_PROFILE" >/dev/null
echo "== Notarize exported app =="
APP_ZIP="$RELEASE_DIR/$PRODUCT_NAME-$VERSION-notary.zip"
ditto -c -k --keepParent "$APP" "$APP_ZIP"
xcrun notarytool submit "$APP_ZIP" --keychain-profile "$NOTARY_PROFILE" --wait
xcrun stapler staple "$APP"
xcrun stapler validate "$APP"
rm -f "$APP_ZIP"
"$SCRIPT_DIR/verify-signed-app.sh" "$APP"
fi
echo "== Create drag-to-Applications DMG =="
mkdir -p "$DMG_STAGING"
ditto "$APP" "$DMG_STAGING/$PRODUCT_NAME.app"
ln -s /Applications "$DMG_STAGING/Applications"
cat > "$DMG_STAGING/README.txt" <<TXT
$DISPLAY_NAME $VERSION
Install: drag $DISPLAY_NAME to Applications.
Then eject this disk image and launch $DISPLAY_NAME from Applications.
TXT
hdiutil create \
-volname "$DISPLAY_NAME $VERSION" \
-srcfolder "$DMG_STAGING" \
-ov \
-format UDZO \
-imagekey zlib-level=9 \
"$DMG" >/dev/null
hdiutil verify "$DMG" >/dev/null
if [[ "$SKIP_NOTARIZATION" -eq 0 ]]; then
echo "== Notarize final DMG =="
xcrun notarytool submit "$DMG" --keychain-profile "$NOTARY_PROFILE" --wait
xcrun stapler staple "$DMG"
xcrun stapler validate "$DMG"
echo "== Final Gatekeeper verification =="
"$SCRIPT_DIR/verify-signed-app.sh" "$APP"
spctl --assess --type open --context context:primary-signature --verbose=4 "$DMG"
[[ -s "$ICON_PATH" ]] || { echo "ERROR: Missing bundled icon $ICON_PATH" >&2; exit 1; }
PLIST_PATH="$APP_PATH/Contents/Info.plist"
ICON_KEY="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIconFile' "$PLIST_PATH" 2>/dev/null || true)"
if [[ "$ICON_KEY" != "$(basename "$ICON_PATH" .icns)" && "$ICON_KEY" != "$(basename "$ICON_PATH")" ]]; then
echo "==> Setting CFBundleIconFile in exported app (before signing)..."
if /usr/libexec/PlistBuddy -c 'Print :CFBundleIconFile' "$PLIST_PATH" >/dev/null 2>&1; then
/usr/libexec/PlistBuddy -c 'Set :CFBundleIconFile $(basename "$ICON_PATH")' "$PLIST_PATH"
else
echo "WARNING: Notarization was skipped. This output is NOT ready for public distribution." >&2
/usr/libexec/PlistBuddy -c 'Add :CFBundleIconFile string $(basename "$ICON_PATH")' "$PLIST_PATH"
fi
fi
ICON_KEY="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIconFile' "$PLIST_PATH")"
[[ "$ICON_KEY" == "$(basename "$ICON_PATH" .icns)" || "$ICON_KEY" == "$(basename "$ICON_PATH")" ]] || {
echo "ERROR: CFBundleIconFile still incorrect: '$ICON_KEY'" >&2; exit 1;
}
echo "==> Application icon verified: $ICON_KEY"
sips -g pixelWidth -g pixelHeight "$ICON_PATH"
printf '%s %s\n' "$(shasum -a 256 "$DMG" | awk '{print $1}')" "$(basename "$DMG")" > "$CHECKSUM"
# Export may omit hardened runtime; sign exported app explicitly.
# Sign embedded code first if your project adds frameworks/helpers in future.
echo '==> Developer ID signing with hardened runtime...'
codesign --force --deep --options runtime --timestamp --sign "$SIGNING_IDENTITY" "$APP_PATH"
codesign --verify --deep --strict --verbose=2 "$APP_PATH"
SIGNATURE_INFO="$(codesign -dv --verbose=4 "$APP_PATH" 2>&1 || true)"
printf '%s\n' "$SIGNATURE_INFO" | grep -E 'Identifier=|Authority=|TeamIdentifier=|flags=' || true
[[ "$SIGNATURE_INFO" == *"TeamIdentifier=$TEAM_ID"* ]] || { echo 'ERROR: Wrong signing team' >&2; exit 1; }
[[ "$SIGNATURE_INFO" == *'(runtime)'* ]] || { echo 'ERROR: Hardened runtime missing' >&2; exit 1; }
DMG_SIZE="$(stat -f '%z' "$DMG")"
DMG_SHA256="$(awk '{print $1}' "$CHECKSUM")"
cat > "$METADATA" <<INFO
Product: $DISPLAY_NAME
Bundle ID: $BUNDLE_ID
Version: $VERSION
Build: $BUILD
Artifact: $(basename "$DMG")
Bytes: $DMG_SIZE
SHA-256: $DMG_SHA256
Notarized: $([[ "$SKIP_NOTARIZATION" -eq 0 ]] && echo yes || echo no)
Notary profile: $NOTARY_PROFILE
Generated (UTC): $(date -u '+%Y-%m-%dT%H:%M:%SZ')
INFO
echo '==> Notarizing and stapling app...'
ditto -c -k --keepParent "$APP_PATH" "$APP_ZIP"
xcrun notarytool submit "$APP_ZIP" --keychain-profile "$NOTARY_PROFILE" --wait
xcrun stapler staple "$APP_PATH"
xcrun stapler validate "$APP_PATH"
spctl --assess --type execute --verbose=4 "$APP_PATH"
rm -f "$EXPORT_OPTIONS"
echo '==> Preparing writable DMG...'
ditto "$APP_PATH" "$DMG_STAGE/$APP_NAME.app"
ln -s /Applications "$DMG_STAGE/Applications"
# Create writable image first: the custom volume icon must be set ON the
# mounted filesystem, not on the staging directory.
hdiutil create -size 100m -fs HFS+ -volname "$APP_NAME" -ov "$RW_DMG"
MOUNTED=0
cleanup() {
if [[ "$MOUNTED" == 1 ]]; then hdiutil detach "$MOUNT_POINT" -quiet || true; fi
}
trap cleanup EXIT
hdiutil attach "$RW_DMG" -nobrowse -noverify -mountpoint "$MOUNT_POINT" -quiet
MOUNTED=1
ditto "$DMG_STAGE/$APP_NAME.app" "$MOUNT_POINT/$APP_NAME.app"
ln -s /Applications "$MOUNT_POINT/Applications"
cp "$ICON_PATH" "$MOUNT_POINT/.VolumeIcon.icns"
if xcrun --find SetFile >/dev/null 2>&1; then xcrun SetFile -a C "$MOUNT_POINT"; else echo "WARNING: SetFile unavailable; skipping custom volume icon flag"; fi
hdiutil detach "$MOUNT_POINT" -quiet
MOUNTED=0
hdiutil convert "$RW_DMG" -format UDZO -o "$TMP_DMG" -ov
echo
echo "Release artifact created successfully:"
echo " $DMG"
echo " $CHECKSUM"
echo " $METADATA"
echo
echo "Publishing is intentionally NOT automated. Test the DMG on a clean Mac before upload."
# A DMG *file* icon is different from its mounted volume icon. Use AppKit to
# assign a Finder custom icon to the DMG file before signing/notarization.
# This creates Finder metadata (custom icon resource/finder flag).
ICON_SWIFT="$BUILD_DIR/set-dmg-file-icon.swift"
cat > "$ICON_SWIFT" <<'SWIFT'
import AppKit
import Foundation
let arguments = CommandLine.arguments
precondition(arguments.count == 3, "Usage: icon.swift <icon.icns> <file.dmg>")
guard let icon = NSImage(contentsOfFile: arguments[1]) else {
fputs("ERROR: Cannot load ICNS image\n", stderr); exit(1)
}
guard NSWorkspace.shared.setIcon(icon, forFile: arguments[2], options: []) else {
fputs("ERROR: NSWorkspace could not set DMG Finder icon\n", stderr); exit(1)
}
print("Custom Finder icon assigned to DMG file")
SWIFT
echo '==> Assigning Finder icon to DMG file...'
swift "$ICON_SWIFT" "$ICON_PATH" "$TMP_DMG"
echo '==> Signing and notarizing DMG...'
codesign --force --timestamp --sign "$SIGNING_IDENTITY" "$TMP_DMG"
codesign --verify --verbose=2 "$TMP_DMG"
xcrun notarytool submit "$TMP_DMG" --keychain-profile "$NOTARY_PROFILE" --wait
xcrun stapler staple "$TMP_DMG"
xcrun stapler validate "$TMP_DMG"
spctl --assess --type open --context context:primary-signature --verbose=4 "$TMP_DMG"
# Publish using ditto so Finder metadata/resource forks survive the copy.
ditto "$TMP_DMG" "$DMG_PATH"
echo '==> Release ready:'
ls -lh "$DMG_PATH"
shasum -a 256 "$DMG_PATH"
printf '\n========== RSYNC STUDIO RELEASE COMPLETE ==========\n%s\n' "$DMG_PATH"
-31
View File
@@ -1,31 +0,0 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
PROJECT="$ROOT/Rsync Studio.xcodeproj"
echo "== Rsync Studio project security checks =="
grep -q 'ENABLE_HARDENED_RUNTIME = YES' "$PROJECT/project.pbxproj"
grep -q 'ENABLE_APP_SANDBOX = NO' "$PROJECT/project.pbxproj"
grep -q 'INFOPLIST_KEY_CFBundleIconName = AppIcon' "$PROJECT/project.pbxproj"
test -f "$ROOT/Rsync Studio/Resources/PrivacyInfo.xcprivacy"
if grep -R --line-number -E 'StrictHostKeyChecking[ =]+no|UserKnownHostsFile[ =]+/dev/null|sshpass' "$ROOT/Rsync Studio" --include='*.swift'; then
echo "Unsafe SSH configuration detected." >&2
exit 1
fi
grep -q 'BatchMode=yes' "$ROOT/Rsync Studio/Services/RsyncCommandBuilder.swift"
grep -q 'allowUnattendedDelete' "$ROOT/Rsync Studio/Models/SyncJob.swift"
grep -q 'RsyncSecurityValidator' "$ROOT/Rsync Studio/Services/RsyncCommandBuilder.swift"
grep -q 'isSafeRemotePath' "$ROOT/Rsync Studio/Services/RsyncCommandBuilder.swift"
grep -q 'hasBalancedShellSyntax' "$ROOT/Rsync Studio/Services/RsyncCommandBuilder.swift"
grep -q -- '--files-from' "$ROOT/Rsync Studio/Services/RsyncCommandBuilder.swift"
grep -q -- '--log-file' "$ROOT/Rsync Studio/Services/RsyncCommandBuilder.swift"
echo "Static policy checks passed."
echo ""
echo "After creating a signed Release app, also run:"
echo " codesign --verify --deep --strict --verbose=2 /path/to/Rsync Studio.app"
echo " spctl --assess --type execute --verbose=4 /path/to/Rsync Studio.app"
echo " xcrun stapler validate /path/to/Rsync Studio.app"
-51
View File
@@ -1,51 +0,0 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
PBX="$ROOT/Rsync Studio.xcodeproj/project.pbxproj"
EXPECTED_BUNDLE_ID="net.absdev.RsyncStudio"
EXPECTED_VERSION="1.0.0"
EXPECTED_BUILD="1"
EXPECTED_MIN_MACOS="14.0"
fail() { echo "ERROR: $*" >&2; exit 1; }
pass() { echo "OK: $*"; }
[[ -f "$PBX" ]] || fail "Missing Xcode project file"
[[ $(grep -c "PRODUCT_BUNDLE_IDENTIFIER = $EXPECTED_BUNDLE_ID;" "$PBX") -ge 2 ]] || fail "Bundle identifier must remain $EXPECTED_BUNDLE_ID in all app configurations"
pass "Bundle identifier is $EXPECTED_BUNDLE_ID"
[[ $(grep -c "MARKETING_VERSION = $EXPECTED_VERSION;" "$PBX") -ge 2 ]] || fail "Marketing version must be $EXPECTED_VERSION"
pass "Marketing version is $EXPECTED_VERSION"
[[ $(grep -c "CURRENT_PROJECT_VERSION = $EXPECTED_BUILD;" "$PBX") -ge 2 ]] || fail "Build number must be $EXPECTED_BUILD"
pass "Build number is $EXPECTED_BUILD"
[[ $(grep -c 'ENABLE_HARDENED_RUNTIME = YES;' "$PBX") -ge 2 ]] || fail "Hardened Runtime must be enabled"
pass "Hardened Runtime is enabled"
[[ $(grep -c 'ENABLE_APP_SANDBOX = NO;' "$PBX") -ge 2 ]] || fail "App Sandbox baseline changed unexpectedly"
pass "App Sandbox remains intentionally disabled for direct distribution"
[[ $(grep -c "MACOSX_DEPLOYMENT_TARGET = $EXPECTED_MIN_MACOS;" "$PBX") -ge 2 ]] || fail "macOS deployment target must remain $EXPECTED_MIN_MACOS"
pass "Minimum macOS is $EXPECTED_MIN_MACOS"
[[ $(grep -c 'INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";' "$PBX") -ge 2 ]] || fail "Application category is missing"
pass "Application category is Utilities"
if grep -q 'CODE_SIGN_IDENTITY = "Apple Development"' "$PBX"; then
fail "A project-level Apple Development identity is pinned; Release distribution must use Developer ID Application"
fi
pass "No development certificate is pinned into the project"
if grep -q 'com.apple.security.get-task-allow' "$ROOT/Rsync Studio"/*.entitlements 2>/dev/null; then
fail "get-task-allow must never ship in a commercial Release build"
fi
pass "No get-task-allow entitlement is present in source entitlements"
cat <<'MSG'
Release identity baseline passed.
Before the first commercial archive, select your Apple Developer Team in Xcode and ensure the archived app is signed with a "Developer ID Application" certificate. The exact certificate identity is intentionally not committed to source control.
MSG
-44
View File
@@ -1,44 +0,0 @@
#!/bin/bash
set -euo pipefail
APP="${1:-}"
[[ -n "$APP" ]] || { echo "Usage: $0 /path/to/Rsync Studio.app" >&2; exit 2; }
[[ -d "$APP" ]] || { echo "ERROR: App not found: $APP" >&2; exit 2; }
for tool in codesign spctl; do
command -v "$tool" >/dev/null || { echo "ERROR: Missing required tool: $tool" >&2; exit 2; }
done
echo "== Verifying code signature =="
codesign --verify --deep --strict --verbose=2 "$APP"
echo "== Inspecting signing authority =="
SIGNING_INFO="$(codesign -dvvv "$APP" 2>&1)"
printf '%s\n' "$SIGNING_INFO"
grep -q 'Authority=Developer ID Application:' <<<"$SIGNING_INFO" || {
echo "ERROR: App is not signed with Developer ID Application." >&2
exit 1
}
echo "== Checking Hardened Runtime =="
grep -Eq '^CodeDirectory .* flags=.*runtime' <<<"$SIGNING_INFO" || {
echo "ERROR: Hardened Runtime flag was not found in the signed app." >&2
exit 1
}
echo "== Checking release entitlements =="
ENTITLEMENTS="$(codesign -d --entitlements :- "$APP" 2>/dev/null || true)"
if grep -q 'com.apple.security.get-task-allow' <<<"$ENTITLEMENTS"; then
echo "ERROR: Release app contains com.apple.security.get-task-allow." >&2
exit 1
fi
if grep -q '<true/>' <<<"$(printf '%s\n' "$ENTITLEMENTS" | sed -n '/com.apple.security.app-sandbox/,+1p')"; then
echo "ERROR: App Sandbox is unexpectedly enabled in the direct-distribution build." >&2
exit 1
fi
echo "== Gatekeeper assessment =="
spctl --assess --type execute --verbose=4 "$APP"
echo "Signed Developer ID app verification passed: $APP"
+18
View File
@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[{compose,docker-compose}.{yml,yaml}]
indent_size = 4
+87
View File
@@ -0,0 +1,87 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost:8000
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# Rsync Studio commercial/licensing
RSYNC_STUDIO_LICENSE_PRIVATE_KEY_PATH=
RSYNC_STUDIO_LICENSE_PUBLIC_KEY=
RSYNC_STUDIO_DEFAULT_EDITION=Professional
RSYNC_STUDIO_DEFAULT_MAX_MAJOR_VERSION=1
RSYNC_STUDIO_PURCHASE_URL=
RSYNC_STUDIO_PORTAL_URL=
RSYNC_STUDIO_SUPPORT_URL=
PADDLE_API_KEY=
PADDLE_API_URL=https://sandbox-api.paddle.com
PADDLE_CLIENT_SIDE_TOKEN=
PADDLE_ENVIRONMENT=sandbox
PADDLE_WEBHOOK_SECRET=
PADDLE_WEBHOOK_TOLERANCE=300
PADDLE_PERSONAL_PRODUCT_ID=
PADDLE_PERSONAL_MONTHLY_PRICE_ID=
PADDLE_PERSONAL_ANNUAL_PRICE_ID=
PADDLE_PROFESSIONAL_PRODUCT_ID=
PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID=
PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID=
LEMON_SQUEEZY_WEBHOOK_SECRET=
+11
View File
@@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore
+27
View File
@@ -0,0 +1,27 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.codex
/.cursor/
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/fonts-manifest.dev.json
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
_ide_helper.php
Homestead.json
Homestead.yaml
Thumbs.db
+2
View File
@@ -0,0 +1,2 @@
ignore-scripts=true
audit=true
+47
View File
@@ -0,0 +1,47 @@
<laravel-boost-guidelines>
# Laravel Application
This repository contains a Laravel application. Complete the following setup before working on the user's request.
## Prerequisites
Verify that PHP and Composer are available:
```sh
php -v
composer -V
```
If either command is unavailable, detect the user's operating system and install the prerequisites with the appropriate command:
macOS:
```sh
/bin/bash -c "$(curl -fsSL https://php.new/install/mac/8.5)"
```
Windows PowerShell:
```powershell
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://php.new/install/windows/8.5'))
```
Linux:
```sh
/bin/bash -c "$(curl -fsSL https://php.new/install/linux/8.5)"
```
After installation, ask the user to restart their terminal. If the agent needs the restarted shell to continue, ask the user to reopen their terminal and rerun their original prompt.
## Agent Setup
Install Laravel Boost from the application root before making application changes:
```sh
composer require laravel/boost --dev
php artisan boost:install
```
Boost replaces these bootstrap instructions with guidelines tailored to the application. After installation, read `AGENTS.md` again and continue with the user's original request using the generated guidelines.
</laravel-boost-guidelines>
+47
View File
@@ -0,0 +1,47 @@
<laravel-boost-guidelines>
# Laravel Application
This repository contains a Laravel application. Complete the following setup before working on the user's request.
## Prerequisites
Verify that PHP and Composer are available:
```sh
php -v
composer -V
```
If either command is unavailable, detect the user's operating system and install the prerequisites with the appropriate command:
macOS:
```sh
/bin/bash -c "$(curl -fsSL https://php.new/install/mac/8.5)"
```
Windows PowerShell:
```powershell
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://php.new/install/windows/8.5'))
```
Linux:
```sh
/bin/bash -c "$(curl -fsSL https://php.new/install/linux/8.5)"
```
After installation, ask the user to restart their terminal. If the agent needs the restarted shell to continue, ask the user to reopen their terminal and rerun their original prompt.
## Agent Setup
Install Laravel Boost from the application root before making application changes:
```sh
composer require laravel/boost --dev
php artisan boost:install
```
Boost replaces these bootstrap instructions with guidelines tailored to the application. After installation, read `AGENTS.md` again and continue with the user's original request using the generated guidelines.
</laravel-boost-guidelines>
+5
View File
@@ -0,0 +1,5 @@
# Rsync Studio Licensing Server
Production licensing backend for Rsync Studio. This directory is an overlay for a fresh Laravel 13 application and is intentionally kept separate from the macOS target.
See `../Docs/COMMERCIAL_RELEASE_STAGE4.md` for deployment, key management and payment-provider setup.
@@ -0,0 +1,36 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
final class RsyncStudioAdminCreate extends Command
{
protected $signature = 'rsync-studio:admin {email} {--name=Administrator}';
protected $description = 'Create or promote an Rsync Studio licensing administrator';
public function handle(): int
{
$email = strtolower(trim($this->argument('email')));
$password = Str::password(24, true, true, false, false);
$u = User::firstOrNew(['email' => $email]);
$u->name = $this->option('name');
$u->is_admin = true;
$u->email_verified_at = now();
if (! $u->exists) {
$u->password = Hash::make($password);
} $u->save();
$this->info("Administrator ready: {$email}");
if (! $u->wasRecentlyCreated) {
$this->line('Existing password retained.');
} else {
$this->warn("Temporary password (shown once): {$password}");
}
return self::SUCCESS;
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Console\Commands;
use App\Models\LicenseProduct;
use App\Services\Licensing\LicenseIssuer;
use Illuminate\Console\Command;
class RsyncStudioLicenseIssue extends Command
{
protected $signature = 'rsync-studio:license:issue {product} {--name=} {--email=}';
protected $description = 'Issue a manual Rsync Studio licence key';
public function handle(LicenseIssuer $issuer): int
{
$p = LicenseProduct::where('code', $this->argument('product'))->firstOrFail();
[$l,$key] = $issuer->issue($p, $this->option('name') ?: null, $this->option('email') ?: null);
if (! $key) {
$this->warn('Licence already existed; plaintext key is intentionally not recoverable.');
return self::FAILURE;
} $this->line('Licence ID: '.$l->id);
$this->line('Licence key: '.$key);
$this->warn('This is the only time the plaintext key is shown.');
return self::SUCCESS;
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Console\Commands;
use App\Models\License;
use Illuminate\Console\Command;
class RsyncStudioLicenseRevoke extends Command
{
protected $signature = 'rsync-studio:license:revoke {id} {--reason=Administrative revocation}';
protected $description = 'Revoke a Rsync Studio licence';
public function handle(): int
{
$l = License::findOrFail($this->argument('id'));
$l->update(['revoked_at' => now(), 'revoke_reason' => $this->option('reason')]);
$this->info('Licence revoked.');
return self::SUCCESS;
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Console\Commands;
use App\Models\LicenseProduct;
use Illuminate\Console\Command;
class RsyncStudioProductCreate extends Command
{
protected $signature = 'rsync-studio:product {code} {--name=Rsync Studio} {--edition=Professional} {--activations=1} {--max-major=1} {--provider=} {--product-id=} {--price-id=}';
protected $description = 'Create or update a Rsync Studio licensing product';
public function handle(): int
{
$p = LicenseProduct::updateOrCreate(['code' => $this->argument('code')], ['name' => $this->option('name'), 'edition' => $this->option('edition'), 'activation_limit' => (int) $this->option('activations'), 'max_major_version' => $this->option('max-major') !== '' ? (int) $this->option('max-major') : null, 'payment_provider' => $this->option('provider') ?: null, 'external_product_id' => $this->option('product-id') ?: null, 'external_price_id' => $this->option('price-id') ?: null, 'active' => true]);
$this->info("Product {$p->code} ready.");
return self::SUCCESS;
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\License;
use App\Models\LicenseActivation;
use App\Models\User;
use Illuminate\Http\Request;
final class AdminController extends Controller
{
public function index()
{
return view('admin.index', ['licenseCount' => License::count(), 'activeCount' => License::whereNull('revoked_at')->count(), 'activationCount' => LicenseActivation::whereNull('deactivated_at')->count(), 'userCount' => User::count()]);
}
public function licenses(Request $r)
{
$q = License::with('product')->latest();
if ($s = trim((string) $r->query('q'))) {
$q->where(fn ($x) => $x->where('customer_email', 'like', "%{$s}%")->orWhere('customer_name', 'like', "%{$s}%")->orWhere('license_key_last4', 'like', "%{$s}%"));
}
return view('admin.licenses', ['licenses' => $q->paginate(50)->withQueryString()]);
}
public function show(License $license)
{
$license->load(['product', 'activations']);
return view('admin.license', compact('license'));
}
public function revoke(Request $request, License $license)
{
$data = $request->validate(['reason' => ['nullable', 'string', 'max:255']]);
$license->update(['revoked_at' => now(), 'revoke_reason' => $data['reason'] ?: 'Revoked by administrator']);
return back()->with('status', 'Licence revoked.');
}
public function restore(License $license)
{
$license->update(['revoked_at' => null, 'revoke_reason' => null]);
return back()->with('status','Licence restored.');
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\SupportTicket;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
final class SupportTicketController extends Controller
{
public function index(Request $request)
{
$tickets = SupportTicket::query()
->with('assignee')
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
->latest()
->paginate(50)
->withQueryString();
return view('admin.support.index', compact('tickets'));
}
public function show(SupportTicket $ticket)
{
$ticket->load(['user', 'assignee']);
if (auth()->user()) {
auth()->user()->unreadNotifications()->get()
->filter(fn ($notification) => (int) ($notification->data['support_ticket_id'] ?? 0) === $ticket->id)
->each->markAsRead();
}
return view('admin.support.show', [
'ticket' => $ticket,
'admins' => User::query()->where('is_admin', true)->orderBy('name')->get(['id', 'name', 'email']),
]);
}
public function update(Request $request, SupportTicket $ticket): RedirectResponse
{
$data = $request->validate([
'status' => ['required', 'in:new,open,waiting,resolved,closed'],
'priority' => ['required', 'in:low,normal,high,urgent'],
'assigned_to' => ['nullable', 'integer', 'exists:users,id'],
]);
if ($data['assigned_to'] ?? null) {
abort_unless(User::whereKey($data['assigned_to'])->where('is_admin', true)->exists(), 422);
}
$data['resolved_at'] = in_array($data['status'], ['resolved', 'closed'], true)
? ($ticket->resolved_at ?? now())
: null;
$ticket->update($data);
return back()->with('status', 'Support ticket updated.');
}
}
@@ -0,0 +1,164 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\License;
use App\Models\LicenseActivation;
use App\Services\Licensing\LicenseKey;
use App\Services\Licensing\LicenseSigner;
use App\Services\Licensing\LicenseTokenVerifier;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class LicenseActivationController extends Controller
{
public function activate(Request $request, LicenseSigner $signer): JsonResponse
{
$data = $request->validate([
'licenseKey' => ['required', 'string', 'max:128'],
'installationID' => ['required', 'uuid'],
'appVersion' => ['required', 'string', 'max:32'],
'platform' => ['required', 'string', 'max:32'],
]);
return DB::transaction(function () use ($data, $signer) {
$license = License::where('license_key_hash', LicenseKey::hash($data['licenseKey']))
->lockForUpdate()
->first();
if (! $license) {
throw ValidationException::withMessages(['licenseKey' => 'The licence key is not valid.']);
}
if (! $license->is_usable) {
return response()->json(['message' => 'This licence is expired or has been revoked.'], 403);
}
$installationID = strtolower($data['installationID']);
$activation = LicenseActivation::where('license_id', $license->id)
->where('installation_id', $installationID)
->first();
if ($activation && $activation->deactivated_at === null) {
$activation->update([
'last_seen_at' => now(),
'platform' => $data['platform'],
'app_version' => $data['appVersion'],
]);
} else {
$activeCount = LicenseActivation::where('license_id', $license->id)
->whereNull('deactivated_at')
->count();
if ($activeCount >= $license->activation_limit) {
return response()->json(['message' => 'This licence has reached its activation limit. Deactivate another Mac first.'], 409);
}
if ($activation) {
$activation->update([
'deactivated_at' => null,
'activated_at' => now(),
'last_seen_at' => now(),
'platform' => $data['platform'],
'app_version' => $data['appVersion'],
]);
} else {
LicenseActivation::create([
'license_id' => $license->id,
'installation_id' => $installationID,
'platform' => $data['platform'],
'app_version' => $data['appVersion'],
'activated_at' => now(),
'last_seen_at' => now(),
]);
}
}
return response()->json([
'licenseToken' => $signer->sign($license, $data['installationID']),
]);
});
}
public function validateInstallation(
Request $request,
LicenseTokenVerifier $verifier,
LicenseSigner $signer,
): JsonResponse {
$data = $request->validate([
'licenseToken' => ['required', 'string', 'max:8192'],
'installationID' => ['required', 'uuid'],
'appVersion' => ['required', 'string', 'max:32'],
'platform' => ['required', 'string', 'max:32'],
]);
try {
$payload = $verifier->verify($data['licenseToken']);
} catch (\Throwable) {
return response()->json(['message' => 'The licence token is invalid.'], 400);
}
if (strcasecmp((string) ($payload['installationID'] ?? ''), $data['installationID']) !== 0) {
return response()->json(['message' => 'The installation does not match this licence token.'], 400);
}
return DB::transaction(function () use ($data, $payload, $signer) {
$license = License::whereKey((string) ($payload['licenseID'] ?? ''))
->lockForUpdate()
->first();
if (! $license || ! $license->is_usable) {
return response()->json(['message' => 'This licence is expired or has been revoked.'], 403);
}
$activation = LicenseActivation::where('license_id', $license->id)
->where('installation_id', strtolower($data['installationID']))
->first();
if (! $activation || $activation->deactivated_at !== null) {
return response()->json(['message' => 'This Mac has been deactivated from the licence portal.'], 403);
}
$activation->update([
'last_seen_at' => now(),
'platform' => $data['platform'],
'app_version' => $data['appVersion'],
]);
return response()->json([
'active' => true,
// Refreshing the signed token also applies subscription edition/expiry changes
// without requiring the user to reactivate manually.
'licenseToken' => $signer->sign($license, $data['installationID']),
]);
});
}
public function deactivate(Request $request, LicenseTokenVerifier $verifier): JsonResponse
{
$data = $request->validate([
'licenseToken' => ['required', 'string', 'max:8192'],
'installationID' => ['required', 'uuid'],
]);
try {
$payload = $verifier->verify($data['licenseToken']);
} catch (\Throwable) {
return response()->json(['message' => 'The licence token is invalid.'], 400);
}
if (strcasecmp((string) ($payload['installationID'] ?? ''), $data['installationID']) !== 0) {
return response()->json(['message' => 'The installation does not match this licence token.'], 400);
}
LicenseActivation::where('license_id', (string) ($payload['licenseID'] ?? ''))
->where('installation_id', strtolower($data['installationID']))
->whereNull('deactivated_at')
->update(['deactivated_at' => now(), 'last_seen_at' => now()]);
return response()->json((object) []);
}
}
@@ -0,0 +1,78 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\LicenseWebhookEvent;
use App\Services\Licensing\LicenseDeliveryService;
use App\Services\Payments\LemonSqueezyWebhookVerifier;
use App\Services\Payments\PaddleWebhookVerifier;
use App\Services\Payments\PurchaseProvisioner;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
final class PaymentWebhookController extends Controller
{
public function __invoke(string $provider, Request $request, PurchaseProvisioner $provisioner, LicenseDeliveryService $delivery): JsonResponse
{
$raw = (string) $request->getContent();
$verifier = match ($provider) {
'paddle' => app(PaddleWebhookVerifier::class),'lemon-squeezy' => app(LemonSqueezyWebhookVerifier::class),default => null
};
if (! $verifier) {
return response()->json(['message' => 'Unknown webhook provider.'], 404);
}
try {
$verifier->verify($request, $raw);
} catch (\Throwable $e) {
report($e);
return response()->json(['message' => 'Invalid webhook signature.'], 400);
}
try {
$payload = json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
} catch (\Throwable) {
return response()->json(['message' => 'Invalid JSON.'], 400);
}
$externalID = (string) ($provider === 'paddle' ? ($payload['event_id'] ?? '') : ($payload['meta']['event_name'] ?? '').':'.($payload['data']['id'] ?? ''));
$eventType = (string) ($provider === 'paddle' ? ($payload['event_type'] ?? 'unknown') : ($payload['meta']['event_name'] ?? 'unknown'));
if ($externalID === '') {
return response()->json(['message' => 'Webhook event has no stable identifier.'], 400);
}
try {
DB::transaction(function () use ($provider, $externalID, $eventType, $payload, $raw, $provisioner) {
$event = LicenseWebhookEvent::firstOrCreate(['provider' => $provider, 'external_event_id' => $externalID], ['event_type' => $eventType, 'payload_sha256' => hash('sha256', $raw), 'payload' => $payload]);
if ($event->processed_at) {
return;
}
if (! hash_equals($event->payload_sha256, hash('sha256', $raw))) {
throw new \RuntimeException('Webhook ID was reused with a different payload.');
}
try {
$provisioner->provision($provider, $payload);
$event->update(['processed_at' => now(), 'processing_error' => null]);
} catch (\Throwable $e) {
$event->update(['processing_error' => mb_substr($e->getMessage(), 0, 4000)]);
throw $e;
}
});
} catch (\Throwable $e) {
report($e);
return response()->json(['message' => 'Webhook could not be processed.'], 500);
}
// Delivery is intentionally outside the webhook transaction. A temporary mail
// outage must not roll back an otherwise valid Paddle event. Pending outbox
// rows remain available for portal resend or a later webhook delivery attempt.
try {
$delivery->deliverPending(10);
} catch (\Throwable $e) {
report($e);
}
return response()->json(['ok' => true]);
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
final class AuthenticatedSessionController extends Controller
{
public function create()
{
return view('auth.login');
}
public function store(Request $request)
{
$credentials = $request->validate(['email' => ['required', 'email'], 'password' => ['required', 'string']]);
if (! Auth::attempt(['email' => strtolower($credentials['email']), 'password' => $credentials['password']], $request->boolean('remember'))) {
return back()->withErrors(['email' => 'The supplied credentials are invalid.'])->onlyInput('email');
}
$request->session()->regenerate();
return redirect()->intended(route('portal.index'));
}
public function destroy(Request $request)
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login');
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\Request;
final class EmailVerificationController extends Controller
{
public function notice()
{
return view('auth.verify-email');
}
public function verify(EmailVerificationRequest $request)
{
$request->fulfill();
return redirect()->route('portal.index')->with('status', 'Email verified.');
}
public function resend(Request $request)
{
if (! $request->user()->hasVerifiedEmail()) {
$request->user()->sendEmailVerificationNotification();
}
return back()->with('status', 'Verification link sent.');
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password as PasswordRule;
final class PasswordController extends Controller
{
public function requestForm()
{
return view('auth.forgot-password');
}
public function email(Request $request)
{
$request->validate(['email' => ['required', 'email']]);
$status = Password::sendResetLink(['email' => strtolower($request->email)]);
return $status === Password::RESET_LINK_SENT ? back()->with('status', __($status)) : back()->withErrors(['email' => __($status)]);
}
public function resetForm(Request $request, string $token)
{
return view('auth.reset-password', ['token' => $token, 'email' => $request->query('email')]);
}
public function reset(Request $request)
{
$data = $request->validate(['token' => ['required'], 'email' => ['required', 'email'], 'password' => ['required', 'confirmed', PasswordRule::min(12)->letters()->mixedCase()->numbers()]]);
$status = Password::reset($data, function (User $user, string $password) {
$user->forceFill(['password' => $password, 'remember_token' => Str::random(60)])->save();
event(new PasswordReset($user));
});
return $status === Password::PASSWORD_RESET ? redirect()->route('login')->with('status', __($status)) : back()->withErrors(['email' => __($status)]);
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rules\Password;
final class RegisteredUserController extends Controller
{
public function create()
{
return view('auth.register');
}
public function store(Request $request)
{
$data = $request->validate(['name' => ['required', 'string', 'max:120'], 'email' => ['required', 'email', 'max:255', 'unique:users,email'], 'password' => ['required', 'confirmed', Password::min(12)->letters()->mixedCase()->numbers()]]);
$user = User::create(['name' => $data['name'], 'email' => strtolower($data['email']), 'password' => $data['password']]);
event(new Registered($user));
Auth::login($user);
$request->session()->regenerate();
return redirect()->route('verification.notice');
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Contracts\View\View;
use RuntimeException;
final class CheckoutController extends Controller
{
public function __invoke(): View
{
$paddle = config('rsync-studio.paddle');
$token = trim((string) ($paddle['client_side_token'] ?? ''));
if ($token === '') {
throw new RuntimeException('PADDLE_CLIENT_SIDE_TOKEN is not configured.');
}
$plans = [
'personal-monthly' => ['name' => 'Personal', 'billing' => 'Monthly', 'display_price' => '£1.99 / month', 'price_id' => $paddle['personal_monthly_price_id'], 'activation_limit' => 1],
'personal-annual' => ['name' => 'Personal', 'billing' => 'Annual', 'display_price' => '£14.99 / year', 'price_id' => $paddle['personal_annual_price_id'], 'activation_limit' => 1],
'professional-monthly' => ['name' => 'Professional', 'billing' => 'Monthly', 'display_price' => '£3.99 / month', 'price_id' => $paddle['professional_monthly_price_id'], 'activation_limit' => 3],
'professional-annual' => ['name' => 'Professional', 'billing' => 'Annual', 'display_price' => '£29.99 / year', 'price_id' => $paddle['professional_annual_price_id'], 'activation_limit' => 3],
];
foreach ($plans as $plan) {
if (trim((string) $plan['price_id']) === '') {
throw new RuntimeException('One or more Paddle price IDs are not configured.');
}
}
return view('checkout.buy', [
'plans' => $plans,
'paddleToken' => $token,
'paddleEnvironment' => (string) ($paddle['environment'] ?? 'sandbox'),
]);
}
}
@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
// Application controller base class.
}
@@ -0,0 +1,142 @@
<?php
namespace App\Http\Controllers\Portal;
use App\Http\Controllers\Controller;
use App\Models\License;
use App\Models\LicenseDeliveryOutbox;
use App\Models\LicenseProduct;
use App\Services\Payments\PaddleClient;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Mail;
use RuntimeException;
final class PortalController extends Controller
{
public function __construct(private PaddleClient $paddle) {}
private function owned(Request $request)
{
return License::with(['product', 'activations'])
->whereRaw('lower(customer_email) = ?', [strtolower($request->user()->email)]);
}
public function index(Request $request)
{
$licenses = $this->owned($request)->latest()->get();
$hasProfessional = $licenses->contains(fn (License $license) => $license->is_usable && $license->edition === 'Professional');
return view('portal.index', compact('licenses', 'hasProfessional'));
}
public function show(Request $request, License $license)
{
$this->authorise($request, $license);
$license->load(['product', 'activations']);
return view('portal.show', compact('license'));
}
public function upgrade(Request $request, License $license): RedirectResponse
{
$this->authorise($request, $license);
abort_unless($license->payment_provider === 'paddle' && $license->external_subscription_id, 422);
abort_unless($license->edition === 'Personal' && $license->is_usable, 422);
$targetPriceID = $this->professionalPriceFor($license);
$targetProduct = LicenseProduct::query()
->where('payment_provider', 'paddle')
->where('external_price_id', $targetPriceID)
->where('active', true)
->firstOrFail();
try {
$subscription = $this->paddle->updateSubscriptionPrice($license->external_subscription_id, $targetPriceID);
} catch (RuntimeException $exception) {
return back()->withErrors(['licence' => $exception->getMessage()]);
}
// Apply the entitlement immediately. Paddle webhooks remain authoritative and
// will reconcile subscription status/current-period details afterwards.
$license->update([
'license_product_id' => $targetProduct->id,
'edition' => $targetProduct->edition,
'activation_limit' => $targetProduct->activation_limit,
'max_major_version' => $targetProduct->max_major_version,
'external_price_id' => $targetPriceID,
'subscription_status' => (string) ($subscription['status'] ?? $license->subscription_status),
'revoked_at' => null,
'revoke_reason' => null,
]);
return redirect()->route('portal.show', $license)->with('status', 'Upgraded to Professional. Your existing Paddle subscription was changed; no second subscription was created.');
}
public function cancel(Request $request, License $license): RedirectResponse
{
$this->authorise($request, $license);
abort_unless($license->payment_provider === 'paddle' && $license->external_subscription_id, 422);
abort_unless(! in_array($license->subscription_status, ['canceled'], true), 422);
try {
$this->paddle->cancelSubscriptionImmediately($license->external_subscription_id);
} catch (RuntimeException $exception) {
return back()->withErrors(['licence' => $exception->getMessage()]);
}
$license->update([
'subscription_status' => 'canceled',
'expires_at' => now(),
'revoked_at' => now(),
'revoke_reason' => 'Subscription canceled from licence portal',
'cancel_scheduled_at' => null,
]);
return redirect()->route('portal.index')->with('status', 'Subscription canceled in Paddle and the licence is no longer active.');
}
public function deactivate(Request $request, License $license, int $activation)
{
$this->authorise($request, $license);
$item = $license->activations()->whereKey($activation)->firstOrFail();
if (! $item->deactivated_at) {
$item->update(['deactivated_at' => now()]);
}
return back()->with('status', 'Mac deactivated. Rsync Studio will detect this through the licensing API.');
}
public function resend(Request $request, License $license)
{
$this->authorise($request, $license);
$out = LicenseDeliveryOutbox::where('license_id', $license->id)->first();
if (! $out) {
return back()->withErrors(['licence' => 'This licence was issued manually and its full key is not recoverable. Contact support.']);
}
$plain = Crypt::decryptString($out->encrypted_license_key);
Mail::raw("Your Rsync Studio licence key is:\n\n{$plain}\n\nKeep this key private.", fn ($m) => $m->to($request->user()->email)->subject('Your Rsync Studio licence key'));
$out->update(['delivered_at' => now()]);
return back()->with('status', 'Licence key sent to your verified email address.');
}
private function authorise(Request $request, License $license): void
{
abort_unless(strtolower((string) $license->customer_email) === strtolower($request->user()->email), 404);
}
private function professionalPriceFor(License $license): string
{
$paddle = config('rsync-studio.paddle');
$current = (string) $license->external_price_id;
if ($current === (string) ($paddle['personal_annual_price_id'] ?? '')) {
return (string) ($paddle['professional_annual_price_id'] ?? '');
}
return (string) ($paddle['professional_monthly_price_id'] ?? '');
}
}
@@ -0,0 +1,92 @@
<?php
namespace App\Http\Controllers\Support;
use App\Http\Controllers\Controller;
use App\Models\SupportTicket;
use App\Models\User;
use App\Notifications\NewSupportTicket;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Str;
final class SupportController extends Controller
{
public function create(Request $request)
{
return view('support', [
'categories' => [
'technical' => 'Technical support',
'licensing' => 'Licence & activation',
'billing' => 'Billing & subscription',
'feature' => 'Feature request',
'privacy' => 'Privacy / data request',
'general' => 'General enquiry',
],
]);
}
public function store(Request $request): RedirectResponse
{
// Honeypot: bots tend to fill hidden fields. Return success without retaining spam.
if (filled($request->input('company_website'))) {
return back()->with('status', 'Your support request has been received.');
}
$data = $request->validate([
'name' => ['required', 'string', 'max:120'],
'email' => ['required', 'email:rfc', 'max:254'],
'category' => ['required', 'in:technical,licensing,billing,feature,privacy,general'],
'subject' => ['required', 'string', 'max:180'],
'message' => ['required', 'string', 'min:10', 'max:10000'],
'privacy_notice' => ['accepted'],
]);
$ticket = SupportTicket::create([
'user_id' => $request->user()?->id,
'reference' => $this->reference(),
'name' => $data['name'],
'email' => strtolower($data['email']),
'category' => $data['category'],
'subject' => $data['subject'],
'message' => $data['message'],
'privacy_notice_accepted_at' => now(),
]);
$this->notifyTeam($ticket);
Mail::raw(
"We received your Rsync Studio support request.\n\nReference: {$ticket->reference}\nSubject: {$ticket->subject}\n\nWe will reply to this email address. Please keep the reference in any follow-up.",
fn ($mail) => $mail->to($ticket->email)->subject("Rsync Studio support request {$ticket->reference}")
);
return redirect()->route('support')->with('status', "Support request {$ticket->reference} received. A confirmation has been emailed to you.");
}
private function notifyTeam(SupportTicket $ticket): void
{
if (config('rsync-studio.support.notify_admin_users', true)) {
User::query()->where('is_admin', true)->get()->each->notify(new NewSupportTicket($ticket));
}
$emails = collect(config('rsync-studio.support.alert_emails', []))
->filter(fn ($email) => filter_var($email, FILTER_VALIDATE_EMAIL))
->unique()
->values();
if ($emails->isNotEmpty()) {
Notification::route('mail', $emails->all())->notify(new NewSupportTicket($ticket));
}
}
private function reference(): string
{
do {
$reference = 'RS-'.now()->format('ymd').'-'.Str::upper(Str::random(6));
} while (SupportTicket::where('reference', $reference)->exists());
return $reference;
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class AdminOnly
{
public function handle(Request $request, Closure $next): Response
{
abort_unless($request->user()?->is_admin === true, 403);
return $next($request);
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class License extends Model
{
use HasUuids;
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = ['license_product_id', 'license_key_hash', 'license_key_last4', 'customer_name', 'customer_email', 'edition', 'activation_limit', 'max_major_version', 'expires_at', 'revoked_at', 'revoke_reason', 'payment_provider', 'external_order_id', 'external_customer_id', 'external_subscription_id', 'external_price_id', 'subscription_status', 'current_period_ends_at', 'cancel_scheduled_at'];
protected $casts = ['expires_at' => 'datetime', 'revoked_at' => 'datetime', 'activation_limit' => 'integer', 'max_major_version' => 'integer', 'current_period_ends_at' => 'datetime', 'cancel_scheduled_at' => 'datetime'];
public function product(): BelongsTo
{
return $this->belongsTo(LicenseProduct::class, 'license_product_id');
}
public function activations(): HasMany
{
return $this->hasMany(LicenseActivation::class);
}
public function getIsUsableAttribute(): bool
{
return $this->revoked_at === null && ($this->expires_at === null || $this->expires_at->isFuture());
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class LicenseActivation extends Model
{
public $timestamps = false;
protected $fillable = ['license_id', 'installation_id', 'installation_name', 'platform', 'app_version', 'activated_at', 'last_seen_at', 'deactivated_at'];
protected $casts = ['activated_at' => 'datetime', 'last_seen_at' => 'datetime', 'deactivated_at' => 'datetime'];
}
@@ -0,0 +1,14 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class LicenseDeliveryOutbox extends Model
{
protected $table = 'license_delivery_outbox';
protected $fillable = ['license_id', 'encrypted_license_key', 'recipient_email', 'delivered_at'];
protected $casts = ['delivered_at' => 'datetime'];
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class LicenseProduct extends Model
{
protected $fillable = ['code', 'name', 'edition', 'activation_limit', 'max_major_version', 'payment_provider', 'external_product_id', 'external_price_id', 'active'];
protected $casts = ['active' => 'boolean', 'activation_limit' => 'integer', 'max_major_version' => 'integer'];
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class LicenseWebhookEvent extends Model
{
protected $fillable = ['provider', 'external_event_id', 'event_type', 'payload_sha256', 'payload', 'processed_at', 'processing_error'];
protected $casts = ['payload' => 'array', 'processed_at' => 'datetime'];
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Prunable;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SupportTicket extends Model
{
use Prunable;
protected $fillable = [
'user_id', 'assigned_to', 'reference', 'name', 'email', 'category',
'subject', 'message', 'status', 'priority', 'privacy_notice_accepted_at',
'resolved_at',
];
protected function casts(): array
{
return [
'privacy_notice_accepted_at' => 'datetime',
'resolved_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function assignee(): BelongsTo
{
return $this->belongsTo(User::class, 'assigned_to');
}
public function prunable(): Builder
{
$days = max(30, (int) config('rsync-studio.privacy.support_retention_days', 730));
return static::query()
->whereNotNull('resolved_at')
->where('resolved_at', '<=', now()->subDays($days));
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable implements MustVerifyEmail
{
use HasFactory, Notifiable;
protected $fillable = ['name', 'email', 'password', 'is_admin'];
protected $hidden = ['password', 'remember_token'];
protected function casts(): array
{
return ['email_verified_at' => 'datetime', 'password' => 'hashed', 'is_admin' => 'boolean'];
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Notifications;
use App\Models\SupportTicket;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\AnonymousNotifiable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class NewSupportTicket extends Notification
{
use Queueable;
public function __construct(public readonly SupportTicket $ticket) {}
public function via(object $notifiable): array
{
return $notifiable instanceof AnonymousNotifiable ? ['mail'] : ['database', 'mail'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject("New Rsync Studio support request {$this->ticket->reference}")
->greeting('New support request')
->line("{$this->ticket->name} <{$this->ticket->email}>")
->line("Category: {$this->ticket->category}")
->line("Subject: {$this->ticket->subject}")
->line($this->ticket->message)
->action('Open support ticket', route('admin.support.show', $this->ticket));
}
public function toArray(object $notifiable): array
{
return [
'support_ticket_id' => $this->ticket->id,
'reference' => $this->ticket->reference,
'subject' => $this->ticket->subject,
'email' => $this->ticket->email,
];
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
final class RsyncStudioLicensingServiceProvider extends ServiceProvider
{
public function boot(): void
{
RateLimiter::for('license-activation', function (Request $request): array {
return [
Limit::perMinute(12)->by($request->ip()),
Limit::perMinute(6)->by(hash('sha256', (string) $request->input('installationID'))),
];
});
RateLimiter::for('login', fn (Request $request) => Limit::perMinute(5)
->by(strtolower((string) $request->input('email')).'|'.$request->ip()));
RateLimiter::for('registration', fn (Request $request) => Limit::perHour(5)->by($request->ip()));
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Services\Licensing;
use App\Models\LicenseDeliveryOutbox;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Mail;
final class LicenseDeliveryService
{
public function deliverPending(int $limit = 20): int
{
$delivered = 0;
$pending = LicenseDeliveryOutbox::query()
->whereNull('delivered_at')
->whereNotNull('recipient_email')
->where('recipient_email', '!=', '')
->oldest()
->limit(max(1, min($limit, 100)))
->get();
foreach ($pending as $outbox) {
$plain = Crypt::decryptString($outbox->encrypted_license_key);
$recipient = strtolower(trim((string) $outbox->recipient_email));
if ($recipient === '') {
continue;
}
Mail::raw(
"Thank you for choosing Rsync Studio.\n\nYour licence key is:\n\n{$plain}\n\nOpen Rsync Studio → Settings → Licence and enter this key to activate your Mac.\n\nKeep this key private. You can manage activations from the Rsync Studio licence portal.",
fn ($message) => $message->to($recipient)->subject('Your Rsync Studio licence key')
);
$outbox->update(['delivered_at' => now()]);
$delivered++;
}
return $delivered;
}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Services\Licensing;
use App\Models\License;
use App\Models\LicenseDeliveryOutbox;
use App\Models\LicenseProduct;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
final class LicenseIssuer
{
public function issue(LicenseProduct $product, ?string $name, ?string $email, ?string $provider = null, ?string $orderID = null, ?string $customerID = null, bool $queueDelivery = false): array
{
return DB::transaction(function () use ($product, $name, $email, $provider, $orderID, $customerID, $queueDelivery) {
if ($provider && $orderID) {
$existing = License::where('payment_provider', $provider)->where('external_order_id', $orderID)->first();
if ($existing) {
return [$existing, null];
}
}
$plain = LicenseKey::generate();
$license = License::create([
'license_product_id' => $product->id,
'license_key_hash' => LicenseKey::hash($plain),
'license_key_last4' => substr(LicenseKey::normalize($plain), -4),
'customer_name' => $name,
'customer_email' => $email ? strtolower(trim($email)) : null,
'edition' => $product->edition,
'activation_limit' => $product->activation_limit,
'max_major_version' => $product->max_major_version,
'payment_provider' => $provider,
'external_order_id' => $orderID,
'external_customer_id' => $customerID,
]);
if ($queueDelivery) {
LicenseDeliveryOutbox::updateOrCreate(
['license_id' => $license->id],
['encrypted_license_key' => Crypt::encryptString($plain), 'recipient_email' => $license->customer_email, 'delivered_at' => null]
);
}
return [$license, $plain];
});
}
}

Some files were not shown because too many files have changed in this diff Show More