mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
Compare commits
13 Commits
31ba3f40b2
...
feat/relea
| Author | SHA1 | Date | |
|---|---|---|---|
| 4730554c2c | |||
| cbb535d998 | |||
| a0ebd7c71b | |||
| cc194f6a7b | |||
| 8de190a36e | |||
| 73bc87d3d1 | |||
| 2166aa9ce4 | |||
| 22b93ce94e | |||
| f3124371ee | |||
| ea2f8b1cc7 | |||
| 9b8d66f97d | |||
| a8a168ffde | |||
| 516c4ace84 |
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*.af filter=lfs diff=lfs merge=lfs -text
|
||||||
230
.github/workflows/apple-release.yml
vendored
Normal file
230
.github/workflows/apple-release.yml
vendored
Normal file
@@ -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-<version>.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
|
||||||
5
.github/workflows/apple.yml
vendored
5
.github/workflows/apple.yml
vendored
@@ -76,3 +76,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Build and test Apple app
|
- name: Build and test Apple app
|
||||||
run: make check-apple
|
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
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -24,3 +24,4 @@ config.override.mk
|
|||||||
# Local design export scratch
|
# Local design export scratch
|
||||||
output/
|
output/
|
||||||
.screenshots
|
.screenshots
|
||||||
|
apple/RELEASE-MACOS.md
|
||||||
|
|||||||
3
LICENSE
3
LICENSE
@@ -187,7 +187,8 @@
|
|||||||
same "printed page" as the copyright notice for easier
|
same "printed page" as the copyright notice for easier
|
||||||
identification within third-party archives.
|
identification within third-party archives.
|
||||||
|
|
||||||
Copyright [yyyy] [name of copyright owner]
|
Copyright 2026 VniDrop
|
||||||
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
|||||||
6
Makefile
6
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).
|
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
|
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.
|
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; }
|
@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"
|
$(OPEN) "$(APPLE_DERIVED_DATA)/Build/Products/$(APPLE_CONFIGURATION)/VniDrop.app"
|
||||||
|
|||||||
4
apple/.gitignore
vendored
4
apple/.gitignore
vendored
@@ -18,3 +18,7 @@ Local.xcconfig
|
|||||||
.swiftpm/
|
.swiftpm/
|
||||||
DerivedData/
|
DerivedData/
|
||||||
*.xcuserstate
|
*.xcuserstate
|
||||||
|
|
||||||
|
# Direct-download (.dmg) build outputs — apple/scripts/build-dmg.sh
|
||||||
|
.build-dmg/
|
||||||
|
dist/
|
||||||
|
|||||||
@@ -32,3 +32,18 @@ custom_rules:
|
|||||||
regex: '\b(Text|Label|Button|Toggle|Link|NavigationLink|Section|Picker|Stepper|TextField|SecureField|DisclosureGroup|Menu|GroupBox)\("[^"]'
|
regex: '\b(Text|Label|Button|Toggle|Link|NavigationLink|Section|Picker|Stepper|TextField|SecureField|DisclosureGroup|Menu|GroupBox)\("[^"]'
|
||||||
message: "Pass a typed L10n.* accessor (or Text(verbatim:)), not a raw string literal."
|
message: "Pass a typed L10n.* accessor (or Text(verbatim:)), not a raw string literal."
|
||||||
severity: warning
|
severity: warning
|
||||||
|
raw_alert_message:
|
||||||
|
name: "Raw NFC/alert message"
|
||||||
|
# User-facing UIKit/CoreNFC prompts (e.g. NFCReaderSession.alertMessage) must
|
||||||
|
# be localized, not hardcoded English.
|
||||||
|
regex: '\balertMessage\s*=\s*"'
|
||||||
|
message: "Assign a localized value (String(localized: L10n.*)), not a raw string literal."
|
||||||
|
severity: warning
|
||||||
|
raw_invitation_error:
|
||||||
|
name: "Raw InvitationError literal"
|
||||||
|
# InvitationError.raw is the escape hatch for genuinely dynamic system/core
|
||||||
|
# messages; a string literal here is a loose user-facing string that belongs
|
||||||
|
# in a typed InvitationError case mapped to L10n in UserFacingError.swift.
|
||||||
|
regex: 'InvitationError\.raw\("'
|
||||||
|
message: "Add a typed InvitationError case + L10n mapping instead of a literal .raw(\"…\")."
|
||||||
|
severity: warning
|
||||||
|
|||||||
@@ -34,12 +34,30 @@ Prerequisites: Xcode, Rust with the Apple targets
|
|||||||
make apple-core # Rust core, Swift bindings, and XCFramework
|
make apple-core # Rust core, Swift bindings, and XCFramework
|
||||||
make apple-project # generate apple/VniDrop.xcodeproj
|
make apple-project # generate apple/VniDrop.xcodeproj
|
||||||
make open-apple-project # generate and open the project in Xcode
|
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 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
|
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
|
Use `APPLE_PROFILE=release` to request a release Rust core, or set
|
||||||
`APPLE_DESTINATION` to override the automatically selected iOS simulator.
|
`APPLE_DESTINATION` to override the automatically selected iOS simulator.
|
||||||
Code signing is disabled for the app and test targets; local and CI builds do
|
Code signing is disabled for the app and test targets; local and CI builds do
|
||||||
|
|||||||
@@ -21,13 +21,13 @@ final class UiMessageControllerTests: XCTestCase {
|
|||||||
|
|
||||||
func testErrorSuppressesUserCancellation() {
|
func testErrorSuppressesUserCancellation() {
|
||||||
let c = UiMessageController()
|
let c = UiMessageController()
|
||||||
c.error(InvitationError.message("QR scanning was cancelled"))
|
c.error(InvitationError.cancelled)
|
||||||
XCTAssertNil(c.current) // cancellations are swallowed
|
XCTAssertNil(c.current) // cancellations are swallowed
|
||||||
}
|
}
|
||||||
|
|
||||||
func testErrorShowsNonCancellation() {
|
func testErrorShowsNonCancellation() {
|
||||||
let c = UiMessageController()
|
let c = UiMessageController()
|
||||||
c.error(InvitationError.message("The transfer was refused"))
|
c.error(InvitationError.raw("The transfer was refused"))
|
||||||
XCTAssertEqual(c.current?.tone, .error)
|
XCTAssertEqual(c.current?.tone, .error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -36,20 +36,23 @@ final class UiMessageControllerTests: XCTestCase {
|
|||||||
final class UserFacingErrorTests: XCTestCase {
|
final class UserFacingErrorTests: XCTestCase {
|
||||||
|
|
||||||
func testIsUserCancellation() {
|
func testIsUserCancellation() {
|
||||||
XCTAssertTrue(InvitationError.message("NFC reading was cancelled").isUserCancellation)
|
XCTAssertTrue(InvitationError.cancelled.isUserCancellation)
|
||||||
XCTAssertTrue(InvitationError.message("User canceled the picker").isUserCancellation)
|
XCTAssertTrue(InvitationError.raw("User canceled the picker").isUserCancellation)
|
||||||
XCTAssertFalse(InvitationError.message("A database error occurred").isUserCancellation)
|
XCTAssertFalse(InvitationError.raw("A database error occurred").isUserCancellation)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testToUiTextMapsKnownReasons() {
|
func testToUiTextMapsKnownReasons() {
|
||||||
XCTAssertEqual(InvitationError.message("The transfer was refused").toUiText(), .resource(L10n.Error.permission))
|
// Typed cases map directly at the UI boundary.
|
||||||
XCTAssertEqual(InvitationError.message("invalid ticket").toUiText(), .resource(L10n.Error.invalidTicket))
|
XCTAssertEqual(InvitationError.shareEmpty.toUiText(), .resource(L10n.Error.shareEmpty))
|
||||||
XCTAssertEqual(InvitationError.message("Select at least one file to share").toUiText(), .resource(L10n.Error.shareEmpty))
|
XCTAssertEqual(InvitationError.cameraUnavailable.toUiText(), .resource(L10n.Error.camera))
|
||||||
XCTAssertEqual(InvitationError.message("Camera access is required").toUiText(), .resource(L10n.Error.camera))
|
XCTAssertEqual(InvitationError.nfcFailed.toUiText(), .resource(L10n.Error.nfc))
|
||||||
|
// Dynamic `.raw` payloads still fall through the substring hints.
|
||||||
|
XCTAssertEqual(InvitationError.raw("The transfer was refused").toUiText(), .resource(L10n.Error.permission))
|
||||||
|
XCTAssertEqual(InvitationError.raw("invalid ticket").toUiText(), .resource(L10n.Error.invalidTicket))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testToUiTextFallsBackToGeneric() {
|
func testToUiTextFallsBackToGeneric() {
|
||||||
XCTAssertEqual(InvitationError.message("something entirely unexpected").toUiText(), .resource(L10n.Error.generic))
|
XCTAssertEqual(InvitationError.raw("something entirely unexpected").toUiText(), .resource(L10n.Error.generic))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testToUiTextMapsTypedTransferFailures() {
|
func testToUiTextMapsTypedTransferFailures() {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ final class AppGraph: ObservableObject {
|
|||||||
let filePreviewRepository: FilePreviewRepository
|
let filePreviewRepository: FilePreviewRepository
|
||||||
let approvalCoordinator: ApprovalCoordinator
|
let approvalCoordinator: ApprovalCoordinator
|
||||||
let transferNotificationCoordinator: TransferNotificationCoordinator
|
let transferNotificationCoordinator: TransferNotificationCoordinator
|
||||||
|
let backgroundActivity: BackgroundActivityController
|
||||||
|
|
||||||
init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil) {
|
init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil) {
|
||||||
self.dependencies = dependencies
|
self.dependencies = dependencies
|
||||||
@@ -39,6 +40,7 @@ final class AppGraph: ObservableObject {
|
|||||||
visibility: visibility,
|
visibility: visibility,
|
||||||
messages: messages
|
messages: messages
|
||||||
)
|
)
|
||||||
|
self.backgroundActivity = BackgroundActivityController(repository: coreRepository)
|
||||||
AppLogger.info("lifecycle", "graph created", ["platform": dependencies.environment.name])
|
AppLogger.info("lifecycle", "graph created", ["platform": dependencies.environment.name])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ struct RootView: View {
|
|||||||
|
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
|
/// Drives the approval sheet; toggled from the pending-approval `onChange` so the
|
||||||
|
/// presentation can be deferred until the Share/QR sheet has dismissed on macOS.
|
||||||
|
@State private var showApproval = false
|
||||||
|
|
||||||
init(dependencies: AppDependencies) {
|
init(dependencies: AppDependencies) {
|
||||||
let graph = AppGraph(dependencies: dependencies)
|
let graph = AppGraph(dependencies: dependencies)
|
||||||
_graph = StateObject(wrappedValue: graph)
|
_graph = StateObject(wrappedValue: graph)
|
||||||
@@ -58,6 +62,7 @@ struct RootView: View {
|
|||||||
navigation(windowClass: windowClass)
|
navigation(windowClass: windowClass)
|
||||||
SnackbarHost(controller: messages)
|
SnackbarHost(controller: messages)
|
||||||
ApprovalModalHost(
|
ApprovalModalHost(
|
||||||
|
isPresented: $showApproval,
|
||||||
state: approvals.state,
|
state: approvals.state,
|
||||||
onAccept: approvals.accept,
|
onAccept: approvals.accept,
|
||||||
onRefuse: approvals.refuse
|
onRefuse: approvals.refuse
|
||||||
@@ -81,22 +86,43 @@ struct RootView: View {
|
|||||||
switch phase {
|
switch phase {
|
||||||
case .active:
|
case .active:
|
||||||
graph.visibility.setForeground(true)
|
graph.visibility.setForeground(true)
|
||||||
|
graph.backgroundActivity.didBecomeForeground()
|
||||||
settingsModel.refreshNotificationPermission()
|
settingsModel.refreshNotificationPermission()
|
||||||
// Reconcile against the durable snapshot: while the window was
|
// Reconcile against the durable snapshot: while the window was
|
||||||
// unfocused/occluded (common on macOS) live events may not have
|
// unfocused/occluded (common on macOS) live events may not have
|
||||||
// rendered, leaving progress/status stale.
|
// rendered, leaving progress/status stale.
|
||||||
Task { _ = await graph.coreRepository.refresh() }
|
Task { _ = await graph.coreRepository.refresh() }
|
||||||
case .background, .inactive:
|
case .background:
|
||||||
|
graph.visibility.setForeground(false)
|
||||||
|
// Hold the process open for iOS's grace window so an active
|
||||||
|
// transfer can finish and notify before suspension.
|
||||||
|
graph.backgroundActivity.didEnterBackground()
|
||||||
|
case .inactive:
|
||||||
graph.visibility.setForeground(false)
|
graph.visibility.setForeground(false)
|
||||||
@unknown default:
|
@unknown default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A pending approval is a blocking modal; close the sender's detail panel
|
// A pending approval is a blocking modal. Close the sender's detail panel
|
||||||
// (e.g. the Share/QR sheet) so the approval sheet isn't presented under it
|
// (e.g. the Share/QR sheet) first, then present the approval sheet — but on
|
||||||
// on macOS.
|
// macOS a sheet presented while another is still dismissing is silently
|
||||||
|
// dropped, so defer the presentation until that dismissal finishes.
|
||||||
.onChange(of: approvals.state.current?.id) { _, id in
|
.onChange(of: approvals.state.current?.id) { _, id in
|
||||||
if id != nil { sendModel.closeDetailPanel() }
|
guard id != nil else { showApproval = false; return }
|
||||||
|
let wasShowingSheet = sendModel.state.detailPanel != nil
|
||||||
|
sendModel.closeDetailPanel()
|
||||||
|
#if os(macOS)
|
||||||
|
if wasShowingSheet {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) {
|
||||||
|
if approvals.state.current != nil { showApproval = true }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showApproval = true
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
_ = wasShowingSheet
|
||||||
|
showApproval = true
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
// macOS keeps `scenePhase == .active` even when the app loses focus, so
|
// macOS keeps `scenePhase == .active` even when the app loses focus, so
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ private let mainWindowId = "main"
|
|||||||
@main
|
@main
|
||||||
struct VniDropApp: App {
|
struct VniDropApp: App {
|
||||||
@StateObject private var externalInvitations = ExternalInvitationController()
|
@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 {
|
var body: some Scene {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@@ -18,6 +22,11 @@ struct VniDropApp: App {
|
|||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
.onOpenURL(perform: openInvitation)
|
.onOpenURL(perform: openInvitation)
|
||||||
}
|
}
|
||||||
|
#if DIRECT_DISTRIBUTION
|
||||||
|
.commands {
|
||||||
|
UpdatesCommands(controller: updater)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
#else
|
#else
|
||||||
WindowGroup(id: mainWindowId) {
|
WindowGroup(id: mainWindowId) {
|
||||||
RootView(dependencies: makeAppDependencies(externalInvitations: externalInvitations))
|
RootView(dependencies: makeAppDependencies(externalInvitations: externalInvitations))
|
||||||
|
|||||||
77
apple/VniDrop/Core/BackgroundActivityController.swift
Normal file
77
apple/VniDrop/Core/BackgroundActivityController.swift
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// Keeps the Rust core alive across the app moving to the background, within the
|
||||||
|
/// bounds Apple actually allows for a serverless P2P transfer app.
|
||||||
|
///
|
||||||
|
/// iOS suspends the whole process (freezing the core's network threads) shortly
|
||||||
|
/// after the app leaves the foreground. When a transfer or share is active we
|
||||||
|
/// take a `UIApplication` background-task assertion so iOS grants its finite
|
||||||
|
/// grace window — long enough for an in-flight transfer to finish streaming and
|
||||||
|
/// for its completion/failure notification to fire. There is no App-Store-legal
|
||||||
|
/// mechanism to keep serving or receiving *indefinitely* while backgrounded, and
|
||||||
|
/// `BGTaskScheduler` wake-ups run only opportunistically and cannot detect an
|
||||||
|
/// incoming peer connection, so they are deliberately not used here.
|
||||||
|
///
|
||||||
|
/// macOS does not suspend the process on focus loss, so this is a no-op there and
|
||||||
|
/// the core keeps running normally.
|
||||||
|
@MainActor
|
||||||
|
final class BackgroundActivityController {
|
||||||
|
private let repository: CoreRepository
|
||||||
|
|
||||||
|
init(repository: CoreRepository) {
|
||||||
|
self.repository = repository
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
private var assertionId: UIBackgroundTaskIdentifier = .invalid
|
||||||
|
private var idleCancellable: AnyCancellable?
|
||||||
|
|
||||||
|
/// The app moved to the background. Hold the process open while there is live
|
||||||
|
/// work; release as soon as it drains, on return to foreground, or when iOS
|
||||||
|
/// ends the grace window (whichever comes first).
|
||||||
|
func didEnterBackground() {
|
||||||
|
guard assertionId == .invalid, hasActiveWork else { return }
|
||||||
|
assertionId = UIApplication.shared.beginBackgroundTask(withName: "vnidrop.transfer") { [weak self] in
|
||||||
|
// Expiration handler: iOS is reclaiming the window; end cleanly to
|
||||||
|
// avoid the watchdog terminating the app.
|
||||||
|
self?.endAssertion()
|
||||||
|
}
|
||||||
|
// Release the assertion the moment work finishes instead of holding it for
|
||||||
|
// the full window (battery, and it lets the process suspend sooner). Events
|
||||||
|
// still deliver on the main actor while the window is open, so the core's
|
||||||
|
// active counts drop here when a transfer completes.
|
||||||
|
idleCancellable = repository.statePublisher
|
||||||
|
.map { ($0.status?.activeTransfers ?? 0) == 0 && ($0.status?.activeShares ?? 0) == 0 }
|
||||||
|
.removeDuplicates()
|
||||||
|
.sink { [weak self] idle in
|
||||||
|
if idle { self?.endAssertion() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The app returned to the foreground; the process is live again, so drop any
|
||||||
|
/// held assertion.
|
||||||
|
func didBecomeForeground() {
|
||||||
|
endAssertion()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasActiveWork: Bool {
|
||||||
|
let status = repository.state.status
|
||||||
|
return (status?.activeTransfers ?? 0) > 0 || (status?.activeShares ?? 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func endAssertion() {
|
||||||
|
idleCancellable?.cancel()
|
||||||
|
idleCancellable = nil
|
||||||
|
guard assertionId != .invalid else { return }
|
||||||
|
UIApplication.shared.endBackgroundTask(assertionId)
|
||||||
|
assertionId = .invalid
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
func didEnterBackground() {}
|
||||||
|
func didBecomeForeground() {}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -156,7 +156,7 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
|||||||
return .failure(CoreNetworkLifecycleError.transitionInProgress)
|
return .failure(CoreNetworkLifecycleError.transitionInProgress)
|
||||||
}
|
}
|
||||||
guard !sources.isEmpty else {
|
guard !sources.isEmpty else {
|
||||||
return .failure(InvitationError.message("Select at least one file to share"))
|
return .failure(InvitationError.shareEmpty)
|
||||||
}
|
}
|
||||||
return await runCore {
|
return await runCore {
|
||||||
let result = try self.requireCore().shareFiles(
|
let result = try self.requireCore().shareFiles(
|
||||||
@@ -353,7 +353,7 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
|||||||
|
|
||||||
private nonisolated func requireCore() throws -> VnidropCore {
|
private nonisolated func requireCore() throws -> VnidropCore {
|
||||||
guard let core = self.core else {
|
guard let core = self.core else {
|
||||||
throw InvitationError.message("Initialize the core first.")
|
throw InvitationError.coreNotInitialized
|
||||||
}
|
}
|
||||||
return core
|
return core
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,23 +23,42 @@ final class ExternalInvitationController: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func reportOpenFailure(message: String) {
|
func reportOpenFailure(message: String) {
|
||||||
continuation?.yield(.failure(InvitationError.message(message)))
|
continuation?.yield(.failure(InvitationError.raw(message)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Semantic, UI-agnostic invitation/transfer failures. Cases carry no display
|
||||||
|
/// text: `Error.toUiText()` (UI layer) maps each case to a localized `L10n` key,
|
||||||
|
/// so there are no free-form English strings to keep in sync or substring-match.
|
||||||
|
/// `.raw` is the escape hatch for genuinely dynamic system/core messages (e.g. a
|
||||||
|
/// `CoreNFC` `localizedDescription` or a picker's failure reason), never shown
|
||||||
|
/// verbatim — it is still routed through `reasonHints`.
|
||||||
enum InvitationError: LocalizedError {
|
enum InvitationError: LocalizedError {
|
||||||
case empty
|
case empty
|
||||||
case tooLarge
|
case tooLarge
|
||||||
case invalidEncoding
|
case invalidEncoding
|
||||||
case message(String)
|
case shareEmpty
|
||||||
|
case cancelled
|
||||||
|
case coreNotInitialized
|
||||||
|
case unsupportedOperation
|
||||||
|
case noWindowAvailable
|
||||||
|
case viewControllerUnavailable
|
||||||
|
case filesystemUnavailable
|
||||||
|
case invalidInvitationURL
|
||||||
|
case nfcUnavailable
|
||||||
|
case nfcFailed
|
||||||
|
case cameraUnavailable
|
||||||
|
case qrUnavailable
|
||||||
|
case bugReportingUnavailable
|
||||||
|
case selectionFailed
|
||||||
|
case deleteRecordsFailed
|
||||||
|
case raw(String)
|
||||||
|
|
||||||
|
/// Developer/log-facing only — never surfaced to users. Derived from the case
|
||||||
|
/// so there are no hand-written English blobs; `.raw` passes its payload through.
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
if case .raw(let reason) = self { return reason }
|
||||||
case .empty: return "The invitation is empty"
|
return String(describing: self)
|
||||||
case .tooLarge: return "The invitation is too large"
|
|
||||||
case .invalidEncoding: return "The invitation is not valid text"
|
|
||||||
case .message(let m): return m
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ struct PickedShareFile: Equatable, Identifiable, Sendable {
|
|||||||
var isTemporaryCopy: Bool = false
|
var isTemporaryCopy: Bool = false
|
||||||
/// When true, `value` is a directory (path or security-scoped folder URL).
|
/// When true, `value` is a directory (path or security-scoped folder URL).
|
||||||
var isDirectory: Bool = false
|
var isDirectory: Bool = false
|
||||||
|
/// macOS sandbox: a security-scoped bookmark captured at pick time so access to
|
||||||
|
/// `value` can be re-acquired when the core imports the file (the picker's own
|
||||||
|
/// scope ends immediately). Nil on iOS (which copies into the container instead).
|
||||||
|
var securityScopeBookmark: Data? = nil
|
||||||
|
|
||||||
var id: String { value }
|
var id: String { value }
|
||||||
}
|
}
|
||||||
@@ -48,7 +52,7 @@ extension FileSystemService {
|
|||||||
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false }
|
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false }
|
||||||
|
|
||||||
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error> {
|
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error> {
|
||||||
.failure(InvitationError.message("Revealing the receive folder is not supported"))
|
.failure(InvitationError.unsupportedOperation)
|
||||||
}
|
}
|
||||||
|
|
||||||
func discardPickedFiles(_ files: [PickedShareFile]) async {}
|
func discardPickedFiles(_ files: [PickedShareFile]) async {}
|
||||||
|
|||||||
@@ -5,13 +5,17 @@ import SFSafeSymbols
|
|||||||
/// be swiped away. The endpoint id is the trusted identity; display names are
|
/// be swiped away. The endpoint id is the trusted identity; display names are
|
||||||
/// peer-provided.
|
/// peer-provided.
|
||||||
struct ApprovalModalHost: View {
|
struct ApprovalModalHost: View {
|
||||||
|
/// Driven by the host so presentation can be deferred until any competing sheet
|
||||||
|
/// (the Share/QR drawer) has finished dismissing — macOS silently drops a sheet
|
||||||
|
/// presented while another is still animating out.
|
||||||
|
@Binding var isPresented: Bool
|
||||||
let state: ApprovalState
|
let state: ApprovalState
|
||||||
let onAccept: (String) -> Void
|
let onAccept: (String) -> Void
|
||||||
let onRefuse: (String) -> Void
|
let onRefuse: (String) -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Color.clear
|
Color.clear
|
||||||
.sheet(isPresented: .constant(state.current != nil)) {
|
.sheet(isPresented: $isPresented) {
|
||||||
if let request = state.current {
|
if let request = state.current {
|
||||||
ApprovalSheet(state: state, request: request, onAccept: onAccept, onRefuse: onRefuse)
|
ApprovalSheet(state: state, request: request, onAccept: onAccept, onRefuse: onRefuse)
|
||||||
.interactiveDismissDisabled(true)
|
.interactiveDismissDisabled(true)
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ final class SendModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func onFilePickFailed(_ reason: String) {
|
func onFilePickFailed(_ reason: String) {
|
||||||
messages.error(InvitationError.message(reason.isEmpty ? "selection failed" : reason))
|
messages.error(reason.isEmpty ? InvitationError.selectionFailed : InvitationError.raw(reason))
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearSelectedSource() {
|
func clearSelectedSource() {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ protocol BugReportService {
|
|||||||
/// Offline-safe no-op used until the diagnostics transport is configured.
|
/// Offline-safe no-op used until the diagnostics transport is configured.
|
||||||
struct NoopBugReportService: BugReportService {
|
struct NoopBugReportService: BugReportService {
|
||||||
func submit(_ draft: BugReportDraft, deviceInfo: DeviceInfo?) async -> Result<Void, Error> {
|
func submit(_ draft: BugReportDraft, deviceInfo: DeviceInfo?) async -> Result<Void, Error> {
|
||||||
.failure(InvitationError.message("Bug reporting is not configured"))
|
.failure(InvitationError.bugReportingUnavailable)
|
||||||
}
|
}
|
||||||
func previewLogBytes() async -> Int { 0 }
|
func previewLogBytes() async -> Int { 0 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ final class SettingsModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func onReceiveFolderPicked(_ folder: ReceiveFolder) { preferences.setReceiveFolder(folder) }
|
func onReceiveFolderPicked(_ folder: ReceiveFolder) { preferences.setReceiveFolder(folder) }
|
||||||
func onReceiveFolderPickFailed(_ reason: String) { messages.error(InvitationError.message(reason)) }
|
func onReceiveFolderPickFailed(_ reason: String) { messages.error(InvitationError.raw(reason)) }
|
||||||
func resetReceiveFolder() { preferences.resetReceiveFolder() }
|
func resetReceiveFolder() { preferences.resetReceiveFolder() }
|
||||||
|
|
||||||
/// Whether the current receive folder is the platform default (so the reset
|
/// Whether the current receive folder is the platform default (so the reset
|
||||||
@@ -475,7 +475,7 @@ final class SettingsModel: ObservableObject {
|
|||||||
loadStorageUsage()
|
loadStorageUsage()
|
||||||
messages.show(UiMessage(text: .resource(L10n.Storage.transfersDeleted), tone: .success))
|
messages.show(UiMessage(text: .resource(L10n.Storage.transfersDeleted), tone: .success))
|
||||||
} else {
|
} else {
|
||||||
messages.error(InvitationError.message("Could not delete \(failures) transfer records"))
|
messages.error(InvitationError.deleteRecordsFailed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,21 @@ struct SettingsScreen: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack(path: path) {
|
NavigationStack(path: path) {
|
||||||
Form {
|
Form {
|
||||||
|
#if os(iOS)
|
||||||
|
// iOS suspends the app in the background, so serving/receiving
|
||||||
|
// can't run indefinitely there (unlike macOS). Tell users up
|
||||||
|
// front so the platform limit doesn't read as a bug.
|
||||||
|
Section {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Label(String(localized: L10n.Settings.iosBackgroundNoticeTitle), systemSymbol: .moonZzz)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
Text(String(localized: L10n.Settings.iosBackgroundNoticeBody))
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
Section {
|
Section {
|
||||||
NavigationLink(value: SettingsSection.preferences) {
|
NavigationLink(value: SettingsSection.preferences) {
|
||||||
SettingsRow(icon: .personCropCircle, title: String(localized: L10n.Preferences.title), value: model.state.username)
|
SettingsRow(icon: .personCropCircle, title: String(localized: L10n.Preferences.title), value: model.state.username)
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ struct IosFileSystemService: FileSystemService {
|
|||||||
|
|
||||||
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error> {
|
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error> {
|
||||||
guard canRevealReceiveFolder(folder) else {
|
guard canRevealReceiveFolder(folder) else {
|
||||||
return .failure(InvitationError.message("The receive folder is not VniDrop Documents"))
|
return .failure(InvitationError.filesystemUnavailable)
|
||||||
}
|
}
|
||||||
guard let url = URL(string: "shareddocuments://\(folder.value)") else {
|
guard let url = URL(string: "shareddocuments://\(folder.value)") else {
|
||||||
return .failure(InvitationError.message("The Files location URL is unavailable"))
|
return .failure(InvitationError.filesystemUnavailable)
|
||||||
}
|
}
|
||||||
let opened = await withCheckedContinuation { continuation in
|
let opened = await withCheckedContinuation { continuation in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
@@ -44,7 +44,7 @@ struct IosFileSystemService: FileSystemService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return opened ? .success(()) : .failure(InvitationError.message("Could not open VniDrop Documents in Files"))
|
return opened ? .success(()) : .failure(InvitationError.filesystemUnavailable)
|
||||||
}
|
}
|
||||||
|
|
||||||
func discardPickedFiles(_ files: [PickedShareFile]) async {
|
func discardPickedFiles(_ files: [PickedShareFile]) async {
|
||||||
@@ -62,7 +62,7 @@ struct IosFileSystemService: FileSystemService {
|
|||||||
accessPolicy: ShareAccessPolicy
|
accessPolicy: ShareAccessPolicy
|
||||||
) async -> Result<Share, Error> {
|
) async -> Result<Share, Error> {
|
||||||
guard !files.isEmpty else {
|
guard !files.isEmpty else {
|
||||||
return .failure(InvitationError.message("Select at least one file to share"))
|
return .failure(InvitationError.shareEmpty)
|
||||||
}
|
}
|
||||||
let sources = files.map { $0.toIosShareSource() }
|
let sources = files.map { $0.toIosShareSource() }
|
||||||
return await repository.shareSources(
|
return await repository.shareSources(
|
||||||
|
|||||||
@@ -42,8 +42,24 @@ struct MacFileSystemService: FileSystemService {
|
|||||||
accessPolicy: ShareAccessPolicy
|
accessPolicy: ShareAccessPolicy
|
||||||
) async -> Result<Share, Error> {
|
) async -> Result<Share, Error> {
|
||||||
guard !files.isEmpty else {
|
guard !files.isEmpty else {
|
||||||
return .failure(InvitationError.message("Select at least one file to share"))
|
return .failure(InvitationError.shareEmpty)
|
||||||
}
|
}
|
||||||
|
// Re-acquire security-scoped access to every picked source (from the bookmark
|
||||||
|
// captured at pick time) and hold it across the whole share call. The core
|
||||||
|
// imports the bytes during shareFiles(), so access only needs to survive that
|
||||||
|
// call; without this, the import fails with EPERM under the App Store sandbox.
|
||||||
|
var scopedURLs: [URL] = []
|
||||||
|
for file in files {
|
||||||
|
guard let bookmark = file.securityScopeBookmark else { continue }
|
||||||
|
var stale = false
|
||||||
|
guard let url = try? URL(
|
||||||
|
resolvingBookmarkData: bookmark, options: .withSecurityScope,
|
||||||
|
relativeTo: nil, bookmarkDataIsStale: &stale
|
||||||
|
), url.startAccessingSecurityScopedResource() else { continue }
|
||||||
|
scopedURLs.append(url)
|
||||||
|
}
|
||||||
|
defer { scopedURLs.forEach { $0.stopAccessingSecurityScopedResource() } }
|
||||||
|
|
||||||
let sources = files.map {
|
let sources = files.map {
|
||||||
ShareSource(kind: .path, value: $0.value, displayName: $0.displayName, isDirectory: $0.isDirectory)
|
ShareSource(kind: .path, value: $0.value, displayName: $0.displayName, isDirectory: $0.isDirectory)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,9 +107,15 @@ enum PickerSupport {
|
|||||||
)
|
)
|
||||||
#else
|
#else
|
||||||
let size = isDirectory ? nil : (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize.map { UInt64($0) }
|
let size = isDirectory ? nil : (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize.map { UInt64($0) }
|
||||||
|
// Capture a security-scoped bookmark while the picker's scope is still held,
|
||||||
|
// so the core can re-acquire access to open the file at import time (under
|
||||||
|
// the App Store sandbox). Non-sandboxed builds don't need it but it's harmless.
|
||||||
|
let bookmark = try? url.bookmarkData(
|
||||||
|
options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil
|
||||||
|
)
|
||||||
return PickedShareFile(
|
return PickedShareFile(
|
||||||
value: url.path, displayName: url.lastPathComponent, sizeBytes: size,
|
value: url.path, displayName: url.lastPathComponent, sizeBytes: size,
|
||||||
isTemporaryCopy: false, isDirectory: isDirectory
|
isTemporaryCopy: false, isDirectory: isDirectory, securityScopeBookmark: bookmark
|
||||||
)
|
)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID
|
|||||||
picker.delegate = self
|
picker.delegate = self
|
||||||
picker.modalPresentationStyle = .formSheet
|
picker.modalPresentationStyle = .formSheet
|
||||||
guard let presenter = topPresenter() else {
|
guard let presenter = topPresenter() else {
|
||||||
return onResult(.failure(InvitationError.message("Could not find an iOS view controller")))
|
return onResult(.failure(InvitationError.viewControllerUnavailable))
|
||||||
}
|
}
|
||||||
presenter.present(picker, animated: true)
|
presenter.present(picker, animated: true)
|
||||||
}
|
}
|
||||||
@@ -37,12 +37,12 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID
|
|||||||
func scanQrCode(onResult: @escaping (Result<String, Error>) -> Void) {
|
func scanQrCode(onResult: @escaping (Result<String, Error>) -> Void) {
|
||||||
cancel()
|
cancel()
|
||||||
guard let presenter = topPresenter() else {
|
guard let presenter = topPresenter() else {
|
||||||
return onResult(.failure(InvitationError.message("Could not find an iOS view controller")))
|
return onResult(.failure(InvitationError.viewControllerUnavailable))
|
||||||
}
|
}
|
||||||
ensureCameraAccess { [weak self] granted in
|
ensureCameraAccess { [weak self] granted in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
guard granted else {
|
guard granted else {
|
||||||
return onResult(.failure(InvitationError.message("Camera access is required to scan QR codes")))
|
return onResult(.failure(InvitationError.cameraUnavailable))
|
||||||
}
|
}
|
||||||
let scanner = QrScannerViewController { result in
|
let scanner = QrScannerViewController { result in
|
||||||
self.qrController = nil
|
self.qrController = nil
|
||||||
@@ -57,7 +57,7 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID
|
|||||||
func readNfcInvitation(onResult: @escaping (Result<String, Error>) -> Void) {
|
func readNfcInvitation(onResult: @escaping (Result<String, Error>) -> Void) {
|
||||||
cancel()
|
cancel()
|
||||||
guard NFCNDEFReaderSession.readingAvailable else {
|
guard NFCNDEFReaderSession.readingAvailable else {
|
||||||
return onResult(.failure(InvitationError.message("NFC reading is unavailable on this device")))
|
return onResult(.failure(InvitationError.nfcUnavailable))
|
||||||
}
|
}
|
||||||
let reader = InvitationNfcReader { [weak self] result in
|
let reader = InvitationNfcReader { [weak self] result in
|
||||||
self?.nfcReader = nil
|
self?.nfcReader = nil
|
||||||
@@ -80,7 +80,7 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID
|
|||||||
let result = documentResult
|
let result = documentResult
|
||||||
documentResult = nil
|
documentResult = nil
|
||||||
result?(Result {
|
result?(Result {
|
||||||
guard let url = urls.first else { throw InvitationError.message("The selected invitation URL was invalid") }
|
guard let url = urls.first else { throw InvitationError.invalidInvitationURL }
|
||||||
let started = url.startAccessingSecurityScopedResource()
|
let started = url.startAccessingSecurityScopedResource()
|
||||||
defer { if started { url.stopAccessingSecurityScopedResource() } }
|
defer { if started { url.stopAccessingSecurityScopedResource() } }
|
||||||
let data = try Data(contentsOf: url)
|
let data = try Data(contentsOf: url)
|
||||||
@@ -157,19 +157,19 @@ final class QrScannerViewController: UIViewController, AVCaptureMetadataOutputOb
|
|||||||
}
|
}
|
||||||
|
|
||||||
func cancelScan() {
|
func cancelScan() {
|
||||||
finish(.failure(InvitationError.message("QR scanning was cancelled")))
|
finish(.failure(InvitationError.cancelled))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureSession() {
|
private func configureSession() {
|
||||||
guard let device = AVCaptureDevice.default(for: .video),
|
guard let device = AVCaptureDevice.default(for: .video),
|
||||||
let input = try? AVCaptureDeviceInput(device: device),
|
let input = try? AVCaptureDeviceInput(device: device),
|
||||||
session.canAddInput(input) else {
|
session.canAddInput(input) else {
|
||||||
return finish(.failure(InvitationError.message("No camera is available")))
|
return finish(.failure(InvitationError.cameraUnavailable))
|
||||||
}
|
}
|
||||||
session.addInput(input)
|
session.addInput(input)
|
||||||
let output = AVCaptureMetadataOutput()
|
let output = AVCaptureMetadataOutput()
|
||||||
guard session.canAddOutput(output) else {
|
guard session.canAddOutput(output) else {
|
||||||
return finish(.failure(InvitationError.message("Could not configure the QR scanner")))
|
return finish(.failure(InvitationError.cameraUnavailable))
|
||||||
}
|
}
|
||||||
session.addOutput(output)
|
session.addOutput(output)
|
||||||
output.setMetadataObjectsDelegate(self, queue: .main)
|
output.setMetadataObjectsDelegate(self, queue: .main)
|
||||||
@@ -220,7 +220,7 @@ final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate, @unchec
|
|||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
let reader = NFCNDEFReaderSession(delegate: self, queue: .main, invalidateAfterFirstRead: true)
|
let reader = NFCNDEFReaderSession(delegate: self, queue: .main, invalidateAfterFirstRead: true)
|
||||||
reader.alertMessage = "Hold your iPhone near a VniDrop invitation tag"
|
reader.alertMessage = String(localized: L10n.Receive.nfcWaiting)
|
||||||
session = reader
|
session = reader
|
||||||
reader.begin()
|
reader.begin()
|
||||||
}
|
}
|
||||||
@@ -233,7 +233,7 @@ final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate, @unchec
|
|||||||
func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
|
func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
|
||||||
if finished { return }
|
if finished { return }
|
||||||
let cancelled = (error as NSError).code == 200
|
let cancelled = (error as NSError).code == 200
|
||||||
finish(.failure(InvitationError.message(cancelled ? "NFC reading was cancelled" : error.localizedDescription)))
|
finish(.failure(cancelled ? InvitationError.cancelled : InvitationError.raw(error.localizedDescription)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {
|
func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {
|
||||||
@@ -242,7 +242,7 @@ final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate, @unchec
|
|||||||
.flatMap { $0.records }
|
.flatMap { $0.records }
|
||||||
.compactMap { payloadAsInvitation($0) }
|
.compactMap { payloadAsInvitation($0) }
|
||||||
.first
|
.first
|
||||||
guard let ticket else { throw InvitationError.message("This NFC tag does not contain a VniDrop invitation") }
|
guard let ticket else { throw InvitationError.nfcFailed }
|
||||||
return ticket
|
return ticket
|
||||||
}
|
}
|
||||||
session.invalidate()
|
session.invalidate()
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ final class MacReceiveInvitationActions: ReceiveInvitationActions {
|
|||||||
}
|
}
|
||||||
panel.begin { response in
|
panel.begin { response in
|
||||||
guard response == .OK, let url = panel.url else {
|
guard response == .OK, let url = panel.url else {
|
||||||
onResult(.failure(InvitationError.message("cancelled")))
|
onResult(.failure(InvitationError.cancelled))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
onResult(Result {
|
onResult(Result {
|
||||||
@@ -33,11 +33,11 @@ final class MacReceiveInvitationActions: ReceiveInvitationActions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func scanQrCode(onResult: @escaping (Result<String, Error>) -> Void) {
|
func scanQrCode(onResult: @escaping (Result<String, Error>) -> Void) {
|
||||||
onResult(.failure(InvitationError.message("QR scanning is unavailable on macOS")))
|
onResult(.failure(InvitationError.qrUnavailable))
|
||||||
}
|
}
|
||||||
|
|
||||||
func readNfcInvitation(onResult: @escaping (Result<String, Error>) -> Void) {
|
func readNfcInvitation(onResult: @escaping (Result<String, Error>) -> Void) {
|
||||||
onResult(.failure(InvitationError.message("NFC is unavailable on macOS")))
|
onResult(.failure(InvitationError.nfcUnavailable))
|
||||||
}
|
}
|
||||||
|
|
||||||
func cancel() {}
|
func cancel() {}
|
||||||
|
|||||||
49
apple/VniDrop/Platform/SparkleUpdater.swift
Normal file
49
apple/VniDrop/Platform/SparkleUpdater.swift
Normal file
@@ -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
|
||||||
@@ -36,7 +36,7 @@ final class IosTransferShareActions: NSObject, TransferShareActions {
|
|||||||
func writeInvitationToNfc(ticket: String, onResult: @escaping (Result<Void, Error>) -> Void) {
|
func writeInvitationToNfc(ticket: String, onResult: @escaping (Result<Void, Error>) -> Void) {
|
||||||
cancelNfcWrite()
|
cancelNfcWrite()
|
||||||
guard NFCNDEFReaderSession.readingAvailable else {
|
guard NFCNDEFReaderSession.readingAvailable else {
|
||||||
onResult(.failure(InvitationError.message("NFC is unavailable on this device")))
|
onResult(.failure(InvitationError.nfcUnavailable))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let writer = InvitationNfcWriter(ticket: ticket) { [weak self] result in
|
let writer = InvitationNfcWriter(ticket: ticket) { [weak self] result in
|
||||||
@@ -55,7 +55,7 @@ final class IosTransferShareActions: NSObject, TransferShareActions {
|
|||||||
@MainActor
|
@MainActor
|
||||||
private func present(_ controller: UIViewController) throws {
|
private func present(_ controller: UIViewController) throws {
|
||||||
guard let presenter = topPresenter() else {
|
guard let presenter = topPresenter() else {
|
||||||
throw InvitationError.message("Could not find an iOS view controller")
|
throw InvitationError.viewControllerUnavailable
|
||||||
}
|
}
|
||||||
presenter.present(controller, animated: true)
|
presenter.present(controller, animated: true)
|
||||||
}
|
}
|
||||||
@@ -76,7 +76,7 @@ final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate, @unchec
|
|||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
let reader = NFCNDEFReaderSession(delegate: self, queue: .main, invalidateAfterFirstRead: false)
|
let reader = NFCNDEFReaderSession(delegate: self, queue: .main, invalidateAfterFirstRead: false)
|
||||||
reader.alertMessage = "Hold your iPhone near a writable NFC tag"
|
reader.alertMessage = String(localized: L10n.Transfer.nfcWaiting)
|
||||||
session = reader
|
session = reader
|
||||||
reader.begin()
|
reader.begin()
|
||||||
}
|
}
|
||||||
@@ -89,14 +89,14 @@ final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate, @unchec
|
|||||||
func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
|
func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
|
||||||
if finished { return }
|
if finished { return }
|
||||||
let cancelled = (error as NSError).code == 200 // readerSessionInvalidationErrorUserCanceled
|
let cancelled = (error as NSError).code == 200 // readerSessionInvalidationErrorUserCanceled
|
||||||
finish(.failure(InvitationError.message(cancelled ? "NFC writing was cancelled" : error.localizedDescription)))
|
finish(.failure(cancelled ? InvitationError.cancelled : InvitationError.raw(error.localizedDescription)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {}
|
func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {}
|
||||||
|
|
||||||
func readerSession(_ session: NFCNDEFReaderSession, didDetect tags: [NFCNDEFTag]) {
|
func readerSession(_ session: NFCNDEFReaderSession, didDetect tags: [NFCNDEFTag]) {
|
||||||
guard let firstTag = tags.first else {
|
guard let firstTag = tags.first else {
|
||||||
return finish(.failure(InvitationError.message("No NFC tag was detected")))
|
return finish(.failure(InvitationError.nfcFailed))
|
||||||
}
|
}
|
||||||
// CoreNFC completion handlers run on the session's `.main` queue; these
|
// CoreNFC completion handlers run on the session's `.main` queue; these
|
||||||
// framework values are safe to use there.
|
// framework values are safe to use there.
|
||||||
@@ -109,18 +109,18 @@ final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate, @unchec
|
|||||||
if let queryError { return self.finish(.failure(queryError)) }
|
if let queryError { return self.finish(.failure(queryError)) }
|
||||||
switch status {
|
switch status {
|
||||||
case .notSupported:
|
case .notSupported:
|
||||||
self.finish(.failure(InvitationError.message("This NFC tag does not support NDEF")))
|
self.finish(.failure(InvitationError.nfcFailed))
|
||||||
case .readOnly:
|
case .readOnly:
|
||||||
self.finish(.failure(InvitationError.message("This NFC tag is read-only")))
|
self.finish(.failure(InvitationError.nfcFailed))
|
||||||
default:
|
default:
|
||||||
guard let message = self.invitationMessage() else {
|
guard let message = self.invitationMessage() else {
|
||||||
return self.finish(.failure(InvitationError.message("Could not encode the invitation for NFC")))
|
return self.finish(.failure(InvitationError.nfcFailed))
|
||||||
}
|
}
|
||||||
tag.writeNDEF(message) { writeError in
|
tag.writeNDEF(message) { writeError in
|
||||||
if let writeError {
|
if let writeError {
|
||||||
self.finish(.failure(writeError))
|
self.finish(.failure(writeError))
|
||||||
} else {
|
} else {
|
||||||
session.alertMessage = "Invitation written"
|
session.alertMessage = String(localized: L10n.Transfer.nfcWritten)
|
||||||
session.invalidate()
|
session.invalidate()
|
||||||
self.finish(.success(()))
|
self.finish(.success(()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ final class MacTransferShareActions: TransferShareActions {
|
|||||||
panel.allowedContentTypes = []
|
panel.allowedContentTypes = []
|
||||||
panel.begin { response in
|
panel.begin { response in
|
||||||
guard response == .OK, let url = panel.url else {
|
guard response == .OK, let url = panel.url else {
|
||||||
onResult(.failure(InvitationError.message("cancelled")))
|
onResult(.failure(InvitationError.cancelled))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
onResult(Result { try ticket.write(to: url, atomically: true, encoding: .utf8) })
|
onResult(Result { try ticket.write(to: url, atomically: true, encoding: .utf8) })
|
||||||
@@ -28,7 +28,7 @@ final class MacTransferShareActions: TransferShareActions {
|
|||||||
do {
|
do {
|
||||||
let url = try writeTemporaryInvitation(ticket: ticket, transferName: transferName)
|
let url = try writeTemporaryInvitation(ticket: ticket, transferName: transferName)
|
||||||
guard let view = NSApp.keyWindow?.contentView else {
|
guard let view = NSApp.keyWindow?.contentView else {
|
||||||
onResult(.failure(InvitationError.message("No window available")))
|
onResult(.failure(InvitationError.noWindowAvailable))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let picker = NSSharingServicePicker(items: [url])
|
let picker = NSSharingServicePicker(items: [url])
|
||||||
@@ -40,7 +40,7 @@ final class MacTransferShareActions: TransferShareActions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeInvitationToNfc(ticket: String, onResult: @escaping (Result<Void, Error>) -> Void) {
|
func writeInvitationToNfc(ticket: String, onResult: @escaping (Result<Void, Error>) -> Void) {
|
||||||
onResult(.failure(InvitationError.message("NFC is unavailable on macOS")))
|
onResult(.failure(InvitationError.nfcUnavailable))
|
||||||
}
|
}
|
||||||
|
|
||||||
func cancelNfcWrite() {}
|
func cancelNfcWrite() {}
|
||||||
|
|||||||
8
apple/VniDrop/Resources/AppIcon.icon/Assets/Drop.svg
Normal file
8
apple/VniDrop/Resources/AppIcon.icon/Assets/Drop.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<path d="M520.4,431.72C495.8,464.52 443.32,530.12 420.36,577.68C390.84,636.72 403.96,699.04 446.6,731.84C487.6,758.08 549.92,758.08 592.56,730.2C633.56,700.68 646.68,636.72 620.44,577.68C597.48,530.12 546.64,464.52 520.4,431.72Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(302.875,217.566,-217.566,302.875,404.439,431.72)"><stop offset="0" style="stop-color:rgb(168,85,247);stop-opacity:1"/><stop offset="0.48" style="stop-color:rgb(157,77,244);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(124,42,239);stop-opacity:1"/></linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
17
apple/VniDrop/Resources/AppIcon.icon/Assets/Mask.svg
Normal file
17
apple/VniDrop/Resources/AppIcon.icon/Assets/Mask.svg
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<defs>
|
||||||
|
<mask id="Mask">
|
||||||
|
|
||||||
|
<g transform="matrix(1,-0,-0,1,0,0)"><image id="_Image2" width="1024px" height="1024px" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABAAAAAQACAAAAABadnRfAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAH6ElEQVR4nO3cv2udVRjA8ZM6CU0HFVTUTK2NWA1IUCdFg24KDkoN+GOwi4IgHfwDnGzVRVB0EQvi4ODYRWILdXMwWsQgOBRbQTvlihA0qX9Blfu+J/c55z6fzx+Q82S43zznvW9bCgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAkNlC9AAwwMLty8vLR25aPLR48EC9n7r752SyPZlc+mlr67dr9X5sywSA3iwcXlt77OZ9PmSy9cPXG5f3+ZAGCABdWXjg5aeXZnXY1sbGuauzOgz4X99fm63ds8/fGP077ycbAF0JuJpvf3Hmwt7sj50NAaArMc/mfjn9yU7IwftOAOhK1MP5K6c//ivo6H0lAHQl7tu5P977YDvs8H0jAHQl8uv5309+NndvBwgAXYn9BJ5/9cfQ8+ur+BoVzLtHN98+GD1DXTYAuhK+g19a/yZ6hJpsADCNpfNvztOHxgZAV8I3gFLK2Rfn5/1gAaArLQSgXD5+IXqEWuZpm4HZuOPcC9Ej1CIAMLUbzpyMHqESAYAB3jk1H7fn+fgtSKOJZwCllFI+PfF39AgVCABdaScA5cvn/okeYTxXABjmmQ/n4M+nAMBAr7wVPcF4c9AwMmnoClBKef396AnGEgC60lYAyvrn0ROMJAB0pbEA7Dz8XfQI4wgAXWksAOXn1b7/myAPAWGEIx/1/TdUAGCM4yeiJxil73yRTmtXgFJ2HtqMHmEEAaAr7QWgbK52/EagKwCMs/Ja9AQj2ADoSoMbQJksX4keYTAbAIy0+G70BMPZAOhKixtAKU98FT3BUAJAV9oMwNa9u9EjDOQKAKMdfTZ6gqFsAHSlzQ2gXFzZix5hGBsAjHfsqegJBrIB0JVGN4Dy7YOtTvbfbABQweqT0RMMIwBQwxvRAwzjCkBXml209+7q8nVAGwDUcGA9eoJBbAB0pdkNoFy8v93Zrs8GAFUcW4meYAgBgDpeih5gCFcAutLwmv3rUsPDXY8NAOq483D0BAMIAFSyFj3AAAIAlTwePcAAngHQlZav2Vdv7e+fBNoAoJJb7oueYHoCALU8Ej3A9AQAarkneoDpCQDUcjR6gOkJANSyHD3A9HwLQFda/haglEOT6AmmZQOAau6OHmBqAgDV9HcHEACo5rboAaYmAFDNYvQAUxMAqEYAIDEBgMQEABITAEhMACCx/j5O/U0MVCMAkJgAQGICAIkJACQmAJCYAEBiAgCJCQAkJgCQmABAYgIAiQkAJCYAkJgAQGICAIkJACQmAJCYAEBiAgCJCQAkJgCQmABAYgIAiQkAJCYAkJgAQGICAIkJACQmAJCYAEBiAgCJCQAkJgCQmABAYgIAiQkAJCYAkJgAQGICAIkJACQmAJCYAEBiAgCJCQAkJgCQmABAYgIAiQkAJCYAkJgAQGICAIkJACQmAJCYAEBiAgCJCQAkJgCQmABAYgIAiQkAJCYAkJgAQGICAIkJACQmAJCYAEBiAgCJCQAkJgCQmABAYgIAiQkAJCYAkJgAQGICAIkJACQmAJCYAEBiAgCJCQAkJgCQmABAYgIAiQkAJCYAkJgAQGICAIkJAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwc/8CNInt3t3vKtwAAAAASUVORK5CYII="/>
|
||||||
|
</g>
|
||||||
|
</mask>
|
||||||
|
</defs>
|
||||||
|
<g mask="url(#Mask)">
|
||||||
|
<path d="M688,148L782,148C832,148 842,171.8 847.48,210L847.48,303.68L688,148Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(110.156,162.233,-162.233,110.156,683.48,144.6)"><stop offset="0" style="stop-color:rgb(242,221,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(192,132,252);stop-opacity:1"/></linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
8
apple/VniDrop/Resources/AppIcon.icon/Assets/U.svg
Normal file
8
apple/VniDrop/Resources/AppIcon.icon/Assets/U.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<path d="M236.68,148L338.36,148C366.24,148 387.56,170.96 387.56,198.84L387.56,564.56C387.56,597.36 372.8,620.32 372.8,646.56C372.8,725.28 436.76,787.6 522.04,787.6C607.32,787.6 668,725.28 668,646.56C668,620.32 656.52,597.36 656.52,564.56L656.52,198.84C656.52,170.96 677.84,148 705.72,148L781.16,148C817.24,148 846.76,177.52 846.76,213.6L846.76,564.56C846.76,738.4 704.08,879.44 522.04,879.44C340,879.44 194.04,738.4 194.04,564.56L194.04,374.32L220.28,374.32L220.28,305.44C195.68,305.44 176,297.24 176,280.84L176,246.4C176,231.64 187.48,220.16 202.24,220.16L236.68,220.16L236.68,148ZM256.36,239.84C251.44,239.84 248.16,244.76 248.16,249.68L248.16,275.92C248.16,282.48 253.08,285.76 259.64,285.76L282.6,285.76C289.16,285.76 292.44,280.84 292.44,274.28L292.44,251.32C292.44,244.76 287.52,239.84 280.96,239.84L256.36,239.84Z" style="fill:url(#_Linear1);"/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(728.706,668.252,-668.252,728.706,176,148)"><stop offset="0" style="stop-color:rgb(168,85,247);stop-opacity:1"/><stop offset="0.48" style="stop-color:rgb(157,77,244);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(124,42,239);stop-opacity:1"/></linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
62
apple/VniDrop/Resources/AppIcon.icon/icon.json
Normal file
62
apple/VniDrop/Resources/AppIcon.icon/icon.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"fill": {
|
||||||
|
"linear-gradient": [
|
||||||
|
"extended-gray:1.00000,1.00000",
|
||||||
|
"display-p3:0.55433,0.59923,0.92884,1.00000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"blend-mode": "normal",
|
||||||
|
"blur-material": null,
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"image-name": "Mask.svg",
|
||||||
|
"name": "Mask"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lighting": "individual",
|
||||||
|
"refractivity": {
|
||||||
|
"depth": 0.5,
|
||||||
|
"enabled": true,
|
||||||
|
"strength": 0
|
||||||
|
},
|
||||||
|
"shadow": {
|
||||||
|
"kind": "neutral",
|
||||||
|
"opacity": 0.6
|
||||||
|
},
|
||||||
|
"specular": true,
|
||||||
|
"translucency": {
|
||||||
|
"enabled": true,
|
||||||
|
"value": 0.8
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"image-name": "Drop.svg",
|
||||||
|
"name": "Drop"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"image-name": "U.svg",
|
||||||
|
"name": "U"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lighting": "combined",
|
||||||
|
"shadow": {
|
||||||
|
"kind": "neutral",
|
||||||
|
"opacity": 0.6
|
||||||
|
},
|
||||||
|
"translucency": {
|
||||||
|
"enabled": true,
|
||||||
|
"value": 0.4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"supported-platforms": {
|
||||||
|
"circles": [
|
||||||
|
"watchOS"
|
||||||
|
],
|
||||||
|
"squares": "shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{ "idiom" : "universal", "platform" : "ios", "size" : "1024x1024", "filename" : "app-icon.png" },
|
|
||||||
{ "idiom" : "mac", "scale" : "2x", "size" : "512x512", "filename" : "app-icon.png" }
|
|
||||||
],
|
|
||||||
"info" : { "author" : "xcode", "version" : 1 }
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 49 KiB |
@@ -20,6 +20,8 @@
|
|||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>VniDrop</string>
|
<string>VniDrop</string>
|
||||||
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
<false/>
|
||||||
<!-- macOS: a single instance only; re-launching activates the running app
|
<!-- macOS: a single instance only; re-launching activates the running app
|
||||||
instead of spawning another copy. -->
|
instead of spawning another copy. -->
|
||||||
<key>LSMultipleInstancesProhibited</key>
|
<key>LSMultipleInstancesProhibited</key>
|
||||||
@@ -55,6 +57,20 @@
|
|||||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
<key>LSApplicationCategoryType</key>
|
<key>LSApplicationCategoryType</key>
|
||||||
<string>public.app-category.utilities</string>
|
<string>public.app-category.utilities</string>
|
||||||
|
<!-- Sparkle auto-update (direct-download .dmg build only). These keys are inert
|
||||||
|
in the App Store build, which never loads Sparkle (DIRECT_DISTRIBUTION off).
|
||||||
|
The feed is appcast.xml attached as an asset to each GitHub Release; the
|
||||||
|
/releases/latest/download/ path always redirects to the newest (non-
|
||||||
|
prerelease) release's copy, and its <enclosure> points at that same release's
|
||||||
|
.dmg — no GitHub Pages or repo commits needed. SUPublicEDKey must be the EdDSA
|
||||||
|
public key printed by Sparkle's `generate_keys` — replace the placeholder
|
||||||
|
before shipping (see apple/RELEASE-MACOS.md). -->
|
||||||
|
<key>SUFeedURL</key>
|
||||||
|
<string>https://github.com/sudosylabs/vnidrop/releases/latest/download/appcast.xml</string>
|
||||||
|
<key>SUPublicEDKey</key>
|
||||||
|
<string>/vcOgyrhPi3e58yL8M7hZvDCOgsAKyBsQu/7ChAUk1M=</string>
|
||||||
|
<key>SUEnableAutomaticChecks</key>
|
||||||
|
<true/>
|
||||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NFCReaderUsageDescription</key>
|
<key>NFCReaderUsageDescription</key>
|
||||||
@@ -73,8 +89,6 @@
|
|||||||
<false/>
|
<false/>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>fetch</string>
|
|
||||||
<string>processing</string>
|
|
||||||
<string>remote-notification</string>
|
<string>remote-notification</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UIFileSharingEnabled</key>
|
<key>UIFileSharingEnabled</key>
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<!-- iOS: NFC NDEF reading. -->
|
<!-- iOS: NFC reader formats. The iOS 26 SDK requires TAG here and rejects
|
||||||
|
NDEF at App Store upload (error 90778 "NDEF is disallowed"). Our
|
||||||
|
NFCNDEFReaderSession usage keeps working under the TAG entitlement. -->
|
||||||
<key>com.apple.developer.nfc.readersession.formats</key>
|
<key>com.apple.developer.nfc.readersession.formats</key>
|
||||||
<array>
|
<array>
|
||||||
<string>NDEF</string>
|
<string>TAG</string>
|
||||||
</array>
|
</array>
|
||||||
|
|
||||||
<!-- macOS App Sandbox: user-selected files for share/receive, and network
|
<!-- macOS App Sandbox: user-selected files for share/receive, and network
|
||||||
|
|||||||
15
apple/VniDrop/Resources/VniDropDirect.entitlements
Normal file
15
apple/VniDrop/Resources/VniDropDirect.entitlements
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<!-- Entitlements for the direct-download (Developer ID + notarized) macOS build.
|
||||||
|
|
||||||
|
Deliberately NOT sandboxed: a sandboxed app signed for Developer ID requires a
|
||||||
|
provisioning profile, whereas direct-distribution apps run outside the App
|
||||||
|
Store sandbox by convention. Gatekeeper trust here comes from the hardened
|
||||||
|
runtime (ENABLE_HARDENED_RUNTIME) plus notarization, not the sandbox. The App
|
||||||
|
Store target (VniDrop) keeps VniDrop.entitlements with the sandbox enabled.
|
||||||
|
|
||||||
|
Networking and user file access need no entitlements once unsandboxed. -->
|
||||||
|
<dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -5,6 +5,9 @@ import VnidropCore
|
|||||||
/// `ui/feedback/UserFacingError.kt`. Never exposes raw `reason=` blobs.
|
/// `ui/feedback/UserFacingError.kt`. Never exposes raw `reason=` blobs.
|
||||||
extension Error {
|
extension Error {
|
||||||
func toUiText() -> UiText {
|
func toUiText() -> UiText {
|
||||||
|
if let invitation = self as? InvitationError {
|
||||||
|
return invitation.uiText
|
||||||
|
}
|
||||||
if let vni = self as? VnidropError {
|
if let vni = self as? VnidropError {
|
||||||
switch vni {
|
switch vni {
|
||||||
case .Ticket:
|
case .Ticket:
|
||||||
@@ -40,6 +43,7 @@ extension Error {
|
|||||||
|
|
||||||
/// True when the user intentionally backed out of a flow.
|
/// True when the user intentionally backed out of a flow.
|
||||||
var isUserCancellation: Bool {
|
var isUserCancellation: Bool {
|
||||||
|
if let invitation = self as? InvitationError, case .cancelled = invitation { return true }
|
||||||
if let vni = self as? VnidropError, case .Cancelled = vni { return true }
|
if let vni = self as? VnidropError, case .Cancelled = vni { return true }
|
||||||
let haystack = technicalDetail.lowercased()
|
let haystack = technicalDetail.lowercased()
|
||||||
if haystack.isEmpty {
|
if haystack.isEmpty {
|
||||||
@@ -76,6 +80,39 @@ extension Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Maps each semantic `InvitationError` case to a localized user-facing message.
|
||||||
|
/// This is the sole `InvitationError` → `L10n` boundary: no substring guessing,
|
||||||
|
/// except for `.raw`, whose dynamic payload still falls through `reasonHints`.
|
||||||
|
extension InvitationError {
|
||||||
|
var uiText: UiText {
|
||||||
|
switch self {
|
||||||
|
case .empty:
|
||||||
|
return .resource(L10n.Error.invitationEmpty)
|
||||||
|
case .tooLarge, .unsupportedOperation, .noWindowAvailable,
|
||||||
|
.viewControllerUnavailable, .qrUnavailable, .bugReportingUnavailable, .cancelled:
|
||||||
|
return .resource(L10n.Error.generic)
|
||||||
|
case .invalidEncoding, .invalidInvitationURL:
|
||||||
|
return .resource(L10n.Error.invalidTicket)
|
||||||
|
case .shareEmpty:
|
||||||
|
return .resource(L10n.Error.shareEmpty)
|
||||||
|
case .coreNotInitialized:
|
||||||
|
return .resource(L10n.Error.startingUp)
|
||||||
|
case .filesystemUnavailable:
|
||||||
|
return .resource(L10n.Error.filesystem)
|
||||||
|
case .nfcUnavailable, .nfcFailed:
|
||||||
|
return .resource(L10n.Error.nfc)
|
||||||
|
case .cameraUnavailable:
|
||||||
|
return .resource(L10n.Error.camera)
|
||||||
|
case .selectionFailed:
|
||||||
|
return .resource(L10n.Error.selectionFailed)
|
||||||
|
case .deleteRecordsFailed:
|
||||||
|
return .resource(L10n.Error.repository)
|
||||||
|
case .raw(let reason):
|
||||||
|
return reasonHints(reason) ?? .resource(L10n.Error.generic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Maps a receiver delivery/refusal reason code to a user-facing message, never
|
/// Maps a receiver delivery/refusal reason code to a user-facing message, never
|
||||||
/// surfacing the raw core code (e.g. `destination_exists`). Unknown codes fall back
|
/// surfacing the raw core code (e.g. `destination_exists`). Unknown codes fall back
|
||||||
/// to the substring hints, then a generic message.
|
/// to the substring hints, then a generic message.
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ options:
|
|||||||
macOS: "15.0"
|
macOS: "15.0"
|
||||||
createIntermediateGroups: true
|
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).
|
# Project-wide build settings (applied to every target/config).
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
@@ -26,27 +35,44 @@ packages:
|
|||||||
SFSafeSymbols:
|
SFSafeSymbols:
|
||||||
url: https://github.com/SFSafeSymbols/SFSafeSymbols
|
url: https://github.com/SFSafeSymbols/SFSafeSymbols
|
||||||
from: "5.3.0"
|
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:
|
# Shared definition for the two shipping app targets. `VniDrop` (App Store /
|
||||||
VniDrop:
|
# 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
|
type: application
|
||||||
supportedDestinations: [iOS, macOS]
|
|
||||||
configFiles:
|
configFiles:
|
||||||
Debug: Signing.xcconfig
|
Debug: Signing.xcconfig
|
||||||
Release: Signing.xcconfig
|
Release: Signing.xcconfig
|
||||||
|
Release-Direct: Signing.xcconfig
|
||||||
sources:
|
sources:
|
||||||
- path: VniDrop
|
- path: VniDrop
|
||||||
excludes:
|
excludes:
|
||||||
- "Resources/Info.plist"
|
- "Resources/Info.plist"
|
||||||
- "Resources/VniDrop.entitlements"
|
- "Resources/VniDrop.entitlements"
|
||||||
|
- "Resources/VniDropDirect.entitlements"
|
||||||
- "Resources/**/.DS_Store"
|
- "Resources/**/.DS_Store"
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
|
PRODUCT_NAME: VniDrop
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: com.vnidrop.app
|
PRODUCT_BUNDLE_IDENTIFIER: com.vnidrop.app
|
||||||
MARKETING_VERSION: "0.1.0"
|
MARKETING_VERSION: "0.1.0"
|
||||||
|
# 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"
|
CURRENT_PROJECT_VERSION: "1"
|
||||||
GENERATE_INFOPLIST_FILE: NO
|
GENERATE_INFOPLIST_FILE: NO
|
||||||
INFOPLIST_FILE: VniDrop/Resources/Info.plist
|
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
|
# Mirror the Info.plist identity so Xcode's Identity editor shows it too
|
||||||
# (the editor reads these build settings, not the manual plist).
|
# (the editor reads these build settings, not the manual plist).
|
||||||
INFOPLIST_KEY_CFBundleDisplayName: VniDrop
|
INFOPLIST_KEY_CFBundleDisplayName: VniDrop
|
||||||
@@ -59,10 +85,11 @@ targets:
|
|||||||
# AccentColor asset mirrors VniDropColors.brandPurple — keep them in sync.
|
# AccentColor asset mirrors VniDropColors.brandPurple — keep them in sync.
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
|
||||||
configs:
|
configs:
|
||||||
debug:
|
|
||||||
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
|
|
||||||
release:
|
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:
|
dependencies:
|
||||||
- package: VnidropCore
|
- package: VnidropCore
|
||||||
- package: SFSafeSymbols
|
- package: SFSafeSymbols
|
||||||
@@ -84,6 +111,52 @@ targets:
|
|||||||
echo "error: SwiftLint not installed — run 'brew install swiftlint'"
|
echo "error: SwiftLint not installed — run 'brew install swiftlint'"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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:
|
VniDropTests:
|
||||||
type: bundle.unit-test
|
type: bundle.unit-test
|
||||||
@@ -91,6 +164,7 @@ targets:
|
|||||||
configFiles:
|
configFiles:
|
||||||
Debug: Signing.xcconfig
|
Debug: Signing.xcconfig
|
||||||
Release: Signing.xcconfig
|
Release: Signing.xcconfig
|
||||||
|
Release-Direct: Signing.xcconfig
|
||||||
sources:
|
sources:
|
||||||
- path: Tests
|
- path: Tests
|
||||||
settings:
|
settings:
|
||||||
@@ -112,3 +186,21 @@ schemes:
|
|||||||
config: Debug
|
config: Debug
|
||||||
targets:
|
targets:
|
||||||
- VniDropTests
|
- VniDropTests
|
||||||
|
# TestFlight ships the Release build; make the Archive/Profile actions explicit
|
||||||
|
# so Product → Archive can never pick up a Debug configuration.
|
||||||
|
profile:
|
||||||
|
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
|
||||||
|
|||||||
19
apple/scripts/ExportOptions-DeveloperID.plist
Normal file
19
apple/scripts/ExportOptions-DeveloperID.plist
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<!-- Export options for the direct-download (Developer ID) macOS build consumed by
|
||||||
|
`xcodebuild -exportArchive` in build-dmg.sh. This produces a Developer
|
||||||
|
ID–signed, hardened-runtime .app suitable for notarization and distribution
|
||||||
|
outside the Mac App Store. The App Store build uses a different flow entirely. -->
|
||||||
|
<dict>
|
||||||
|
<key>method</key>
|
||||||
|
<string>developer-id</string>
|
||||||
|
<key>signingStyle</key>
|
||||||
|
<string>manual</string>
|
||||||
|
<!-- Xcode manages the Developer ID Application certificate lookup from the
|
||||||
|
keychain; the hardened runtime is enabled via ENABLE_HARDENED_RUNTIME in the
|
||||||
|
VniDropDirect target. -->
|
||||||
|
<key>teamID</key>
|
||||||
|
<string>${DEVELOPMENT_TEAM}</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
158
apple/scripts/build-dmg.sh
Executable file
158
apple/scripts/build-dmg.sh
Executable file
@@ -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-<version>.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:-<unknown>}"
|
||||||
|
|
||||||
|
# --- 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"
|
||||||
68
apple/scripts/generate-appcast.sh
Executable file
68
apple/scripts/generate-appcast.sh
Executable file
@@ -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 <enclosure> 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/"
|
||||||
@@ -3430,6 +3430,40 @@
|
|||||||
"ru": "Дополнительно"
|
"ru": "Дополнительно"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"settings_ios_background_notice_body": {
|
||||||
|
"context": "Settings overview: explains iOS/iPadOS background limits so users don't think the app is broken. Apple platforms only.",
|
||||||
|
"targets": [
|
||||||
|
"apple"
|
||||||
|
],
|
||||||
|
"translations": {
|
||||||
|
"en": "iPhone and iPad limit what apps may do in the background. VniDrop keeps a transfer that's already running alive long enough to finish and notify you after you leave the app, but it can't keep serving or receiving on its own once it's been in the background for a while. For long transfers, keep VniDrop open. On Mac, transfers continue in the background normally.",
|
||||||
|
"fr": "L’iPhone et l’iPad limitent ce que les apps peuvent faire en arrière-plan. VniDrop maintient un transfert déjà en cours assez longtemps pour le terminer et vous avertir après avoir quitté l’app, mais il ne peut pas continuer à envoyer ou recevoir seul une fois resté en arrière-plan un certain temps. Pour les transferts longs, gardez VniDrop ouvert. Sur Mac, les transferts se poursuivent normalement en arrière-plan.",
|
||||||
|
"es": "El iPhone y el iPad limitan lo que las apps pueden hacer en segundo plano. VniDrop mantiene una transferencia ya en curso el tiempo suficiente para terminarla y avisarte tras salir de la app, pero no puede seguir enviando o recibiendo por sí solo cuando lleva un rato en segundo plano. Para transferencias largas, mantén VniDrop abierto. En Mac, las transferencias continúan en segundo plano con normalidad.",
|
||||||
|
"it": "iPhone e iPad limitano ciò che le app possono fare in background. VniDrop mantiene attivo un trasferimento già in corso quanto basta per completarlo e avvisarti dopo che esci dall’app, ma non può continuare a inviare o ricevere da solo dopo un po’ in background. Per i trasferimenti lunghi, tieni VniDrop aperto. Su Mac i trasferimenti proseguono normalmente in background.",
|
||||||
|
"de": "iPhone und iPad schränken ein, was Apps im Hintergrund tun dürfen. VniDrop hält eine bereits laufende Übertragung lange genug am Leben, um sie abzuschließen und dich zu benachrichtigen, nachdem du die App verlässt, kann aber nicht von selbst weiter senden oder empfangen, wenn es länger im Hintergrund war. Lass VniDrop bei langen Übertragungen geöffnet. Auf dem Mac laufen Übertragungen im Hintergrund normal weiter.",
|
||||||
|
"pt": "O iPhone e o iPad limitam o que as apps podem fazer em segundo plano. O VniDrop mantém uma transferência já em curso ativa o tempo suficiente para terminar e notificá-lo depois de sair da app, mas não consegue continuar a enviar ou receber sozinho depois de algum tempo em segundo plano. Para transferências longas, mantenha o VniDrop aberto. No Mac, as transferências continuam normalmente em segundo plano.",
|
||||||
|
"pl": "iPhone i iPad ograniczają to, co aplikacje mogą robić w tle. VniDrop utrzymuje już trwający transfer wystarczająco długo, aby go dokończyć i powiadomić Cię po opuszczeniu aplikacji, ale nie może samodzielnie wysyłać ani odbierać po dłuższym czasie w tle. Przy długich transferach nie zamykaj VniDrop. Na Macu transfery są kontynuowane w tle normalnie.",
|
||||||
|
"nl": "iPhone en iPad beperken wat apps op de achtergrond mogen doen. VniDrop houdt een al lopende overdracht lang genoeg actief om deze te voltooien en je te melden nadat je de app verlaat, maar kan niet zelf blijven verzenden of ontvangen als het al een tijd op de achtergrond is. Houd VniDrop open bij lange overdrachten. Op de Mac gaan overdrachten normaal door op de achtergrond.",
|
||||||
|
"ru": "iPhone и iPad ограничивают действия приложений в фоне. VniDrop удерживает уже идущую передачу достаточно долго, чтобы завершить её и уведомить вас после выхода из приложения, но не может сам продолжать отправку или приём, пробыв некоторое время в фоне. Для долгих передач держите VniDrop открытым. На Mac передачи продолжаются в фоне как обычно."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings_ios_background_notice_title": {
|
||||||
|
"context": "Settings overview: title of the iOS/iPadOS background-limits notice. Apple platforms only.",
|
||||||
|
"targets": [
|
||||||
|
"apple"
|
||||||
|
],
|
||||||
|
"translations": {
|
||||||
|
"en": "Background limits on iPhone & iPad",
|
||||||
|
"fr": "Limites en arrière-plan sur iPhone et iPad",
|
||||||
|
"es": "Límites en segundo plano en iPhone y iPad",
|
||||||
|
"it": "Limiti in background su iPhone e iPad",
|
||||||
|
"de": "Hintergrund-Grenzen auf iPhone & iPad",
|
||||||
|
"pt": "Limites em segundo plano no iPhone e iPad",
|
||||||
|
"pl": "Ograniczenia w tle na iPhonie i iPadzie",
|
||||||
|
"nl": "Achtergrondlimieten op iPhone en iPad",
|
||||||
|
"ru": "Ограничения фона на iPhone и iPad"
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings_network_title": {
|
"settings_network_title": {
|
||||||
"context": "Settings overview row and Network settings screen title.",
|
"context": "Settings overview row and Network settings screen title.",
|
||||||
"translations": {
|
"translations": {
|
||||||
@@ -4595,6 +4629,23 @@
|
|||||||
"ru": "Поделиться"
|
"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": {
|
"value_unavailable": {
|
||||||
"context": "Placeholder shown when a device-info or metadata value can't be read.",
|
"context": "Placeholder shown when a device-info or metadata value can't be read.",
|
||||||
"translations": {
|
"translations": {
|
||||||
|
|||||||
7
packaging/apple/.gitignore
vendored
Normal file
7
packaging/apple/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Exported screenshots (large; regenerated from the design source) — not tracked.
|
||||||
|
*.jpg
|
||||||
|
*.jpeg
|
||||||
|
|
||||||
|
# macOS / editor junk
|
||||||
|
.DS_Store
|
||||||
|
*~lock~
|
||||||
3
packaging/apple/AppStore.af
Normal file
3
packaging/apple/AppStore.af
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:2aa9a22a914aa9503f202cf2f2336e9dc236a7aa2cb09fc52a6f1ac61fc90ca7
|
||||||
|
size 10653377
|
||||||
54
packaging/homebrew/tap-README.md
Normal file
54
packaging/homebrew/tap-README.md
Normal file
@@ -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.
|
||||||
36
packaging/homebrew/vnidrop.rb
Normal file
36
packaging/homebrew/vnidrop.rb
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user