Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1340c53cd5 | ||
|
|
b81829012a | ||
|
|
ac0c5410c1 | ||
|
|
26a2b448a5 | ||
|
|
7cde037a4b | ||
|
|
f10ed942c0 | ||
|
|
d541f492cf | ||
|
|
e802dd8cf8 | ||
|
|
a9bc0e5038 | ||
|
|
7fbbd42e79 | ||
|
|
b4aa94a389 | ||
|
|
5b9c19c52f |
@@ -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/
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 157 KiB |
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 596 B After Width: | Height: | Size: 596 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 157 KiB |
@@ -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,8 +19,11 @@ struct ContentView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
mainContent
|
||||
.opacity(showingSplash ? 0 : 1)
|
||||
Group {
|
||||
if licensing.canUseApp { mainContent }
|
||||
else { LicenseRequiredView() }
|
||||
}
|
||||
.opacity(showingSplash ? 0 : 1)
|
||||
|
||||
if showingSplash {
|
||||
SplashView()
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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."
|
||||
@@ -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"
|
||||
[[ -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
|
||||
/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"
|
||||
|
||||
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
|
||||
# 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; }
|
||||
|
||||
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
|
||||
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"
|
||||
|
||||
hdiutil verify "$DMG" >/dev/null
|
||||
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
|
||||
|
||||
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"
|
||||
# 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 "== Final Gatekeeper verification =="
|
||||
"$SCRIPT_DIR/verify-signed-app.sh" "$APP"
|
||||
spctl --assess --type open --context context:primary-signature --verbose=4 "$DMG"
|
||||
else
|
||||
echo "WARNING: Notarization was skipped. This output is NOT ready for public distribution." >&2
|
||||
fi
|
||||
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"
|
||||
|
||||
printf '%s %s\n' "$(shasum -a 256 "$DMG" | awk '{print $1}')" "$(basename "$DMG")" > "$CHECKSUM"
|
||||
|
||||
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
|
||||
|
||||
rm -f "$EXPORT_OPTIONS"
|
||||
|
||||
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."
|
||||
# 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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
ignore-scripts=true
|
||||
audit=true
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
});
|
||||
}
|
||||
}
|
||||