From cc194f6a7b844a1f8fb5f431d665ee993043bd6d Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:31:36 +0200 Subject: [PATCH] feat(apple): add direct-download macOS channel (notarized DMG + Sparkle + Homebrew) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a second macOS shipping channel alongside the App Store build: - New VniDropDirect target (Release-Direct config) sharing VniDrop's sources via an AppBase target template; links Sparkle behind the DIRECT_DISTRIBUTION flag so the App Store binary never bundles a self-updater. arm64-only (core is arm64). - Sparkle updater (SparkleUpdater.swift) + "Check for Updates" menu, compiled only under DIRECT_DISTRIBUTION; Info.plist SUFeedURL points at the GitHub Release /latest/download/appcast.xml, non-sandboxed entitlements for Developer ID. - build-dmg.sh (archive → Developer ID export → DMG → sign → notarize → staple), generate-appcast.sh, and ExportOptions-DeveloperID.plist. - apple-release.yml: on tag v*.*.*, build/notarize the DMG, publish the GitHub Release with appcast, and push the Homebrew cask to sudosylabs/homebrew-vnidrop. apple.yml gains a PR compile-check of the direct target. - CFBundleVersion is stamped at build time as a UTC YYMMDD.HHMM timestamp for both channels, replacing the hand-maintained build number. - Docs (RELEASE-MACOS.md, README), cask template + tap README, localized updates_check string, Makefile targets, gitignore for dist/ artifacts. --- .github/workflows/apple-release.yml | 230 ++++++++++++++++++ .github/workflows/apple.yml | 5 + Makefile | 6 + apple/.gitignore | 4 + apple/README.md | 22 +- apple/RELEASE-MACOS.md | 155 ++++++++++++ apple/VniDrop/App/VniDropApp.swift | 9 + apple/VniDrop/Platform/SparkleUpdater.swift | 49 ++++ apple/VniDrop/Resources/Info.plist | 14 ++ .../Resources/VniDropDirect.entitlements | 15 ++ apple/project.yml | 98 +++++++- apple/scripts/ExportOptions-DeveloperID.plist | 19 ++ apple/scripts/build-dmg.sh | 158 ++++++++++++ apple/scripts/generate-appcast.sh | 68 ++++++ localization/strings.json | 17 ++ packaging/homebrew/tap-README.md | 54 ++++ packaging/homebrew/vnidrop.rb | 36 +++ 17 files changed, 950 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/apple-release.yml create mode 100644 apple/RELEASE-MACOS.md create mode 100644 apple/VniDrop/Platform/SparkleUpdater.swift create mode 100644 apple/VniDrop/Resources/VniDropDirect.entitlements create mode 100644 apple/scripts/ExportOptions-DeveloperID.plist create mode 100755 apple/scripts/build-dmg.sh create mode 100755 apple/scripts/generate-appcast.sh create mode 100644 packaging/homebrew/tap-README.md create mode 100644 packaging/homebrew/vnidrop.rb diff --git a/.github/workflows/apple-release.yml b/.github/workflows/apple-release.yml new file mode 100644 index 0000000..9812b69 --- /dev/null +++ b/.github/workflows/apple-release.yml @@ -0,0 +1,230 @@ +name: Apple release (macOS DMG) + +# Builds, signs, notarizes, and publishes the direct-download macOS build: +# - a Developer ID–signed, notarized VniDrop-.dmg, +# - a Sparkle appcast.xml (both attached to the GitHub Release), and +# - an updated Homebrew cask pushed to the sudosylabs/homebrew-vnidrop tap. +# +# The App Store / TestFlight build is NOT produced here — that goes through Xcode +# Organizer / App Store Connect. This workflow only covers direct distribution. +# +# Trigger: push a tag vMAJOR.MINOR.PATCH (must point at a commit on master), or +# run manually with an explicit version (produces artifacts, no Release). + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + inputs: + version: + description: Release version in MAJOR.MINOR.PATCH form + required: true + default: "0.1.0" + type: string + +permissions: + contents: read + +concurrency: + group: apple-release-${{ github.ref }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + build: + name: Build & notarize DMG + runs-on: macos-latest + timeout-minutes: 90 + permissions: + contents: write + outputs: + version: ${{ steps.version.outputs.app }} + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Verify tag is on master + if: github.event_name == 'push' + run: | + if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/master; then + echo "Release tags must point to a commit on master" >&2 + exit 1 + fi + + - name: Resolve version + id: version + env: + REQUESTED_VERSION: ${{ inputs.version || '' }} + run: | + if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then + version="${GITHUB_REF_NAME#v}" + else + version="$REQUESTED_VERSION" + fi + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "bad version '$version'" >&2; exit 1; } + echo "app=$version" >> "$GITHUB_OUTPUT" + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode.app + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # v1 + with: + toolchain: stable + targets: aarch64-apple-darwin + + - name: Cache Cargo + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: apple-release-cargo-${{ hashFiles('Cargo.lock') }} + restore-keys: apple-release-cargo- + + - name: Install tooling + run: brew install xcodegen swiftlint create-dmg + + - name: Install Bun + uses: oven-sh/setup-bun@v2 + + - name: Download Sparkle tools + # generate_appcast + sign_update ship in the Sparkle release tarball. + run: | + set -euo pipefail + ver="2.9.4" + curl -fsSL -o /tmp/sparkle.tar.xz \ + "https://github.com/sparkle-project/Sparkle/releases/download/${ver}/Sparkle-${ver}.tar.xz" + mkdir -p /tmp/sparkle && tar -xJf /tmp/sparkle.tar.xz -C /tmp/sparkle + echo "SPARKLE_BIN=/tmp/sparkle/bin" >> "$GITHUB_ENV" + + - name: Import Developer ID certificate + env: + CERT_P12_BASE64: ${{ secrets.DEVELOPER_ID_CERT_P12 }} + CERT_PASSWORD: ${{ secrets.DEVELOPER_ID_CERT_PASSWORD }} + run: | + set -euo pipefail + keychain="$RUNNER_TEMP/signing.keychain-db" + kpw="$(openssl rand -hex 20)" + security create-keychain -p "$kpw" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$kpw" "$keychain" + echo "$CERT_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12" + security import "$RUNNER_TEMP/cert.p12" -k "$keychain" -P "$CERT_PASSWORD" \ + -T /usr/bin/codesign -T /usr/bin/security + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$kpw" "$keychain" + # Prepend our keychain so codesign/xcodebuild can find the identity. + security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"') + rm -f "$RUNNER_TEMP/cert.p12" + + - name: Store notarytool credentials + env: + NOTARY_KEY_P8: ${{ secrets.NOTARY_API_KEY }} + NOTARY_KEY_ID: ${{ secrets.NOTARY_KEY_ID }} + NOTARY_ISSUER: ${{ secrets.NOTARY_ISSUER }} + run: | + set -euo pipefail + echo "$NOTARY_KEY_P8" | base64 --decode > "$RUNNER_TEMP/notary.p8" + xcrun notarytool store-credentials vnidrop-notary \ + --key "$RUNNER_TEMP/notary.p8" \ + --key-id "$NOTARY_KEY_ID" \ + --issuer "$NOTARY_ISSUER" + echo "NOTARY_PROFILE=vnidrop-notary" >> "$GITHUB_ENV" + + - name: Write Sparkle signing key + env: + SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} + run: | + printf '%s' "$SPARKLE_ED_PRIVATE_KEY" > "$RUNNER_TEMP/sparkle_ed_private_key" + echo "SPARKLE_ED_KEY_FILE=$RUNNER_TEMP/sparkle_ed_private_key" >> "$GITHUB_ENV" + + - name: Build, sign & notarize DMG + run: apple/scripts/build-dmg.sh "${{ steps.version.outputs.app }}" + + - name: Generate appcast + env: + RELEASE_REPO: ${{ github.repository }} + run: apple/scripts/generate-appcast.sh "${{ steps.version.outputs.app }}" + + - name: Upload artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: vnidrop-${{ steps.version.outputs.app }}-macos-dmg + path: | + apple/dist/VniDrop-*.dmg + apple/dist/appcast.xml + if-no-files-found: error + retention-days: 14 + + - name: Publish GitHub Release + if: github.event_name == 'push' && github.ref_type == 'tag' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + set -euo pipefail + tag="$GITHUB_REF_NAME" + version="${tag#v}" + if gh release view "$tag" >/dev/null 2>&1; then + echo "Release $tag already exists; refusing to replace assets" >&2 + exit 1 + fi + gh release create "$tag" \ + "apple/dist/VniDrop-${version}.dmg" \ + "apple/dist/appcast.xml" \ + --verify-tag \ + --title "VniDrop $version" \ + --generate-notes + + update-cask: + name: Update Homebrew cask + needs: build + if: github.event_name == 'push' && github.ref_type == 'tag' + runs-on: ubuntu-22.04 + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Download DMG artifact + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + with: + name: vnidrop-${{ needs.build.outputs.version }}-macos-dmg + path: dist + + - name: Render cask + env: + VERSION: ${{ needs.build.outputs.version }} + run: | + set -euo pipefail + sha="$(sha256sum "dist/VniDrop-${VERSION}.dmg" | cut -d' ' -f1)" + sed -e "s/^ version \".*\"/ version \"${VERSION}\"/" \ + -e "s/^ sha256 \".*\"/ sha256 \"${sha}\"/" \ + packaging/homebrew/vnidrop.rb > /tmp/vnidrop.rb + echo "Rendered cask:"; cat /tmp/vnidrop.rb + + - name: Push to tap + env: + TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + VERSION: ${{ needs.build.outputs.version }} + run: | + set -euo pipefail + git clone "https://x-access-token:${TAP_TOKEN}@github.com/sudosylabs/homebrew-vnidrop.git" tap + mkdir -p tap/Casks + cp /tmp/vnidrop.rb tap/Casks/vnidrop.rb + cd tap + git config user.name "vnidrop-release-bot" + git config user.email "release-bot@users.noreply.github.com" + git add Casks/vnidrop.rb + git commit -m "vnidrop ${VERSION}" || { echo "no cask changes"; exit 0; } + git push diff --git a/.github/workflows/apple.yml b/.github/workflows/apple.yml index 415aebe..64a810b 100644 --- a/.github/workflows/apple.yml +++ b/.github/workflows/apple.yml @@ -76,3 +76,8 @@ jobs: - name: Build and test Apple app run: make check-apple + + - name: Build direct-download macOS target (Sparkle, unsigned) + # Keeps the VniDropDirect (.dmg/Sparkle) target compiling; signing and + # notarization happen only in apple-release.yml on a tag. + run: make build-apple-macos-direct diff --git a/Makefile b/Makefile index 81b36df..4010dc8 100644 --- a/Makefile +++ b/Makefile @@ -122,6 +122,12 @@ open-apple-project: apple-project ## Generate and open the native Apple Xcode pr build-apple-macos: apple-project ## Build the native macOS app (unsigned by default). cd $(ROOT)/apple && $(XCODEBUILD) -project VniDrop.xcodeproj -scheme VniDrop -configuration $(APPLE_CONFIGURATION) -derivedDataPath "$(APPLE_DERIVED_DATA)" -destination 'platform=macOS' CODE_SIGNING_ALLOWED=$(APPLE_CODE_SIGNING) CODE_SIGNING_REQUIRED=$(APPLE_CODE_SIGNING) build +build-apple-macos-direct: apple-project ## Build the direct-download macOS target (Sparkle, unsigned) — CI compile check. + cd $(ROOT)/apple && $(XCODEBUILD) -project VniDrop.xcodeproj -scheme VniDropDirect -configuration Release-Direct -derivedDataPath "$(APPLE_DERIVED_DATA)" -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO build + +build-apple-dmg: ## Build the signed/notarized direct-download .dmg (see apple/RELEASE-MACOS.md for required env). + cd $(ROOT) && apple/scripts/build-dmg.sh $(VERSION) + open-apple: build-apple-macos ## Build and launch the native macOS app. @test -d "$(APPLE_DERIVED_DATA)/Build/Products/$(APPLE_CONFIGURATION)/VniDrop.app" || { printf 'Built macOS app was not found.\n' >&2; exit 1; } $(OPEN) "$(APPLE_DERIVED_DATA)/Build/Products/$(APPLE_CONFIGURATION)/VniDrop.app" diff --git a/apple/.gitignore b/apple/.gitignore index 40759f5..19c342e 100644 --- a/apple/.gitignore +++ b/apple/.gitignore @@ -18,3 +18,7 @@ Local.xcconfig .swiftpm/ DerivedData/ *.xcuserstate + +# Direct-download (.dmg) build outputs — apple/scripts/build-dmg.sh +.build-dmg/ +dist/ diff --git a/apple/README.md b/apple/README.md index 4912d53..e99f093 100644 --- a/apple/README.md +++ b/apple/README.md @@ -34,12 +34,30 @@ Prerequisites: Xcode, Rust with the Apple targets make apple-core # Rust core, Swift bindings, and XCFramework make apple-project # generate apple/VniDrop.xcodeproj make open-apple-project # generate and open the project in Xcode -make build-apple-macos # unsigned macOS build +make build-apple-macos # unsigned macOS build (App Store target) make open-apple # build and launch the macOS app -make build-apple-ios # unsigned iOS simulator build +make build-apple-ios # unsigned iOS simulator app make check-apple # iOS simulator tests ``` +### macOS shipping channels + +The macOS app ships through two targets that build identical sources: + +- **`VniDrop`** (`Release`) — Mac App Store / TestFlight. Sandboxed, no + self-updater. +- **`VniDropDirect`** (`Release-Direct`) — direct-download `.dmg` on GitHub + Releases + Homebrew cask. Adds the **Sparkle** auto-updater behind the + `DIRECT_DISTRIBUTION` compile flag, so the App Store binary never links Sparkle. + +```bash +make build-apple-macos-direct # unsigned compile-check of the direct target +make build-apple-dmg VERSION=x.y.z # signed (+ notarized) .dmg +``` + +Full signing, notarization, appcast, and cask flow: see +[`RELEASE-MACOS.md`](RELEASE-MACOS.md). + Use `APPLE_PROFILE=release` to request a release Rust core, or set `APPLE_DESTINATION` to override the automatically selected iOS simulator. Code signing is disabled for the app and test targets; local and CI builds do diff --git a/apple/RELEASE-MACOS.md b/apple/RELEASE-MACOS.md new file mode 100644 index 0000000..40b5ce4 --- /dev/null +++ b/apple/RELEASE-MACOS.md @@ -0,0 +1,155 @@ +# Releasing VniDrop for macOS + +VniDrop ships on macOS through **two independent channels**: + +| Channel | Target / config | Signing | Updates | +| --- | --- | --- | --- | +| **Mac App Store / TestFlight** | `VniDrop` / `Release` | Apple Distribution | App Store | +| **Direct download (.dmg)** | `VniDropDirect` / `Release-Direct` | Developer ID + notarization | Sparkle | + +The direct build adds the **Sparkle** auto-updater, gated behind the +`DIRECT_DISTRIBUTION` compile flag so the App Store binary never links or ships a +self-updater (which Apple forbids). Because Swift Package Manager links products +per *target*, the isolation is a dedicated `VniDropDirect` target — not just a +build configuration. + +This document covers the **direct-download** channel. The App Store channel goes +through Xcode Organizer / App Store Connect as before (see +`RELEASE-TESTFLIGHT.fr.md`). + +## Versioning + +Both channels use Apple's two-field convention, shown as `MARKETING_VERSION (build)`: + +- **`CFBundleShortVersionString`** = `MARKETING_VERSION` — the human `X.Y.Z` version + (from the git tag for a release). This is what users, the DMG filename, the + GitHub Release, and the Homebrew cask all use. +- **`CFBundleVersion`** = a **UTC `YYMMDD.HHMM` timestamp**, stamped into the built + Info.plist by the "Stamp build number" build phase (`apple/project.yml`). It fires + for every build — Xcode Organizer archive and CLI alike — so each build is + monotonic and self-describing. Sparkle compares this field to order updates, and + App Store Connect requires each upload's build to exceed the previous one; a + timestamp satisfies both automatically. `build-dmg.sh` pins one timestamp per + archive (via `VNIDROP_BUILD`) so the app, DMG, and appcast agree. + +No build number is maintained by hand. + +--- + +## One-time setup + +### 1. Developer ID Application certificate + +Direct distribution requires a **Developer ID Application** certificate (distinct +from the *Apple Distribution* cert used for the App Store). In Xcode ▸ Settings ▸ +Accounts ▸ Manage Certificates ▸ **+** ▸ *Developer ID Application*. Confirm it's +in the keychain: + +```bash +security find-identity -v -p codesigning | grep "Developer ID Application" +``` + +### 2. Notarization credentials (App Store Connect API key) + +Create an API key at App Store Connect ▸ Users and Access ▸ Integrations ▸ Keys +(role: *Developer*). Download the `AuthKey_XXXX.p8`, and note the **Key ID** and +**Issuer ID**. Store a local notarytool profile: + +```bash +xcrun notarytool store-credentials vnidrop-notary \ + --key /path/to/AuthKey_XXXX.p8 \ + --key-id \ + --issuer +``` + +### 3. Sparkle EdDSA signing key + +Generate the update-signing key once (private key stored in the login keychain): + +```bash +# Sparkle's generate_keys, from the downloaded Sparkle release (bin/generate_keys) +./bin/generate_keys +``` + +It prints the **public** key. Put it in `apple/VniDrop/Resources/Info.plist` as +`SUPublicEDKey`, replacing `REPLACE_WITH_SPARKLE_ED_PUBLIC_KEY`. Export the +**private** key for CI: + +```bash +./bin/generate_keys -x sparkle_ed_private_key # writes the private key file +``` + +### 4. Homebrew tap repo + +Create an empty GitHub repo **`sudosylabs/homebrew-vnidrop`** (a `Casks/` folder +is enough). The release workflow pushes `Casks/vnidrop.rb` there. Users install: + +```bash +brew install --cask sudosylabs/vnidrop/vnidrop +``` + +### 5. CI secrets + +Add these to the `sudosylabs/vnidrop` repo (Settings ▸ Secrets ▸ Actions): + +| Secret | Value | +| --- | --- | +| `DEVELOPER_ID_CERT_P12` | base64 of the exported Developer ID `.p12` | +| `DEVELOPER_ID_CERT_PASSWORD` | password for that `.p12` | +| `NOTARY_API_KEY` | base64 of `AuthKey_XXXX.p8` | +| `NOTARY_KEY_ID` | App Store Connect key ID | +| `NOTARY_ISSUER` | App Store Connect issuer ID | +| `SPARKLE_ED_PRIVATE_KEY` | contents of the exported Sparkle private key file | +| `HOMEBREW_TAP_TOKEN` | PAT with write access to `homebrew-vnidrop` | + +> `SUFeedURL` in Info.plist points at +> `https://github.com/sudosylabs/vnidrop/releases/latest/download/appcast.xml`. +> GitHub's `/releases/latest/download/` path always redirects to the newest +> non-prerelease release's asset, so no GitHub Pages or repo commits are needed. + +--- + +## Per-release flow + +1. Bump `MARKETING_VERSION` (and `CURRENT_PROJECT_VERSION`) in `apple/project.yml` + if needed, commit to `master`. +2. Tag and push: + + ```bash + git tag v0.2.0 + git push origin v0.2.0 + ``` + +3. `.github/workflows/apple-release.yml` then: + - builds the Rust core (release), regenerates the project, + - archives + exports the `VniDropDirect` target (Developer ID, hardened runtime), + - builds, signs, **notarizes and staples** `VniDrop-.dmg`, + - runs `generate_appcast` to produce `appcast.xml` (enclosure → the release DMG), + - creates the GitHub Release with both assets, and + - renders + pushes the Homebrew cask to the tap. + +Existing installs pick up the new version automatically via Sparkle (feed → +latest release's `appcast.xml`); `brew upgrade` respects `auto_updates true`. + +--- + +## Building a DMG locally + +```bash +# Signed DMG (skips notarization unless NOTARY_PROFILE is set): +make build-apple-dmg VERSION=0.2.0 + +# Full signed + notarized DMG: +NOTARY_PROFILE=vnidrop-notary make build-apple-dmg VERSION=0.2.0 +``` + +Output: `apple/dist/VniDrop-.dmg`. See `apple/scripts/build-dmg.sh` for +the environment variables (`DEVELOPER_ID_APP`, `DEVELOPMENT_TEAM`, +`NOTARY_PROFILE`). Generate the appcast with +`apple/scripts/generate-appcast.sh `. + +To compile-check the direct target without signing: + +```bash +make build-apple-macos-direct +``` diff --git a/apple/VniDrop/App/VniDropApp.swift b/apple/VniDrop/App/VniDropApp.swift index 3882b17..09475e1 100644 --- a/apple/VniDrop/App/VniDropApp.swift +++ b/apple/VniDrop/App/VniDropApp.swift @@ -8,6 +8,10 @@ private let mainWindowId = "main" @main struct VniDropApp: App { @StateObject private var externalInvitations = ExternalInvitationController() + #if DIRECT_DISTRIBUTION && os(macOS) + // Sparkle auto-updater, present only in the direct-download (.dmg) build. + @StateObject private var updater = SparkleUpdaterController() + #endif var body: some Scene { #if os(macOS) @@ -18,6 +22,11 @@ struct VniDropApp: App { .ignoresSafeArea() .onOpenURL(perform: openInvitation) } + #if DIRECT_DISTRIBUTION + .commands { + UpdatesCommands(controller: updater) + } + #endif #else WindowGroup(id: mainWindowId) { RootView(dependencies: makeAppDependencies(externalInvitations: externalInvitations)) diff --git a/apple/VniDrop/Platform/SparkleUpdater.swift b/apple/VniDrop/Platform/SparkleUpdater.swift new file mode 100644 index 0000000..1aef1c8 --- /dev/null +++ b/apple/VniDrop/Platform/SparkleUpdater.swift @@ -0,0 +1,49 @@ +#if DIRECT_DISTRIBUTION && os(macOS) +import Combine +import Sparkle +import SwiftUI + +/// Owns the Sparkle updater for the direct-download (.dmg) build. +/// +/// Compiled only under `DIRECT_DISTRIBUTION`, so the App Store / TestFlight target +/// (which must not ship a self-updater) never compiles or links Sparkle. The feed +/// URL and public EdDSA key are read from Info.plist (`SUFeedURL`, `SUPublicEDKey`). +@MainActor +final class SparkleUpdaterController: ObservableObject { + private let updaterController: SPUStandardUpdaterController + /// Mirrors `SPUUpdater.canCheckForUpdates` so the menu item can disable itself + /// while a check is already in flight. + @Published private(set) var canCheckForUpdates = false + + init() { + // `startingUpdater: true` begins the automatic background check schedule. + updaterController = SPUStandardUpdaterController( + startingUpdater: true, + updaterDelegate: nil, + userDriverDelegate: nil + ) + updaterController.updater + .publisher(for: \.canCheckForUpdates) + .assign(to: &$canCheckForUpdates) + } + + func checkForUpdates() { + updaterController.checkForUpdates(nil) + } +} + +/// Adds a "Check for Updates…" item to the application menu (right after the +/// standard "About VniDrop" item), matching the macOS convention. +struct UpdatesCommands: Commands { + @ObservedObject var controller: SparkleUpdaterController + + var body: some Commands { + CommandGroup(after: .appInfo) { + Button(String(localized: L10n.Updates.check)) { + controller.checkForUpdates() + } + .disabled(!controller.canCheckForUpdates) + } + } +} +#endif diff --git a/apple/VniDrop/Resources/Info.plist b/apple/VniDrop/Resources/Info.plist index 31ed620..84d9cb3 100644 --- a/apple/VniDrop/Resources/Info.plist +++ b/apple/VniDrop/Resources/Info.plist @@ -57,6 +57,20 @@ $(CURRENT_PROJECT_VERSION) LSApplicationCategoryType public.app-category.utilities + + SUFeedURL + https://github.com/sudosylabs/vnidrop/releases/latest/download/appcast.xml + SUPublicEDKey + /vcOgyrhPi3e58yL8M7hZvDCOgsAKyBsQu/7ChAUk1M= + SUEnableAutomaticChecks + LSSupportsOpeningDocumentsInPlace NFCReaderUsageDescription diff --git a/apple/VniDrop/Resources/VniDropDirect.entitlements b/apple/VniDrop/Resources/VniDropDirect.entitlements new file mode 100644 index 0000000..8df2838 --- /dev/null +++ b/apple/VniDrop/Resources/VniDropDirect.entitlements @@ -0,0 +1,15 @@ + + + + + + + diff --git a/apple/project.yml b/apple/project.yml index 8f8fede..faf3d7c 100644 --- a/apple/project.yml +++ b/apple/project.yml @@ -12,6 +12,15 @@ options: macOS: "15.0" createIntermediateGroups: true +# Build configurations. Declaring `configs` replaces XcodeGen's Debug/Release +# defaults, so both are re-listed here. `Release-Direct` is a release-type config +# used only by the VniDropDirect (notarized DMG + Sparkle) target; the App Store +# `VniDrop` target ships under plain `Release`. +configs: + Debug: debug + Release: release + Release-Direct: release + # Project-wide build settings (applied to every target/config). settings: base: @@ -26,27 +35,44 @@ packages: SFSafeSymbols: url: https://github.com/SFSafeSymbols/SFSafeSymbols from: "5.3.0" + # Sparkle powers in-app auto-updates for the direct-download (.dmg) build only. + # It is linked exclusively by the VniDropDirect target — SwiftPM links products + # per target, not per config, so keeping it off the App Store target is what + # guarantees the store binary never bundles a self-updater (App Store forbids it). + Sparkle: + url: https://github.com/sparkle-project/Sparkle + from: "2.9.4" -targets: - VniDrop: +# Shared definition for the two shipping app targets. `VniDrop` (App Store / +# TestFlight) and `VniDropDirect` (notarized DMG + Sparkle) build the exact same +# sources; only their destinations, extra dependencies, and the DIRECT_DISTRIBUTION +# compile flag differ (set per target below). +targetTemplates: + AppBase: type: application - supportedDestinations: [iOS, macOS] configFiles: Debug: Signing.xcconfig Release: Signing.xcconfig + Release-Direct: Signing.xcconfig sources: - path: VniDrop excludes: - "Resources/Info.plist" - "Resources/VniDrop.entitlements" + - "Resources/VniDropDirect.entitlements" - "Resources/**/.DS_Store" settings: base: + PRODUCT_NAME: VniDrop PRODUCT_BUNDLE_IDENTIFIER: com.vnidrop.app MARKETING_VERSION: "0.1.0" - CURRENT_PROJECT_VERSION: "7" + # Placeholder only — the real CFBundleVersion is stamped at build time as a + # UTC YYMMDD.HHMM timestamp by the "Stamp build number" phase below, so every + # build is monotonic and self-describing (shown as "MARKETING_VERSION (build)"). + CURRENT_PROJECT_VERSION: "1" GENERATE_INFOPLIST_FILE: NO INFOPLIST_FILE: VniDrop/Resources/Info.plist + CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements # Mirror the Info.plist identity so Xcode's Identity editor shows it too # (the editor reads these build settings, not the manual plist). INFOPLIST_KEY_CFBundleDisplayName: VniDrop @@ -59,12 +85,11 @@ targets: # AccentColor asset mirrors VniDropColors.brandPurple — keep them in sync. ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor configs: - debug: - CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements release: - CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements # Produce a dSYM in the archive so symbol upload succeeds. DEBUG_INFORMATION_FORMAT: dwarf-with-dsym + release-direct: + DEBUG_INFORMATION_FORMAT: dwarf-with-dsym dependencies: - package: VnidropCore - package: SFSafeSymbols @@ -86,6 +111,52 @@ targets: echo "error: SwiftLint not installed — run 'brew install swiftlint'" exit 1 fi + postBuildScripts: + # Stamp CFBundleVersion as a UTC YYMMDD.HHMM timestamp into the built + # Info.plist before code signing. Runs for every build (Xcode GUI archive and + # CLI alike), so both the App Store and direct-download channels get a + # monotonic, meaningful build id. CI/reproducible builds can pin it via the + # VNIDROP_BUILD env var. MARKETING_VERSION stays the human X.Y.Z version. + - name: Stamp build number (UTC timestamp) + basedOnDependencyAnalysis: false + script: | + build="${VNIDROP_BUILD:-$(date -u +%y%m%d.%H%M)}" + plist="${TARGET_BUILD_DIR}/${INFOPLIST_PATH}" + if [ -f "$plist" ]; then + /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $build" "$plist" + echo "Stamped CFBundleVersion = $build" + else + echo "warning: Info.plist not found at $plist; CFBundleVersion not stamped" + fi + +targets: + # App Store / TestFlight target. iOS + macOS, sandboxed, no self-updater. + VniDrop: + templates: [AppBase] + supportedDestinations: [iOS, macOS] + + # Direct-download macOS target: Developer ID signed, notarized, ships in a .dmg + # and self-updates via Sparkle. DIRECT_DISTRIBUTION gates all Sparkle code so the + # App Store target above never compiles or links it. + VniDropDirect: + templates: [AppBase] + supportedDestinations: [macOS] + settings: + base: + SWIFT_ACTIVE_COMPILATION_CONDITIONS: "$(inherited) DIRECT_DISTRIBUTION" + # Notarization requires the hardened runtime. + ENABLE_HARDENED_RUNTIME: YES + # Non-sandboxed entitlements: a sandboxed Developer ID app needs a + # provisioning profile, which direct distribution avoids. (App Store target + # keeps VniDrop.entitlements with the sandbox.) + CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDropDirect.entitlements + # The Rust core's macOS slice (vnidrop.xcframework) is arm64-only + # (build-core.sh builds aarch64-apple-darwin only), so the direct build is + # Apple-Silicon-only. Pin ARCHS so the Release-Direct (universal-by-default) + # link doesn't fail looking for x86_64 symbols. + ARCHS: arm64 + dependencies: + - package: Sparkle VniDropTests: type: bundle.unit-test @@ -93,6 +164,7 @@ targets: configFiles: Debug: Signing.xcconfig Release: Signing.xcconfig + Release-Direct: Signing.xcconfig sources: - path: Tests settings: @@ -120,3 +192,15 @@ schemes: config: Release archive: config: Release + + # Direct-download build: always the Release-Direct config (Sparkle + notarization). + VniDropDirect: + build: + targets: + VniDropDirect: all + run: + config: Release-Direct + profile: + config: Release-Direct + archive: + config: Release-Direct diff --git a/apple/scripts/ExportOptions-DeveloperID.plist b/apple/scripts/ExportOptions-DeveloperID.plist new file mode 100644 index 0000000..27229b8 --- /dev/null +++ b/apple/scripts/ExportOptions-DeveloperID.plist @@ -0,0 +1,19 @@ + + + + + + method + developer-id + signingStyle + manual + + teamID + ${DEVELOPMENT_TEAM} + + diff --git a/apple/scripts/build-dmg.sh b/apple/scripts/build-dmg.sh new file mode 100755 index 0000000..6504a7f --- /dev/null +++ b/apple/scripts/build-dmg.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# +# Builds the direct-download macOS artifact: a Developer ID–signed, notarized +# .dmg of the VniDropDirect target (the Sparkle-enabled build). Produces: +# - apple/dist/VniDrop-.dmg (signed + stapled when notarizing) +# +# This is the direct-distribution counterpart to the App Store archive flow; it +# never touches the App Store `VniDrop` target. The Rust crate is not modified. +# +# Usage: apple/scripts/build-dmg.sh [version] +# version MAJOR.MINOR.PATCH; defaults to MARKETING_VERSION / the git tag. +# +# Environment: +# DEVELOPER_ID_APP Codesign identity, e.g. "Developer ID Application: … (TEAMID)". +# Auto-detected from the keychain when unset. +# DEVELOPMENT_TEAM Apple team ID (10 chars). Auto-derived from the identity. +# NOTARY_PROFILE Name of a `xcrun notarytool store-credentials` keychain +# profile. When set, the DMG is notarized and stapled; when +# unset the build still produces a signed DMG and prints the +# pending notarization step (useful before creds exist). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +APPLE_DIR="$REPO_ROOT/apple" +DIST_DIR="$APPLE_DIR/dist" +BUILD_DIR="$APPLE_DIR/.build-dmg" +PROJECT="$APPLE_DIR/VniDrop.xcodeproj" +SCHEME="VniDropDirect" +CONFIG="Release-Direct" +APP_NAME="VniDrop" + +# --- Resolve version (arg > git tag > project MARKETING_VERSION) ------------- +resolve_version() { + local v="${1:-}" + if [ -z "$v" ] && [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then + v="${GITHUB_REF_NAME#v}" + fi + if [ -z "$v" ]; then + v="$(sed -nE 's/.*MARKETING_VERSION: "([0-9.]+)".*/\1/p' "$APPLE_DIR/project.yml" | head -1)" + fi + if [[ ! "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "version must be MAJOR.MINOR.PATCH (got '$v')" >&2 + exit 1 + fi + printf '%s' "$v" +} +VERSION="$(resolve_version "${1:-}")" + +# CFBundleVersion is a UTC YYMMDD.HHMM timestamp stamped by the target's +# "Stamp build number" build phase. Pin it here (one value for the whole archive) +# so the app, DMG, and appcast all agree; Sparkle compares it to order updates. +BUILD_NUMBER="$(date -u +%y%m%d.%H%M)" +export VNIDROP_BUILD="$BUILD_NUMBER" + +# --- Resolve signing identity ------------------------------------------------ +if [ -z "${DEVELOPER_ID_APP:-}" ]; then + DEVELOPER_ID_APP="$(security find-identity -v -p codesigning 2>/dev/null \ + | sed -nE 's/.*"(Developer ID Application: [^"]+)".*/\1/p' | head -1)" +fi +if [ -z "${DEVELOPER_ID_APP:-}" ]; then + echo "error: no 'Developer ID Application' identity found in the keychain." >&2 + echo " Create one in Xcode ▸ Settings ▸ Accounts, or set DEVELOPER_ID_APP." >&2 + exit 1 +fi +if [ -z "${DEVELOPMENT_TEAM:-}" ]; then + # The team ID is the 10-char code in the trailing parenthesis of the identity. + DEVELOPMENT_TEAM="$(printf '%s' "$DEVELOPER_ID_APP" | sed -nE 's/.*\(([A-Z0-9]{10})\)$/\1/p')" +fi +echo "==> Direct build v$VERSION (CFBundleVersion $BUILD_NUMBER)" +echo " identity: $DEVELOPER_ID_APP" +echo " team: ${DEVELOPMENT_TEAM:-}" + +# --- Build core + regenerate project ---------------------------------------- +# Release core needs LTO disabled (workspace thin-LTO miscompiles proc-macros). +echo "==> Building Rust core (release)" +CARGO_PROFILE_RELEASE_LTO=false "$SCRIPT_DIR/build-core.sh" release +echo "==> Regenerating Xcode project" +( cd "$APPLE_DIR" && xcodegen generate >/dev/null ) + +rm -rf "$BUILD_DIR" && mkdir -p "$BUILD_DIR" "$DIST_DIR" +ARCHIVE="$BUILD_DIR/$APP_NAME.xcarchive" +EXPORT_DIR="$BUILD_DIR/export" + +# --- Archive + export (Developer ID) ---------------------------------------- +echo "==> Archiving $SCHEME ($CONFIG)" +xcodebuild archive \ + -project "$PROJECT" \ + -scheme "$SCHEME" \ + -configuration "$CONFIG" \ + -destination 'generic/platform=macOS' \ + -archivePath "$ARCHIVE" \ + MARKETING_VERSION="$VERSION" \ + DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM" \ + CODE_SIGN_STYLE=Manual \ + CODE_SIGN_IDENTITY="$DEVELOPER_ID_APP" \ + | xcbeautify 2>/dev/null || true +[ -d "$ARCHIVE" ] || { echo "error: archive failed" >&2; exit 1; } + +echo "==> Exporting Developer ID app" +EXPORT_OPTS="$BUILD_DIR/ExportOptions.plist" +sed "s/\${DEVELOPMENT_TEAM}/$DEVELOPMENT_TEAM/" \ + "$SCRIPT_DIR/ExportOptions-DeveloperID.plist" > "$EXPORT_OPTS" +xcodebuild -exportArchive \ + -archivePath "$ARCHIVE" \ + -exportPath "$EXPORT_DIR" \ + -exportOptionsPlist "$EXPORT_OPTS" +APP="$EXPORT_DIR/$APP_NAME.app" +[ -d "$APP" ] || { echo "error: export failed" >&2; exit 1; } + +# --- Build the DMG ----------------------------------------------------------- +DMG="$DIST_DIR/$APP_NAME-$VERSION.dmg" +rm -f "$DMG" +STAGING="$BUILD_DIR/dmg-staging" +rm -rf "$STAGING" && mkdir -p "$STAGING" +cp -R "$APP" "$STAGING/" +ln -s /Applications "$STAGING/Applications" + +echo "==> Building DMG" +if command -v create-dmg >/dev/null 2>&1; then + create-dmg \ + --volname "$APP_NAME" \ + --app-drop-link 380 205 \ + --icon "$APP_NAME.app" 130 205 \ + --window-size 540 380 \ + --no-internet-enable \ + "$DMG" "$STAGING" >/dev/null || { + # create-dmg exits non-zero if it can't set the fancy layout; fall back. + [ -f "$DMG" ] || hdiutil create -volname "$APP_NAME" -srcfolder "$STAGING" \ + -ov -format UDZO "$DMG" >/dev/null + } +else + hdiutil create -volname "$APP_NAME" -srcfolder "$STAGING" \ + -ov -format UDZO "$DMG" >/dev/null +fi + +echo "==> Signing DMG" +codesign --force --sign "$DEVELOPER_ID_APP" --timestamp "$DMG" + +# --- Notarize + staple ------------------------------------------------------- +if [ -n "${NOTARY_PROFILE:-}" ]; then + echo "==> Notarizing (profile: $NOTARY_PROFILE)" + xcrun notarytool submit "$DMG" --keychain-profile "$NOTARY_PROFILE" --wait + echo "==> Stapling" + xcrun stapler staple "$DMG" + xcrun stapler validate "$DMG" + spctl -a -vvv --type install "$DMG" || true +else + echo "==> NOTARY_PROFILE unset — skipping notarization." + echo " The DMG is signed but NOT notarized; Gatekeeper will block it until" + echo " you run 'xcrun notarytool store-credentials' and re-run with NOTARY_PROFILE set." +fi + +SIZE="$(stat -f%z "$DMG")" +echo "==> Done." +echo " dmg: $DMG" +echo " version: $VERSION" +echo " size: $SIZE bytes" diff --git a/apple/scripts/generate-appcast.sh b/apple/scripts/generate-appcast.sh new file mode 100755 index 0000000..762bd43 --- /dev/null +++ b/apple/scripts/generate-appcast.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# +# Generates/updates the Sparkle appcast for the direct-download build. Runs +# Sparkle's `generate_appcast` over the DMGs in apple/dist/, writing: +# - apple/dist/appcast.xml +# +# The URLs point at the matching GitHub Release download assets, and +# each item is signed with the project's EdDSA key (from a key file or the +# keychain). The resulting appcast.xml is uploaded as a release asset; the app's +# SUFeedURL (/releases/latest/download/appcast.xml) always resolves to the newest. +# +# Usage: apple/scripts/generate-appcast.sh [version] +# +# Environment: +# DIST_DIR Folder holding the DMG(s). Default: apple/dist +# SPARKLE_BIN Dir containing generate_appcast. Auto-located when unset. +# SPARKLE_ED_KEY_FILE Path to the EdDSA private key file. When unset, +# generate_appcast reads the key from the login keychain. +# RELEASE_REPO owner/repo for enclosure URLs. Default: sudosylabs/vnidrop +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +APPLE_DIR="$REPO_ROOT/apple" +DIST_DIR="${DIST_DIR:-$APPLE_DIR/dist}" +RELEASE_REPO="${RELEASE_REPO:-sudosylabs/vnidrop}" + +VERSION="${1:-}" +if [ -z "$VERSION" ] && [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then + VERSION="${GITHUB_REF_NAME#v}" +fi +[ -n "$VERSION" ] || { echo "error: version required (arg or tag)" >&2; exit 1; } + +# Enclosure URLs resolve to the specific release's assets. +DOWNLOAD_PREFIX="https://github.com/$RELEASE_REPO/releases/download/v$VERSION" + +# --- Locate generate_appcast ------------------------------------------------- +find_tool() { + local name="$1" + if [ -n "${SPARKLE_BIN:-}" ] && [ -x "$SPARKLE_BIN/$name" ]; then + printf '%s' "$SPARKLE_BIN/$name"; return 0 + fi + if command -v "$name" >/dev/null 2>&1; then command -v "$name"; return 0; fi + # Sparkle SPM artifact bundle lands under DerivedData SourcePackages. + local dd="${APPLE_DERIVED_DATA:-$HOME/Library/Developer/Xcode/DerivedData}" + local hit + hit="$(find "$dd" "$HOME/Library/Caches/org.swift.swiftpm" -type f -name "$name" \ + -perm -111 2>/dev/null | head -1 || true)" + [ -n "$hit" ] && { printf '%s' "$hit"; return 0; } + return 1 +} +GENERATE_APPCAST="$(find_tool generate_appcast || true)" +if [ -z "$GENERATE_APPCAST" ]; then + echo "error: generate_appcast not found. Set SPARKLE_BIN to Sparkle's bin/ dir" >&2 + echo " (download from https://github.com/sparkle-project/Sparkle/releases)." >&2 + exit 1 +fi +echo "==> Using $GENERATE_APPCAST" + +# --- Generate ---------------------------------------------------------------- +args=( --download-url-prefix "$DOWNLOAD_PREFIX/" -o "$DIST_DIR/appcast.xml" ) +if [ -n "${SPARKLE_ED_KEY_FILE:-}" ]; then + args+=( --ed-key-file "$SPARKLE_ED_KEY_FILE" ) +fi +echo "==> Generating appcast (v$VERSION) → $DIST_DIR/appcast.xml" +"$GENERATE_APPCAST" "${args[@]}" "$DIST_DIR" + +echo "==> Done. Enclosure prefix: $DOWNLOAD_PREFIX/" diff --git a/localization/strings.json b/localization/strings.json index e44bfd8..2196c4f 100644 --- a/localization/strings.json +++ b/localization/strings.json @@ -4629,6 +4629,23 @@ "ru": "Поделиться" } }, + "updates_check": { + "context": "macOS app menu item that checks for a new version via Sparkle. Direct-download (.dmg) build only; never shown in the App Store build.", + "targets": [ + "apple" + ], + "translations": { + "en": "Check for Updates…", + "fr": "Rechercher les mises à jour…", + "es": "Buscar actualizaciones…", + "it": "Cerca aggiornamenti…", + "de": "Nach Updates suchen…", + "pt": "Procurar atualizações…", + "pl": "Sprawdź aktualizacje…", + "nl": "Zoeken naar updates…", + "ru": "Проверить наличие обновлений…" + } + }, "value_unavailable": { "context": "Placeholder shown when a device-info or metadata value can't be read.", "translations": { diff --git a/packaging/homebrew/tap-README.md b/packaging/homebrew/tap-README.md new file mode 100644 index 0000000..9296b54 --- /dev/null +++ b/packaging/homebrew/tap-README.md @@ -0,0 +1,54 @@ +# homebrew-vnidrop + +Homebrew tap for [VniDrop](https://github.com/sudosylabs/vnidrop) — direct, +private device-to-device file and folder transfer for macOS. + +> This repo only holds the Homebrew **cask**. The app itself lives at +> [sudosylabs/vnidrop](https://github.com/sudosylabs/vnidrop). The cask here is +> updated automatically by VniDrop's release pipeline on each tagged release. + +## Install + +```sh +brew tap sudosylabs/vnidrop +brew install --cask vnidrop +``` + +Or in one line: + +```sh +brew install --cask sudosylabs/vnidrop/vnidrop +``` + +## Update + +VniDrop updates itself in-app via [Sparkle](https://sparkle-project.org), so you +normally don't need to do anything. To update through Homebrew instead: + +```sh +brew upgrade --cask vnidrop +``` + +## Uninstall + +```sh +brew uninstall --cask vnidrop +``` + +Add `--zap` to also remove VniDrop's application support, cache, and preference +files: + +```sh +brew uninstall --zap --cask vnidrop +``` + +## Requirements + +- macOS 15 (Sequoia) or later, Apple Silicon. + +## What you get + +The cask installs the Developer ID–signed, notarized `VniDrop.app` from the +matching [GitHub Release](https://github.com/sudosylabs/vnidrop/releases). App +Store users should install from the Mac App Store instead — that build does not +include the Sparkle self-updater. diff --git a/packaging/homebrew/vnidrop.rb b/packaging/homebrew/vnidrop.rb new file mode 100644 index 0000000..f81fca9 --- /dev/null +++ b/packaging/homebrew/vnidrop.rb @@ -0,0 +1,36 @@ +# Homebrew cask for the direct-download (notarized .dmg) macOS build. +# +# This file is the source template. The Apple release workflow substitutes the +# version + sha256 for each release and pushes the result to the tap repo +# (sudosylabs/homebrew-vnidrop, path Casks/vnidrop.rb). Users then install with: +# brew install --cask sudosylabs/vnidrop/vnidrop +# +# `auto_updates true` tells Homebrew that the app updates itself via Sparkle, so +# `brew upgrade` won't fight the in-app updater. +cask "vnidrop" do + version "0.0.0" + sha256 "0000000000000000000000000000000000000000000000000000000000000000" + + url "https://github.com/sudosylabs/vnidrop/releases/download/v#{version}/VniDrop-#{version}.dmg", + verified: "github.com/sudosylabs/vnidrop/" + name "VniDrop" + desc "Direct device-to-device file and folder transfer over the network" + homepage "https://github.com/sudosylabs/vnidrop" + + livecheck do + url :url + strategy :github_latest + end + + auto_updates true + depends_on arch: :arm64 + depends_on macos: ">= :sequoia" + + app "VniDrop.app" + + zap trash: [ + "~/Library/Application Support/com.vnidrop.app", + "~/Library/Caches/com.vnidrop.app", + "~/Library/Preferences/com.vnidrop.app.plist", + ] +end