diff --git a/Docs/AUDIT.md b/Docs/AUDIT.md deleted file mode 100644 index 2cd53b0..0000000 --- a/Docs/AUDIT.md +++ /dev/null @@ -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. diff --git a/Docs/COMMERCIAL_RELEASE_STAGE1.md b/Docs/COMMERCIAL_RELEASE_STAGE1.md deleted file mode 100644 index 4cb1e70..0000000 --- a/Docs/COMMERCIAL_RELEASE_STAGE1.md +++ /dev/null @@ -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. diff --git a/Docs/COMMERCIAL_RELEASE_STAGE2.md b/Docs/COMMERCIAL_RELEASE_STAGE2.md deleted file mode 100644 index 9885e71..0000000 --- a/Docs/COMMERCIAL_RELEASE_STAGE2.md +++ /dev/null @@ -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/-`. 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. diff --git a/Docs/COMMERCIAL_RELEASE_STAGE3.md b/Docs/COMMERCIAL_RELEASE_STAGE3.md deleted file mode 100644 index 9fc4f22..0000000 --- a/Docs/COMMERCIAL_RELEASE_STAGE3.md +++ /dev/null @@ -1,97 +0,0 @@ -# Rsync Studio — Commercial Release Stage 3 - -Stage 3 adds the commercial licensing foundation and 30-day evaluation period. It deliberately does not add payment processing or a production licensing server yet. - -## Runtime behaviour - -- A new installation receives a 30-day trial on first launch. -- Trial start, last-seen time, installation UUID and installed licence token are stored in macOS Keychain. -- The installation UUID is random. Rsync Studio does not use the Mac serial number, MAC address or other hardware fingerprint. -- A backwards clock movement greater than five minutes causes the trial to pause with a clock-correction message rather than silently extending the trial. -- When the trial expires, background scheduling/file watching are stopped and the operational UI is replaced by the licence-required view. -- Jobs and history remain stored locally and are not deleted. -- A valid signed licence immediately re-enables the application. - -Deleting the application's Keychain entries can reset local-only trial state. A production licensing service can close that gap by recording trial/activation state server-side. Stage 3 intentionally avoids invasive hardware fingerprinting just to make a local trial impossible to reset. - -## Licence token design - -Tokens use: - -`RS3..` - -The payload contains licence ID, customer display information, edition, installation UUID, issue time, optional expiry and optional maximum major version. Rsync Studio verifies the signature locally with an embedded public key using CryptoKit. The private signing key never belongs in the app, Git repository, release archive or DMG. - -## Generate the signing keys - -Run once on a trusted Mac: - -`swift Scripts/license-keygen.swift` - -The script stores the private/public keypair under `~/Library/Application Support/Rsync Studio Licensing/`. Protect and back up the private key. The private key should later be moved into production secret storage used by the licensing service. - -Copy the printed public key into the Xcode build setting that generates: - -`RsyncStudioLicensePublicKey` - -The Stage 3 project contains an empty value by default so no secret/test key is accidentally shipped. - -## Create a test offline licence - -Copy the Installation ID shown in Settings → Licence, then run: - -`swift Scripts/license-sign.swift --installation-id "INSTALLATION-UUID" --name "Test Customer" --email "customer@example.com" --edition "Professional" --max-major 1` - -Paste the returned `RS3...` token into Settings → Licence → Install Signed Token. - -## Online activation API contract - -Set `RsyncStudioLicenseAPIBaseURL` to an HTTPS base URL, for example `https://licensing.example.com/`. Rsync Studio then uses: - -### POST /v1/activate - -Request: - -```json -{ - "licenseKey": "XXXX-XXXX-XXXX-XXXX", - "installationID": "random-uuid", - "appVersion": "1.0.0", - "platform": "macOS" -} -``` - -Success: - -```json -{ - "licenseToken": "RS3.payload.signature" -} -``` - -Errors should use a non-2xx response and preferably: - -```json -{ "message": "Human-readable error" } -``` - -### POST /v1/deactivate - -Request: - -```json -{ - "licenseToken": "RS3.payload.signature", - "installationID": "random-uuid" -} -``` - -A successful response may be `{}` or an empty 2xx body. - -## Information sent during activation - -Only the licence key, random installation UUID, application version and platform are sent for activation. Deactivation sends the signed licence token and installation UUID. Rsync Studio does not send sync paths, filenames, SSH hosts, SSH keys, credentials, job history, rsync logs or file contents. - -## Production boundary - -Stage 4 should implement the production licensing service and merchant-of-record/payment webhook integration, licence records, activation limits, signed-token issuance, revocation/deactivation and administrative tooling. The Ed25519 private key belongs there, not in Rsync Studio. diff --git a/Docs/COMMERCIAL_RELEASE_STAGE4.md b/Docs/COMMERCIAL_RELEASE_STAGE4.md deleted file mode 100644 index 3d1b510..0000000 --- a/Docs/COMMERCIAL_RELEASE_STAGE4.md +++ /dev/null @@ -1,125 +0,0 @@ -# Commercial Release Stage 4 — Licensing service and purchase provisioning - -Stage 4 adds a production Laravel 13 licensing backend under `Licensing Server/`. The server owns customer licence records, activation limits, revocation and purchase webhook processing. Rsync Studio itself never receives payment card data. - -## Security architecture - -- Plaintext licence keys are shown once and only a SHA-256 hash plus last four characters are stored. -- The Ed25519 private signing seed stays outside source control and outside the web root. -- The server signs an installation-specific `RS3` token; the Mac verifies it locally with the embedded public key. -- Activation is idempotent for the same installation and locked inside a database transaction to enforce machine limits safely under concurrency. -- Deactivation verifies the signed token before freeing a seat. -- Purchase webhook events are signature-verified against their raw request bodies before JSON parsing and are persisted idempotently. -- Refund/reversal events revoke the corresponding licence. -- No synced paths, filenames, SSH hosts, credentials, logs or file contents are handled by the licensing service. - -## Install into Laravel 13 - -Create a fresh private Laravel application on the server, then copy this overlay into it. Example: - -`composer create-project laravel/laravel:^13.0 rsync-studio-licensing` - -From the Rsync Studio source tree: - -`./"Licensing Server"/install-overlay.sh /var/www/rsync-studio-licensing` - -Add `App\\Providers\\RsyncStudioLicensingServiceProvider::class` to `bootstrap/providers.php`. Add `require __DIR__.'/api.stage4.php';` to `routes/api.php`, or rename `api.stage4.php` to your normal API routes file if it is a dedicated service. - -Ensure PHP Sodium is enabled, then migrate: - -`php artisan migrate` - -## Signing key - -Use the exact 32-byte Base64 private key created during Stage 3. Keep it outside the repository, for example `/etc/rsync-studio/license-private-key.txt`, readable only by the PHP-FPM account or a tightly controlled deployment group. - -Environment: - -`RSYNC_STUDIO_LICENSE_PRIVATE_KEY_PATH=/etc/rsync-studio/license-private-key.txt` - -`RSYNC_STUDIO_LICENSE_PUBLIC_KEY=THE_STAGE3_PUBLIC_KEY` - -Never put the private key into `.env` as a literal if your deployment tooling or diagnostics may expose environment values; the file path approach supports restrictive filesystem permissions and rotation procedures. - -## Create products - -Personal, one Mac: - -`php artisan rsync-studio:product personal --name="Rsync Studio Personal" --edition=Personal --activations=1 --max-major=1` - -Professional, three Macs: - -`php artisan rsync-studio:product professional --name="Rsync Studio Professional" --edition=Professional --activations=3 --max-major=1` - -Business, five Macs: - -`php artisan rsync-studio:product business-5 --name="Rsync Studio Business 5" --edition=Business --activations=5 --max-major=1` - -Manual support licence: - -`php artisan rsync-studio:license:issue professional --name="Customer Name" --email="customer@example.com"` - -The plaintext licence key is intentionally printed only at issue time. - -## Mac API configuration - -Set the signed application's generated Info.plist key `RsyncStudioLicenseAPIBaseURL` to your HTTPS endpoint, for example `https://licensing.example.com/api/`. - -The Mac uses: - -- `POST /api/v1/activate` -- `POST /api/v1/deactivate` - -Stage 3 already implements these client calls. - -## Paddle webhook - -Set: - -`PADDLE_WEBHOOK_SECRET=YOUR_NOTIFICATION_DESTINATION_SECRET` - -Configure Paddle to send verified webhooks to: - -`https://licensing.example.com/api/v1/webhooks/paddle` - -Create the local product mapping with the Paddle price or product ID, for example: - -`php artisan rsync-studio:product professional --name="Rsync Studio Professional" --edition=Professional --activations=3 --max-major=1 --provider=paddle --price-id=pri_xxxxx` - -The verifier uses the raw body, `Paddle-Signature`, timestamp plus `:` plus raw body, HMAC-SHA256 and a constant-time comparison. The default replay tolerance in this implementation is 300 seconds and can be changed with `PADDLE_WEBHOOK_TOLERANCE`. - -## Lemon Squeezy webhook - -Set: - -`LEMON_SQUEEZY_WEBHOOK_SECRET=YOUR_SIGNING_SECRET` - -Webhook URL: - -`https://licensing.example.com/api/v1/webhooks/lemon-squeezy` - -Map the Lemon Squeezy variant ID as the external price ID: - -`php artisan rsync-studio:product professional --name="Rsync Studio Professional" --edition=Professional --activations=3 --max-major=1 --provider=lemon-squeezy --price-id=YOUR_VARIANT_ID` - -The raw request body is HMAC-SHA256 signed with the configured secret and compared with the `X-Signature` header before processing. - -## Webhook delivery of the licence key - -Purchase-created plaintext keys are placed into `license_delivery_outbox` encrypted with Laravel's application encryption key. They are not logged and are not stored in plaintext. Stage 5 will consume that outbox to send the transactional licence email/customer portal response and then mark or purge the delivery record. Manual `rsync-studio:license:issue` keys are printed once and are not queued. This prevents Stage 4 purchases from creating an unrecoverable key before the delivery layer exists. - -## Deployment minimum - -- HTTPS only. -- Production Laravel settings and `APP_DEBUG=false`. -- MySQL/PostgreSQL with backups. -- Redis-backed rate limiting/cache recommended. -- Webhook routes excluded from CSRF only when placed in the API route stack. -- Restrict server/private-key permissions. -- Do not expose database/admin ports to the public Internet. -- Alert on failed webhooks and repeated activation abuse. -- Back up licence/customer data and the signing seed separately. - -## Stage boundary - -Stage 4 creates and protects the commercial licence source of truth. Stage 5 should add customer delivery and self-service: checkout website, transactional licence email, customer portal/resend/deactivate-all flow, support/admin UI, and a clean purchase button inside Rsync Studio. diff --git a/Docs/COMMERCIAL_RELEASE_STAGE5.md b/Docs/COMMERCIAL_RELEASE_STAGE5.md deleted file mode 100644 index f3cb2ba..0000000 --- a/Docs/COMMERCIAL_RELEASE_STAGE5.md +++ /dev/null @@ -1,47 +0,0 @@ -# Rsync Studio Commercial Release — Stage 5 - -Stage 5 adds the authenticated customer and administrator experience to the Stage 4 licensing API. - -## Security model -- Mac activation remains licence-key based and rate limited. -- Customer portal uses Laravel sessions, CSRF protection, password hashing, session regeneration and email verification. -- Portal licence ownership is matched only to the authenticated **verified** email address. -- Admin access requires an explicit `is_admin` database flag. -- Login and registration endpoints are throttled by the licensing service provider. -- Password reset uses Laravel's signed/reset-token flow. -- Full purchase-created licence keys remain encrypted at rest in `license_delivery_outbox`; they are never shown in the portal and may only be mailed to the verified owner. -- Manual licence keys remain non-recoverable by design. - -## Install / update -Run the overlay from the Rsync Studio repository root: - -`./"Licensing Server"/install-overlay.sh /path/to/rsync-studio-licensing` - -Then run: - -`php artisan migrate && php artisan optimize:clear` - -Create the first administrator: - -`php artisan rsync-studio:admin admin@example.com --name="Rsync Studio Admin"` - -The temporary password is printed once for a newly created admin. Sign in and change/reset it before production use. - -## Required production environment -Set HTTPS `APP_URL`, production mail settings, database/session/cache/queue settings, the Stage 4 signing keys and payment webhook secrets. Add: - -- `RSYNC_STUDIO_PURCHASE_URL` -- `RSYNC_STUDIO_PORTAL_URL` -- `RSYNC_STUDIO_SUPPORT_URL` - -The macOS app can embed those HTTPS addresses in its generated Info.plist. - -## Customer flow -1. Checkout provider posts a verified webhook. -2. Stage 4 provisions the licence and encrypts its one-time key in the delivery outbox. -3. Delivery mail sends the key to the purchase email. -4. Customer creates a portal account with that same email and verifies it. -5. Portal exposes licence status and active installations, allows seat deactivation and secure key resend when the encrypted delivery record exists. - -## Admin flow -The admin dashboard is behind `auth`, `verified`, and `AdminOnly` middleware. It supports search, licence inspection, activations, revoke and restore. diff --git a/Docs/PADDLE_SANDBOX_CHECKOUT.md b/Docs/PADDLE_SANDBOX_CHECKOUT.md index 0fe6618..9713c8d 100644 --- a/Docs/PADDLE_SANDBOX_CHECKOUT.md +++ b/Docs/PADDLE_SANDBOX_CHECKOUT.md @@ -42,3 +42,10 @@ The app is configured for: - 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. diff --git a/Docs/RELEASE.md b/Docs/RELEASE.md deleted file mode 100644 index 66bc689..0000000 --- a/Docs/RELEASE.md +++ /dev/null @@ -1,119 +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. - - -## Stage 4 licensing backend - -See `Docs/COMMERCIAL_RELEASE_STAGE4.md` and the `Licensing Server/` Laravel overlay for production activation, purchase provisioning and webhook handling. diff --git a/Rsync Studio.xcodeproj/project.pbxproj b/Rsync Studio.xcodeproj/project.pbxproj index f1dcee8..b01280e 100644 --- a/Rsync Studio.xcodeproj/project.pbxproj +++ b/Rsync Studio.xcodeproj/project.pbxproj @@ -253,10 +253,11 @@ ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "Rsync Studio/Info.plist"; 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_RsyncStudioLicenseAPIBaseURL = "https://rsync.stage.absdev.net/api/"; INFOPLIST_KEY_RsyncStudioLicensePublicKey = "8/rlCrS5mVEi4rGNXkw0Z22XkRpSH5+jPnD4f0GyV2k="; INFOPLIST_KEY_RsyncStudioPortalURL = ""; INFOPLIST_KEY_RsyncStudioPurchaseURL = ""; @@ -265,7 +266,6 @@ 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; @@ -366,10 +366,11 @@ ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "Rsync Studio/Info.plist"; 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_RsyncStudioLicenseAPIBaseURL = "https://rsync.stage.absdev.net/api/"; INFOPLIST_KEY_RsyncStudioLicensePublicKey = "8/rlCrS5mVEi4rGNXkw0Z22XkRpSH5+jPnD4f0GyV2k="; INFOPLIST_KEY_RsyncStudioPortalURL = ""; INFOPLIST_KEY_RsyncStudioPurchaseURL = ""; @@ -378,7 +379,6 @@ 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; diff --git a/Rsync Studio/Assets.xcassets/AccentColor.colorset/Contents.json b/Rsync Studio/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index 740fb4f..0000000 --- a/Rsync Studio/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors": [ - { - "idiom": "universal", - "color": { - "color-space": "srgb", - "components": { - "red": "0.078", - "green": "0.455", - "blue": "0.902", - "alpha": "1.000" - } - } - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} \ No newline at end of file diff --git a/Rsync Studio/Assets.xcassets/AppIcon.appiconset/Contents.json b/Rsync Studio/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index ccbe64b..0000000 --- a/Rsync Studio/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "images": [ - { - "idiom": "mac", - "size": "16x16", - "scale": "1x", - "filename": "appicon_16x16@1x.png" - }, - { - "idiom": "mac", - "size": "16x16", - "scale": "2x", - "filename": "appicon_16x16@2x.png" - }, - { - "idiom": "mac", - "size": "32x32", - "scale": "1x", - "filename": "appicon_32x32@1x.png" - }, - { - "idiom": "mac", - "size": "32x32", - "scale": "2x", - "filename": "appicon_32x32@2x.png" - }, - { - "idiom": "mac", - "size": "128x128", - "scale": "1x", - "filename": "appicon_128x128@1x.png" - }, - { - "idiom": "mac", - "size": "128x128", - "scale": "2x", - "filename": "appicon_128x128@2x.png" - }, - { - "idiom": "mac", - "size": "256x256", - "scale": "1x", - "filename": "appicon_256x256@1x.png" - }, - { - "idiom": "mac", - "size": "256x256", - "scale": "2x", - "filename": "appicon_256x256@2x.png" - }, - { - "idiom": "mac", - "size": "512x512", - "scale": "1x", - "filename": "appicon_512x512@1x.png" - }, - { - "idiom": "mac", - "size": "512x512", - "scale": "2x", - "filename": "appicon_512x512@2x.png" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} \ No newline at end of file diff --git a/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_128x128@1x.png b/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_128x128@1x.png deleted file mode 100644 index 0166945..0000000 Binary files a/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_128x128@1x.png and /dev/null differ diff --git a/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_128x128@2x.png b/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_128x128@2x.png deleted file mode 100644 index 0fce1cf..0000000 Binary files a/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_128x128@2x.png and /dev/null differ diff --git a/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_16x16@1x.png b/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_16x16@1x.png deleted file mode 100644 index de6a01b..0000000 Binary files a/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_16x16@1x.png and /dev/null differ diff --git a/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_16x16@2x.png b/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_16x16@2x.png deleted file mode 100644 index 37b29b9..0000000 Binary files a/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_16x16@2x.png and /dev/null differ diff --git a/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_256x256@1x.png b/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_256x256@1x.png deleted file mode 100644 index 0fce1cf..0000000 Binary files a/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_256x256@1x.png and /dev/null differ diff --git a/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_256x256@2x.png b/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_256x256@2x.png deleted file mode 100644 index 75e61eb..0000000 Binary files a/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_256x256@2x.png and /dev/null differ diff --git a/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_32x32@1x.png b/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_32x32@1x.png deleted file mode 100644 index 37b29b9..0000000 Binary files a/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_32x32@1x.png and /dev/null differ diff --git a/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_32x32@2x.png b/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_32x32@2x.png deleted file mode 100644 index be6fbfa..0000000 Binary files a/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_32x32@2x.png and /dev/null differ diff --git a/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_512x512@1x.png b/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_512x512@1x.png deleted file mode 100644 index 75e61eb..0000000 Binary files a/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_512x512@1x.png and /dev/null differ diff --git a/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_512x512@2x.png b/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_512x512@2x.png deleted file mode 100644 index f11c1f3..0000000 Binary files a/Rsync Studio/Assets.xcassets/AppIcon.appiconset/appicon_512x512@2x.png and /dev/null differ diff --git a/Rsync Studio/Assets.xcassets/Contents.json b/Rsync Studio/Assets.xcassets/Contents.json deleted file mode 100644 index c47b5f2..0000000 --- a/Rsync Studio/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info": { - "author": "xcode", - "version": 1 - } -} \ No newline at end of file diff --git a/Rsync Studio/ContentView.swift b/Rsync Studio/ContentView.swift index 5aa9c5b..d9c7602 100644 --- a/Rsync Studio/ContentView.swift +++ b/Rsync Studio/ContentView.swift @@ -6,6 +6,7 @@ struct ContentView: View { @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 @@ -43,12 +44,17 @@ struct ContentView: View { withAnimation(.easeOut(duration: 0.28)) { showingSplash = false } } .task { + await licensing.revalidateIfNeeded() while !Task.isCancelled { - try? await Task.sleep(for: .seconds(900)) + try? await Task.sleep(for: .seconds(LicensingManager.remoteValidationInterval)) if Task.isCancelled { return } - licensing.refresh() + await licensing.revalidateIfNeeded() } } + .onChange(of: scenePhase) { _, phase in + guard phase == .active else { return } + Task { await licensing.revalidateIfNeeded(force: true) } + } } private var mainContent: some View { diff --git a/Rsync Studio/Info.plist b/Rsync Studio/Info.plist deleted file mode 100644 index 5450062..0000000 --- a/Rsync Studio/Info.plist +++ /dev/null @@ -1,35 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - $(MARKETING_VERSION) - CFBundleVersion - $(CURRENT_PROJECT_VERSION) - LSMinimumSystemVersion - $(MACOSX_DEPLOYMENT_TARGET) - - RsyncStudioLicenseAPIBaseURL - https://rsync.stage.absdev.net/api/ - RsyncStudioLicensePublicKey - /QWCMp3he0bUM0VIYYmH9y1/BCncpUo4h/J8sM8gPKg= - RsyncStudioPurchaseURL - https://rsync.stage.absdev.net/buy - RsyncStudioPortalURL - https://rsync.stage.absdev.net/portal - RsyncStudioSupportURL - https://rsync.stage.absdev.net/support - - diff --git a/Rsync Studio/Models/SyncJob.swift b/Rsync Studio/Models/SyncJob.swift deleted file mode 100644 index a64f6ef..0000000 --- a/Rsync Studio/Models/SyncJob.swift +++ /dev/null @@ -1,110 +0,0 @@ -import Foundation -import SwiftData - -@Model -final class SyncJob { - var id: UUID - var name: String - var sourcePath: String - var destinationPath: String - var isRemote: Bool - var remoteHost: String - var remoteUser: String - var remotePort: Int - var sshKeyPath: String - var archive: Bool - var compress: Bool - var deleteExtraneous: Bool - var preservePermissions: Bool - var preserveExtendedAttributes: Bool - var showProgress: Bool - var exclusionsText: String - var additionalArguments: String - var isEnabled: Bool - var createdAt: Date - var updatedAt: Date - var lastRunAt: Date? - var lastExitCode: Int? - var scheduleEnabled: Bool = false - var scheduleKindRaw: String = SyncScheduleKind.manual.rawValue - var scheduleStartAt: Date = Date().addingTimeInterval(3600) - var scheduleIntervalMinutes: Int = 30 - var nextRunAt: Date? = nil - var watchEnabled: Bool = false - var watchPath: String = "" - var watchDebounceSeconds: Int = 3 - var lastWatchEventAt: Date? = nil - var allowUnattendedDelete: Bool = false - var retryCount: Int = 2 - var retryDelaySeconds: Int = 10 - var notifyOnFailure: Bool = true - var notifyOnSuccess: Bool = false - var requireExpectedVolume: Bool = false - var expectedVolumeIdentifier: String = "" - var minimumFreeSpaceMB: Int = 512 - - init( - name: String = "New Sync Job", - sourcePath: String = "", - destinationPath: String = "" - ) { - self.id = UUID() - self.name = name - self.sourcePath = sourcePath - self.destinationPath = destinationPath - self.isRemote = false - self.remoteHost = "" - self.remoteUser = "" - self.remotePort = 22 - self.sshKeyPath = "" - self.archive = true - self.compress = false - self.deleteExtraneous = false - self.preservePermissions = true - self.preserveExtendedAttributes = true - self.showProgress = true - self.exclusionsText = "" - self.additionalArguments = "" - self.isEnabled = true - self.createdAt = Date() - self.updatedAt = Date() - self.lastRunAt = nil - self.lastExitCode = nil - self.scheduleEnabled = false - self.scheduleKindRaw = SyncScheduleKind.manual.rawValue - self.scheduleStartAt = Date().addingTimeInterval(3600) - self.scheduleIntervalMinutes = 30 - self.nextRunAt = nil - self.watchEnabled = false - self.watchPath = "" - self.watchDebounceSeconds = 3 - self.lastWatchEventAt = nil - self.allowUnattendedDelete = false - self.retryCount = 2 - self.retryDelaySeconds = 10 - self.notifyOnFailure = true - self.notifyOnSuccess = false - self.requireExpectedVolume = false - self.expectedVolumeIdentifier = "" - self.minimumFreeSpaceMB = 512 - } - - var exclusions: [String] { - exclusionsText - .split(whereSeparator: \.isNewline) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - } - - var expectedVolumeDisplay: String { - expectedVolumeIdentifier.isEmpty ? "Not pinned" : expectedVolumeIdentifier - } - - var destinationDisplay: String { - if isRemote { - let userPrefix = remoteUser.isEmpty ? "" : "\(remoteUser)@" - return "\(userPrefix)\(remoteHost):\(destinationPath)" - } - return destinationPath - } -} diff --git a/Rsync Studio/Models/SyncRun.swift b/Rsync Studio/Models/SyncRun.swift deleted file mode 100644 index 378c078..0000000 --- a/Rsync Studio/Models/SyncRun.swift +++ /dev/null @@ -1,150 +0,0 @@ -import Foundation -import SwiftData - -@Model -final class SyncRun { - var id: UUID - var jobID: UUID - var jobName: String - var startedAt: Date - var finishedAt: Date? - var command: String - var output: String - var errorOutput: String - var exitCode: Int? - var wasDryRun: Bool - var wasCancelled: Bool - var triggerRaw: String = SyncRunTrigger.manual.rawValue - - init(job: SyncJob, command: String, wasDryRun: Bool, trigger: SyncRunTrigger = .manual) { - self.id = UUID() - self.jobID = job.id - self.jobName = job.name - self.startedAt = Date() - self.command = command - self.output = "" - self.errorOutput = "" - self.exitCode = nil - self.wasDryRun = wasDryRun - self.wasCancelled = false - self.triggerRaw = trigger.rawValue - } - - var trigger: SyncRunTrigger { SyncRunTrigger(rawValue: triggerRaw) ?? .manual } - var succeeded: Bool { exitCode == 0 && !wasCancelled } - var isRunning: Bool { finishedAt == nil } - - var duration: TimeInterval? { - guard let finishedAt else { return nil } - return finishedAt.timeIntervalSince(startedAt) - } - - var summary: RsyncSummary { - RsyncSummary.parse(output + "\n" + errorOutput) - } - - - static func sanitizedLog(_ value: String, maxBytes: Int = 2_000_000) -> String { - let stripped = stripANSI(value) - let redacted = stripped - .replacingOccurrences(of: #"(?i)--password-file(?:=|\s+)[^\s]+"#, with: "--password-file=[REDACTED]", options: .regularExpression) - .replacingOccurrences(of: #"(?i)(password|passwd|token|secret)=([^\s]+)"#, with: "$1=[REDACTED]", options: .regularExpression) - - let data = Data(redacted.utf8) - guard data.count > maxBytes else { return redacted } - let prefix = data.prefix(maxBytes) - return String(decoding: prefix, as: UTF8.self) + "\n\n[Log truncated by Rsync Studio for safety and storage limits.]\n" - } - - private static func stripANSI(_ value: String) -> String { - value.replacingOccurrences( - of: #"\u001B\[[0-9;?]*[ -/]*[@-~]"#, - with: "", - options: .regularExpression - ) - } - - var exportText: String { - var text = "Rsync Studio Run Log\n" - text += "Job: \(jobName)\n" - text += "Started: \(startedAt.formatted(date: .complete, time: .standard))\n" - if let finishedAt { text += "Finished: \(finishedAt.formatted(date: .complete, time: .standard))\n" } - text += "Trigger: \(trigger.title)\n" - text += "Dry run: \(wasDryRun ? "Yes" : "No")\n" - text += "\nCommand\n-------\n$ \(command)\n\nOutput\n------\n" - if !output.isEmpty { text += output } - if !errorOutput.isEmpty { - if !text.hasSuffix("\n") { text += "\n" } - text += "\n--- stderr ---\n\(errorOutput)" - } - if let exitCode { - if !text.hasSuffix("\n") { text += "\n" } - text += "\nExit code: \(exitCode)\n" - } - return text - } -} - -struct RsyncSummary: Equatable { - var numberOfFiles: String? - var numberOfFilesTransferred: String? - var totalFileSize: String? - var totalTransferredFileSize: String? - var unmatchedData: String? - var matchedData: String? - var fileListSize: String? - var fileListGenerationTime: String? - var fileListTransferTime: String? - var totalSent: String? - var totalReceived: String? - var transferRate: String? - var totalSizeAndSpeedup: String? - - var hasStatistics: Bool { - [ - numberOfFiles, - numberOfFilesTransferred, - totalFileSize, - totalTransferredFileSize, - totalSent, - totalReceived - ].contains { $0 != nil } - } - - static func parse(_ text: String) -> RsyncSummary { - var result = RsyncSummary() - - for rawLine in text.components(separatedBy: .newlines) { - let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) - guard !line.isEmpty else { continue } - - func value(after prefix: String) -> String? { - guard line.hasPrefix(prefix) else { return nil } - return String(line.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces) - } - - if let value = value(after: "Number of files:") { result.numberOfFiles = value; continue } - if let value = value(after: "Number of files transferred:") { result.numberOfFilesTransferred = value; continue } - if let value = value(after: "Total file size:") { result.totalFileSize = value; continue } - if let value = value(after: "Total transferred file size:") { result.totalTransferredFileSize = value; continue } - if let value = value(after: "Unmatched data:") { result.unmatchedData = value; continue } - if let value = value(after: "Matched data:") { result.matchedData = value; continue } - if let value = value(after: "File list size:") { result.fileListSize = value; continue } - if let value = value(after: "File list generation time:") { result.fileListGenerationTime = value; continue } - if let value = value(after: "File list transfer time:") { result.fileListTransferTime = value; continue } - if let value = value(after: "Total sent:") { result.totalSent = value; continue } - if let value = value(after: "Total received:") { result.totalReceived = value; continue } - - if line.hasPrefix("sent "), line.contains(" bytes/sec") { - result.transferRate = line - continue - } - - if line.hasPrefix("total size is "), line.contains("speedup is") { - result.totalSizeAndSpeedup = line - } - } - - return result - } -} diff --git a/Rsync Studio/Models/SyncSchedule.swift b/Rsync Studio/Models/SyncSchedule.swift deleted file mode 100644 index 0a13809..0000000 --- a/Rsync Studio/Models/SyncSchedule.swift +++ /dev/null @@ -1,142 +0,0 @@ -import Foundation - -enum SyncScheduleKind: String, CaseIterable, Identifiable, Codable { - case manual - case once - case hourly - case daily - case weekly - case monthly - case custom - - var id: String { rawValue } - - var title: String { - switch self { - case .manual: return "Manual only" - case .once: return "Once" - case .hourly: return "Hourly" - case .daily: return "Daily" - case .weekly: return "Weekly" - case .monthly: return "Monthly" - case .custom: return "Custom interval" - } - } - - var systemImage: String { - switch self { - case .manual: return "hand.tap" - case .once: return "calendar.badge.clock" - case .hourly: return "clock.arrow.circlepath" - case .daily: return "sun.max" - case .weekly: return "calendar" - case .monthly: return "calendar.circle" - case .custom: return "timer" - } - } -} - -enum SyncRunTrigger: String, Codable { - case manual - case scheduled - case watched - case syncAll - - var title: String { - switch self { - case .manual: return "Manual" - case .scheduled: return "Scheduled" - case .watched: return "File change" - case .syncAll: return "Sync All" - } - } - - var systemImage: String { - switch self { - case .manual: return "play.fill" - case .scheduled: return "calendar.badge.clock" - case .watched: return "eye.fill" - case .syncAll: return "arrow.triangle.2.circlepath" - } - } -} - -extension SyncJob { - var scheduleKind: SyncScheduleKind { - get { SyncScheduleKind(rawValue: scheduleKindRaw) ?? .manual } - set { scheduleKindRaw = newValue.rawValue } - } - - var effectiveWatchPath: String { - let trimmed = watchPath.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? sourcePath : trimmed - } - - var scheduleDescription: String { - guard scheduleEnabled, scheduleKind != .manual else { return "Manual" } - let time = scheduleStartAt.formatted(date: .omitted, time: .shortened) - switch scheduleKind { - case .manual: return "Manual" - case .once: return "Once · \(scheduleStartAt.formatted(date: .abbreviated, time: .shortened))" - case .hourly: return "Every hour" - case .daily: return "Daily · \(time)" - case .weekly: - return "Weekly · \(scheduleStartAt.formatted(.dateTime.weekday(.abbreviated))) · \(time)" - case .monthly: - return "Monthly · day \(Calendar.current.component(.day, from: scheduleStartAt)) · \(time)" - case .custom: - return "Every \(max(1, scheduleIntervalMinutes)) min" - } - } - - func calculateNextRun(after date: Date = Date()) -> Date? { - guard scheduleEnabled, scheduleKind != .manual else { return nil } - let calendar = Calendar.current - - switch scheduleKind { - case .manual: - return nil - case .once: - return scheduleStartAt > date ? scheduleStartAt : nil - case .hourly: - return nextInterval(after: date, seconds: 3600) - case .custom: - return nextInterval(after: date, seconds: TimeInterval(max(1, scheduleIntervalMinutes) * 60)) - case .daily: - let time = calendar.dateComponents([.hour, .minute, .second], from: scheduleStartAt) - return calendar.nextDate(after: date, matching: time, matchingPolicy: .nextTime) - case .weekly: - var components = calendar.dateComponents([.hour, .minute, .second], from: scheduleStartAt) - components.weekday = calendar.component(.weekday, from: scheduleStartAt) - return calendar.nextDate(after: date, matching: components, matchingPolicy: .nextTime) - case .monthly: - var components = calendar.dateComponents([.hour, .minute, .second], from: scheduleStartAt) - components.day = calendar.component(.day, from: scheduleStartAt) - return calendar.nextDate(after: date, matching: components, matchingPolicy: .nextTime) - } - } - - func occurrences(from start: Date, through end: Date, limit: Int = 400) -> [Date] { - guard scheduleEnabled, scheduleKind != .manual, start < end else { return [] } - var output: [Date] = [] - var cursor = start.addingTimeInterval(-1) - - if scheduleKind == .once { - return (scheduleStartAt >= start && scheduleStartAt < end) ? [scheduleStartAt] : [] - } - - while output.count < limit, let next = calculateNextRun(after: cursor), next < end { - if next >= start { output.append(next) } - cursor = next.addingTimeInterval(0.5) - } - return output - } - - private func nextInterval(after date: Date, seconds: TimeInterval) -> Date? { - guard seconds > 0 else { return nil } - if scheduleStartAt > date { return scheduleStartAt } - let elapsed = date.timeIntervalSince(scheduleStartAt) - let steps = floor(elapsed / seconds) + 1 - return scheduleStartAt.addingTimeInterval(steps * seconds) - } -} diff --git a/Rsync Studio/Resources/PrivacyInfo.xcprivacy b/Rsync Studio/Resources/PrivacyInfo.xcprivacy deleted file mode 100644 index 937278f..0000000 --- a/Rsync Studio/Resources/PrivacyInfo.xcprivacy +++ /dev/null @@ -1,23 +0,0 @@ - - - - - NSPrivacyTracking - - NSPrivacyTrackingDomains - - NSPrivacyCollectedDataTypes - - NSPrivacyAccessedAPITypes - - - NSPrivacyAccessedAPIType - NSPrivacyAccessedAPICategoryUserDefaults - NSPrivacyAccessedAPITypeReasons - - CA92.1 - - - - - diff --git a/Rsync Studio/Resources/Rsync Studio Help.md b/Rsync Studio/Resources/Rsync Studio Help.md deleted file mode 100644 index 91baef3..0000000 --- a/Rsync Studio/Resources/Rsync Studio Help.md +++ /dev/null @@ -1,575 +0,0 @@ -# Rsync Studio Help - -Rsync Studio is a macOS front end for `rsync`. It can run local copies, SSH-based remote synchronisation, dry runs, scheduled jobs, and file/folder watch jobs. Every run is recorded in History with the command, result and transfer statistics. - -## 1. Before You Start - -Rsync Studio looks for rsync in this order: - -1. `/opt/homebrew/bin/rsync` -2. `/usr/local/bin/rsync` -3. `/usr/bin/rsync` - -For the best compatibility with modern Linux servers, install a current rsync using Homebrew. The macOS supplied `/usr/bin/rsync` is older and behaves differently for some extended-attribute options. - -SSH jobs also require the normal macOS `ssh` client and network access to the remote computer. - -## 2. Create a Local Sync Job - -1. Select **New Sync Job**. -2. Give the job a descriptive name, for example `Documents Backup`. -3. Choose **Local** as the destination type. -4. Select the source folder. -5. Select the destination folder. -6. Choose the required rsync options. -7. Save the job. -8. Run **Dry Run** first, then use **Run Now** when the result looks correct. - -### Example - -Source: - -`/Users/mag/Documents/` - -Destination: - -`/Volumes/Backup/Documents/` - -A typical job using Archive mode is equivalent to: - -`rsync -a --human-readable --stats /Users/mag/Documents/ /Volumes/Backup/Documents/` - -### Trailing slash behaviour - -Rsync treats these differently: - -`/Users/mag/Documents/` - -copies the **contents** of Documents into the destination. - -`/Users/mag/Documents` - -copies the **Documents directory itself** into the destination. - -Check the source carefully before enabling Delete mode. - -## 3. Create an SSH Remote Job - -1. Create or edit a job. -2. Select **SSH Remote**. -3. Enter the remote host, for example `192.168.1.2` or `pegasus.local`. -4. Enter the SSH username, for example `mag`. -5. Set the SSH port, normally `22`. -6. Select a **private** SSH key if required. -7. Enter a remote destination path such as `/home/mag/backups/Documents`. -8. Save and run a Dry Run. - -### Correct remote example - -Host: `pegasus.local` - -Username: `mag` - -Destination: `/home/mag/backups/photos` - -The resulting destination is similar to: - -`mag@pegasus.local:/home/mag/backups/photos` - -### Incorrect remote destination - -Do not put the host address in the Destination field. These are wrong: - -`192.168.1.2` - -`mag@192.168.1.2` - -The Destination field must contain a path on the remote machine. - -## 4. SSH Keys - -Use the private key, not the `.pub` file. - -Correct examples: - -`~/.ssh/id_ed25519` - -`~/.ssh/id_rsa` - -Incorrect: - -`~/.ssh/id_ed25519.pub` - -If you accidentally select a `.pub` key and the matching private key exists beside it, Rsync Studio attempts to use the private key. If only the public key exists, the run is rejected. - -### Test SSH outside the app - -Before troubleshooting rsync, verify SSH itself works: - -`ssh -p 22 -i ~/.ssh/id_ed25519 mag@pegasus.local` - -If that command cannot connect, fix SSH/network/authentication first. - -## 5. Default Connection Settings - -Open **Rsync Studio > Settings > Connections** to configure defaults for new SSH jobs: - -- Default username -- Default host -- Default SSH port -- Default private key -- Default password storage - -Passwords are stored in macOS Keychain, not SwiftData or UserDefaults. Current rsync execution is designed primarily around SSH keys or an SSH agent; a stored password is not placed on the command line. - -## 6. Rsync Options - -### Archive - -Archive mode (`-a`) is the normal choice for directory backup/synchronisation. It recursively copies files and preserves common metadata. - -### Compress - -Compress (`-z`) can help on slower network links. It is often unnecessary for local copies or already-compressed files such as JPEG, ZIP and video. - -### Delete Extraneous Files - -Delete mode (`--delete`) removes destination files that no longer exist at the source. - -**Use Dry Run before enabling a destructive sync.** A mistaken source or destination combined with Delete can remove destination data. - -### Preserve Permissions - -Preserves permissions when Archive mode is not being used. - -### Extended Attributes - -On modern rsync, Rsync Studio uses `-X`. With Apple `/usr/bin/rsync`, local jobs use the Apple-compatible `-E` form. For SSH to modern Linux rsync, the Apple-only extended attribute flag is omitted to avoid an incompatible remote option. - -### Exclusions - -Add patterns for files or directories you do not want transferred. - -Examples: - -`.DS_Store` - -`*.tmp` - -`node_modules/` - -`.git/` - -### Additional arguments - -Advanced rsync options can be entered as additional arguments. Use this only when you understand the rsync option and its effect. - -Example: - -`--max-size=2G` - -## 7. Dry Run - -Dry Run adds `--dry-run`. Rsync reports what it **would** change without transferring or deleting files. - -Use Dry Run when: - -- Creating a new job -- Changing source or destination paths -- Enabling Delete -- Changing exclusions -- Changing SSH destinations -- Testing a large sync - -The summary window shows files checked, files that would transfer, data size and transfer statistics. - -## 8. Run Now - -Select a job and choose **Run Now**. Manual runs display a progress/result sheet. The complete stdout/stderr output is retained in History. - -Only one rsync process is run by the current runner at a time. If another run is already active, a second job waits for the automation manager or is rejected for a direct manual launch depending on how it was triggered. - -## 9. Scheduling Jobs - -Edit a job and enable **Schedule**. - -Available schedule types include: - -- Once -- Hourly -- Daily -- Weekly -- Monthly -- Custom interval - -Scheduled runs execute without displaying the manual progress sheet. Results still appear in History. - -### Daily example - -Job: `Nightly Documents` - -Schedule: Daily - -Start time: `23:30` - -Result: the job runs each day at approximately the configured application schedule time while Rsync Studio is running. - -### Weekly example - -Job: `Weekly Photos` - -Schedule: Weekly - -Choose the desired start date/time. The application calculates and displays the next run. - -### Custom interval example - -Set a custom interval of `30` minutes to request a run every 30 minutes. - -Avoid extremely short intervals for large jobs. A new run should not repeatedly start before the previous transfer has finished. - -## 10. Calendar - -Select **Calendar** at the top of the main window. - -The calendar shows scheduled jobs by date and allows you to review upcoming automated work. Use it to confirm that a newly edited schedule produces the next run you expected. - -If a job does not appear: - -1. Confirm the job is enabled. -2. Confirm Schedule is enabled for that job. -3. Confirm the start date is valid. -4. Return to the job and check the displayed **Next scheduled run** value. - -## 11. Watch for File or Folder Changes - -Edit a job and enable **File Watching**. - -Rsync Studio uses macOS FSEvents rather than repeatedly scanning the whole directory tree. - -### Watch the job source directory - -Leave the custom watch path empty. The job source is watched automatically. - -Example source: - -`/Users/mag/Developer/MyProject/` - -When changes occur beneath that directory, the watch service debounces the events and triggers the configured sync. - -### Watch one specific file - -Set the watch path to a specific file, for example: - -`/Users/mag/Documents/config.json` - -The service watches the containing directory and filters events so only changes affecting that file trigger the job. - -### Debounce - -Applications often save a file several times in quick succession. The debounce period groups those events into one rsync run. - -Example: - -Debounce: `5 seconds` - -Saving 10 files over a three-second period results in a single sync after activity settles, rather than 10 separate rsync processes. - -### Good watch-job examples - -- Sync a development project to a test server after edits. -- Mirror a document directory to a local backup disk. -- Upload generated reports when files change. -- Synchronise a configuration directory to another Mac or Linux machine. - -### Avoid watch loops - -Do not configure the destination inside the watched source tree. Otherwise rsync's own writes can generate more filesystem events and repeatedly trigger the job. - -Bad example: - -Source: `/Users/mag/Data/` - -Destination: `/Users/mag/Data/Backup/` - -Prefer a destination outside the watched source hierarchy. - -## 12. Background Operation - -Scheduled and file-watch jobs run without opening the manual progress sheet. The application must still be running for its scheduler and FSEvents watchers to operate. - -Enable **Launch at Login** in Settings if you want Rsync Studio to start automatically after you sign in to macOS. You can then close or minimise the main window while the app continues running. - -Quitting Rsync Studio stops in-process schedules and file watching until the application starts again. - -## 13. Jobs Screen - -The Jobs screen is the main status dashboard. Each job shows its key automation and run state. - -Use the filters to review: - -- All jobs -- Scheduled jobs -- Watching jobs -- Jobs with issues - -Select a job to open its full detail view. The **Edit Job** button remains fixed at the top of the detail pane so it is available even after scrolling or on narrower windows. - -## 14. History - -Select **History** to review completed runs. - -History records whether the run was started by: - -- Manual action -- Schedule -- File change - -Select a run to inspect: - -- Start/end time -- Exit status -- rsync command -- Transfer summary -- stdout -- stderr -- Dry-run state - -Use **Export Log** from the run result/detail view when you need a text copy for troubleshooting or auditing. - -## 15. Common Rsync Exit Codes - -`0` — Success. - -`1` — Syntax or usage error. - -`2` — Protocol incompatibility. - -`3` — File selection/input-output errors. - -`10` — Socket I/O error. - -`11` — File I/O error. - -`12` — Error in rsync protocol data stream. - -`23` — Partial transfer due to error. - -`24` — Partial transfer because source files vanished during the run. - -`30` — Timeout in data send/receive. - -Always inspect stderr in History for the actual diagnostic message. - -## 16. Troubleshooting - -### Permission denied - -Check that the macOS user running Rsync Studio can read the source and write to the destination. For remote jobs, check permissions for the remote SSH user. - -### Host key verification failed - -Connect once using Terminal: - -`ssh mag@pegasus.local` - -Verify the host fingerprint and allow SSH to update `~/.ssh/known_hosts`. - -Do not disable host-key verification simply to hide this warning. - -### Public key authentication failed - -Check: - -- The private key path is correct. -- The remote user's `~/.ssh/authorized_keys` contains the matching public key. -- File permissions on the remote `.ssh` directory/key files are valid. -- The SSH username is correct. - -### Remote rsync option error - -If you are using `/usr/bin/rsync` on macOS against modern Linux, avoid manually adding Apple-specific rsync options. Installing Homebrew rsync provides better modern compatibility. - -### Nothing transfers - -Run a Dry Run and inspect the command and output. Check exclusions, trailing slash behaviour and whether source/destination already contain identical data. - -### Watch job does not trigger - -Check that: - -- The job is enabled. -- Global file watching is enabled in Settings. -- File Watching is enabled on the job. -- The watch path exists. -- Rsync Studio is still running. -- The change occurred beneath the watched path, or matches the selected single file. - -### Schedule does not run - -Check that: - -- Global scheduling is enabled in Settings. -- The job itself is enabled. -- Schedule is enabled for the job. -- The displayed next-run time is correct. -- Rsync Studio is running at the scheduled time. - -## 17. Recommended Backup Workflow - -For an important backup job: - -1. Create the job without Delete enabled. -2. Run a Dry Run. -3. Review the source and destination in the generated command. -4. Run the job manually. -5. Check History for exit code 0. -6. Verify several files at the destination. -7. Only then enable a schedule. -8. Enable Delete only when you specifically require a mirror and understand the deletion behaviour. - -Rsync synchronisation is not automatically a versioned backup. If the destination mirrors source deletions, it does not replace a separate versioned/offline backup strategy. - -## 18. Example Configurations - -### Local external-drive backup - -Name: `Documents to USB` - -Source: `/Users/mag/Documents/` - -Destination: `/Volumes/Backup/Documents/` - -Archive: On - -Delete: Off initially - -Schedule: Daily at 22:00 - -### Mac to Linux server - -Name: `Projects to Pegasus` - -Source: `/Users/mag/Developer/` - -Host: `pegasus.local` - -User: `mag` - -Port: `22` - -Private key: `/Users/mag/.ssh/id_ed25519` - -Remote destination: `/home/mag/backups/Developer/` - -Archive: On - -Compress: On - -Schedule: Daily - -### Live project watcher - -Name: `Website Live Sync` - -Source: `/Users/mag/Sites/MySite/` - -Remote destination: `/var/www/mysite/` - -Watch: On - -Watch path: leave empty to watch the source - -Debounce: 5 seconds - -Exclusions: `.git/`, `node_modules/`, `.DS_Store` - -Use this style only when automatic deployment to that destination is intentional. - -## 19. Safety Checklist - -Before running a new job, confirm: - -- Source is correct. -- Destination is correct. -- Remote host/user are correct. -- The selected SSH key is private, not `.pub`. -- Delete mode is intentional. -- Exclusions are correct. -- Dry Run output looks correct. -- The destination has enough free space. -- A watch destination is not inside its watched source. - -## 20. Getting Help Information from the App - -Select the **Help** button in the main Rsync Studio header. The bundled guide you are reading opens inside the application and is available without an Internet connection. - -For an rsync-specific option not documented here, run `man rsync` in Terminal for the version of rsync installed on your Mac. Behaviour can differ between the Apple-supplied rsync and current Homebrew rsync. - - -## Sync All - -Use **Sync All** in the Jobs sidebar to run every enabled job in sequence. Rsync Studio queues the jobs rather than starting several rsync processes at once. Each job creates its own History entry, and the header shows the current batch progress. Disabled jobs are skipped. - - -# Security and unattended operation - -Rsync Studio is designed for direct Developer ID distribution. It uses the macOS Hardened Runtime and intentionally does not use the App Sandbox because the utility must launch `rsync`/`ssh` and work with arbitrary user-selected paths. - -## SSH security - -SSH authentication uses a private key or your normal macOS SSH agent. Rsync Studio does not store SSH passwords and does not place passwords on a process command line. Automated SSH uses `BatchMode=yes`, a connection timeout, keepalive intervals and a finite keepalive failure count. Host-key verification is never disabled; connect to a new host once in Terminal first if SSH needs you to approve its host key. - -Example: - -```text -ssh -p 22 -i ~/.ssh/id_ed25519 mag@pegasus.local -``` - -## Protected advanced arguments - -The Advanced field is for ordinary rsync tuning only. Rsync Studio rejects arguments that can replace the SSH transport, run a different remote command, read options/file lists from other files, write logs/batches to arbitrary paths, load a password file, enter daemon mode, remove source files or bypass the app's controlled delete switch. Examples that are rejected include `-e`, `-M`, `--rsh`, `--rsync-path`, `--password-file`, `--files-from`, `--log-file`, `--write-batch`, `--remove-source-files` and `--delete*`. Unmatched quotes and trailing escape characters are also rejected. - -## Delete protection - -Manual `--delete` runs always show a destructive-operation warning. If a scheduled or file-watch job uses Delete, you must also explicitly enable **Allow unattended delete** in the job editor. This prevents a job from becoming destructively automated merely because Schedule or File Watch was enabled later. - -Run a Dry Run and review the destination before authorising unattended delete. - -## Path overlap protection - -For local jobs, source and destination must be separate directory trees. Rsync Studio rejects configurations where the destination is inside the source or the source is inside the destination. This prevents recursive backup trees and common file-watch loops. - -## History and privacy - -Rsync Studio does not include analytics or tracking. Run output is sanitised before persistence, terminal control sequences are removed, likely credential-shaped values are redacted, and each stdout/stderr log is capped at approximately 2 MB. History retention is configurable in **Settings > Automation > History & Privacy** and defaults to 90 days. - -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. - - -# Production Reliability - -## Test Job / Preflight -Use **Test Job** before relying on unattended automation. Local jobs verify configuration, rsync availability, source reachability, destination volume identity, and configured free-space reserve. SSH jobs verify batch authentication, remote rsync availability, and remote destination access. - -## External volume protection -For removable drives, enable **Require this exact destination volume** and capture the mounted destination. If that volume is absent, Rsync Studio refuses to sync to another filesystem that happens to appear at the same path. - -## Retries and notifications -Scheduled and watched jobs can retry automatically with increasing delays. Per-job notifications can report failures and, optionally, successful completion. Remote queued jobs wait while the Mac is offline and resume when network connectivity returns. - -## Sync All -Sync All runs enabled jobs sequentially, displays succeeded/failed/skipped totals, and can be cancelled from the Jobs dashboard. - -## Configuration backup -Use the Jobs menu to export or import a versioned JSON job configuration. Private SSH-key paths are intentionally omitted. Imported remote jobs are disabled until authentication is confirmed on that Mac. - -## Diagnostics -Settings → General → **Export Diagnostics…** creates a redacted support report containing application, macOS and rsync versions, job status and recent failures without exporting SSH private-key paths or credentials. - -## Updates -The built-in update checker accepts only an HTTPS manifest signed with the Ed25519 public key embedded in the signed app. Until a production feed and key are configured, update checking remains safely disabled. diff --git a/Rsync Studio/Rsync StudioApp.swift b/Rsync Studio/Rsync StudioApp.swift deleted file mode 100644 index bc7cbba..0000000 --- a/Rsync Studio/Rsync StudioApp.swift +++ /dev/null @@ -1,88 +0,0 @@ -import SwiftUI -import SwiftData - -@main -@MainActor -struct RsyncStudioApp: App { - let sharedModelContainer: ModelContainer - let startupStorageError: String? - @StateObject private var runner: RsyncRunner - @StateObject private var automation: BackgroundSyncCoordinator - @StateObject private var licensing: LicensingManager - - init() { - let schema = Schema([ - SyncJob.self, - SyncRun.self, - ]) - let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) - KeychainStore.delete("defaultSSHPassword") - _licensing = StateObject(wrappedValue: LicensingManager()) - - do { - let container = try ModelContainer(for: schema, configurations: [configuration]) - sharedModelContainer = container - startupStorageError = nil - let rsyncRunner = RsyncRunner() - _runner = StateObject(wrappedValue: rsyncRunner) - _automation = StateObject(wrappedValue: BackgroundSyncCoordinator(modelContext: container.mainContext, runner: rsyncRunner)) - } catch { - let fallbackConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) - do { - let fallback = try ModelContainer(for: schema, configurations: [fallbackConfiguration]) - sharedModelContainer = fallback - startupStorageError = "Rsync Studio could not open its persistent database and is running with temporary in-memory storage for this session. Jobs or history created now will not persist. Original error: \(error.localizedDescription)" - let rsyncRunner = RsyncRunner() - _runner = StateObject(wrappedValue: rsyncRunner) - _automation = StateObject(wrappedValue: BackgroundSyncCoordinator(modelContext: fallback.mainContext, runner: rsyncRunner)) - } catch { - fatalError("Rsync Studio could not initialise persistent or temporary storage: \(error)") - } - } - } - - var body: some Scene { - WindowGroup { - ContentView(startupStorageError: startupStorageError) - .frame(minWidth: 1040, minHeight: 680) - .environmentObject(runner) - .environmentObject(automation) - .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 { - RsyncStudioCommands() - } - - Window("Rsync Studio Help", id: "help") { - HelpView() - } - .defaultSize(width: 940, height: 780) - - Settings { - SettingsView() - .environmentObject(automation) - .environmentObject(licensing) - } - } -} - -private struct RsyncStudioCommands: Commands { - @Environment(\.openWindow) private var openWindow - - var body: some Commands { - CommandGroup(replacing: .help) { - Button("Rsync Studio Help") { - openWindow(id: "help") - } - .keyboardShortcut("?", modifiers: [.command]) - } - } -} diff --git a/Rsync Studio/Services/BackgroundSyncCoordinator.swift b/Rsync Studio/Services/BackgroundSyncCoordinator.swift deleted file mode 100644 index 0920bdf..0000000 --- a/Rsync Studio/Services/BackgroundSyncCoordinator.swift +++ /dev/null @@ -1,370 +0,0 @@ -import Foundation -import SwiftData -import Network -@preconcurrency import UserNotifications - -@MainActor -final class BackgroundSyncCoordinator: ObservableObject { - enum Activity: Equatable { - case idle - case waiting(String) - case running(String) - - var title: String { - switch self { - case .idle: return "Idle" - case .waiting(let name): return "Waiting · \(name)" - case .running(let name): return "Running · \(name)" - } - } - } - - @Published private(set) var activity: Activity = .idle - @Published private(set) var watchedJobCount = 0 - @Published private(set) var scheduledJobCount = 0 - @Published private(set) var isSyncAllRunning = false - @Published private(set) var syncAllCompleted = 0 - @Published private(set) var syncAllTotal = 0 - @Published private(set) var syncAllFailed = 0 - @Published private(set) var syncAllSkipped = 0 - @Published private(set) var isNetworkAvailable = true - @Published private(set) var lastError: String? - - var activityDisplayTitle: String { - if isSyncAllRunning { - return "Sync All · \(min(syncAllCompleted + 1, syncAllTotal)) of \(syncAllTotal)" - } - return activity.title - } - - private let modelContext: ModelContext - private let runner: RsyncRunner - private let watcher = FileWatchService() - private var timer: Timer? - private var pending: [(UUID, SyncRunTrigger)] = [] - private var pendingIDs = Set() - private var isProcessingQueue = false - private var watchDebounceTasks: [UUID: Task] = [:] - private var started = false - private var notificationToken: NSObjectProtocol? - private let networkMonitor = NWPathMonitor() - private let networkQueue = DispatchQueue(label: "net.absdev.RsyncStudio.network") - private var currentTrigger: SyncRunTrigger? - - init(modelContext: ModelContext, runner: RsyncRunner) { - self.modelContext = modelContext - self.runner = runner - watcher.onChange = { [weak self] jobID, path in - Task { @MainActor [weak self] in - self?.fileChanged(jobID: jobID, path: path) - } - } - networkMonitor.pathUpdateHandler = { [weak self] path in - Task { @MainActor [weak self] in - self?.isNetworkAvailable = path.status == .satisfied - if path.status == .satisfied { self?.processQueue() } - } - } - networkMonitor.start(queue: networkQueue) - notificationToken = NotificationCenter.default.addObserver( - forName: .syncJobsChanged, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in self?.refreshConfiguration() } - } - } - - deinit { - networkMonitor.cancel() - if let notificationToken { NotificationCenter.default.removeObserver(notificationToken) } - } - - func start() { - guard !started else { return } - started = true - refreshConfiguration() - pruneHistoryIfNeeded(force: true) - checkSchedules() - timer = Timer.scheduledTimer(withTimeInterval: 20, repeats: true) { [weak self] _ in - Task { @MainActor [weak self] in - self?.refreshConfiguration() - self?.checkSchedules() - self?.processQueue() - } - } - } - - func stop() { - timer?.invalidate() - timer = nil - watcher.stop() - watchDebounceTasks.values.forEach { $0.cancel() } - watchDebounceTasks.removeAll() - started = false - } - - func syncAllEnabledJobs() { - guard !isSyncAllRunning else { return } - - let enabledJobs = fetchJobs().filter(\.isEnabled) - syncAllCompleted = 0 - syncAllTotal = 0 - syncAllFailed = 0 - syncAllSkipped = 0 - - for job in enabledJobs { - if enqueue(jobID: job.id, trigger: .syncAll) { - syncAllTotal += 1 - } - } - - isSyncAllRunning = syncAllTotal > 0 - if !isSyncAllRunning { - syncAllCompleted = 0 - } - } - - func cancelSyncAll() { - guard isSyncAllRunning else { return } - let before = pending.count - pending.removeAll { item in - if item.1 == .syncAll { - pendingIDs.remove(item.0) - return true - } - return false - } - syncAllSkipped += max(0, before - pending.count) - syncAllTotal = max(syncAllCompleted + syncAllFailed + syncAllSkipped, syncAllTotal) - if currentTrigger == .syncAll && runner.isRunning { runner.cancel() } - if currentTrigger != .syncAll { - isSyncAllRunning = false - } - } - - func refreshConfiguration() { - let jobs = fetchJobs() - scheduledJobCount = jobs.filter { $0.isEnabled && $0.scheduleEnabled && $0.scheduleKind != .manual }.count - watchedJobCount = jobs.filter { $0.isEnabled && $0.watchEnabled }.count - - if UserDefaults.standard.object(forKey: "fileWatchingEnabled") as? Bool ?? true { - watcher.update(jobs: jobs) - } else { - watcher.update(jobs: []) - } - - for job in jobs where job.scheduleEnabled && job.scheduleKind != .manual && job.nextRunAt == nil { - job.nextRunAt = job.calculateNextRun(after: Date().addingTimeInterval(-1)) - } - saveContext("refreshing automation configuration") - } - - private func checkSchedules() { - guard UserDefaults.standard.object(forKey: "backgroundSchedulesEnabled") as? Bool ?? true else { return } - let now = Date() - for job in fetchJobs() where job.isEnabled && job.scheduleEnabled && job.scheduleKind != .manual { - guard let next = job.nextRunAt, next <= now else { continue } - enqueue(jobID: job.id, trigger: .scheduled) - - if job.scheduleKind == .once { - job.scheduleEnabled = false - job.nextRunAt = nil - } else { - job.nextRunAt = job.calculateNextRun(after: now) - } - } - saveContext("updating scheduled jobs") - } - - private func fileChanged(jobID: UUID, path: String) { - guard UserDefaults.standard.object(forKey: "fileWatchingEnabled") as? Bool ?? true else { return } - guard let job = fetchJob(id: jobID), job.isEnabled, job.watchEnabled else { return } - job.lastWatchEventAt = Date() - watchDebounceTasks[jobID]?.cancel() - let delay = max(1, job.watchDebounceSeconds) - watchDebounceTasks[jobID] = Task { [weak self] in - try? await Task.sleep(for: .seconds(delay)) - guard !Task.isCancelled, let self else { return } - _ = self.enqueue(jobID: jobID, trigger: .watched) - } - saveContext("recording file-watch activity") - } - - @discardableResult - private func enqueue(jobID: UUID, trigger: SyncRunTrigger) -> Bool { - guard !pendingIDs.contains(jobID), runner.currentJobID != jobID else { return false } - pending.append((jobID, trigger)) - pendingIDs.insert(jobID) - processQueue() - return true - } - - private func processQueue() { - guard !isProcessingQueue, !runner.isRunning, !pending.isEmpty else { - if (isProcessingQueue || runner.isRunning), let first = pending.first, let job = fetchJob(id: first.0) { - activity = .waiting(job.name) - } - return - } - - let item = pending.removeFirst() - pendingIDs.remove(item.0) - guard let job = fetchJob(id: item.0), job.isEnabled else { - if item.1 == .syncAll { syncAllSkipped += 1 } - finishSyncAllItemIfNeeded(item.1, succeeded: false, skipped: true) - processQueue() - return - } - - if job.isRemote && !isNetworkAvailable { - pending.insert(item, at: 0) - pendingIDs.insert(item.0) - activity = .waiting("Network · \(job.name)") - return - } - - isProcessingQueue = true - currentTrigger = item.1 - activity = .running(job.name) - Task { [weak self] in - guard let self else { return } - let succeeded = await self.execute(job: job, trigger: item.1) - self.finishSyncAllItemIfNeeded(item.1, succeeded: succeeded, skipped: false) - self.isProcessingQueue = false - self.currentTrigger = nil - self.activity = .idle - self.processQueue() - } - } - - private func finishSyncAllItemIfNeeded(_ trigger: SyncRunTrigger, succeeded: Bool, skipped: Bool) { - guard trigger == .syncAll else { return } - if skipped { syncAllSkipped += 0 } - else if succeeded { syncAllCompleted += 1 } - else { syncAllFailed += 1 } - if syncAllCompleted + syncAllFailed + syncAllSkipped >= syncAllTotal { - isSyncAllRunning = false - sendNotification(title: "Sync All complete", body: "\(syncAllCompleted) succeeded · \(syncAllFailed) failed · \(syncAllSkipped) skipped") - } - } - - private func execute(job: SyncJob, trigger: SyncRunTrigger) async -> Bool { - let executable = RsyncLocator.locate() - let command = RsyncCommandBuilder.displayCommand(job: job, dryRun: false, executable: executable) - let run = SyncRun(job: job, command: command, wasDryRun: false, trigger: trigger) - modelContext.insert(run) - saveContext("creating run history") - - let automatic = trigger == .scheduled || trigger == .watched - let maxAttempts = automatic ? max(1, job.retryCount + 1) : 1 - var finalSuccess = false - var combinedOutput = "" - var combinedError = "" - - for attempt in 1...maxAttempts { - if attempt > 1 { - let delay = max(1, job.retryDelaySeconds) * (attempt - 1) - combinedError += "\nRetry \(attempt - 1) of \(maxAttempts - 1) after \(delay)s…\n" - try? await Task.sleep(for: .seconds(delay)) - } - do { - let preflight = await runner.preflight(job: job) - guard preflight.passed else { - combinedError += preflight.checks.filter { $0.severity == .error }.map(\.detail).joined(separator: "\n") - break - } - let result = try await runner.run(job: job, dryRun: false, trigger: trigger) - combinedOutput += result.stdout - combinedError += result.stderr - run.exitCode = Int(result.exitCode) - run.wasCancelled = result.wasCancelled - if result.exitCode == 0 { - finalSuccess = true - break - } - if result.wasCancelled { break } - } catch { - combinedError += "\n\(error.localizedDescription)\n" - run.exitCode = -1 - } - } - - run.output = SyncRun.sanitizedLog(combinedOutput) - run.errorOutput = SyncRun.sanitizedLog(combinedError) - run.finishedAt = Date() - job.lastRunAt = run.finishedAt - job.lastExitCode = run.exitCode - job.updatedAt = Date() - saveContext("saving run history") - pruneHistoryIfNeeded() - - if finalSuccess, job.notifyOnSuccess { sendNotification(title: "Sync complete", body: job.name) } - if !finalSuccess, job.notifyOnFailure { sendNotification(title: "Sync failed", body: job.name) } - return finalSuccess - } - - private func sendNotification(title: String, body: String) { - Task { @MainActor in - do { - let center = UNUserNotificationCenter.current() - let granted = try await center.requestAuthorization(options: [.alert, .sound]) - guard granted else { return } - - let content = UNMutableNotificationContent() - content.title = title - content.body = body - content.sound = .default - - let request = UNNotificationRequest( - identifier: UUID().uuidString, - content: content, - trigger: nil - ) - try await center.add(request) - } catch { - lastError = "Could not deliver notification: \(error.localizedDescription)" - } - } - } - - private func pruneHistoryIfNeeded(force: Bool = false) { - let now = Date() - if !force, let last = UserDefaults.standard.object(forKey: "lastHistoryPruneAt") as? Date, now.timeIntervalSince(last) < 3600 { return } - - let configuredDays = UserDefaults.standard.integer(forKey: "historyRetentionDays") - let days = configuredDays == 0 ? 90 : max(7, min(configuredDays, 3650)) - let cutoff = now.addingTimeInterval(-TimeInterval(days) * 86_400) - - do { - let runs = try modelContext.fetch(FetchDescriptor()) - for run in runs where run.finishedAt != nil && run.startedAt < cutoff { - modelContext.delete(run) - } - try modelContext.save() - UserDefaults.standard.set(now, forKey: "lastHistoryPruneAt") - } catch { - lastError = "Could not prune run history: \(error.localizedDescription)" - } - } - - private func saveContext(_ operation: String) { - do { - try modelContext.save() - } catch { - lastError = "Database error while \(operation): \(error.localizedDescription)" - } - } - - private func fetchJobs() -> [SyncJob] { - (try? modelContext.fetch(FetchDescriptor(sortBy: [SortDescriptor(\SyncJob.name)]))) ?? [] - } - - private func fetchJob(id: UUID) -> SyncJob? { - fetchJobs().first { $0.id == id } - } -} - -extension Notification.Name { - static let syncJobsChanged = Notification.Name("RsyncStudio.syncJobsChanged") -} diff --git a/Rsync Studio/Services/FileWatchService.swift b/Rsync Studio/Services/FileWatchService.swift deleted file mode 100644 index 99010b1..0000000 --- a/Rsync Studio/Services/FileWatchService.swift +++ /dev/null @@ -1,94 +0,0 @@ -import Foundation -import CoreServices - -final class FileWatchService { - typealias ChangeHandler = @Sendable (UUID, String) -> Void - - private struct WatchTarget: Equatable { - let targetPath: String - let streamRoot: String - let isDirectory: Bool - } - - private var stream: FSEventStreamRef? - private var watchedTargets: [UUID: WatchTarget] = [:] - private let queue = DispatchQueue(label: "net.absdev.RsyncStudio.fsevents", qos: .utility) - var onChange: ChangeHandler? - - deinit { stop() } - - func update(jobs: [SyncJob]) { - var targets: [UUID: WatchTarget] = [:] - for job in jobs where job.isEnabled && job.watchEnabled { - let path = (job.effectiveWatchPath as NSString).expandingTildeInPath - guard !path.isEmpty else { continue } - - var isDirectory: ObjCBool = false - let exists = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) - let directory = exists ? isDirectory.boolValue : true - let streamRoot = directory ? path : (path as NSString).deletingLastPathComponent - targets[job.id] = WatchTarget(targetPath: path, streamRoot: streamRoot, isDirectory: directory) - } - guard targets != watchedTargets else { return } - watchedTargets = targets - rebuild() - } - - func stop() { - if let stream { - FSEventStreamStop(stream) - FSEventStreamInvalidate(stream) - FSEventStreamRelease(stream) - self.stream = nil - } - } - - private func rebuild() { - stop() - let paths = Array(Set(watchedTargets.values.map(\.streamRoot))) - guard !paths.isEmpty else { return } - - var context = FSEventStreamContext( - version: 0, - info: Unmanaged.passUnretained(self).toOpaque(), - retain: nil, - release: nil, - copyDescription: nil - ) - - let callback: FSEventStreamCallback = { _, info, count, eventPaths, _, _ in - guard let info else { return } - let service = Unmanaged.fromOpaque(info).takeUnretainedValue() - let paths = unsafeBitCast(eventPaths, to: NSArray.self) as? [String] ?? [] - for index in 0.. 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(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.") } diff --git a/Rsync Studio/Services/RsyncCommandBuilder.swift b/Rsync Studio/Services/RsyncCommandBuilder.swift deleted file mode 100644 index a2b11ab..0000000 --- a/Rsync Studio/Services/RsyncCommandBuilder.swift +++ /dev/null @@ -1,304 +0,0 @@ -import Foundation - -struct RsyncCommandBuilder { - static func build(job: SyncJob, dryRun: Bool, executable: String) -> [String] { - var args: [String] = [] - - if job.archive { - args.append("-a") - } else if job.preservePermissions { - args.append("-p") - } - - if job.compress { args.append("-z") } - - if job.preserveExtendedAttributes { - if isAppleRsync(executable) { - if !job.isRemote { args.append("-E") } - } else { - args.append("-X") - } - } - - if job.deleteExtraneous { args.append("--delete") } - if job.showProgress { args.append("--progress") } - if dryRun { args.append("--dry-run") } - - args += ["--human-readable", "--stats"] - - for exclusion in job.exclusions { - args.append("--exclude=\(exclusion)") - } - - if !job.additionalArguments.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args += shellSplit(job.additionalArguments) - } - - if job.isRemote { - var sshParts = [ - "ssh", - "-o", "BatchMode=yes", - "-o", "ConnectTimeout=15", - "-o", "ServerAliveInterval=30", - "-o", "ServerAliveCountMax=3", - "-p", String(job.remotePort) - ] - let keyPath = normalizedPrivateKeyPath(job.sshKeyPath) - if !keyPath.isEmpty { - sshParts += ["-o", "IdentitiesOnly=yes", "-i", keyPath] - } - args += ["-e", sshParts.map(shellEscape).joined(separator: " ")] - } - - // End option parsing before user-controlled filesystem operands. This prevents - // a local path beginning with '-' from being interpreted as another rsync option. - args.append("--") - args.append(normalizeSource(job.sourcePath)) - args.append(job.destinationDisplay) - return args - } - - static func displayCommand(job: SyncJob, dryRun: Bool, executable: String) -> String { - ([executable] + build(job: job, dryRun: dryRun, executable: executable)) - .map(shellEscape) - .joined(separator: " ") - } - - static func normalizedPrivateKeyPath(_ path: String) -> String { - let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.hasSuffix(".pub") else { return (trimmed as NSString).expandingTildeInPath } - - let privatePath = String(trimmed.dropLast(4)) - let expandedPrivatePath = (privatePath as NSString).expandingTildeInPath - if FileManager.default.fileExists(atPath: expandedPrivatePath) { - return expandedPrivatePath - } - return (trimmed as NSString).expandingTildeInPath - } - - private static func isAppleRsync(_ executable: String) -> Bool { - executable == "/usr/bin/rsync" - } - - private static func normalizeSource(_ path: String) -> String { path } - - static func shellEscape(_ value: String) -> String { - if value.isEmpty { return "''" } - let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_./:@%+=,")) - if value.unicodeScalars.allSatisfy({ allowed.contains($0) }) { return value } - return "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" - } - - static func shellSplit(_ string: String) -> [String] { - var result: [String] = [] - var current = "" - var quote: Character? - var escaping = false - - for ch in string { - if escaping { - current.append(ch) - escaping = false - continue - } - if ch == "\\" { - escaping = true - continue - } - if let q = quote { - if ch == q { quote = nil } else { current.append(ch) } - continue - } - if ch == "\"" || ch == "'" { - quote = ch - } else if ch.isWhitespace { - if !current.isEmpty { - result.append(current) - current = "" - } - } else { - current.append(ch) - } - } - if !current.isEmpty { result.append(current) } - return result - } -} - -enum RsyncSecurityValidator { - static func validationMessage(for job: SyncJob, trigger: SyncRunTrigger = .manual) -> String? { - let source = job.sourcePath.trimmingCharacters(in: .whitespacesAndNewlines) - let destination = job.destinationPath.trimmingCharacters(in: .whitespacesAndNewlines) - guard !source.isEmpty, !destination.isEmpty else { - return "Source and destination are required." - } - - if containsControlCharacters(source) || containsControlCharacters(destination) { - return "Paths may not contain control characters." - } - - if job.watchEnabled && containsControlCharacters(job.effectiveWatchPath) { - return "The file-watch path may not contain control characters." - } - - if job.exclusions.contains(where: containsControlCharacters) { - return "Exclusion patterns may not contain control characters." - } - - if let advancedError = validateAdditionalArguments(job.additionalArguments) { - return advancedError - } - - if job.isRemote { - let host = job.remoteHost.trimmingCharacters(in: .whitespacesAndNewlines) - let user = job.remoteUser.trimmingCharacters(in: .whitespacesAndNewlines) - guard isValidHost(host) else { - return "The SSH host is invalid. Use a hostname or IP address without spaces or command-line option prefixes." - } - if !user.isEmpty, !isValidUser(user) { - return "The SSH username is invalid. Use letters, numbers, '.', '_' or '-'." - } - guard (1...65535).contains(job.remotePort) else { - return "The SSH port must be between 1 and 65535." - } - if destination.hasPrefix("-") { - return "A remote destination may not begin with '-'." - } - if !isSafeRemotePath(destination) { - return "The remote destination contains characters that are unsafe for an SSH/rsync remote shell. Use a normal path containing letters, numbers, spaces, '/', '.', '_', '-', '~', '+', '@', '%' or '=' only." - } - if destination == host || destination == "\(user)@\(host)" { - return "Remote destination must be a path on the remote machine, not the host address." - } - - let keyPath = RsyncCommandBuilder.normalizedPrivateKeyPath(job.sshKeyPath) - if !job.sshKeyPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - if keyPath.hasSuffix(".pub") { - return "Select the private SSH key, not the .pub public key." - } - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: keyPath, isDirectory: &isDirectory), !isDirectory.boolValue else { - return "The selected private SSH key does not exist or is not a regular file." - } - } - } else { - if pathsOverlap(source: source, destination: destination) { - return "Source and destination overlap. Choose separate directory trees to prevent recursive copies or file-watch loops." - } - if let volume = SyncVolumeInspector.info(forPath: destination) { - if job.requireExpectedVolume && !job.expectedVolumeIdentifier.isEmpty && volume.identifier != job.expectedVolumeIdentifier { - return "The configured destination volume is not mounted. Rsync Studio will not use a different volume at the same path." - } - if job.minimumFreeSpaceMB > 0 && volume.freeBytes < Int64(job.minimumFreeSpaceMB) * 1_048_576 { - return "The destination does not have the configured minimum free space of \(job.minimumFreeSpaceMB) MB." - } - } else { - return "The destination volume cannot be identified." - } - } - - if job.deleteExtraneous, - (trigger == .scheduled || trigger == .watched), - !job.allowUnattendedDelete { - return "This job uses --delete but unattended destructive sync is not authorised. Edit the job and explicitly enable unattended delete." - } - - return nil - } - - private static func validateAdditionalArguments(_ raw: String) -> String? { - if !hasBalancedShellSyntax(raw) { - return "Advanced rsync arguments contain an unmatched quote or trailing escape character." - } - - let arguments = RsyncCommandBuilder.shellSplit(raw) - let blockedExact: Set = [ - "-e", "-M", "--rsh", "--rsync-path", "--remote-option", - "--password-file", "--daemon", "--config", "--port", "--address", - "--sockopts", "--remove-source-files", "--copy-as", "--files-from", - "--exclude-from", "--include-from", "--log-file", "--log-file-format", - "--backup-dir", "--temp-dir", "--partial-dir", "--chown", "--usermap", - "--groupmap", "--rsync-path", "--write-batch", "--only-write-batch", - "--read-batch" - ] - let blockedPrefixes = [ - "--rsh=", "--rsync-path=", "--remote-option=", "--password-file=", - "--config=", "--port=", "--address=", "--sockopts=", "--copy-as=", - "--delete", "--remove-source-files", "--files-from=", "--exclude-from=", - "--include-from=", "--log-file=", "--log-file-format=", "--backup-dir=", - "--temp-dir=", "--partial-dir=", "--chown=", "--usermap=", "--groupmap=", - "--write-batch=", "--only-write-batch=", "--read-batch=" - ] - - for argument in arguments { - if blockedExact.contains(argument) || blockedPrefixes.contains(where: { argument.hasPrefix($0) }) { - return "Advanced argument '\(argument)' is security-sensitive or destructive and is managed by Rsync Studio. Remove it and use the dedicated job controls instead." - } - if containsControlCharacters(argument) { - return "Advanced rsync arguments may not contain control characters." - } - } - return nil - } - - - private static func isSafeRemotePath(_ value: String) -> Bool { - guard !value.isEmpty, !containsControlCharacters(value) else { return false } - // Rsync invokes the remote side through SSH. Keep remote paths out of shell - // metacharacter territory so a configured path can never become a remote command. - let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: " /._-~+@%=")) - return value.unicodeScalars.allSatisfy { allowed.contains($0) } - } - - private static func hasBalancedShellSyntax(_ value: String) -> Bool { - var quote: Character? - var escaping = false - for ch in value { - if escaping { - escaping = false - continue - } - if ch == "\\" { - escaping = true - continue - } - if let activeQuote = quote { - if ch == activeQuote { quote = nil } - continue - } - if ch == "\"" || ch == "'" { quote = ch } - } - return quote == nil && !escaping - } - - private static func isValidHost(_ value: String) -> Bool { - guard !value.isEmpty, !value.hasPrefix("-"), !containsControlCharacters(value) else { return false } - let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ".-_:[]")) - return value.unicodeScalars.allSatisfy { allowed.contains($0) } - } - - private static func isValidUser(_ value: String) -> Bool { - guard !value.hasPrefix("-"), !containsControlCharacters(value) else { return false } - let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-")) - return value.unicodeScalars.allSatisfy { allowed.contains($0) } - } - - private static func containsControlCharacters(_ value: String) -> Bool { - value.unicodeScalars.contains { CharacterSet.controlCharacters.contains($0) } - } - - private static func pathsOverlap(source: String, destination: String) -> Bool { - let src = canonicalLocalPath(source) - let dst = canonicalLocalPath(destination) - guard !src.isEmpty, !dst.isEmpty else { return false } - if src == dst { return true } - return dst.hasPrefix(src + "/") || src.hasPrefix(dst + "/") - } - - private static func canonicalLocalPath(_ value: String) -> String { - let expanded = (value as NSString).expandingTildeInPath - let standardized = (expanded as NSString).standardizingPath - if standardized == "/" { return standardized } - return standardized.hasSuffix("/") ? String(standardized.dropLast()) : standardized - } -} diff --git a/Rsync Studio/Services/RsyncLocator.swift b/Rsync Studio/Services/RsyncLocator.swift deleted file mode 100644 index 3c5d4a9..0000000 --- a/Rsync Studio/Services/RsyncLocator.swift +++ /dev/null @@ -1,29 +0,0 @@ -import Foundation - -enum RsyncLocator { - static let candidates = [ - "/opt/homebrew/bin/rsync", - "/usr/local/bin/rsync", - "/usr/bin/rsync" - ] - - static func locate() -> String { - candidates.first(where: isTrustedExecutableCandidate) ?? "/usr/bin/rsync" - } - - static func isTrustedExecutableCandidate(_ path: String) -> Bool { - let manager = FileManager.default - guard manager.isExecutableFile(atPath: path) else { return false } - - let resolved = URL(fileURLWithPath: path).resolvingSymlinksInPath().path - var isDirectory: ObjCBool = false - guard manager.fileExists(atPath: resolved, isDirectory: &isDirectory), !isDirectory.boolValue else { return false } - - do { - let attributes = try manager.attributesOfItem(atPath: resolved) - return attributes[.type] as? FileAttributeType == .typeRegular - } catch { - return false - } - } -} diff --git a/Rsync Studio/Services/RsyncRunner.swift b/Rsync Studio/Services/RsyncRunner.swift deleted file mode 100644 index 7b6d0cb..0000000 --- a/Rsync Studio/Services/RsyncRunner.swift +++ /dev/null @@ -1,260 +0,0 @@ -import Foundation - - -@MainActor -final class RsyncRunner: ObservableObject { - @Published private(set) var isRunning = false - @Published private(set) var currentJobID: UUID? - - private var process: Process? - - struct Result { - let exitCode: Int32 - let stdout: String - let stderr: String - let wasCancelled: Bool - } - - func run(job: SyncJob, dryRun: Bool, trigger: SyncRunTrigger = .manual) async throws -> Result { - guard !isRunning else { throw RunnerError.alreadyRunning } - if let message = RsyncSecurityValidator.validationMessage(for: job, trigger: trigger) { - throw RunnerError.securityValidation(message) - } - - let executable = RsyncLocator.locate() - guard RsyncLocator.isTrustedExecutableCandidate(executable) else { - throw RunnerError.invalidExecutable(executable) - } - let arguments = RsyncCommandBuilder.build(job: job, dryRun: dryRun, executable: executable) - - isRunning = true - currentJobID = job.id - let activityToken = ProcessInfo.processInfo.beginActivity( - options: [.userInitiated, .idleSystemSleepDisabled], - reason: "Rsync Studio is synchronising \(job.name)" - ) - - defer { - ProcessInfo.processInfo.endActivity(activityToken) - isRunning = false - currentJobID = nil - process = nil - } - - let process = Process() - self.process = process - process.executableURL = URL(fileURLWithPath: executable) - process.arguments = arguments - - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - process.standardOutput = stdoutPipe - process.standardError = stderrPipe - - // Keep rsync's potentially very chatty output off the MainActor. Updating a - // SwiftUI String for every rsync progress chunk makes the whole window jerk. - // These locked buffers collect every byte for history/export without causing - // any UI invalidation while the transfer is running. - let stdoutBuffer = LockedDataBuffer() - let stderrBuffer = LockedDataBuffer() - - stdoutPipe.fileHandleForReading.readabilityHandler = { handle in - let data = handle.availableData - guard !data.isEmpty else { return } - stdoutBuffer.append(data) - } - - stderrPipe.fileHandleForReading.readabilityHandler = { handle in - let data = handle.availableData - guard !data.isEmpty else { return } - stderrBuffer.append(data) - } - - return try await withCheckedThrowingContinuation { continuation in - process.terminationHandler = { proc in - stdoutPipe.fileHandleForReading.readabilityHandler = nil - stderrPipe.fileHandleForReading.readabilityHandler = nil - - // Consume anything left between the final readability callback and EOF. - let remainingOut = stdoutPipe.fileHandleForReading.readDataToEndOfFile() - let remainingErr = stderrPipe.fileHandleForReading.readDataToEndOfFile() - if !remainingOut.isEmpty { stdoutBuffer.append(remainingOut) } - if !remainingErr.isEmpty { stderrBuffer.append(remainingErr) } - - continuation.resume(returning: Result( - exitCode: proc.terminationStatus, - stdout: stdoutBuffer.stringValue, - stderr: stderrBuffer.stringValue, - wasCancelled: proc.terminationReason == .uncaughtSignal - )) - } - - do { - try process.run() - } catch { - stdoutPipe.fileHandleForReading.readabilityHandler = nil - stderrPipe.fileHandleForReading.readabilityHandler = nil - continuation.resume(throwing: error) - } - } - } - - private func remoteDestinationLooksLikeHost(_ job: SyncJob) -> Bool { - let destination = job.destinationPath.trimmingCharacters(in: .whitespacesAndNewlines) - let host = job.remoteHost.trimmingCharacters(in: .whitespacesAndNewlines) - return destination == host || destination == "\(job.remoteUser)@\(host)" - } - - func preflight(job: SyncJob) async -> JobPreflightReport { - var checks: [JobPreflightCheck] = [] - - if let message = RsyncSecurityValidator.validationMessage(for: job, trigger: .manual) { - checks.append(.init(title: "Configuration", detail: message, severity: .error)) - return JobPreflightReport(checks: checks) - } - checks.append(.init(title: "Configuration", detail: "Security and argument validation passed.", severity: .success)) - - let executable = RsyncLocator.locate() - if RsyncLocator.isTrustedExecutableCandidate(executable) { - checks.append(.init(title: "Rsync", detail: executable, severity: .success)) - } else { - checks.append(.init(title: "Rsync", detail: "No trusted rsync executable is available.", severity: .error)) - } - - if job.isRemote { - let ssh = "/usr/bin/ssh" - guard FileManager.default.isExecutableFile(atPath: ssh) else { - checks.append(.init(title: "SSH", detail: "The system SSH client is unavailable.", severity: .error)) - return JobPreflightReport(checks: checks) - } - let userHost = job.remoteUser.isEmpty ? job.remoteHost : "\(job.remoteUser)@\(job.remoteHost)" - var args = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=1", "-p", String(job.remotePort)] - let key = RsyncCommandBuilder.normalizedPrivateKeyPath(job.sshKeyPath) - if !key.isEmpty { args += ["-o", "IdentitiesOnly=yes", "-i", key] } - let remotePath = RsyncCommandBuilder.shellEscape(job.destinationPath) - args += [userHost, "command -v rsync >/dev/null 2>&1 && test -d \(remotePath)"] - let result = await Self.runProbe(executable: ssh, arguments: args) - if result == 0 { - checks.append(.init(title: "Remote connection", detail: "SSH authentication, remote rsync and destination directory are available.", severity: .success)) - } else { - checks.append(.init(title: "Remote connection", detail: "Could not authenticate, find remote rsync, or access the destination directory.", severity: .error)) - } - } else { - var isDir: ObjCBool = false - if FileManager.default.fileExists(atPath: (job.sourcePath as NSString).expandingTildeInPath, isDirectory: &isDir) { - checks.append(.init(title: "Source", detail: "Source exists and is reachable.", severity: .success)) - } else { - checks.append(.init(title: "Source", detail: "Source does not exist or is not reachable.", severity: .error)) - } - - let destination = (job.destinationPath as NSString).expandingTildeInPath - let volume = SyncVolumeInspector.info(forPath: destination) - if let volume { - checks.append(.init(title: "Destination volume", detail: "\(volume.name) · \(volume.freeSpaceDescription) free", severity: volume.freeBytes >= Int64(job.minimumFreeSpaceMB) * 1_048_576 ? .success : .error)) - if job.requireExpectedVolume && !job.expectedVolumeIdentifier.isEmpty && volume.identifier != job.expectedVolumeIdentifier { - checks.append(.init(title: "Volume identity", detail: "Expected \(job.expectedVolumeIdentifier), found \(volume.identifier).", severity: .error)) - } else if job.requireExpectedVolume { - checks.append(.init(title: "Volume identity", detail: "Expected destination volume is mounted.", severity: .success)) - } - } else { - checks.append(.init(title: "Destination volume", detail: "Could not identify the destination volume.", severity: .error)) - } - } - return JobPreflightReport(checks: checks) - } - - private static func runProbe(executable: String, arguments: [String]) async -> Int32 { - await withCheckedContinuation { continuation in - let process = Process() - process.executableURL = URL(fileURLWithPath: executable) - process.arguments = arguments - process.standardOutput = FileHandle.nullDevice - process.standardError = FileHandle.nullDevice - process.terminationHandler = { continuation.resume(returning: $0.terminationStatus) } - do { try process.run() } catch { continuation.resume(returning: -1) } - } - } - - func cancel() { - guard let process, process.isRunning else { return } - process.interrupt() - } - - enum RunnerError: LocalizedError { - case alreadyRunning - case securityValidation(String) - case invalidExecutable(String) - - var errorDescription: String? { - switch self { - case .alreadyRunning: - return "Another rsync job is already running." - case .securityValidation(let message): - return message - case .invalidExecutable(let path): - return "The rsync executable at \(path) is unavailable or is not a trusted regular executable file." - } - } - } -} - -private final class LockedDataBuffer: @unchecked Sendable { - private let lock = NSLock() - private var data = Data() - - func append(_ newData: Data) { - lock.lock() - data.append(newData) - lock.unlock() - } - - var stringValue: String { - lock.lock() - let snapshot = data - lock.unlock() - return String(decoding: snapshot, as: UTF8.self) - } -} - - -enum JobPreflightSeverity: Int, Comparable { - case success = 0, warning = 1, error = 2 - static func < (lhs: Self, rhs: Self) -> Bool { lhs.rawValue < rhs.rawValue } -} - -struct JobPreflightCheck: Identifiable { - let id = UUID() - let title: String - let detail: String - let severity: JobPreflightSeverity -} - -struct JobPreflightReport { - let checks: [JobPreflightCheck] - var severity: JobPreflightSeverity { checks.map(\.severity).max() ?? .success } - var passed: Bool { severity != .error } - var title: String { passed ? (severity == .warning ? "Ready with warnings" : "Ready") : "Needs attention" } -} - -struct SyncVolumeInfo { - let identifier: String - let name: String - let freeBytes: Int64 - var freeSpaceDescription: String { ByteCountFormatter.string(fromByteCount: freeBytes, countStyle: .file) } -} - -enum SyncVolumeInspector { - static func info(forPath path: String) -> SyncVolumeInfo? { - var url = URL(fileURLWithPath: path) - while !FileManager.default.fileExists(atPath: url.path), url.path != "/" { url.deleteLastPathComponent() } - do { - let values = try url.resourceValues(forKeys: [.volumeIdentifierKey, .volumeNameKey, .volumeAvailableCapacityForImportantUsageKey]) - guard let rawID = values.volumeIdentifier else { return nil } - return SyncVolumeInfo( - identifier: String(describing: rawID), - name: values.volumeName ?? url.path, - freeBytes: values.volumeAvailableCapacityForImportantUsage ?? 0 - ) - } catch { return nil } - } -} diff --git a/Rsync Studio/SettingsView.swift b/Rsync Studio/SettingsView.swift deleted file mode 100644 index 3e7985e..0000000 --- a/Rsync Studio/SettingsView.swift +++ /dev/null @@ -1,321 +0,0 @@ -import SwiftUI -import ServiceManagement -import SwiftData -import AppKit -import UniformTypeIdentifiers -import CryptoKit - -struct SettingsView: View { - @Environment(\.modelContext) private var modelContext - @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 - @AppStorage("defaultRemoteHost") private var defaultRemoteHost = "" - @AppStorage("backgroundSchedulesEnabled") private var backgroundSchedulesEnabled = true - @AppStorage("fileWatchingEnabled") private var fileWatchingEnabled = true - @AppStorage("defaultWatchDebounceSeconds") private var defaultWatchDebounceSeconds = 3 - @AppStorage("historyRetentionDays") private var historyRetentionDays = 90 - @State private var launchAtLogin = false - @State private var launchError: String? - @State private var diagnosticsMessage: String? - @State private var updateStatus: String? - @State private var isCheckingUpdates = false - - var body: some View { - TabView { - Form { - Section("Default SSH Connection") { - TextField("Username", text: $defaultSSHUsername, prompt: Text(NSUserName())) - TextField("Default host", text: $defaultRemoteHost, prompt: Text("server.local")) - Stepper("SSH port: \(defaultSSHPort)", value: $defaultSSHPort, in: 1...65535) - - HStack { - TextField("Private SSH key", text: $defaultSSHKeyPath, prompt: Text("~/.ssh/id_ed25519")) - Button("Choose…") { - if let selected = FilePicker.chooseFile() { defaultSSHKeyPath = selected } - } - } - - Label("Authentication uses an SSH private key or your macOS SSH agent. Passwords are intentionally not stored or passed on the command line.", systemImage: "lock.shield") - .font(.caption) - .foregroundStyle(.secondary) - } - - Section("New Jobs") { - Text("New SSH jobs use these values as defaults and can override them independently.") - .font(.caption) - .foregroundStyle(.secondary) - } - } - .formStyle(.grouped) - .tabItem { Label("Connections", systemImage: "network") } - - Form { - Section("Background Scheduling") { - Toggle("Run scheduled jobs", isOn: $backgroundSchedulesEnabled) - Toggle("Launch Rsync Studio at login", isOn: Binding( - get: { launchAtLogin }, - set: { setLaunchAtLogin($0) } - )) - HStack { - Label("Configured jobs", systemImage: "calendar.badge.clock") - Spacer() - Text("\(automation.scheduledJobCount)") - .foregroundStyle(.secondary) - } - Text("Scheduled jobs run without opening a modal and are written to History. Rsync Studio continues processing them while its window is hidden, minimised or closed, provided the application itself remains running.") - .font(.caption) - .foregroundStyle(.secondary) - } - - Section("History & Privacy") { - Stepper("Keep run history for \(historyRetentionDays) days", value: $historyRetentionDays, in: 7...3650, step: 7) - Text("Run output is sanitised, ANSI control sequences are removed, and individual stdout/stderr logs are capped at approximately 2 MB before storage.") - .font(.caption) - .foregroundStyle(.secondary) - } - - Section("File Watching") { - Toggle("Watch enabled jobs for changes", isOn: $fileWatchingEnabled) - Stepper("Default debounce: \(defaultWatchDebounceSeconds) seconds", value: $defaultWatchDebounceSeconds, in: 1...60) - HStack { - Label("Watched jobs", systemImage: "eye.fill") - Spacer() - Text("\(automation.watchedJobCount)") - .foregroundStyle(.secondary) - } - Text("Rsync Studio uses macOS FSEvents, so watched trees do not need to be repeatedly scanned. A short debounce combines rapid saves into a single sync.") - .font(.caption) - .foregroundStyle(.secondary) - } - } - .formStyle(.grouped) - .tabItem { Label("Automation", systemImage: "calendar.badge.clock") } - - Form { - Section("Rsync") { - LabeledContent("Detected executable", value: RsyncLocator.locate()) - LabeledContent("Executable validation", value: RsyncLocator.isTrustedExecutableCandidate(RsyncLocator.locate()) ? "Passed" : "Failed") - Text("Only executable regular-file rsync candidates are accepted. Symlinks are resolved before validation.") - .font(.caption) - .foregroundStyle(.secondary) - } - - Section("Security") { - LabeledContent("SSH mode", value: "Batch / key or agent") - LabeledContent("Host-key verification", value: "System default") - Text("SSH connections use BatchMode, connection timeouts and keepalive limits. Rsync Studio never disables SSH host-key verification.") - .font(.caption) - .foregroundStyle(.secondary) - } - - Section("Application") { - LabeledContent("Bundle identifier", value: Bundle.main.bundleIdentifier ?? "net.absdev.RsyncStudio") - LabeledContent("Version", value: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0") - LabeledContent("Background service", value: automation.activity.title) - LabeledContent("Network", value: automation.isNetworkAvailable ? "Available" : "Offline") - } - - 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) - .foregroundStyle(.secondary) - LabeledContent("Updater", value: "Signed manifest") - Button { - checkForUpdates() - } label: { - if isCheckingUpdates { ProgressView().controlSize(.small) } - else { Label("Check for Updates…", systemImage: "arrow.triangle.2.circlepath") } - } - .disabled(isCheckingUpdates) - Text("The update checker only accepts an HTTPS manifest whose payload verifies against the Ed25519 public key embedded in the signed app. It will not trust or open an unsigned update.") - .font(.caption) - .foregroundStyle(.secondary) - } - } - .formStyle(.grouped) - .tabItem { Label("General", systemImage: "gearshape") } - - LicenseView() - .environmentObject(licensing) - .tabItem { Label("Licence", systemImage: "key") } - } - .frame(width: 680, height: 500) - .onAppear { - KeychainStore.delete("defaultSSHPassword") - launchAtLogin = SMAppService.mainApp.status == .enabled - } - .onChange(of: backgroundSchedulesEnabled) { _, _ in automation.refreshConfiguration() } - .onChange(of: fileWatchingEnabled) { _, _ in automation.refreshConfiguration() } - .alert("Launch at Login", isPresented: Binding( - get: { launchError != nil }, - set: { if !$0 { launchError = nil } } - )) { - Button("OK", role: .cancel) { launchError = nil } - } message: { - Text(launchError ?? "Unable to change the login item setting.") - } - .alert("Diagnostics", isPresented: Binding( - get: { diagnosticsMessage != nil }, - set: { if !$0 { diagnosticsMessage = nil } } - )) { - Button("OK", role: .cancel) { diagnosticsMessage = nil } - } message: { - Text(diagnosticsMessage ?? "") - } - .alert("Software Update", isPresented: Binding( - get: { updateStatus != nil }, - set: { if !$0 { updateStatus = nil } } - )) { - Button("OK", role: .cancel) { updateStatus = nil } - } message: { - Text(updateStatus ?? "") - } - } - - 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 { - let result = await SecureUpdateChecker.check() - isCheckingUpdates = false - switch result { - case .upToDate(let message): updateStatus = message - case .unavailable(let message): updateStatus = message - case .available(let message, let url): - updateStatus = message - NSWorkspace.shared.open(url) - } - } - } - - private func exportDiagnostics() { - let panel = NSSavePanel() - panel.allowedContentTypes = [.plainText] - panel.nameFieldStringValue = "Rsync Studio-Diagnostics.txt" - guard panel.runModal() == .OK, let url = panel.url else { return } - let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" - let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown" - let os = ProcessInfo.processInfo.operatingSystemVersionString - let executable = RsyncLocator.locate() - var lines = [ - "Rsync Studio Diagnostics", - "Generated: \(Date().formatted(.iso8601))", - "Version: \(version) (\(build))", - "macOS: \(os)", - "Rsync: \(executable)", - "Network: \(automation.isNetworkAvailable ? "Available" : "Offline")", - "Jobs: \(jobs.count)", - "Recent runs: \(runs.count)", - "" - ] - for job in jobs { - lines += [ - "JOB: \(job.name)", - " Type: \(job.isRemote ? "SSH" : "Local")", - " Source: \(job.sourcePath)", - " Destination: \(job.isRemote ? "" : job.destinationPath)", - " Host: \(job.isRemote ? job.remoteHost : "-")", - " SSH key: \(job.sshKeyPath.isEmpty ? "Agent/default" : "Configured (path redacted)")", - " Schedule: \(job.scheduleDescription)", - " Watching: \(job.watchEnabled ? "Yes" : "No")", - " Last exit: \(job.lastExitCode.map(String.init) ?? "Never")", - "" - ] - } - let failures = runs.filter { !$0.succeeded && !$0.isRunning }.prefix(20) - if !failures.isEmpty { - lines.append("RECENT FAILURES") - for run in failures { - lines.append(" \(run.startedAt.formatted(.iso8601)) · \(run.jobName) · exit \(run.exitCode.map(String.init) ?? "unknown") · \(SyncRun.sanitizedLog(run.errorOutput).prefix(500))") - } - } - do { - try lines.joined(separator: "\n").write(to: url, atomically: true, encoding: .utf8) - diagnosticsMessage = "Diagnostics exported successfully." - } catch { diagnosticsMessage = "Could not export diagnostics: \(error.localizedDescription)" } - } - - private func setLaunchAtLogin(_ enabled: Bool) { - do { - if enabled { - try SMAppService.mainApp.register() - } else { - try SMAppService.mainApp.unregister() - } - launchAtLogin = SMAppService.mainApp.status == .enabled - } catch { - launchAtLogin = SMAppService.mainApp.status == .enabled - launchError = error.localizedDescription - } - } -} - - -private enum SecureUpdateCheckResult { - case upToDate(String) - case available(String, URL) - case unavailable(String) -} - -private struct SecureUpdateManifest: Codable { - let version: String - let build: Int - let downloadURL: String - let signature: String - - var signedPayload: Data { - Data("\(version)\n\(build)\n\(downloadURL)".utf8) - } -} - -private enum SecureUpdateChecker { - static func check() async -> SecureUpdateCheckResult { - guard let feedString = Bundle.main.object(forInfoDictionaryKey: "RsyncStudioUpdateManifestURL") as? String, - let feedURL = URL(string: feedString), feedURL.scheme == "https", - let keyString = Bundle.main.object(forInfoDictionaryKey: "RsyncStudioUpdatePublicKey") as? String, - !keyString.isEmpty else { - return .unavailable("Signed updates are ready but not configured yet. Add RsyncStudioUpdateManifestURL and RsyncStudioUpdatePublicKey to the Release build after publishing your HTTPS update feed and Ed25519 public key.") - } - do { - let (data, response) = try await URLSession.shared.data(from: feedURL) - guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { - return .unavailable("The update server returned an invalid response.") - } - let manifest = try JSONDecoder().decode(SecureUpdateManifest.self, from: data) - guard let signature = Data(base64Encoded: manifest.signature), let keyData = Data(base64Encoded: keyString) else { - return .unavailable("The update manifest or embedded public key is malformed.") - } - let publicKey = try Curve25519.Signing.PublicKey(rawRepresentation: keyData) - guard publicKey.isValidSignature(signature, for: manifest.signedPayload) else { - return .unavailable("The update manifest signature is invalid. No update was opened.") - } - guard let downloadURL = URL(string: manifest.downloadURL), downloadURL.scheme == "https" else { - return .unavailable("The signed update contains an invalid download URL.") - } - let currentBuild = Int(Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "0") ?? 0 - if manifest.build > currentBuild { - return .available("Rsync Studio \(manifest.version) (build \(manifest.build)) is available. The signed download page will now open.", downloadURL) - } - return .upToDate("Rsync Studio is up to date.") - } catch { - return .unavailable("Could not securely check for updates: \(error.localizedDescription)") - } - } -} diff --git a/Rsync Studio/Utilities/FilePicker.swift b/Rsync Studio/Utilities/FilePicker.swift deleted file mode 100644 index 4d19593..0000000 --- a/Rsync Studio/Utilities/FilePicker.swift +++ /dev/null @@ -1,21 +0,0 @@ -import AppKit - -@MainActor -enum FilePicker { - static func chooseDirectory() -> String? { - let panel = NSOpenPanel() - panel.canChooseDirectories = true - panel.canChooseFiles = false - panel.allowsMultipleSelection = false - panel.canCreateDirectories = true - return panel.runModal() == .OK ? panel.url?.path : nil - } - - static func chooseFile() -> String? { - let panel = NSOpenPanel() - panel.canChooseDirectories = false - panel.canChooseFiles = true - panel.allowsMultipleSelection = false - return panel.runModal() == .OK ? panel.url?.path : nil - } -} diff --git a/Rsync Studio/Utilities/KeychainStore.swift b/Rsync Studio/Utilities/KeychainStore.swift deleted file mode 100644 index c6f62a5..0000000 --- a/Rsync Studio/Utilities/KeychainStore.swift +++ /dev/null @@ -1,55 +0,0 @@ -import Foundation -import Security - -enum KeychainStore { - private static let service = "net.absdev.RsyncStudio" - - static func string(for account: String) -> String { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne - ] - var item: CFTypeRef? - guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, - let data = item as? Data, - let value = String(data: data, encoding: .utf8) else { return "" } - return value - } - - @discardableResult - static func set(_ value: String, for account: String) -> OSStatus { - let base: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account - ] - if value.isEmpty { return delete(account) } - - let data = Data(value.utf8) - let update: [String: Any] = [ - kSecValueData as String: data, - kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked - ] - let updateStatus = SecItemUpdate(base as CFDictionary, update as CFDictionary) - if updateStatus != errSecItemNotFound { return updateStatus } - - var add = base - add[kSecValueData as String] = data - add[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlocked - return SecItemAdd(add as CFDictionary, nil) - } - - @discardableResult - static func delete(_ account: String) -> OSStatus { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account - ] - let status = SecItemDelete(query as CFDictionary) - return status == errSecItemNotFound ? errSecSuccess : status - } -} diff --git a/Rsync Studio/Views/Components/FlowLayout.swift b/Rsync Studio/Views/Components/FlowLayout.swift deleted file mode 100644 index cc6d259..0000000 --- a/Rsync Studio/Views/Components/FlowLayout.swift +++ /dev/null @@ -1,36 +0,0 @@ -import SwiftUI - -struct FlowLayout: Layout { - var spacing: CGFloat = 8 - - func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { - let width = proposal.width ?? .infinity - var x: CGFloat = 0, y: CGFloat = 0, rowHeight: CGFloat = 0 - for view in subviews { - let size = view.sizeThatFits(.unspecified) - if x + size.width > width, x > 0 { - x = 0 - y += rowHeight + spacing - rowHeight = 0 - } - x += size.width + spacing - rowHeight = max(rowHeight, size.height) - } - return CGSize(width: width.isFinite ? width : x, height: y + rowHeight) - } - - func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { - var x = bounds.minX, y = bounds.minY, rowHeight: CGFloat = 0 - for view in subviews { - let size = view.sizeThatFits(.unspecified) - if x + size.width > bounds.maxX, x > bounds.minX { - x = bounds.minX - y += rowHeight + spacing - rowHeight = 0 - } - view.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size)) - x += size.width + spacing - rowHeight = max(rowHeight, size.height) - } - } -} diff --git a/Rsync Studio/Views/Components/PathField.swift b/Rsync Studio/Views/Components/PathField.swift deleted file mode 100644 index f39100b..0000000 --- a/Rsync Studio/Views/Components/PathField.swift +++ /dev/null @@ -1,22 +0,0 @@ -import SwiftUI - -struct PathField: View { - let title: String - @Binding var path: String - var canBrowse: Bool = true - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - Text(title).font(.headline) - HStack { - TextField(title, text: $path) - .textFieldStyle(.roundedBorder) - if canBrowse { - Button("Choose…") { - if let selected = FilePicker.chooseDirectory() { path = selected } - } - } - } - } - } -} diff --git a/Rsync Studio/Views/Components/SplashView.swift b/Rsync Studio/Views/Components/SplashView.swift deleted file mode 100644 index dd796f3..0000000 --- a/Rsync Studio/Views/Components/SplashView.swift +++ /dev/null @@ -1,49 +0,0 @@ -import SwiftUI - -struct SplashView: View { - @State private var animate = false - - var body: some View { - ZStack { - LinearGradient( - colors: [Color.accentColor.opacity(0.95), Color.accentColor.opacity(0.62), Color(nsColor: .windowBackgroundColor)], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - .ignoresSafeArea() - - VStack(spacing: 22) { - ZStack { - RoundedRectangle(cornerRadius: 30, style: .continuous) - .fill(.ultraThinMaterial) - .frame(width: 126, height: 126) - .shadow(color: .black.opacity(0.18), radius: 28, y: 14) - - Image(systemName: "arrow.triangle.2.circlepath") - .font(.system(size: 58, weight: .semibold)) - .symbolRenderingMode(.hierarchical) - .foregroundStyle(.white) - .rotationEffect(.degrees(animate ? 360 : 0)) - } - - VStack(spacing: 6) { - Text("Rsync Studio") - .font(.system(size: 32, weight: .bold, design: .rounded)) - Text("Fast, dependable file synchronisation for macOS") - .font(.headline) - .foregroundStyle(.secondary) - } - - ProgressView() - .controlSize(.small) - .padding(.top, 2) - } - .padding(48) - } - .onAppear { - withAnimation(.linear(duration: 1.25).repeatForever(autoreverses: false)) { - animate = true - } - } - } -} diff --git a/Rsync Studio/Views/Components/StatusBadge.swift b/Rsync Studio/Views/Components/StatusBadge.swift deleted file mode 100644 index bd5171e..0000000 --- a/Rsync Studio/Views/Components/StatusBadge.swift +++ /dev/null @@ -1,25 +0,0 @@ -import SwiftUI - -struct StatusBadge: View { - let run: SyncRun - - var body: some View { - Label(text, systemImage: icon) - .font(.caption.weight(.semibold)) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(.quaternary, in: Capsule()) - } - - private var text: String { - if run.isRunning { return "Running" } - if run.wasCancelled { return "Cancelled" } - return run.succeeded ? "Success" : "Failed" - } - - private var icon: String { - if run.isRunning { return "arrow.triangle.2.circlepath" } - if run.wasCancelled { return "stop.circle" } - return run.succeeded ? "checkmark.circle.fill" : "xmark.circle.fill" - } -} diff --git a/Rsync Studio/Views/HelpView.swift b/Rsync Studio/Views/HelpView.swift deleted file mode 100644 index 533986b..0000000 --- a/Rsync Studio/Views/HelpView.swift +++ /dev/null @@ -1,220 +0,0 @@ -import SwiftUI - -struct HelpView: View { - @Environment(\.dismiss) private var dismiss - @State private var blocks: [HelpBlock] = [.paragraph("Loading help…")] - - var body: some View { - VStack(spacing: 0) { - HStack(spacing: 12) { - Image(systemName: "questionmark.circle.fill") - .font(.title2) - .foregroundStyle(Color.accentColor) - - VStack(alignment: .leading, spacing: 1) { - Text("Rsync Studio Help") - .font(.headline) - Text("Instructions, examples and troubleshooting") - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - - Button("Done") { dismiss() } - .keyboardShortcut(.cancelAction) - } - .padding(.horizontal, 18) - .padding(.vertical, 12) - .background(.bar) - - Divider() - - ScrollView { - LazyVStack(alignment: .leading, spacing: 14) { - ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in - blockView(block) - } - } - .textSelection(.enabled) - .frame(maxWidth: 900, alignment: .leading) - .padding(.horizontal, 34) - .padding(.vertical, 28) - .frame(maxWidth: .infinity, alignment: .center) - } - } - .frame(minWidth: 780, idealWidth: 940, minHeight: 640, idealHeight: 780) - .task { loadHelp() } - } - - @ViewBuilder - private func blockView(_ block: HelpBlock) -> some View { - switch block { - case .heading(let level, let text): - Text(text) - .font(headingFont(level)) - .padding(.top, level == 1 ? 4 : 10) - .frame(maxWidth: .infinity, alignment: .leading) - - case .paragraph(let text): - Text(markdown(text)) - .font(.body) - .lineSpacing(4) - .frame(maxWidth: .infinity, alignment: .leading) - - case .bullet(let text): - HStack(alignment: .firstTextBaseline, spacing: 10) { - Text("•") - .font(.body.bold()) - Text(markdown(text)) - .font(.body) - .lineSpacing(3) - } - .padding(.leading, 6) - - case .numbered(let number, let text): - HStack(alignment: .firstTextBaseline, spacing: 10) { - Text("\(number).") - .font(.body.weight(.semibold)) - .frame(width: 28, alignment: .trailing) - Text(markdown(text)) - .font(.body) - .lineSpacing(3) - } - - case .code(let text): - ScrollView(.horizontal) { - Text(text) - .font(.system(.body, design: .monospaced)) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(14) - } - .background(Color(nsColor: .textBackgroundColor).opacity(0.55), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(.quaternary, lineWidth: 1) - } - } - } - - private func headingFont(_ level: Int) -> Font { - switch level { - case 1: return .largeTitle.bold() - case 2: return .title2.bold() - case 3: return .title3.weight(.semibold) - default: return .headline - } - } - - private func markdown(_ text: String) -> AttributedString { - (try? AttributedString( - markdown: text, - options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) - )) ?? AttributedString(text) - } - - private func loadHelp() { - guard let url = Bundle.main.url(forResource: "Rsync Studio Help", withExtension: "md"), - let text = try? String(contentsOf: url, encoding: .utf8) else { - blocks = [.heading(1, "Help unavailable"), .paragraph("The bundled help document could not be loaded.")] - return - } - blocks = HelpMarkdownParser.parse(text) - } -} - -private enum HelpBlock { - case heading(Int, String) - case paragraph(String) - case bullet(String) - case numbered(Int, String) - case code(String) -} - -private enum HelpMarkdownParser { - static func parse(_ source: String) -> [HelpBlock] { - var result: [HelpBlock] = [] - var paragraph: [String] = [] - var codeLines: [String] = [] - var insideCode = false - - func flushParagraph() { - guard !paragraph.isEmpty else { return } - result.append(.paragraph(paragraph.joined(separator: " "))) - paragraph.removeAll(keepingCapacity: true) - } - - func flushCode() { - guard !codeLines.isEmpty else { return } - result.append(.code(codeLines.joined(separator: "\n"))) - codeLines.removeAll(keepingCapacity: true) - } - - for rawLine in source.components(separatedBy: .newlines) { - let line = rawLine.trimmingCharacters(in: .whitespaces) - - if line.hasPrefix("```") { - if insideCode { - flushCode() - insideCode = false - } else { - flushParagraph() - insideCode = true - } - continue - } - - if insideCode { - codeLines.append(rawLine) - continue - } - - if line.isEmpty { - flushParagraph() - continue - } - - if let heading = heading(from: line) { - flushParagraph() - result.append(.heading(heading.level, heading.text)) - continue - } - - if line.hasPrefix("- ") || line.hasPrefix("* ") { - flushParagraph() - result.append(.bullet(String(line.dropFirst(2)))) - continue - } - - if let numbered = numberedItem(from: line) { - flushParagraph() - result.append(.numbered(numbered.number, numbered.text)) - continue - } - - paragraph.append(line) - } - - flushParagraph() - flushCode() - return result - } - - private static func heading(from line: String) -> (level: Int, text: String)? { - let hashes = line.prefix { $0 == "#" }.count - guard hashes > 0, hashes <= 6 else { return nil } - let remainder = line.dropFirst(hashes) - guard remainder.first == " " else { return nil } - return (hashes, String(remainder.dropFirst())) - } - - private static func numberedItem(from line: String) -> (number: Int, text: String)? { - guard let dot = line.firstIndex(of: ".") else { return nil } - let numberText = line[.., - buttonTitle: String, - action: @escaping () -> Void - ) -> some View { - HStack(spacing: 12) { - ZStack { - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(colour.opacity(0.12)) - Image(systemName: icon) - .foregroundStyle(colour) - } - .frame(width: 34, height: 34) - - VStack(alignment: .leading, spacing: 2) { - Text(title).font(.body.weight(.semibold)) - Text(subtitle) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - } - - Spacer(minLength: 12) - - Toggle("", isOn: isOn) - .labelsHidden() - .toggleStyle(.switch) - - Button(buttonTitle, action: action) - .buttonStyle(.bordered) - .controlSize(.small) - .frame(width: 88) - } - .padding(.vertical, 10) - } - - private var watchSubtitle: String { - guard job.watchEnabled else { return "Off" } - let path = job.effectiveWatchPath - return "Watching \(path) · \(job.watchDebounceSeconds)s debounce" - } - - private var optionsSection: some View { - GroupBox("Options") { - FlowLayout(spacing: 8) { - option("Archive", job.archive) - option("Compress", job.compress) - option("Delete", job.deleteExtraneous) - option("Permissions", job.preservePermissions) - option("Extended Attributes", job.preserveExtendedAttributes) - option("Progress", job.showProgress) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, 3) - } - } - - private var scheduleBinding: Binding { - Binding( - get: { job.scheduleEnabled }, - set: { enabled in - if enabled && job.deleteExtraneous && !job.allowUnattendedDelete { - errorMessage = "This job uses --delete. Edit the job and explicitly enable unattended delete before turning on scheduling." - return - } - job.scheduleEnabled = enabled - if enabled { - if job.scheduleKind == .manual { job.scheduleKind = .daily } - if job.scheduleStartAt < Date() { job.scheduleStartAt = Date().addingTimeInterval(3600) } - job.nextRunAt = job.calculateNextRun(after: Date().addingTimeInterval(-1)) - } else { - job.nextRunAt = nil - } - persistAutomationChange() - } - ) - } - - private var watchBinding: Binding { - Binding( - get: { job.watchEnabled }, - set: { enabled in - if enabled && job.deleteExtraneous && !job.allowUnattendedDelete { - errorMessage = "This job uses --delete. Edit the job and explicitly enable unattended delete before turning on file watching." - return - } - job.watchEnabled = enabled - if enabled && job.watchPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - job.watchPath = job.sourcePath - } - persistAutomationChange() - } - ) - } - - private func openEditor(_ section: JobEditorSection?) { - editorSection = section - showingEditor = true - } - - private func persistAutomationChange() { - job.updatedAt = Date() - do { - try modelContext.save() - NotificationCenter.default.post(name: .syncJobsChanged, object: nil) - } catch { - errorMessage = "Could not save automation settings: \(error.localizedDescription)" - } - } - - @ViewBuilder - private func option(_ name: String, _ enabled: Bool) -> some View { - if enabled { - Label(name, systemImage: "checkmark") - .font(.caption.weight(.medium)) - .padding(.horizontal, 9) - .padding(.vertical, 5) - .background(.quaternary, in: Capsule()) - } - } - - private enum RunPresentation { - case manual - case scheduled - - var showsModal: Bool { - switch self { - case .manual: return true - case .scheduled: return false - } - } - } - - private func launch(dryRun: Bool, presentation: RunPresentation) { - let executable = RsyncLocator.locate() - let command = RsyncCommandBuilder.displayCommand(job: job, dryRun: dryRun, executable: executable) - let run = SyncRun(job: job, command: command, wasDryRun: dryRun, trigger: .manual) - modelContext.insert(run) - do { - try modelContext.save() - } catch { - errorMessage = "Could not create run history: \(error.localizedDescription)" - return - } - - if presentation.showsModal { - presentedRun = run - showingRunSheet = true - } - - Task { - do { - let result = try await runner.run(job: job, dryRun: dryRun, trigger: .manual) - run.output = SyncRun.sanitizedLog(result.stdout) - run.errorOutput = SyncRun.sanitizedLog(result.stderr) - run.exitCode = Int(result.exitCode) - run.wasCancelled = result.wasCancelled - run.finishedAt = Date() - job.lastRunAt = run.finishedAt - job.lastExitCode = Int(result.exitCode) - job.updatedAt = Date() - do { try modelContext.save() } - catch { errorMessage = "Could not save run history: \(error.localizedDescription)" } - } catch { - run.errorOutput = SyncRun.sanitizedLog(error.localizedDescription) - run.exitCode = -1 - run.finishedAt = Date() - job.lastRunAt = run.finishedAt - job.lastExitCode = -1 - do { try modelContext.save() } - catch { errorMessage = "Could not save failed run history: \(error.localizedDescription)" } - - if !presentation.showsModal { - errorMessage = error.localizedDescription - } - } - } - } -} -private struct RunProgressSheet: View { - @Environment(\.dismiss) private var dismiss - @EnvironmentObject private var runner: RsyncRunner - @Bindable var run: SyncRun - let job: SyncJob - @State private var exportError: String? - @State private var showTechnicalDetails = false - @State private var now = Date() - - private var isRunning: Bool { run.finishedAt == nil } - - var body: some View { - VStack(spacing: 0) { - header - Divider() - Group { - if isRunning { runningContent } else { completedContent } - } - } - .frame(width: 680) - .frame(minHeight: 520) - .background(Color(nsColor: .windowBackgroundColor)) - .interactiveDismissDisabled(isRunning) - .task { - while isRunning { - try? await Task.sleep(for: .seconds(1)) - now = Date() - } - } - .alert("Could Not Export Log", isPresented: Binding( - get: { exportError != nil }, - set: { if !$0 { exportError = nil } } - )) { - Button("OK", role: .cancel) { exportError = nil } - } message: { - Text(exportError ?? "Unknown error") - } - } - - private var header: some View { - HStack(spacing: 14) { - ZStack { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color.accentColor.opacity(0.12)) - .frame(width: 44, height: 44) - Image(systemName: run.wasDryRun ? "doc.text.magnifyingglass" : "arrow.triangle.2.circlepath") - .font(.system(size: 20, weight: .semibold)) - .foregroundStyle(Color.accentColor) - } - VStack(alignment: .leading, spacing: 3) { - Text(run.wasDryRun ? "Dry Run" : "Synchronisation") - .font(.title2.bold()) - Text(run.jobName) - .foregroundStyle(.secondary) - } - Spacer() - if !isRunning { - Button("Done") { dismiss() } - .buttonStyle(.borderedProminent) - .keyboardShortcut(.defaultAction) - } - } - .padding(22) - .background(.bar) - } - - private var runningContent: some View { - VStack(spacing: 26) { - Spacer(minLength: 26) - - AnimatedSyncIndicator(size: 124, lineWidth: 9) - .padding(.bottom, 2) - - VStack(spacing: 8) { - Text(run.wasDryRun ? "Checking what would change…" : "Synchronising files…") - .font(.title3.bold()) - Text(run.wasDryRun - ? "Rsync is comparing the source and destination. No files will be changed." - : "Rsync is scanning and transferring changed files. Full output is being captured without slowing the interface.") - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 500) - } - - connectionCard - - HStack(spacing: 22) { - Label("Elapsed \(elapsedText)", systemImage: "timer") - Label("Log captured", systemImage: "doc.text") - } - .font(.caption.weight(.medium)) - .foregroundStyle(.secondary) - - Spacer() - - HStack { - Text("You can continue using the app while this transfer runs.") - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - if runner.currentJobID == run.jobID { - Button("Cancel", role: .destructive) { runner.cancel() } - .buttonStyle(.bordered) - .controlSize(.large) - } - } - .padding(22) - .background(.bar) - } - } - - private var connectionCard: some View { - HStack(spacing: 16) { - VStack(alignment: .leading, spacing: 5) { - Label("Source", systemImage: "folder.fill") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - Text(job.sourcePath) - .font(.system(.body, design: .monospaced)) - .lineLimit(2) - .truncationMode(.middle) - } - .frame(maxWidth: .infinity, alignment: .leading) - - Image(systemName: "arrow.right") - .font(.title3.weight(.semibold)) - .foregroundStyle(Color.accentColor) - - VStack(alignment: .leading, spacing: 5) { - Label("Destination", systemImage: job.isRemote ? "server.rack" : "externaldrive.fill") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - Text(job.destinationDisplay) - .font(.system(.body, design: .monospaced)) - .lineLimit(2) - .truncationMode(.middle) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(16) - .background(.quaternary.opacity(0.45), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) - .padding(.horizontal, 28) - } - - private var completedContent: some View { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - completionHero - - if run.summary.hasStatistics { - HStack(spacing: 12) { - metricCard(title: run.wasDryRun ? "Would transfer" : "Transferred", value: run.summary.numberOfFilesTransferred ?? "—", icon: "doc.on.doc.fill") - metricCard(title: "Data", value: run.summary.totalTransferredFileSize ?? "—", icon: "arrow.down.circle.fill") - metricCard(title: "Files checked", value: run.summary.numberOfFiles ?? "—", icon: "checklist") - } - - GroupBox("Transfer Summary") { - Grid(alignment: .leading, horizontalSpacing: 34, verticalSpacing: 10) { - summaryRow("Files checked", run.summary.numberOfFiles) - summaryRow(run.wasDryRun ? "Files that would transfer" : "Files transferred", run.summary.numberOfFilesTransferred) - summaryRow("Total source size", run.summary.totalFileSize) - summaryRow(run.wasDryRun ? "Data that would transfer" : "Data transferred", run.summary.totalTransferredFileSize) - summaryRow("Unmatched data", run.summary.unmatchedData) - summaryRow("Matched data", run.summary.matchedData) - summaryRow("File list size", run.summary.fileListSize) - summaryRow("List generation", run.summary.fileListGenerationTime) - summaryRow("List transfer", run.summary.fileListTransferTime) - summaryRow("Sent", run.summary.totalSent) - summaryRow("Received", run.summary.totalReceived) - } - .padding(.vertical, 4) - .frame(maxWidth: .infinity, alignment: .leading) - } - - if run.summary.transferRate != nil || run.summary.totalSizeAndSpeedup != nil { - VStack(alignment: .leading, spacing: 6) { - if let transferRate = run.summary.transferRate { Text(transferRate) } - if let speedup = run.summary.totalSizeAndSpeedup { Text(speedup) } - } - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - .textSelection(.enabled) - } - } - - if !run.succeeded { - DisclosureGroup("Technical Details", isExpanded: $showTechnicalDetails) { - Text(run.errorOutput.isEmpty ? "Rsync exited without additional error output." : run.errorOutput) - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 8) - } - .padding(14) - .background(Color.orange.opacity(0.08), in: RoundedRectangle(cornerRadius: 12)) - } - - Divider() - - HStack { - Button { exportLog() } label: { - Label("Export Log…", systemImage: "square.and.arrow.up") - } - .controlSize(.large) - - Spacer() - - if let duration = run.duration { - Label(Duration.seconds(duration).formatted(.units(allowed: [.minutes, .seconds], width: .abbreviated)), systemImage: "timer") - .foregroundStyle(.secondary) - } - - Button("Done") { dismiss() } - .buttonStyle(.borderedProminent) - .controlSize(.large) - } - } - .padding(24) - } - } - - private var completionHero: some View { - HStack(spacing: 16) { - Image(systemName: completionIcon) - .font(.system(size: 42, weight: .semibold)) - .foregroundStyle(completionColour) - VStack(alignment: .leading, spacing: 4) { - Text(completionTitle) - .font(.title2.bold()) - Text(completionMessage) - .foregroundStyle(.secondary) - if let exitCode = run.exitCode, !run.succeeded { - Text("Rsync exit code \(exitCode)") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - } - } - } - .padding(18) - .frame(maxWidth: .infinity, alignment: .leading) - .background(completionColour.opacity(0.09), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) - } - - private func metricCard(title: String, value: String, icon: String) -> some View { - VStack(alignment: .leading, spacing: 8) { - Image(systemName: icon) - .foregroundStyle(Color.accentColor) - Text(value) - .font(.title3.bold()) - .lineLimit(1) - Text(title) - .font(.caption) - .foregroundStyle(.secondary) - } - .padding(14) - .frame(maxWidth: .infinity, alignment: .leading) - .background(.quaternary.opacity(0.45), in: RoundedRectangle(cornerRadius: 13, style: .continuous)) - } - - private var completionIcon: String { - if run.wasCancelled { return "xmark.circle.fill" } - return run.succeeded ? "checkmark.circle.fill" : "exclamationmark.triangle.fill" - } - - private var completionColour: Color { - if run.wasCancelled { return .secondary } - return run.succeeded ? .green : .orange - } - - private var completionTitle: String { - if run.wasCancelled { return "Synchronisation Cancelled" } - if run.succeeded { return run.wasDryRun ? "Dry Run Complete" : "Synchronisation Complete" } - return "Synchronisation Failed" - } - - private var completionMessage: String { - if run.wasCancelled { return "The transfer was stopped before it completed." } - if run.succeeded && run.wasDryRun { return "No files were changed. The summary below shows what a real run would transfer." } - if run.succeeded { return "Your files were synchronised successfully." } - if !run.errorOutput.isEmpty { - return run.errorOutput.components(separatedBy: .newlines).first(where: { !$0.trimmingCharacters(in: .whitespaces).isEmpty }) ?? "Rsync reported an error." - } - return "Rsync could not complete this synchronisation." - } - - private var elapsedText: String { - let interval = max(0, now.timeIntervalSince(run.startedAt)) - return Duration.seconds(interval).formatted(.units(allowed: [.minutes, .seconds], width: .abbreviated)) - } - - @ViewBuilder - private func summaryRow(_ title: String, _ value: String?) -> some View { - if let value { - GridRow { - Text(title).foregroundStyle(.secondary) - Text(value).fontWeight(.medium).textSelection(.enabled) - } - } - } - - private func exportLog() { - let panel = NSSavePanel() - panel.title = "Export Rsync Log" - panel.prompt = "Export" - panel.canCreateDirectories = true - panel.nameFieldStringValue = defaultLogFilename - guard panel.runModal() == .OK, let url = panel.url else { return } - do { try run.exportText.write(to: url, atomically: true, encoding: .utf8) } - catch { exportError = error.localizedDescription } - } - - private var defaultLogFilename: String { - let safeName = run.jobName.replacingOccurrences(of: "/", with: "-").replacingOccurrences(of: ":", with: "-") - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss" - return "\(safeName)-\(formatter.string(from: run.startedAt)).log" - } -} diff --git a/Rsync Studio/Views/Jobs/JobEditorView.swift b/Rsync Studio/Views/Jobs/JobEditorView.swift deleted file mode 100644 index 095849d..0000000 --- a/Rsync Studio/Views/Jobs/JobEditorView.swift +++ /dev/null @@ -1,362 +0,0 @@ -import SwiftUI -import SwiftData - -enum JobEditorSection: Hashable { - case schedule - case fileWatch -} - -struct JobEditorView: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.modelContext) private var modelContext - @AppStorage("defaultSSHUsername") private var defaultSSHUsername = NSUserName() - - let existingJob: SyncJob? - let initialSection: JobEditorSection? - - @State private var name: String - @State private var sourcePath: String - @State private var destinationPath: String - @State private var isRemote: Bool - @State private var remoteHost: String - @State private var remoteUser: String - @State private var remotePort: Int - @State private var sshKeyPath: String - @State private var archive: Bool - @State private var compress: Bool - @State private var deleteExtraneous: Bool - @State private var preservePermissions: Bool - @State private var preserveExtendedAttributes: Bool - @State private var showProgress: Bool - @State private var exclusionsText: String - @State private var additionalArguments: String - @State private var isEnabled: Bool - @State private var scheduleEnabled: Bool - @State private var scheduleKind: SyncScheduleKind - @State private var scheduleStartAt: Date - @State private var scheduleIntervalMinutes: Int - @State private var watchEnabled: Bool - @State private var watchPath: String - @State private var watchDebounceSeconds: Int - @State private var allowUnattendedDelete: Bool - @State private var retryCount: Int - @State private var retryDelaySeconds: Int - @State private var notifyOnFailure: Bool - @State private var notifyOnSuccess: Bool - @State private var requireExpectedVolume: Bool - @State private var expectedVolumeIdentifier: String - @State private var minimumFreeSpaceMB: Int - @State private var validationMessage: String? - - init(job: SyncJob? = nil, initialSection: JobEditorSection? = nil) { - self.existingJob = job - self.initialSection = initialSection - _name = State(initialValue: job?.name ?? "New Sync Job") - _sourcePath = State(initialValue: job?.sourcePath ?? "") - _destinationPath = State(initialValue: job?.destinationPath ?? "") - _isRemote = State(initialValue: job?.isRemote ?? false) - _remoteHost = State(initialValue: job?.remoteHost ?? UserDefaults.standard.string(forKey: "defaultRemoteHost") ?? "") - _remoteUser = State(initialValue: job?.remoteUser ?? UserDefaults.standard.string(forKey: "defaultSSHUsername") ?? NSUserName()) - let savedPort = UserDefaults.standard.integer(forKey: "defaultSSHPort") - _remotePort = State(initialValue: job?.remotePort ?? (savedPort == 0 ? 22 : savedPort)) - _sshKeyPath = State(initialValue: job?.sshKeyPath ?? UserDefaults.standard.string(forKey: "defaultSSHKeyPath") ?? "") - _archive = State(initialValue: job?.archive ?? true) - _compress = State(initialValue: job?.compress ?? false) - _deleteExtraneous = State(initialValue: job?.deleteExtraneous ?? false) - _preservePermissions = State(initialValue: job?.preservePermissions ?? true) - _preserveExtendedAttributes = State(initialValue: job?.preserveExtendedAttributes ?? true) - _showProgress = State(initialValue: job?.showProgress ?? true) - _exclusionsText = State(initialValue: job?.exclusionsText ?? "") - _additionalArguments = State(initialValue: job?.additionalArguments ?? "") - _isEnabled = State(initialValue: job?.isEnabled ?? true) - _scheduleEnabled = State(initialValue: job?.scheduleEnabled ?? false) - _scheduleKind = State(initialValue: job?.scheduleKind ?? .daily) - _scheduleStartAt = State(initialValue: job?.scheduleStartAt ?? Date().addingTimeInterval(3600)) - _scheduleIntervalMinutes = State(initialValue: job?.scheduleIntervalMinutes ?? 30) - _watchEnabled = State(initialValue: job?.watchEnabled ?? false) - _watchPath = State(initialValue: job?.watchPath ?? "") - let defaultDebounce = UserDefaults.standard.integer(forKey: "defaultWatchDebounceSeconds") - _watchDebounceSeconds = State(initialValue: job?.watchDebounceSeconds ?? (defaultDebounce == 0 ? 3 : defaultDebounce)) - _allowUnattendedDelete = State(initialValue: job?.allowUnattendedDelete ?? false) - _retryCount = State(initialValue: job?.retryCount ?? 2) - _retryDelaySeconds = State(initialValue: job?.retryDelaySeconds ?? 10) - _notifyOnFailure = State(initialValue: job?.notifyOnFailure ?? true) - _notifyOnSuccess = State(initialValue: job?.notifyOnSuccess ?? false) - _requireExpectedVolume = State(initialValue: job?.requireExpectedVolume ?? false) - _expectedVolumeIdentifier = State(initialValue: job?.expectedVolumeIdentifier ?? "") - _minimumFreeSpaceMB = State(initialValue: job?.minimumFreeSpaceMB ?? 512) - _validationMessage = State(initialValue: nil) - } - - var body: some View { - NavigationStack { - ScrollViewReader { proxy in - Form { - Section("Job") { - TextField("Name", text: $name) - Toggle("Enabled", isOn: $isEnabled) - } - - Section("Locations") { - PathField(title: "Source", path: $sourcePath) - Picker("Destination type", selection: $isRemote) { - Text("Local / Mounted Volume").tag(false) - Text("SSH / Remote Mac or Server").tag(true) - } - .pickerStyle(.segmented) - - if isRemote { - TextField("Remote path", text: $destinationPath, prompt: Text("/home/mag/backup or ~/backup")) - .textFieldStyle(.roundedBorder) - } else { - PathField(title: "Destination", path: $destinationPath) - } - } - - if !isRemote { - Section("Destination Safety") { - Stepper("Minimum free space: \(minimumFreeSpaceMB) MB", value: $minimumFreeSpaceMB, in: 0...1_048_576, step: 256) - Toggle("Require this exact destination volume", isOn: $requireExpectedVolume) - if requireExpectedVolume { - HStack { - Text(expectedVolumeIdentifier.isEmpty ? "No volume captured" : expectedVolumeIdentifier) - .font(.caption.monospaced()) - .lineLimit(1) - .truncationMode(.middle) - Spacer() - Button("Capture Current Volume") { - let expanded = (destinationPath as NSString).expandingTildeInPath - if FileManager.default.fileExists(atPath: expanded), let info = SyncVolumeInspector.info(forPath: expanded) { - expectedVolumeIdentifier = info.identifier - } else { - validationMessage = "Mount and select the actual destination volume before capturing its identity." - } - } - } - Text("Prevents a missing external drive from being replaced by an unintended local folder with the same path.") - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - - if isRemote { - Section("SSH") { - TextField("Host", text: $remoteHost, prompt: Text("pegasus.local")) - TextField("User", text: $remoteUser, prompt: Text(defaultSSHUsername.isEmpty ? NSUserName() : defaultSSHUsername)) - Stepper("Port: \(remotePort)", value: $remotePort, in: 1...65535) - HStack { - TextField("Private SSH key", text: $sshKeyPath, prompt: Text("~/.ssh/id_ed25519 or ~/.ssh/id_rsa")) - Button("Choose…") { - if let selected = FilePicker.chooseFile() { sshKeyPath = selected } - } - } - if sshKeyPath.hasSuffix(".pub") { - Label("Select the private key, not the .pub public key.", systemImage: "exclamationmark.triangle.fill") - .font(.caption) - .foregroundStyle(.orange) - } - } - } - - Section { - Toggle("Run this job on a schedule", isOn: $scheduleEnabled) - if scheduleEnabled { - Picker("Frequency", selection: $scheduleKind) { - ForEach(SyncScheduleKind.allCases.filter { $0 != .manual }) { kind in - Label(kind.title, systemImage: kind.systemImage).tag(kind) - } - } - - DatePicker( - scheduleKind == .once ? "Run date and time" : "Starting", - selection: $scheduleStartAt, - displayedComponents: scheduleKind == .once ? [.date, .hourAndMinute] : [.date, .hourAndMinute] - ) - - if scheduleKind == .custom { - Stepper("Every \(scheduleIntervalMinutes) minutes", value: $scheduleIntervalMinutes, in: 1...525600) - } - - Label(schedulePreview, systemImage: "calendar.badge.clock") - .font(.caption) - .foregroundStyle(.secondary) - } - } header: { - Text("Schedule") - } footer: { - Text("Scheduled jobs run silently and are recorded in History. They continue while Rsync Studio is running, even when its window is in the background.") - } - .id(JobEditorSection.schedule) - - Section { - Toggle("Watch for file changes", isOn: $watchEnabled) - if watchEnabled { - HStack { - TextField("Watch path", text: $watchPath, prompt: Text(sourcePath.isEmpty ? "Uses the job source" : sourcePath)) - Menu("Choose…") { - Button("Choose Folder…") { - if let selected = FilePicker.chooseDirectory() { watchPath = selected } - } - Button("Choose File…") { - if let selected = FilePicker.chooseFile() { watchPath = selected } - } - } - } - Stepper("Wait \(watchDebounceSeconds) seconds after changes", value: $watchDebounceSeconds, in: 1...60) - Label("Changes are monitored with macOS FSEvents. Bursts of edits are combined into one sync.", systemImage: "waveform.path.ecg") - .font(.caption) - .foregroundStyle(.secondary) - } - } header: { - Text("File Watching") - } footer: { - Text("Leave the watch path empty to watch the source. If the source is a single file, only that file is synced; if it is a folder, the configured folder job runs when anything inside changes.") - } - .id(JobEditorSection.fileWatch) - - Section("Reliability & Notifications") { - Stepper("Automatic retries: \(retryCount)", value: $retryCount, in: 0...10) - Stepper("Initial retry delay: \(retryDelaySeconds) seconds", value: $retryDelaySeconds, in: 1...300) - Text("Retries apply to scheduled and file-watch runs and use an increasing delay between attempts.") - .font(.caption) - .foregroundStyle(.secondary) - Toggle("Notify when a run fails", isOn: $notifyOnFailure) - Toggle("Notify when a run succeeds", isOn: $notifyOnSuccess) - } - - Section("Rsync Options") { - Toggle("Archive mode (-a)", isOn: $archive) - Toggle("Compress during transfer (-z)", isOn: $compress) - Toggle("Delete files missing from source (--delete)", isOn: $deleteExtraneous) - if deleteExtraneous && (scheduleEnabled || watchEnabled) { - Toggle("Allow unattended delete", isOn: $allowUnattendedDelete) - Text("Required before scheduled or file-watch runs may use --delete. Enable only after validating the job with Dry Run.") - .font(.caption) - .foregroundStyle(.orange) - } - Toggle("Preserve permissions", isOn: $preservePermissions) - Toggle("Preserve extended attributes", isOn: $preserveExtendedAttributes) - Toggle("Show transfer progress", isOn: $showProgress) - } - - Section("Exclusions") { - TextEditor(text: $exclusionsText) - .font(.system(.body, design: .monospaced)) - .frame(minHeight: 90) - Text("One exclusion pattern per line.") - .font(.caption) - .foregroundStyle(.secondary) - } - - Section("Advanced") { - TextField("Additional rsync arguments", text: $additionalArguments) - .font(.system(.body, design: .monospaced)) - Text("Transport, password, remote-command and destructive options are blocked here. Use the dedicated controls above for SSH and --delete.") - .font(.caption) - .foregroundStyle(.secondary) - } - } - .formStyle(.grouped) - .navigationTitle(existingJob == nil ? "New Sync Job" : "Edit Sync Job") - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } - } - ToolbarItem(placement: .confirmationAction) { - Button("Save") { save() } - .buttonStyle(.borderedProminent) - .keyboardShortcut(.defaultAction) - .disabled(!canSave) - } - } - .task { - guard let initialSection else { return } - try? await Task.sleep(for: .milliseconds(120)) - withAnimation { proxy.scrollTo(initialSection, anchor: .top) } - } - } - } - .alert("Invalid Sync Job", isPresented: Binding( - get: { validationMessage != nil }, - set: { if !$0 { validationMessage = nil } } - )) { - Button("OK", role: .cancel) { validationMessage = nil } - } message: { - Text(validationMessage ?? "The job configuration is invalid.") - } - .frame(width: 690, height: 820) - } - - private var canSave: Bool { - !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && - !sourcePath.isEmpty && - !destinationPath.isEmpty && - (!isRemote || !remoteHost.isEmpty) - } - - private var schedulePreview: String { - let previewJob = SyncJob() - previewJob.scheduleEnabled = scheduleEnabled - previewJob.scheduleKind = scheduleKind - previewJob.scheduleStartAt = scheduleStartAt - previewJob.scheduleIntervalMinutes = scheduleIntervalMinutes - if let next = previewJob.calculateNextRun() { - return "Next run: \(next.formatted(date: .abbreviated, time: .shortened))" - } - return "No future run is currently scheduled." - } - - private func save() { - let job = existingJob ?? SyncJob() - job.name = name.trimmingCharacters(in: .whitespacesAndNewlines) - job.sourcePath = sourcePath - job.destinationPath = destinationPath - job.isRemote = isRemote - job.remoteHost = remoteHost - job.remoteUser = remoteUser - job.remotePort = remotePort - job.sshKeyPath = sshKeyPath - job.archive = archive - job.compress = compress - job.deleteExtraneous = deleteExtraneous - job.preservePermissions = preservePermissions - job.preserveExtendedAttributes = preserveExtendedAttributes - job.showProgress = showProgress - job.exclusionsText = exclusionsText - job.additionalArguments = additionalArguments - job.isEnabled = isEnabled - job.scheduleEnabled = scheduleEnabled - job.scheduleKind = scheduleEnabled ? scheduleKind : .manual - job.scheduleStartAt = scheduleStartAt - job.scheduleIntervalMinutes = scheduleIntervalMinutes - job.nextRunAt = job.calculateNextRun(after: Date().addingTimeInterval(-1)) - job.watchEnabled = watchEnabled - job.watchPath = watchPath - job.watchDebounceSeconds = watchDebounceSeconds - job.allowUnattendedDelete = deleteExtraneous ? allowUnattendedDelete : false - job.retryCount = retryCount - job.retryDelaySeconds = retryDelaySeconds - job.notifyOnFailure = notifyOnFailure - job.notifyOnSuccess = notifyOnSuccess - job.requireExpectedVolume = !isRemote && requireExpectedVolume - job.expectedVolumeIdentifier = !isRemote && requireExpectedVolume ? expectedVolumeIdentifier : "" - job.minimumFreeSpaceMB = minimumFreeSpaceMB - job.updatedAt = Date() - - let validationTrigger: SyncRunTrigger = (job.scheduleEnabled || job.watchEnabled) ? .scheduled : .manual - if let message = RsyncSecurityValidator.validationMessage(for: job, trigger: validationTrigger) { - validationMessage = message - return - } - - do { - if existingJob == nil { modelContext.insert(job) } - try modelContext.save() - NotificationCenter.default.post(name: .syncJobsChanged, object: nil) - dismiss() - } catch { - validationMessage = "Could not save the job: \(error.localizedDescription)" - } - } -} diff --git a/Rsync Studio/Views/Jobs/JobListView.swift b/Rsync Studio/Views/Jobs/JobListView.swift deleted file mode 100644 index f26b695..0000000 --- a/Rsync Studio/Views/Jobs/JobListView.swift +++ /dev/null @@ -1,523 +0,0 @@ -import SwiftUI -import SwiftData -import AppKit -import UniformTypeIdentifiers - -struct JobListView: View { - @Environment(\.modelContext) private var modelContext - @EnvironmentObject private var automation: BackgroundSyncCoordinator - @Query(sort: \SyncJob.name) private var jobs: [SyncJob] - @Binding var selection: SyncJob? - @Binding var showingNewJob: Bool - @State private var searchText = "" - @State private var filter: JobFilter = .all - @State private var persistenceError: String? - @State private var transferMessage: String? - - var body: some View { - VStack(spacing: 0) { - dashboardHeader - - Picker("Filter", selection: $filter) { - ForEach(JobFilter.allCases) { item in - Text(item.title).tag(item) - } - } - .pickerStyle(.segmented) - .labelsHidden() - .padding(.horizontal, 14) - .padding(.bottom, 10) - - Divider() - - if filteredJobs.isEmpty { - emptyState - } else { - List(selection: $selection) { - ForEach(filteredJobs) { job in - JobRow(job: job, isRunning: automationActivityMatches(job)) - .tag(job) - .listRowInsets(EdgeInsets(top: 5, leading: 10, bottom: 5, trailing: 10)) - .contextMenu { - Button("Open Job") { selection = job } - Button("Duplicate") { duplicate(job) } - Divider() - Button(job.isEnabled ? "Disable" : "Enable") { - job.isEnabled.toggle() - job.updatedAt = Date() - saveAndRefresh() - } - Button("Delete", role: .destructive) { delete(job) } - } - } - } - .listStyle(.sidebar) - } - } - .alert("Database Error", isPresented: Binding( - get: { persistenceError != nil }, - set: { if !$0 { persistenceError = nil } } - )) { - Button("OK", role: .cancel) { persistenceError = nil } - } message: { - Text(persistenceError ?? "Unable to save job changes.") - } - .alert("Job Configuration", isPresented: Binding( - get: { transferMessage != nil }, - set: { if !$0 { transferMessage = nil } } - )) { - Button("OK", role: .cancel) { transferMessage = nil } - } message: { - Text(transferMessage ?? "") - } - } - - private var dashboardHeader: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text("Jobs") - .font(.headline) - Text("\(jobs.count) configured job\(jobs.count == 1 ? "" : "s")") - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - statusPill - - Menu { - Button("Import Jobs…", systemImage: "square.and.arrow.down") { importJobs() } - Button("Export Jobs…", systemImage: "square.and.arrow.up") { exportJobs() } - } label: { - Image(systemName: "ellipsis.circle") - } - .menuStyle(.borderlessButton) - .help("Import or export job configurations") - - Button { - showingNewJob = true - } label: { - Image(systemName: "plus") - } - .buttonStyle(.borderedProminent) - .help("New Sync Job") - } - - HStack(spacing: 8) { - TextField("Search jobs", text: $searchText) - .textFieldStyle(.roundedBorder) - - if automation.isSyncAllRunning { - Button(role: .destructive) { - automation.cancelSyncAll() - } label: { - Label("Cancel All", systemImage: "stop.fill") - } - .buttonStyle(.bordered) - .help("Cancel the current Sync All batch") - } else { - Button { - automation.syncAllEnabledJobs() - } label: { - Label("Sync All", systemImage: "arrow.triangle.2.circlepath") - } - .buttonStyle(.bordered) - .disabled(jobs.allSatisfy { !$0.isEnabled }) - .help("Run every enabled sync job in sequence") - } - } - - HStack(spacing: 9) { - metricCard(value: automation.scheduledJobCount, title: "Scheduled", icon: "calendar.badge.clock", colour: .blue) - metricCard(value: automation.watchedJobCount, title: "Watching", icon: "eye.fill", colour: .purple) - } - - if automation.isSyncAllRunning { - VStack(alignment: .leading, spacing: 6) { - ProgressView(value: Double(automation.syncAllCompleted + automation.syncAllFailed + automation.syncAllSkipped), total: Double(max(1, automation.syncAllTotal))) - HStack { - Text("Sync All · \(automation.syncAllCompleted + automation.syncAllFailed + automation.syncAllSkipped) of \(automation.syncAllTotal)") - Spacer() - Text("✓ \(automation.syncAllCompleted) ! \(automation.syncAllFailed) – \(automation.syncAllSkipped)") - } - .font(.caption2) - .foregroundStyle(.secondary) - } - } - } - .padding(14) - } - - @ViewBuilder - private var statusPill: some View { - if !automation.isNetworkAvailable { - Label("Offline", systemImage: "wifi.slash") - .font(.caption.weight(.semibold)) - .foregroundStyle(.orange) - .padding(.horizontal, 9) - .padding(.vertical, 5) - .background(Color.orange.opacity(0.10), in: Capsule()) - } else if automation.activity == .idle { - Label("Ready", systemImage: "checkmark.circle.fill") - .font(.caption.weight(.semibold)) - .foregroundStyle(.green) - .padding(.horizontal, 9) - .padding(.vertical, 5) - .background(Color.green.opacity(0.10), in: Capsule()) - } else { - Label(automation.activityDisplayTitle, systemImage: "arrow.triangle.2.circlepath") - .font(.caption.weight(.semibold)) - .foregroundStyle(Color.accentColor) - .padding(.horizontal, 9) - .padding(.vertical, 5) - .background(Color.accentColor.opacity(0.10), in: Capsule()) - } - } - - private func metricCard(value: Int, title: String, icon: String, colour: Color) -> some View { - HStack(spacing: 9) { - ZStack { - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(colour.opacity(0.11)) - Image(systemName: icon) - .foregroundStyle(colour) - } - .frame(width: 30, height: 30) - - VStack(alignment: .leading, spacing: 0) { - Text("\(value)") - .font(.headline) - Text(title) - .font(.caption2) - .foregroundStyle(.secondary) - } - Spacer(minLength: 0) - } - .padding(9) - .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(Color.secondary.opacity(0.10)) - } - } - - private var emptyState: some View { - ContentUnavailableView( - searchText.isEmpty ? "No Jobs" : "No Matching Jobs", - systemImage: searchText.isEmpty ? "arrow.left.arrow.right.square" : "magnifyingglass", - description: Text(searchText.isEmpty ? "Create a sync job to get started." : "Try a different search or filter.") - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - private var filteredJobs: [SyncJob] { - jobs.filter { job in - let matchesSearch = searchText.isEmpty || - job.name.localizedCaseInsensitiveContains(searchText) || - job.sourcePath.localizedCaseInsensitiveContains(searchText) || - job.destinationDisplay.localizedCaseInsensitiveContains(searchText) - - let matchesFilter: Bool - switch filter { - case .all: matchesFilter = true - case .scheduled: matchesFilter = job.scheduleEnabled && job.scheduleKind != .manual - case .watching: matchesFilter = job.watchEnabled - case .attention: matchesFilter = job.lastExitCode != nil && job.lastExitCode != 0 - } - return matchesSearch && matchesFilter - } - } - - private func automationActivityMatches(_ job: SyncJob) -> Bool { - if case .running(let name) = automation.activity { return name == job.name } - return false - } - - private func duplicate(_ job: SyncJob) { - let copy = SyncJob(name: job.name + " Copy", sourcePath: job.sourcePath, destinationPath: job.destinationPath) - copy.isRemote = job.isRemote - copy.remoteHost = job.remoteHost - copy.remoteUser = job.remoteUser - copy.remotePort = job.remotePort - copy.sshKeyPath = job.sshKeyPath - copy.archive = job.archive - copy.compress = job.compress - copy.deleteExtraneous = job.deleteExtraneous - copy.preservePermissions = job.preservePermissions - copy.preserveExtendedAttributes = job.preserveExtendedAttributes - copy.showProgress = job.showProgress - copy.exclusionsText = job.exclusionsText - copy.additionalArguments = job.additionalArguments - copy.isEnabled = job.isEnabled - copy.scheduleEnabled = job.scheduleEnabled - copy.scheduleKind = job.scheduleKind - copy.scheduleStartAt = job.scheduleStartAt - copy.scheduleIntervalMinutes = job.scheduleIntervalMinutes - copy.nextRunAt = copy.calculateNextRun(after: Date().addingTimeInterval(-1)) - copy.watchEnabled = job.watchEnabled - copy.watchPath = job.watchPath - copy.watchDebounceSeconds = job.watchDebounceSeconds - copy.allowUnattendedDelete = job.allowUnattendedDelete - copy.retryCount = job.retryCount - copy.retryDelaySeconds = job.retryDelaySeconds - copy.notifyOnFailure = job.notifyOnFailure - copy.notifyOnSuccess = job.notifyOnSuccess - copy.requireExpectedVolume = job.requireExpectedVolume - copy.expectedVolumeIdentifier = job.expectedVolumeIdentifier - copy.minimumFreeSpaceMB = job.minimumFreeSpaceMB - modelContext.insert(copy) - saveAndRefresh() - selection = copy - } - - private func exportJobs() { - let panel = NSSavePanel() - panel.allowedContentTypes = [.json] - panel.nameFieldStringValue = "Rsync Studio-Jobs.json" - guard panel.runModal() == .OK, let url = panel.url else { return } - do { - let document = JobTransferDocument(version: 1, exportedAt: Date(), jobs: jobs.map(JobTransferJob.init)) - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - encoder.dateEncodingStrategy = .iso8601 - try encoder.encode(document).write(to: url, options: .atomic) - transferMessage = "Exported \(jobs.count) job configuration(s). SSH private-key paths are intentionally excluded." - } catch { persistenceError = "Could not export jobs: \(error.localizedDescription)" } - } - - private func importJobs() { - let panel = NSOpenPanel() - panel.allowedContentTypes = [.json] - panel.allowsMultipleSelection = false - guard panel.runModal() == .OK, let url = panel.url else { return } - do { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - let document = try decoder.decode(JobTransferDocument.self, from: Data(contentsOf: url)) - guard document.version == 1 else { throw JobTransferError.unsupportedVersion } - var count = 0 - for item in document.jobs { - let job = item.makeJob() - modelContext.insert(job) - count += 1 - } - try modelContext.save() - NotificationCenter.default.post(name: .syncJobsChanged, object: nil) - transferMessage = "Imported \(count) job configuration(s). Remote jobs are disabled until you select a private key or confirm SSH-agent access." - } catch { persistenceError = "Could not import jobs: \(error.localizedDescription)" } - } - - private func delete(_ job: SyncJob) { - if selection?.id == job.id { selection = nil } - modelContext.delete(job) - saveAndRefresh() - } - - private func saveAndRefresh() { - do { - try modelContext.save() - NotificationCenter.default.post(name: .syncJobsChanged, object: nil) - } catch { - persistenceError = error.localizedDescription - } - } -} - -private struct JobRow: View { - let job: SyncJob - let isRunning: Bool - - var body: some View { - HStack(alignment: .top, spacing: 12) { - ZStack { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(iconColour.opacity(0.12)) - Image(systemName: job.isRemote ? "server.rack" : "folder.fill") - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(iconColour) - } - .frame(width: 38, height: 38) - - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 7) { - Text(job.name) - .font(.body.weight(.semibold)) - .lineLimit(1) - - if isRunning { - AnimatedSyncIndicator(size: 15, lineWidth: 2.2) - } - - Spacer() - statusIcon - } - - pathSummary - - HStack(spacing: 6) { - if job.scheduleEnabled && job.scheduleKind != .manual { - miniBadge(job.scheduleKind.title, icon: "calendar.badge.clock", colour: .blue) - } - if job.watchEnabled { - miniBadge("Watching", icon: "eye.fill", colour: .purple) - } - if !job.isEnabled { - miniBadge("Disabled", icon: "pause.fill", colour: .secondary) - } - } - - Text(timingText) - .font(.caption2) - .foregroundStyle(.tertiary) - .lineLimit(1) - } - } - .padding(10) - .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(Color.secondary.opacity(0.09)) - } - .opacity(job.isEnabled ? 1 : 0.62) - } - - private var pathSummary: some View { - HStack(spacing: 5) { - Text(shortPath(job.sourcePath)) - .lineLimit(1) - .truncationMode(.middle) - Image(systemName: "arrow.right") - .font(.caption2.weight(.semibold)) - .foregroundStyle(.tertiary) - Text(shortPath(job.destinationDisplay)) - .lineLimit(1) - .truncationMode(.middle) - } - .font(.caption) - .foregroundStyle(.secondary) - } - - @ViewBuilder - private var statusIcon: some View { - if isRunning { - Image(systemName: "arrow.triangle.2.circlepath.circle.fill") - .foregroundStyle(Color.accentColor) - } else if job.lastExitCode == 0 { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - } else if job.lastExitCode != nil { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) - } else { - Image(systemName: "circle.dashed") - .foregroundStyle(.tertiary) - } - } - - private var timingText: String { - if let next = job.nextRunAt, job.scheduleEnabled { - return "Next run \(next.formatted(.relative(presentation: .named)))" - } - if let last = job.lastRunAt { - return "Last run \(last.formatted(.relative(presentation: .named)))" - } - return "Not run yet" - } - - private var iconColour: Color { - if !job.isEnabled { return .secondary } - return job.isRemote ? .indigo : .blue - } - - private func shortPath(_ path: String) -> String { - guard path.count > 42 else { return path } - return "…" + path.suffix(41) - } - - private func miniBadge(_ title: String, icon: String, colour: Color) -> some View { - Label(title, systemImage: icon) - .font(.system(size: 9, weight: .semibold)) - .foregroundStyle(colour) - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background(colour.opacity(0.10), in: Capsule()) - } -} - -private enum JobFilter: String, CaseIterable, Identifiable { - case all, scheduled, watching, attention - - var id: String { rawValue } - - var title: String { - switch self { - case .all: return "All" - case .scheduled: return "Scheduled" - case .watching: return "Watching" - case .attention: return "Issues" - } - } -} - - -private struct JobTransferDocument: Codable { - let version: Int - let exportedAt: Date - let jobs: [JobTransferJob] -} - -private struct JobTransferJob: Codable { - let name, sourcePath, destinationPath: String - let isRemote: Bool - let remoteHost, remoteUser: String - let remotePort: Int - let archive, compress, deleteExtraneous, preservePermissions, preserveExtendedAttributes, showProgress: Bool - let exclusionsText, additionalArguments: String - let isEnabled: Bool - let scheduleEnabled: Bool - let scheduleKindRaw: String - let scheduleStartAt: Date - let scheduleIntervalMinutes: Int - let watchEnabled: Bool - let watchPath: String - let watchDebounceSeconds: Int - let allowUnattendedDelete: Bool - let retryCount, retryDelaySeconds: Int - let notifyOnFailure, notifyOnSuccess: Bool - let requireExpectedVolume: Bool - let expectedVolumeIdentifier: String - let minimumFreeSpaceMB: Int - - init(_ job: SyncJob) { - name = job.name; sourcePath = job.sourcePath; destinationPath = job.destinationPath - isRemote = job.isRemote; remoteHost = job.remoteHost; remoteUser = job.remoteUser; remotePort = job.remotePort - archive = job.archive; compress = job.compress; deleteExtraneous = job.deleteExtraneous - preservePermissions = job.preservePermissions; preserveExtendedAttributes = job.preserveExtendedAttributes; showProgress = job.showProgress - exclusionsText = job.exclusionsText; additionalArguments = job.additionalArguments; isEnabled = job.isEnabled - scheduleEnabled = job.scheduleEnabled; scheduleKindRaw = job.scheduleKindRaw; scheduleStartAt = job.scheduleStartAt; scheduleIntervalMinutes = job.scheduleIntervalMinutes - watchEnabled = job.watchEnabled; watchPath = job.watchPath; watchDebounceSeconds = job.watchDebounceSeconds - allowUnattendedDelete = job.allowUnattendedDelete; retryCount = job.retryCount; retryDelaySeconds = job.retryDelaySeconds - notifyOnFailure = job.notifyOnFailure; notifyOnSuccess = job.notifyOnSuccess - requireExpectedVolume = job.requireExpectedVolume; expectedVolumeIdentifier = job.expectedVolumeIdentifier; minimumFreeSpaceMB = job.minimumFreeSpaceMB - } - - func makeJob() -> SyncJob { - let job = SyncJob(name: name, sourcePath: sourcePath, destinationPath: destinationPath) - job.isRemote = isRemote; job.remoteHost = remoteHost; job.remoteUser = remoteUser; job.remotePort = remotePort; job.sshKeyPath = "" - job.archive = archive; job.compress = compress; job.deleteExtraneous = deleteExtraneous; job.preservePermissions = preservePermissions - job.preserveExtendedAttributes = preserveExtendedAttributes; job.showProgress = showProgress; job.exclusionsText = exclusionsText; job.additionalArguments = additionalArguments - job.isEnabled = isRemote ? false : isEnabled - job.scheduleEnabled = isRemote ? false : scheduleEnabled; job.scheduleKindRaw = scheduleKindRaw; job.scheduleStartAt = scheduleStartAt; job.scheduleIntervalMinutes = scheduleIntervalMinutes - job.nextRunAt = job.scheduleEnabled ? job.calculateNextRun(after: Date().addingTimeInterval(-1)) : nil - job.watchEnabled = isRemote ? false : watchEnabled; job.watchPath = watchPath; job.watchDebounceSeconds = watchDebounceSeconds - job.allowUnattendedDelete = allowUnattendedDelete; job.retryCount = retryCount; job.retryDelaySeconds = retryDelaySeconds - job.notifyOnFailure = notifyOnFailure; job.notifyOnSuccess = notifyOnSuccess; job.requireExpectedVolume = requireExpectedVolume - job.expectedVolumeIdentifier = expectedVolumeIdentifier; job.minimumFreeSpaceMB = minimumFreeSpaceMB - return job - } -} - -private enum JobTransferError: LocalizedError { - case unsupportedVersion - var errorDescription: String? { "This job configuration file uses an unsupported schema version." } -} diff --git a/Rsync Studio/Views/LicenseRequiredView.swift b/Rsync Studio/Views/LicenseRequiredView.swift deleted file mode 100644 index a9e56dc..0000000 --- a/Rsync Studio/Views/LicenseRequiredView.swift +++ /dev/null @@ -1,48 +0,0 @@ -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) - } -} diff --git a/Rsync Studio/Views/LicenseView.swift b/Rsync Studio/Views/LicenseView.swift deleted file mode 100644 index 8ae4fbc..0000000 --- a/Rsync Studio/Views/LicenseView.swift +++ /dev/null @@ -1,145 +0,0 @@ -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) - } -} diff --git a/Rsync Studio/Views/Runs/RunHistoryView.swift b/Rsync Studio/Views/Runs/RunHistoryView.swift deleted file mode 100644 index a3fb1c4..0000000 --- a/Rsync Studio/Views/Runs/RunHistoryView.swift +++ /dev/null @@ -1,199 +0,0 @@ -import SwiftUI -import SwiftData -import AppKit -import UniformTypeIdentifiers - -struct RunHistoryView: View { - @Environment(\.modelContext) private var modelContext - @Query(sort: \SyncRun.startedAt, order: .reverse) private var runs: [SyncRun] - @State private var selection: SyncRun? - @State private var persistenceError: String? - @State private var searchText = "" - @State private var filter: HistoryFilter = .all - let onBack: () -> Void - - var body: some View { - HSplitView { - VStack(spacing: 0) { - HStack(spacing: 10) { - VStack(alignment: .leading, spacing: 2) { - Text("Recent Runs") - .font(.headline) - Text("\(runs.count) recorded") - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - - Menu { - Button("Export Selected…") { exportRuns(selection.map { [$0] } ?? []) } - .disabled(selection == nil) - Button("Export All…") { exportRuns(runs) } - .disabled(runs.isEmpty) - } label: { - Image(systemName: "square.and.arrow.up") - } - .menuStyle(.borderlessButton) - .help("Export run logs") - - Button("Clear", role: .destructive) { - runs.forEach(modelContext.delete) - selection = nil - saveHistoryChanges() - } - .buttonStyle(.bordered) - .controlSize(.small) - .disabled(runs.isEmpty) - } - .padding(14) - - VStack(spacing: 8) { - TextField("Search history", text: $searchText) - .textFieldStyle(.roundedBorder) - Picker("History filter", selection: $filter) { - ForEach(HistoryFilter.allCases) { item in Text(item.title).tag(item) } - } - .pickerStyle(.segmented) - .labelsHidden() - } - .padding(.horizontal, 14) - .padding(.bottom, 10) - - Divider() - - if filteredRuns.isEmpty { - ContentUnavailableView( - "No Run History", - systemImage: "clock.arrow.circlepath", - description: Text(runs.isEmpty ? "Completed, failed and background synchronisations will appear here." : "No runs match the current search or filter.") - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - List(selection: $selection) { - ForEach(filteredRuns) { run in - HStack(spacing: 11) { - Image(systemName: statusIcon(run)) - .foregroundStyle(statusColour(run)) - - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(run.jobName) - .fontWeight(.semibold) - .lineLimit(1) - Spacer() - StatusBadge(run: run) - } - - Text(run.startedAt, format: .dateTime.day().month(.abbreviated).year().hour().minute()) - .font(.caption) - .foregroundStyle(.secondary) - - HStack(spacing: 6) { - Label(run.trigger.title, systemImage: run.trigger.systemImage) - .font(.caption2.weight(.semibold)) - .foregroundStyle(run.trigger == .watched ? Color.purple : Color.blue) - if run.wasDryRun { - Text("Dry run") - .font(.caption2.weight(.semibold)) - .foregroundStyle(.blue) - } - } - } - } - .padding(.vertical, 3) - .tag(run) - .contextMenu { - Button("Delete Log", role: .destructive) { - if selection?.id == run.id { selection = nil } - modelContext.delete(run) - saveHistoryChanges() - } - } - } - } - .listStyle(.sidebar) - } - } - .frame(minWidth: 300, idealWidth: 340, maxWidth: 390) - - Group { - if let selection { - RunLogView(run: selection) - } else { - ContentUnavailableView( - "Select a Run", - systemImage: "clock.arrow.circlepath", - description: Text("Select a completed or failed run to inspect its summary and full log.") - ) - } - } - .frame(minWidth: 620, maxWidth: .infinity, maxHeight: .infinity) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .alert("Database Error", isPresented: Binding( - get: { persistenceError != nil }, - set: { if !$0 { persistenceError = nil } } - )) { - Button("OK", role: .cancel) { persistenceError = nil } - } message: { - Text(persistenceError ?? "Unable to update run history.") - } - } - - private var filteredRuns: [SyncRun] { - runs.filter { run in - let matchesSearch = searchText.isEmpty || run.jobName.localizedCaseInsensitiveContains(searchText) || run.output.localizedCaseInsensitiveContains(searchText) || run.errorOutput.localizedCaseInsensitiveContains(searchText) - let matchesFilter: Bool - switch filter { - case .all: matchesFilter = true - case .failed: matchesFilter = !run.isRunning && !run.succeeded && !run.wasCancelled - case .successful: matchesFilter = run.succeeded - case .automatic: matchesFilter = run.trigger == .scheduled || run.trigger == .watched - } - return matchesSearch && matchesFilter - } - } - - private func exportRuns(_ items: [SyncRun]) { - guard !items.isEmpty else { return } - let panel = NSSavePanel() - panel.allowedContentTypes = [.plainText] - panel.nameFieldStringValue = items.count == 1 ? "Rsync Studio-\(items[0].jobName)-Run.txt" : "Rsync Studio-History.txt" - guard panel.runModal() == .OK, let url = panel.url else { return } - let text = items.map(\.exportText).joined(separator: "\n\n" + String(repeating: "=", count: 72) + "\n\n") - do { try text.write(to: url, atomically: true, encoding: .utf8) } - catch { persistenceError = "Could not export history: \(error.localizedDescription)" } - } - - private func saveHistoryChanges() { - do { try modelContext.save() } - catch { persistenceError = error.localizedDescription } - } - - private func statusIcon(_ run: SyncRun) -> String { - if run.isRunning { return "arrow.triangle.2.circlepath" } - if run.wasCancelled { return "xmark.circle.fill" } - return run.succeeded ? "checkmark.circle.fill" : "exclamationmark.triangle.fill" - } - - private func statusColour(_ run: SyncRun) -> Color { - if run.isRunning { return .blue } - if run.wasCancelled { return .secondary } - return run.succeeded ? .green : .orange - } -} - - -private enum HistoryFilter: String, CaseIterable, Identifiable { - case all, failed, successful, automatic - var id: String { rawValue } - var title: String { - switch self { - case .all: return "All" - case .failed: return "Failed" - case .successful: return "Success" - case .automatic: return "Automatic" - } - } -} diff --git a/Rsync Studio/Views/Runs/RunLogView.swift b/Rsync Studio/Views/Runs/RunLogView.swift deleted file mode 100644 index 5d80a95..0000000 --- a/Rsync Studio/Views/Runs/RunLogView.swift +++ /dev/null @@ -1,47 +0,0 @@ -import SwiftUI - -struct RunLogView: View { - let run: SyncRun - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - HStack { - VStack(alignment: .leading) { - Text(run.jobName).font(.title.bold()) - Text(run.startedAt.formatted(date: .abbreviated, time: .standard)) - .foregroundStyle(.secondary) - } - Spacer() - StatusBadge(run: run) - } - - if let duration = run.duration { - LabeledContent("Duration", value: Duration.seconds(duration).formatted(.units(allowed: [.minutes, .seconds], width: .abbreviated))) - } - if let exitCode = run.exitCode { - LabeledContent("Exit code", value: String(exitCode)) - } - LabeledContent("Mode", value: run.wasDryRun ? "Dry Run" : "Live Run") - - console(title: "Command", text: run.command) - if !run.output.isEmpty { console(title: "Output", text: run.output) } - if !run.errorOutput.isEmpty { console(title: "Errors", text: run.errorOutput) } - } - .padding(28) - } - .navigationTitle("Run Log") - } - - private func console(title: String, text: String) -> some View { - GroupBox(title) { - ScrollView(.horizontal) { - Text(text) - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - } -} diff --git a/Rsync Studio/Views/ScheduleCalendarView.swift b/Rsync Studio/Views/ScheduleCalendarView.swift deleted file mode 100644 index 3b2ffa5..0000000 --- a/Rsync Studio/Views/ScheduleCalendarView.swift +++ /dev/null @@ -1,220 +0,0 @@ -import SwiftUI -import SwiftData - -struct ScheduleCalendarView: View { - @Query(sort: \SyncJob.name) private var jobs: [SyncJob] - @State private var displayedMonth = Calendar.current.date(from: Calendar.current.dateComponents([.year, .month], from: Date())) ?? Date() - @State private var selectedDay = Date() - @State private var editingJob: SyncJob? - - private let calendar = Calendar.current - private let columns = Array(repeating: GridItem(.flexible(), spacing: 6), count: 7) - - var body: some View { - HStack(spacing: 0) { - VStack(spacing: 0) { - monthHeader - Divider() - calendarGrid - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - - Divider() - - agenda - .frame(width: 320) - .background(.bar) - } - .sheet(item: $editingJob) { job in - JobEditorView(job: job) - } - } - - private var monthHeader: some View { - HStack { - Button { moveMonth(-1) } label: { Image(systemName: "chevron.left") } - .buttonStyle(.bordered) - VStack(alignment: .leading, spacing: 2) { - Text(displayedMonth.formatted(.dateTime.month(.wide).year())) - .font(.title2.bold()) - Text("\(monthOccurrences.count) scheduled runs") - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - Button("Today") { - displayedMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: Date())) ?? Date() - selectedDay = Date() - } - .buttonStyle(.borderedProminent) - Button { moveMonth(1) } label: { Image(systemName: "chevron.right") } - .buttonStyle(.bordered) - } - .padding(18) - } - - private var calendarGrid: some View { - VStack(spacing: 8) { - LazyVGrid(columns: columns, spacing: 6) { - ForEach(calendar.shortWeekdaySymbols, id: \.self) { symbol in - Text(symbol.uppercased()) - .font(.caption2.weight(.bold)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity) - } - - ForEach(monthCells.indices, id: \.self) { index in - if let date = monthCells[index] { - dayCell(date) - } else { - Color.clear.frame(minHeight: 86) - } - } - } - } - .padding(16) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - } - - private func dayCell(_ date: Date) -> some View { - let occurrences = occurrences(on: date) - let selected = calendar.isDate(date, inSameDayAs: selectedDay) - let today = calendar.isDateInToday(date) - - return Button { - selectedDay = date - } label: { - VStack(alignment: .leading, spacing: 6) { - HStack { - Text("\(calendar.component(.day, from: date))") - .font(.body.weight(today ? .bold : .medium)) - .foregroundStyle(today ? Color.accentColor : .primary) - Spacer() - if !occurrences.isEmpty { - Text("\(occurrences.count)") - .font(.caption2.bold()) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(Color.accentColor.opacity(0.14), in: Capsule()) - } - } - - ForEach(Array(occurrences.prefix(2)), id: \.id) { item in - HStack(spacing: 4) { - Circle().fill(Color.accentColor).frame(width: 5, height: 5) - Text(item.job.name) - .font(.caption2) - .lineLimit(1) - Spacer() - Text(item.date.formatted(date: .omitted, time: .shortened)) - .font(.caption2) - .foregroundStyle(.secondary) - } - } - Spacer(minLength: 0) - } - .padding(9) - .frame(maxWidth: .infinity, minHeight: 86, alignment: .topLeading) - .background(selected ? Color.accentColor.opacity(0.10) : Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) - .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous).stroke(selected ? Color.accentColor.opacity(0.6) : Color.secondary.opacity(0.12))) - } - .buttonStyle(.plain) - } - - private var agenda: some View { - VStack(alignment: .leading, spacing: 16) { - VStack(alignment: .leading, spacing: 3) { - Text("Agenda").font(.title3.bold()) - Text(selectedDay.formatted(date: .complete, time: .omitted)) - .font(.caption) - .foregroundStyle(.secondary) - } - - let items = occurrences(on: selectedDay) - if items.isEmpty { - ContentUnavailableView( - "No Scheduled Runs", - systemImage: "calendar", - description: Text("Select another day or add a schedule to a sync job.") - ) - } else { - ScrollView { - LazyVStack(spacing: 10) { - ForEach(items, id: \.id) { item in - VStack(alignment: .leading, spacing: 6) { - HStack { - Image(systemName: item.job.isRemote ? "server.rack" : "folder.fill") - .foregroundStyle(Color.accentColor) - Text(item.job.name).fontWeight(.semibold) - Spacer() - } - Label(item.date.formatted(date: .omitted, time: .shortened), systemImage: "clock") - .font(.caption) - .foregroundStyle(.secondary) - Text(item.job.destinationDisplay) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(2) - HStack { - Spacer() - Button("Edit Schedule") { editingJob = item.job } - .buttonStyle(.bordered) - .controlSize(.small) - } - } - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - .background(.background, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - } - } - } - } - Spacer() - } - .padding(18) - } - - private var monthInterval: DateInterval { - calendar.dateInterval(of: .month, for: displayedMonth) ?? DateInterval(start: displayedMonth, duration: 2_592_000) - } - - private var monthCells: [Date?] { - let interval = monthInterval - let days = calendar.range(of: .day, in: .month, for: interval.start) ?? 1..<2 - let weekday = calendar.component(.weekday, from: interval.start) - let leading = (weekday - calendar.firstWeekday + 7) % 7 - var cells = Array(repeating: nil, count: leading) - for day in days { - if let date = calendar.date(byAdding: .day, value: day - 1, to: interval.start) { cells.append(date) } - } - while cells.count % 7 != 0 { cells.append(nil) } - return cells - } - - private var monthOccurrences: [CalendarOccurrence] { - scheduledJobs.flatMap { job in - job.occurrences(from: monthInterval.start, through: monthInterval.end).map { CalendarOccurrence(job: job, date: $0) } - }.sorted { $0.date < $1.date } - } - - private var scheduledJobs: [SyncJob] { - jobs.filter { $0.isEnabled && $0.scheduleEnabled && $0.scheduleKind != .manual } - } - - private func occurrences(on day: Date) -> [CalendarOccurrence] { - monthOccurrences.filter { calendar.isDate($0.date, inSameDayAs: day) } - } - - private func moveMonth(_ offset: Int) { - if let next = calendar.date(byAdding: .month, value: offset, to: displayedMonth) { - displayedMonth = next - selectedDay = next - } - } -} - -private struct CalendarOccurrence: Identifiable { - let job: SyncJob - let date: Date - var id: String { "\(job.id.uuidString)-\(date.timeIntervalSince1970)" } -}