mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
Compare commits
118 Commits
4feff29c74
...
feat/share
| Author | SHA1 | Date | |
|---|---|---|---|
| 877083a3ed | |||
| 7cb2270d56 | |||
| eb8498168d | |||
| ac6e837560 | |||
| 3e18378610 | |||
| b68d338097 | |||
| b8a002a2ad | |||
| 232fb125d3 | |||
|
|
d52ac52cea | ||
| fe97c21c7a | |||
| c670dda0a9 | |||
| ff391f5502 | |||
| 9079c81409 | |||
| 5424da855e | |||
| 56d19014d4 | |||
| 51bf0abba2 | |||
| e0fb84ccb9 | |||
| 30025a4ebf | |||
| 50e9a6c1cc | |||
| 224a8e0e7a | |||
| efacfab213 | |||
|
|
0ec7618ce8 | ||
| 8b75423b7a | |||
|
|
7236933b76 | ||
| fc732e1b77 | |||
|
|
d097c82f6a | ||
| caaa9a472d | |||
|
|
4ce124da7c | ||
| 94a8b3481b | |||
|
|
6d908d8dc3 | ||
| c6655da7db | |||
| 2d7982bbb9 | |||
|
|
52d4102308 | ||
| 407a0d2d60 | |||
|
|
fc1d27bf45 | ||
| 4730554c2c | |||
| cbb535d998 | |||
| a0ebd7c71b | |||
| cc194f6a7b | |||
| 8de190a36e | |||
| 73bc87d3d1 | |||
| 2166aa9ce4 | |||
| 22b93ce94e | |||
| 31ba3f40b2 | |||
| f3124371ee | |||
| ea2f8b1cc7 | |||
| 9b8d66f97d | |||
| a8a168ffde | |||
| 516c4ace84 | |||
|
|
c7657c4b37 | ||
| b8e8dd8644 | |||
| 83b66c5eb7 | |||
| 81e84b14f8 | |||
| c81cb7c8b6 | |||
| 319af6f2de | |||
| 3abd4d0cfd | |||
| b66cb8c1f1 | |||
| 98da43b122 | |||
| 7d3f1b9862 | |||
| 83ddf9f059 | |||
| f513a6118e | |||
| 35f06a0b6b | |||
| b65bac021f | |||
| d2924f7ce6 | |||
| 3042005226 | |||
| 9b15a388d8 | |||
| c23f7916bb | |||
|
|
0f8f89641a | ||
| b724c1540f | |||
| 1d049d08f2 | |||
| aab5f243ca | |||
| 065d57e896 | |||
| c7ebaee15b | |||
| 425500ecf2 | |||
| 30dfabf8e7 | |||
| 4bd51106e8 | |||
| 6e2c8b4b2d | |||
| e22e395efc | |||
| 677c3ce47f | |||
| 20597e6e88 | |||
| 42f569dcf0 | |||
| 8b31c0fe78 | |||
| c9ef40f00f | |||
| 0448137d84 | |||
| 6180cb95ce | |||
| 2b2fe93293 | |||
| 3469b122c2 | |||
| 4074f4bee8 | |||
| 7cc0e825f6 | |||
| efb3c474d1 | |||
| 0e0d43bc4d | |||
| 081b59815c | |||
| 3c8267adc5 | |||
| 08e61c57af | |||
| 1563bf80d6 | |||
| ef42875ddb | |||
| 7592d49a59 | |||
| a0bcc5dbff | |||
| cbace73908 | |||
|
|
5939489432 | ||
|
|
90a06e9151 | ||
| 6cf6644c09 | |||
| f0b06ad1cf | |||
| e7c70c9314 | |||
|
|
ad9ffd63f5 | ||
| 82fb549576 | |||
|
|
c6e59bed26 | ||
| 58c470b279 | |||
| 4e2777b917 | |||
|
|
b027177ad3 | ||
|
|
6388d42ec1 | ||
| b46c5e7d72 | |||
| be7a61e948 | |||
| 0e97c014a1 | |||
| 0ab076e5b2 | |||
| a0d352895e | |||
| cf7734fb42 | |||
| 627c205853 |
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*.af filter=lfs diff=lfs merge=lfs -text
|
||||||
141
.github/workflows/android-release.yml
vendored
Normal file
141
.github/workflows/android-release.yml
vendored
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
name: Android release package
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: android-release-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build signed Android APK and AAB
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 90
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up JDK 21
|
||||||
|
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21.0.11+10.0.LTS"
|
||||||
|
|
||||||
|
- name: Set up Gradle
|
||||||
|
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||||
|
with:
|
||||||
|
gradle-home-cache-strict-match: true
|
||||||
|
|
||||||
|
- name: Install Rust 1.91
|
||||||
|
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # v1
|
||||||
|
with:
|
||||||
|
toolchain: "1.91.0"
|
||||||
|
targets: aarch64-linux-android,x86_64-linux-android
|
||||||
|
|
||||||
|
- name: Cache Cargo
|
||||||
|
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: android-release-cargo-1.91.0-${{ hashFiles('Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
android-release-cargo-1.91.0-
|
||||||
|
|
||||||
|
- name: Set up Android SDK
|
||||||
|
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
|
||||||
|
with:
|
||||||
|
packages: "platform-tools platforms;android-36 build-tools;36.0.0"
|
||||||
|
|
||||||
|
- name: Set up Android NDK
|
||||||
|
id: setup-ndk
|
||||||
|
uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 # v1
|
||||||
|
with:
|
||||||
|
ndk-version: r27c
|
||||||
|
link-to-sdk: true
|
||||||
|
add-to-path: false
|
||||||
|
|
||||||
|
- name: Export Android NDK location
|
||||||
|
run: |
|
||||||
|
echo "ANDROID_NDK_HOME=${{ steps.setup-ndk.outputs.ndk-path }}" >> "$GITHUB_ENV"
|
||||||
|
echo "ANDROID_NDK_ROOT=${{ steps.setup-ndk.outputs.ndk-path }}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Resolve canonical version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
packaging/version/resolve-version.sh verify >/dev/null
|
||||||
|
echo "app=$(packaging/version/resolve-version.sh product)" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "code=$(packaging/version/resolve-version.sh android-code)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Validate signing configuration
|
||||||
|
env:
|
||||||
|
KEYSTORE_BASE64: ${{ secrets.ANDROID_UPLOAD_KEYSTORE_BASE64 }}
|
||||||
|
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_UPLOAD_KEYSTORE_PASSWORD }}
|
||||||
|
KEY_ALIAS: ${{ secrets.ANDROID_UPLOAD_KEY_ALIAS }}
|
||||||
|
KEY_PASSWORD: ${{ secrets.ANDROID_UPLOAD_KEY_PASSWORD }}
|
||||||
|
UPLOAD_CERT_SHA256: ${{ vars.ANDROID_UPLOAD_CERT_SHA256 }}
|
||||||
|
run: |
|
||||||
|
for name in \
|
||||||
|
KEYSTORE_BASE64 \
|
||||||
|
KEYSTORE_PASSWORD \
|
||||||
|
KEY_ALIAS \
|
||||||
|
KEY_PASSWORD \
|
||||||
|
UPLOAD_CERT_SHA256; do
|
||||||
|
if [ -z "${!name:-}" ]; then
|
||||||
|
echo "Missing Android release signing configuration: $name" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Decode upload keystore
|
||||||
|
env:
|
||||||
|
KEYSTORE_BASE64: ${{ secrets.ANDROID_UPLOAD_KEYSTORE_BASE64 }}
|
||||||
|
run: |
|
||||||
|
keystore="$RUNNER_TEMP/vnidrop-upload.jks"
|
||||||
|
printf '%s' "$KEYSTORE_BASE64" | base64 --decode > "$keystore"
|
||||||
|
chmod 600 "$keystore"
|
||||||
|
test -s "$keystore"
|
||||||
|
echo "VNIDROP_ANDROID_KEYSTORE_PATH=$keystore" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Build and verify signed release
|
||||||
|
env:
|
||||||
|
VNIDROP_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_UPLOAD_KEYSTORE_PASSWORD }}
|
||||||
|
VNIDROP_ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_UPLOAD_KEY_ALIAS }}
|
||||||
|
VNIDROP_ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_UPLOAD_KEY_PASSWORD }}
|
||||||
|
VNIDROP_ANDROID_UPLOAD_CERT_SHA256: ${{ vars.ANDROID_UPLOAD_CERT_SHA256 }}
|
||||||
|
run: packaging/android/build-release.sh
|
||||||
|
|
||||||
|
- name: Remove upload keystore
|
||||||
|
if: always()
|
||||||
|
run: rm -f "$RUNNER_TEMP/vnidrop-upload.jks"
|
||||||
|
|
||||||
|
- name: Upload Android artifacts
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: vnidrop-${{ steps.version.outputs.app }}-android-release
|
||||||
|
path: build/release/android/
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 90
|
||||||
|
compression-level: 0
|
||||||
|
|
||||||
|
- name: Summarize Android package
|
||||||
|
run: |
|
||||||
|
echo "### Android release package" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "- Version: ${{ steps.version.outputs.app }}" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "- Version code: ${{ steps.version.outputs.code }}" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "- Signing: upload certificate verified" >> "$GITHUB_STEP_SUMMARY"
|
||||||
168
.github/workflows/apple-release.yml
vendored
Normal file
168
.github/workflows/apple-release.yml
vendored
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
name: Apple release (macOS DMG)
|
||||||
|
|
||||||
|
# Builds, signs, notarizes, and uploads the direct-download macOS build:
|
||||||
|
# - a Developer ID–signed, notarized VniDrop-<version>.dmg,
|
||||||
|
# - a Sparkle appcast.xml.
|
||||||
|
#
|
||||||
|
# The central release workflow publishes these artifacts and updates Homebrew.
|
||||||
|
#
|
||||||
|
# The App Store / TestFlight build is NOT produced here — that goes through Xcode
|
||||||
|
# Organizer / App Store Connect. This workflow only covers direct distribution.
|
||||||
|
#
|
||||||
|
# Called by the central tag-release workflow, or run manually to validate the
|
||||||
|
# signed/notarized direct-download artifact.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
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
|
||||||
|
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 canonical version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
packaging/version/resolve-version.sh verify >/dev/null
|
||||||
|
version="$(packaging/version/resolve-version.sh product)"
|
||||||
|
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@0c5077e51419868618aeaa5fe8019c62421857d6 # 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: make build-apple-dmg
|
||||||
|
|
||||||
|
- name: Package prebuilt core
|
||||||
|
# build-apple-dmg builds the release Rust core + Swift bindings; bundle them
|
||||||
|
# (xcframework + Vnidrop.swift + checksum) as a release asset so consumers can
|
||||||
|
# skip building the core. See apple/scripts/package-core.sh.
|
||||||
|
run: make package-apple-core
|
||||||
|
|
||||||
|
- name: Upload notarization diagnostics
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: vnidrop-${{ steps.version.outputs.app }}-notarization-diagnostics
|
||||||
|
path: apple/dist/*.notary-log.json
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
- name: Generate appcast
|
||||||
|
env:
|
||||||
|
RELEASE_REPO: ${{ github.repository }}
|
||||||
|
run: apple/scripts/generate-appcast.sh
|
||||||
|
|
||||||
|
- 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/VniDrop-*.build-info.json
|
||||||
|
apple/dist/appcast.xml
|
||||||
|
apple/dist/VnidropCore-*.zip
|
||||||
|
apple/dist/VnidropCore-*.zip.sha256
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
20
.github/workflows/apple.yml
vendored
20
.github/workflows/apple.yml
vendored
@@ -4,6 +4,8 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- "apple/**"
|
- "apple/**"
|
||||||
|
- "version.properties"
|
||||||
|
- "packaging/version/**"
|
||||||
- "crates/vnidrop/**"
|
- "crates/vnidrop/**"
|
||||||
- "crates/uniffi-bindgen/**"
|
- "crates/uniffi-bindgen/**"
|
||||||
- "Cargo.toml"
|
- "Cargo.toml"
|
||||||
@@ -12,11 +14,14 @@ on:
|
|||||||
- "config.mk"
|
- "config.mk"
|
||||||
- "make/**"
|
- "make/**"
|
||||||
- ".github/workflows/apple.yml"
|
- ".github/workflows/apple.yml"
|
||||||
|
- "localization/**"
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
paths:
|
paths:
|
||||||
- "apple/**"
|
- "apple/**"
|
||||||
|
- "version.properties"
|
||||||
|
- "packaging/version/**"
|
||||||
- "crates/vnidrop/**"
|
- "crates/vnidrop/**"
|
||||||
- "crates/uniffi-bindgen/**"
|
- "crates/uniffi-bindgen/**"
|
||||||
- "Cargo.toml"
|
- "Cargo.toml"
|
||||||
@@ -25,6 +30,7 @@ on:
|
|||||||
- "config.mk"
|
- "config.mk"
|
||||||
- "make/**"
|
- "make/**"
|
||||||
- ".github/workflows/apple.yml"
|
- ".github/workflows/apple.yml"
|
||||||
|
- "localization/**"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -63,5 +69,19 @@ jobs:
|
|||||||
- name: Install XcodeGen
|
- name: Install XcodeGen
|
||||||
run: brew install xcodegen
|
run: brew install xcodegen
|
||||||
|
|
||||||
|
- name: Install SwiftLint
|
||||||
|
# Required by the VniDrop target's SwiftLint build phase (typed-resources rules).
|
||||||
|
run: brew install swiftlint
|
||||||
|
|
||||||
|
- name: Install Bun
|
||||||
|
# The Apple l10n catalog (Localizable.xcstrings) and L10n.swift are
|
||||||
|
# generated from localization/strings.json at build time, not tracked.
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
- 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
|
||||||
|
|||||||
103
.github/workflows/linux-packages.yml
vendored
103
.github/workflows/linux-packages.yml
vendored
@@ -5,6 +5,8 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- ".github/workflows/linux-packages.yml"
|
- ".github/workflows/linux-packages.yml"
|
||||||
- "packaging/linux/**"
|
- "packaging/linux/**"
|
||||||
|
- "packaging/version/**"
|
||||||
|
- "version.properties"
|
||||||
- "assets/linux/**"
|
- "assets/linux/**"
|
||||||
- "desktopApp/**"
|
- "desktopApp/**"
|
||||||
- "shared/**"
|
- "shared/**"
|
||||||
@@ -20,16 +22,8 @@ on:
|
|||||||
- "Makefile"
|
- "Makefile"
|
||||||
- "config.mk"
|
- "config.mk"
|
||||||
- "make/**"
|
- "make/**"
|
||||||
push:
|
workflow_call:
|
||||||
tags:
|
|
||||||
- "v*.*.*"
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: Release version in MAJOR.MINOR.PATCH form
|
|
||||||
required: true
|
|
||||||
default: "1.0.0"
|
|
||||||
type: string
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -69,6 +63,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Set up Gradle
|
- name: Set up Gradle
|
||||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||||
|
with:
|
||||||
|
gradle-home-cache-strict-match: true
|
||||||
|
|
||||||
- name: Set up Rust 1.91
|
- name: Set up Rust 1.91
|
||||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # v1
|
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # v1
|
||||||
@@ -86,16 +82,14 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
linux-deb-x64-cargo-1.91.0-
|
linux-deb-x64-cargo-1.91.0-
|
||||||
|
|
||||||
- name: Resolve version
|
- name: Resolve canonical version
|
||||||
id: version
|
id: version
|
||||||
env:
|
|
||||||
REQUESTED_VERSION: ${{ inputs.version || '1.0.0' }}
|
|
||||||
run: |
|
run: |
|
||||||
version=$(packaging/linux/resolve-version.sh "$REQUESTED_VERSION")
|
version=$(packaging/linux/resolve-version.sh)
|
||||||
echo "app=$version" >> "$GITHUB_OUTPUT"
|
echo "app=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Test and build Debian package
|
- name: Test and build Debian package
|
||||||
run: make package-deb VERSION=${{ steps.version.outputs.app }}
|
run: make package-deb
|
||||||
|
|
||||||
- name: Upload Debian artifact
|
- name: Upload Debian artifact
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
@@ -172,6 +166,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Set up Gradle
|
- name: Set up Gradle
|
||||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||||
|
with:
|
||||||
|
gradle-home-cache-strict-match: true
|
||||||
|
|
||||||
- name: Set up Rust 1.91
|
- name: Set up Rust 1.91
|
||||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # v1
|
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # v1
|
||||||
@@ -189,16 +185,14 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
linux-rpm-x64-cargo-1.91.0-
|
linux-rpm-x64-cargo-1.91.0-
|
||||||
|
|
||||||
- name: Resolve version
|
- name: Resolve canonical version
|
||||||
id: version
|
id: version
|
||||||
env:
|
|
||||||
REQUESTED_VERSION: ${{ inputs.version || '1.0.0' }}
|
|
||||||
run: |
|
run: |
|
||||||
version=$(packaging/linux/resolve-version.sh "$REQUESTED_VERSION")
|
version=$(packaging/linux/resolve-version.sh)
|
||||||
echo "app=$version" >> "$GITHUB_OUTPUT"
|
echo "app=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Build RPM package
|
- name: Build RPM package
|
||||||
run: make package-rpm VERSION=${{ steps.version.outputs.app }}
|
run: make package-rpm
|
||||||
|
|
||||||
- name: Upload RPM artifact
|
- name: Upload RPM artifact
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
@@ -216,74 +210,3 @@ jobs:
|
|||||||
echo "- Version: ${{ steps.version.outputs.app }}-1" >> "$GITHUB_STEP_SUMMARY"
|
echo "- Version: ${{ steps.version.outputs.app }}-1" >> "$GITHUB_STEP_SUMMARY"
|
||||||
echo "- Architecture: x86_64" >> "$GITHUB_STEP_SUMMARY"
|
echo "- Architecture: x86_64" >> "$GITHUB_STEP_SUMMARY"
|
||||||
echo "- Build environment: Fedora 43" >> "$GITHUB_STEP_SUMMARY"
|
echo "- Build environment: Fedora 43" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
publish-release:
|
|
||||||
name: Publish GitHub Release assets
|
|
||||||
if: github.event_name == 'push' && github.ref_type == 'tag'
|
|
||||||
needs:
|
|
||||||
- build-deb
|
|
||||||
- build-rpm
|
|
||||||
runs-on: ubuntu-22.04
|
|
||||||
timeout-minutes: 15
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout release history
|
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Verify tag is on master
|
|
||||||
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: Download Linux artifacts
|
|
||||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
|
||||||
with:
|
|
||||||
pattern: vnidrop-*-linux-*-x64
|
|
||||||
path: build/release/linux
|
|
||||||
merge-multiple: true
|
|
||||||
|
|
||||||
- name: Verify artifacts and checksums
|
|
||||||
run: |
|
|
||||||
cd build/release/linux
|
|
||||||
shopt -s nullglob
|
|
||||||
deb_packages=(*.deb)
|
|
||||||
rpm_packages=(*.rpm)
|
|
||||||
checksum_files=(*.sha256)
|
|
||||||
if (( ${#deb_packages[@]} != 1 || ${#rpm_packages[@]} != 1 || ${#checksum_files[@]} != 2 )); then
|
|
||||||
echo "Expected one DEB, one RPM, and two checksum sidecars" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
version=${GITHUB_REF_NAME#v}
|
|
||||||
if [[ ${deb_packages[0]} != "vnidrop_${version}-1_amd64.deb" || ${rpm_packages[0]} != "vnidrop-${version}-1.x86_64.rpm" ]]; then
|
|
||||||
echo "Downloaded package names do not match tag $GITHUB_REF_NAME" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
sha256sum --check "${checksum_files[@]}"
|
|
||||||
sha256sum "${deb_packages[@]}" "${rpm_packages[@]}" > SHA256SUMS
|
|
||||||
rm -- "${checksum_files[@]}"
|
|
||||||
|
|
||||||
- name: Publish GitHub Release
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
GH_REPO: ${{ github.repository }}
|
|
||||||
run: |
|
|
||||||
tag=${GITHUB_REF_NAME}
|
|
||||||
version=${tag#v}
|
|
||||||
if gh release view "$tag" >/dev/null 2>&1; then
|
|
||||||
echo "GitHub Release $tag already exists; refusing to replace its assets" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
gh release create "$tag" \
|
|
||||||
build/release/linux/*.deb \
|
|
||||||
build/release/linux/*.rpm \
|
|
||||||
build/release/linux/SHA256SUMS \
|
|
||||||
--verify-tag \
|
|
||||||
--title "VniDrop $version" \
|
|
||||||
--generate-notes
|
|
||||||
|
|||||||
53
.github/workflows/release-checks.yml
vendored
Normal file
53
.github/workflows/release-checks.yml
vendored
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
name: Release pipeline checks
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- ".github/workflows/android-release.yml"
|
||||||
|
- ".github/workflows/apple-release.yml"
|
||||||
|
- ".github/workflows/linux-packages.yml"
|
||||||
|
- ".github/workflows/release-checks.yml"
|
||||||
|
- ".github/workflows/release.yml"
|
||||||
|
- ".github/workflows/windows-store.yml"
|
||||||
|
- "packaging/android/**"
|
||||||
|
- "packaging/release/**"
|
||||||
|
- "packaging/version/**"
|
||||||
|
- "version.properties"
|
||||||
|
- "Makefile"
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
paths:
|
||||||
|
- ".github/workflows/android-release.yml"
|
||||||
|
- ".github/workflows/apple-release.yml"
|
||||||
|
- ".github/workflows/linux-packages.yml"
|
||||||
|
- ".github/workflows/release-checks.yml"
|
||||||
|
- ".github/workflows/release.yml"
|
||||||
|
- ".github/workflows/windows-store.yml"
|
||||||
|
- "packaging/android/**"
|
||||||
|
- "packaging/release/**"
|
||||||
|
- "packaging/version/**"
|
||||||
|
- "version.properties"
|
||||||
|
- "Makefile"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-checks-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
scripts:
|
||||||
|
name: Validate release scripts
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 5
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Run release checks
|
||||||
|
run: make check-release
|
||||||
459
.github/workflows/release.yml
vendored
Normal file
459
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*.*.*"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: vnidrop-release
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
preflight:
|
||||||
|
name: Verify release tag
|
||||||
|
if: ${{ vars.RELEASE_PIPELINE_ENABLED == 'true' }}
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 10
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.version.outputs.app }}
|
||||||
|
android_code: ${{ steps.version.outputs.android_code }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout release history
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Verify canonical beta tag on current master
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
packaging/version/resolve-version.sh verify >/dev/null
|
||||||
|
version="$(packaging/version/resolve-version.sh product)"
|
||||||
|
channel="$(packaging/version/resolve-version.sh channel)"
|
||||||
|
master_sha="$(git rev-parse origin/master)"
|
||||||
|
if [ "$GITHUB_SHA" != "$master_sha" ]; then
|
||||||
|
echo "Release tags must point at the current master commit" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$channel" != "beta" ]; then
|
||||||
|
echo "Only beta closed-testing releases are enabled" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "app=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "android_code=$(packaging/version/resolve-version.sh android-code)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Refuse an existing GitHub Release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||||
|
echo "GitHub Release $GITHUB_REF_NAME already exists" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
linux:
|
||||||
|
name: Linux packages
|
||||||
|
needs: preflight
|
||||||
|
uses: ./.github/workflows/linux-packages.yml
|
||||||
|
|
||||||
|
windows:
|
||||||
|
name: Windows Store package
|
||||||
|
needs: preflight
|
||||||
|
uses: ./.github/workflows/windows-store.yml
|
||||||
|
|
||||||
|
macos:
|
||||||
|
name: Signed and notarized macOS package
|
||||||
|
needs: preflight
|
||||||
|
uses: ./.github/workflows/apple-release.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
android:
|
||||||
|
name: Signed Android package
|
||||||
|
needs: preflight
|
||||||
|
uses: ./.github/workflows/android-release.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
play-closed-testing:
|
||||||
|
name: Stage Play closed-testing draft
|
||||||
|
needs:
|
||||||
|
- preflight
|
||||||
|
- linux
|
||||||
|
- windows
|
||||||
|
- macos
|
||||||
|
- android
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 20
|
||||||
|
environment: play-closed-testing
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Download signed Android artifacts
|
||||||
|
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||||
|
with:
|
||||||
|
name: vnidrop-${{ needs.preflight.outputs.version }}-android-release
|
||||||
|
path: build/release/android
|
||||||
|
|
||||||
|
- name: Validate closed-testing configuration
|
||||||
|
env:
|
||||||
|
WORKLOAD_IDENTITY_PROVIDER: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }}
|
||||||
|
PLAY_SERVICE_ACCOUNT: ${{ vars.GCP_PLAY_SERVICE_ACCOUNT }}
|
||||||
|
PLAY_PACKAGE_NAME: ${{ vars.PLAY_PACKAGE_NAME }}
|
||||||
|
PLAY_CLOSED_TRACK: ${{ vars.PLAY_CLOSED_TRACK }}
|
||||||
|
PLAY_APP_SIGNING_CERT_SHA256: ${{ vars.PLAY_APP_SIGNING_CERT_SHA256 }}
|
||||||
|
run: |
|
||||||
|
for name in \
|
||||||
|
WORKLOAD_IDENTITY_PROVIDER \
|
||||||
|
PLAY_SERVICE_ACCOUNT \
|
||||||
|
PLAY_PACKAGE_NAME \
|
||||||
|
PLAY_CLOSED_TRACK \
|
||||||
|
PLAY_APP_SIGNING_CERT_SHA256; do
|
||||||
|
if [ -z "${!name:-}" ]; then
|
||||||
|
echo "Missing Play closed-testing configuration: $name" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
case "${PLAY_CLOSED_TRACK,,}" in
|
||||||
|
production|*:production)
|
||||||
|
echo "Production Play tracks are forbidden" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if [ "$PLAY_PACKAGE_NAME" != "com.vnidrop.app" ]; then
|
||||||
|
echo "Unexpected Play package name: $PLAY_PACKAGE_NAME" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Authenticate to Google with GitHub OIDC
|
||||||
|
id: google-auth
|
||||||
|
uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3
|
||||||
|
with:
|
||||||
|
workload_identity_provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }}
|
||||||
|
service_account: ${{ vars.GCP_PLAY_SERVICE_ACCOUNT }}
|
||||||
|
token_format: access_token
|
||||||
|
access_token_scopes: https://www.googleapis.com/auth/androidpublisher
|
||||||
|
|
||||||
|
- name: Stage AAB and download Play-signed APK
|
||||||
|
env:
|
||||||
|
GOOGLE_PLAY_ACCESS_TOKEN: ${{ steps.google-auth.outputs.access_token }}
|
||||||
|
PLAY_PACKAGE_NAME: ${{ vars.PLAY_PACKAGE_NAME }}
|
||||||
|
PLAY_CLOSED_TRACK: ${{ vars.PLAY_CLOSED_TRACK }}
|
||||||
|
PLAY_APP_SIGNING_CERT_SHA256: ${{ vars.PLAY_APP_SIGNING_CERT_SHA256 }}
|
||||||
|
VERSION: ${{ needs.preflight.outputs.version }}
|
||||||
|
VERSION_CODE: ${{ needs.preflight.outputs.android_code }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
shopt -s nullglob
|
||||||
|
bundles=(build/release/android/*.aab)
|
||||||
|
if [ "${#bundles[@]}" -ne 1 ]; then
|
||||||
|
echo "Expected exactly one signed AAB" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p build/release/play
|
||||||
|
python3 packaging/android/publish_play.py \
|
||||||
|
--bundle "${bundles[0]}" \
|
||||||
|
--package-name "$PLAY_PACKAGE_NAME" \
|
||||||
|
--track "$PLAY_CLOSED_TRACK" \
|
||||||
|
--version-code "$VERSION_CODE" \
|
||||||
|
--release-name "$VERSION" \
|
||||||
|
--expected-app-certificate "$PLAY_APP_SIGNING_CERT_SHA256" \
|
||||||
|
--apk-output "build/release/play/VniDrop-${VERSION}-${VERSION_CODE}-play-universal.apk" \
|
||||||
|
--metadata-output build/release/play/play-release.json
|
||||||
|
|
||||||
|
- name: Set up Android SDK verification tools
|
||||||
|
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
|
||||||
|
with:
|
||||||
|
packages: "platform-tools build-tools;36.0.0"
|
||||||
|
|
||||||
|
- name: Verify Play-signed universal APK
|
||||||
|
env:
|
||||||
|
EXPECTED_CERT_SHA256: ${{ vars.PLAY_APP_SIGNING_CERT_SHA256 }}
|
||||||
|
VERSION: ${{ needs.preflight.outputs.version }}
|
||||||
|
VERSION_CODE: ${{ needs.preflight.outputs.android_code }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
apk="build/release/play/VniDrop-${VERSION}-${VERSION_CODE}-play-universal.apk"
|
||||||
|
apkanalyzer_path="$(
|
||||||
|
find "$ANDROID_SDK_ROOT/cmdline-tools" -type f -name apkanalyzer -perm -111 |
|
||||||
|
sort -r |
|
||||||
|
head -1
|
||||||
|
)"
|
||||||
|
if [ -z "$apkanalyzer_path" ]; then
|
||||||
|
echo "apkanalyzer was not found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
packaging/android/verify-apk-signature.sh \
|
||||||
|
"$apk" \
|
||||||
|
"$EXPECTED_CERT_SHA256" \
|
||||||
|
>/dev/null
|
||||||
|
if [ "$("$apkanalyzer_path" manifest application-id "$apk")" != "com.vnidrop.app" ]; then
|
||||||
|
echo "Play APK package name mismatch" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$("$apkanalyzer_path" manifest version-name "$apk")" != "$VERSION" ]; then
|
||||||
|
echo "Play APK version name mismatch" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$("$apkanalyzer_path" manifest version-code "$apk")" != "$VERSION_CODE" ]; then
|
||||||
|
echo "Play APK version code mismatch" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
(
|
||||||
|
cd build/release/play
|
||||||
|
sha256sum \
|
||||||
|
"VniDrop-${VERSION}-${VERSION_CODE}-play-universal.apk" \
|
||||||
|
play-release.json \
|
||||||
|
> SHA256SUMS
|
||||||
|
)
|
||||||
|
|
||||||
|
- name: Upload Play-signed APK
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: vnidrop-${{ needs.preflight.outputs.version }}-android-play
|
||||||
|
path: build/release/play/
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 90
|
||||||
|
compression-level: 0
|
||||||
|
|
||||||
|
publish-microsoft-store:
|
||||||
|
name: Submit Microsoft Store update
|
||||||
|
needs:
|
||||||
|
- preflight
|
||||||
|
- linux
|
||||||
|
- windows
|
||||||
|
- macos
|
||||||
|
- play-closed-testing
|
||||||
|
runs-on: windows-2025
|
||||||
|
timeout-minutes: 30
|
||||||
|
environment: microsoft-store
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download Windows Store package
|
||||||
|
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||||
|
with:
|
||||||
|
name: vnidrop-${{ needs.preflight.outputs.version }}-windows-store-x64
|
||||||
|
path: build/release/windows
|
||||||
|
|
||||||
|
- name: Validate Microsoft Store configuration
|
||||||
|
id: store-package
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
AZURE_AD_TENANT_ID: ${{ secrets.AZURE_AD_TENANT_ID }}
|
||||||
|
AZURE_AD_APPLICATION_CLIENT_ID: ${{ secrets.AZURE_AD_APPLICATION_CLIENT_ID }}
|
||||||
|
AZURE_AD_APPLICATION_SECRET: ${{ secrets.AZURE_AD_APPLICATION_SECRET }}
|
||||||
|
SELLER_ID: ${{ secrets.SELLER_ID }}
|
||||||
|
MICROSOFT_STORE_PRODUCT_ID: ${{ vars.MICROSOFT_STORE_PRODUCT_ID }}
|
||||||
|
run: |
|
||||||
|
$configuration = @{
|
||||||
|
AZURE_AD_TENANT_ID = $env:AZURE_AD_TENANT_ID
|
||||||
|
AZURE_AD_APPLICATION_CLIENT_ID = $env:AZURE_AD_APPLICATION_CLIENT_ID
|
||||||
|
AZURE_AD_APPLICATION_SECRET = $env:AZURE_AD_APPLICATION_SECRET
|
||||||
|
SELLER_ID = $env:SELLER_ID
|
||||||
|
MICROSOFT_STORE_PRODUCT_ID = $env:MICROSOFT_STORE_PRODUCT_ID
|
||||||
|
}
|
||||||
|
foreach ($entry in $configuration.GetEnumerator()) {
|
||||||
|
if ([string]::IsNullOrWhiteSpace($entry.Value) -or $entry.Value -eq "REPLACE_ME") {
|
||||||
|
throw "Missing Microsoft Store configuration: $($entry.Key)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($env:MICROSOFT_STORE_PRODUCT_ID -ne "9NJ5Q0FG7TGL") {
|
||||||
|
throw "Unexpected Microsoft Store product ID: $env:MICROSOFT_STORE_PRODUCT_ID"
|
||||||
|
}
|
||||||
|
$packages = @(
|
||||||
|
Get-ChildItem build/release/windows -File -Filter *.msixupload -Recurse
|
||||||
|
)
|
||||||
|
if ($packages.Count -ne 1) {
|
||||||
|
throw "Expected exactly one msixupload package, found $($packages.Count)"
|
||||||
|
}
|
||||||
|
"path=$($packages[0].FullName)" >> $env:GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Set up Microsoft Store Developer CLI
|
||||||
|
uses: microsoft/microsoft-store-apppublisher@15abd1c50fcc164b19cb240fb04ef3c49bf715a2 # v1.1
|
||||||
|
with:
|
||||||
|
version: v0.3.9
|
||||||
|
|
||||||
|
- name: Authenticate and verify Store access
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
AZURE_AD_TENANT_ID: ${{ secrets.AZURE_AD_TENANT_ID }}
|
||||||
|
AZURE_AD_APPLICATION_CLIENT_ID: ${{ secrets.AZURE_AD_APPLICATION_CLIENT_ID }}
|
||||||
|
AZURE_AD_APPLICATION_SECRET: ${{ secrets.AZURE_AD_APPLICATION_SECRET }}
|
||||||
|
SELLER_ID: ${{ secrets.SELLER_ID }}
|
||||||
|
MICROSOFT_STORE_PRODUCT_ID: ${{ vars.MICROSOFT_STORE_PRODUCT_ID }}
|
||||||
|
run: |
|
||||||
|
msstore reconfigure `
|
||||||
|
--tenantId "$env:AZURE_AD_TENANT_ID" `
|
||||||
|
--sellerId "$env:SELLER_ID" `
|
||||||
|
--clientId "$env:AZURE_AD_APPLICATION_CLIENT_ID" `
|
||||||
|
--clientSecret "$env:AZURE_AD_APPLICATION_SECRET"
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Microsoft Store authentication failed"
|
||||||
|
}
|
||||||
|
msstore settings --enableTelemetry false
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Failed to disable Microsoft Store CLI telemetry"
|
||||||
|
}
|
||||||
|
msstore apps get "$env:MICROSOFT_STORE_PRODUCT_ID"
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "The Microsoft Store application is not accessible"
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Publish package to Microsoft Store
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
MICROSOFT_STORE_PRODUCT_ID: ${{ vars.MICROSOFT_STORE_PRODUCT_ID }}
|
||||||
|
STORE_PACKAGE: ${{ steps.store-package.outputs.path }}
|
||||||
|
run: |
|
||||||
|
msstore publish "$env:STORE_PACKAGE" `
|
||||||
|
--appId "$env:MICROSOFT_STORE_PRODUCT_ID"
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Microsoft Store package publication failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Summarize Store submission
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.preflight.outputs.version }}
|
||||||
|
MICROSOFT_STORE_PRODUCT_ID: ${{ vars.MICROSOFT_STORE_PRODUCT_ID }}
|
||||||
|
run: |
|
||||||
|
"### Microsoft Store submission" >> $env:GITHUB_STEP_SUMMARY
|
||||||
|
"- App version: $env:VERSION" >> $env:GITHUB_STEP_SUMMARY
|
||||||
|
"- Product ID: $env:MICROSOFT_STORE_PRODUCT_ID" >> $env:GITHUB_STEP_SUMMARY
|
||||||
|
"- Package submitted for certification" >> $env:GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
publish-github:
|
||||||
|
name: Publish coordinated GitHub Release
|
||||||
|
needs:
|
||||||
|
- preflight
|
||||||
|
- linux
|
||||||
|
- windows
|
||||||
|
- macos
|
||||||
|
- play-closed-testing
|
||||||
|
- publish-microsoft-store
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 20
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
id-token: write
|
||||||
|
attestations: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Download Debian package
|
||||||
|
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||||
|
with:
|
||||||
|
name: vnidrop-${{ needs.preflight.outputs.version }}-linux-deb-x64
|
||||||
|
path: build/release/downloads/deb
|
||||||
|
|
||||||
|
- name: Download RPM package
|
||||||
|
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||||
|
with:
|
||||||
|
name: vnidrop-${{ needs.preflight.outputs.version }}-linux-rpm-x64
|
||||||
|
path: build/release/downloads/rpm
|
||||||
|
|
||||||
|
- name: Download macOS package
|
||||||
|
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||||
|
with:
|
||||||
|
name: vnidrop-${{ needs.preflight.outputs.version }}-macos-dmg
|
||||||
|
path: build/release/downloads/macos
|
||||||
|
|
||||||
|
- name: Download Windows Store package
|
||||||
|
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||||
|
with:
|
||||||
|
name: vnidrop-${{ needs.preflight.outputs.version }}-windows-store-x64
|
||||||
|
path: build/release/downloads/windows
|
||||||
|
|
||||||
|
- name: Download Play-signed APK
|
||||||
|
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||||
|
with:
|
||||||
|
name: vnidrop-${{ needs.preflight.outputs.version }}-android-play
|
||||||
|
path: build/release/downloads/play
|
||||||
|
|
||||||
|
- name: Verify and assemble public release assets
|
||||||
|
run: packaging/release/assemble-release.sh
|
||||||
|
|
||||||
|
- name: Attest release provenance
|
||||||
|
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4
|
||||||
|
with:
|
||||||
|
subject-path: build/release/final/*
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
gh release create "$GITHUB_REF_NAME" \
|
||||||
|
build/release/final/* \
|
||||||
|
--repo "$GITHUB_REPOSITORY" \
|
||||||
|
--verify-tag \
|
||||||
|
--title "VniDrop ${{ needs.preflight.outputs.version }}" \
|
||||||
|
--generate-notes
|
||||||
|
|
||||||
|
update-homebrew:
|
||||||
|
name: Update Homebrew cask
|
||||||
|
needs:
|
||||||
|
- preflight
|
||||||
|
- macos
|
||||||
|
- publish-github
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Download macOS package
|
||||||
|
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||||
|
with:
|
||||||
|
name: vnidrop-${{ needs.preflight.outputs.version }}-macos-dmg
|
||||||
|
path: dist
|
||||||
|
|
||||||
|
- name: Render Homebrew cask
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.preflight.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
|
||||||
|
|
||||||
|
- name: Push cask to tap
|
||||||
|
env:
|
||||||
|
TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||||
|
VERSION: ${{ needs.preflight.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 "Homebrew cask already matches ${VERSION}"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
git push
|
||||||
4
.github/workflows/rust-core.yml
vendored
4
.github/workflows/rust-core.yml
vendored
@@ -6,6 +6,8 @@ on:
|
|||||||
- "Cargo.toml"
|
- "Cargo.toml"
|
||||||
- "Cargo.lock"
|
- "Cargo.lock"
|
||||||
- "crates/vnidrop/**"
|
- "crates/vnidrop/**"
|
||||||
|
- "version.properties"
|
||||||
|
- "packaging/version/**"
|
||||||
- "Makefile"
|
- "Makefile"
|
||||||
- "config.mk"
|
- "config.mk"
|
||||||
- "make/**"
|
- "make/**"
|
||||||
@@ -19,6 +21,8 @@ on:
|
|||||||
- "Cargo.toml"
|
- "Cargo.toml"
|
||||||
- "Cargo.lock"
|
- "Cargo.lock"
|
||||||
- "crates/vnidrop/**"
|
- "crates/vnidrop/**"
|
||||||
|
- "version.properties"
|
||||||
|
- "packaging/version/**"
|
||||||
- "Makefile"
|
- "Makefile"
|
||||||
- "config.mk"
|
- "config.mk"
|
||||||
- "make/**"
|
- "make/**"
|
||||||
|
|||||||
4
.github/workflows/shared-kmp.yml
vendored
4
.github/workflows/shared-kmp.yml
vendored
@@ -4,6 +4,8 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- "shared/**"
|
- "shared/**"
|
||||||
|
- "version.properties"
|
||||||
|
- "packaging/version/**"
|
||||||
- "crates/vnidrop/**"
|
- "crates/vnidrop/**"
|
||||||
- "Cargo.toml"
|
- "Cargo.toml"
|
||||||
- "Cargo.lock"
|
- "Cargo.lock"
|
||||||
@@ -24,6 +26,8 @@ on:
|
|||||||
- master
|
- master
|
||||||
paths:
|
paths:
|
||||||
- "shared/**"
|
- "shared/**"
|
||||||
|
- "version.properties"
|
||||||
|
- "packaging/version/**"
|
||||||
- "crates/vnidrop/**"
|
- "crates/vnidrop/**"
|
||||||
- "Cargo.toml"
|
- "Cargo.toml"
|
||||||
- "Cargo.lock"
|
- "Cargo.lock"
|
||||||
|
|||||||
46
.github/workflows/windows-store.yml
vendored
46
.github/workflows/windows-store.yml
vendored
@@ -5,6 +5,8 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- ".github/workflows/windows-store.yml"
|
- ".github/workflows/windows-store.yml"
|
||||||
- "packaging/windows/**"
|
- "packaging/windows/**"
|
||||||
|
- "packaging/version/**"
|
||||||
|
- "version.properties"
|
||||||
- "assets/windows/**"
|
- "assets/windows/**"
|
||||||
- "desktopApp/**"
|
- "desktopApp/**"
|
||||||
- "shared/**"
|
- "shared/**"
|
||||||
@@ -17,16 +19,8 @@ on:
|
|||||||
- "gradle/**"
|
- "gradle/**"
|
||||||
- "gradlew"
|
- "gradlew"
|
||||||
- "gradlew.bat"
|
- "gradlew.bat"
|
||||||
push:
|
workflow_call:
|
||||||
tags:
|
|
||||||
- "v*.*.*"
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: Release version in MAJOR.MINOR.PATCH form
|
|
||||||
required: true
|
|
||||||
default: "1.0.0"
|
|
||||||
type: string
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -77,37 +71,13 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
windows-x64-cargo-1.91.0-
|
windows-x64-cargo-1.91.0-
|
||||||
|
|
||||||
- name: Resolve Store version
|
- name: Resolve canonical version
|
||||||
id: version
|
id: version
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
env:
|
|
||||||
REQUESTED_VERSION: ${{ inputs.version || '1.0.0' }}
|
|
||||||
run: |
|
run: |
|
||||||
$version = $env:REQUESTED_VERSION
|
$version = .\packaging\version\resolve-version.ps1 -Field Json -VerifyTag | ConvertFrom-Json
|
||||||
if ($env:GITHUB_REF_TYPE -eq "tag") {
|
"app=$($version.productVersion)" >> $env:GITHUB_OUTPUT
|
||||||
if ($env:GITHUB_REF_NAME -notmatch "^v[0-9]+\.[0-9]+\.[0-9]+$") {
|
"package=$($version.windowsPackageVersion)" >> $env:GITHUB_OUTPUT
|
||||||
throw "Store release tags must use vMAJOR.MINOR.PATCH"
|
|
||||||
}
|
|
||||||
$version = $env:GITHUB_REF_NAME.Substring(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($version -notmatch "^[0-9]+\.[0-9]+\.[0-9]+$") {
|
|
||||||
throw "Version must use MAJOR.MINOR.PATCH"
|
|
||||||
}
|
|
||||||
$parts = $version.Split(".")
|
|
||||||
for ($index = 0; $index -lt $parts.Count; $index++) {
|
|
||||||
$part = $parts[$index]
|
|
||||||
$number = 0
|
|
||||||
if (-not [int]::TryParse($part, [ref] $number) -or $number.ToString() -ne $part) {
|
|
||||||
throw "Version components must be canonical integers"
|
|
||||||
}
|
|
||||||
if ($number -lt $(if ($index -eq 0) { 1 } else { 0 }) -or $number -gt 65535) {
|
|
||||||
throw "Version components must be between 0 and 65535, with a non-zero major"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
"app=$version" >> $env:GITHUB_OUTPUT
|
|
||||||
"package=$version.0" >> $env:GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Test and build release app image
|
- name: Test and build release app image
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
@@ -115,7 +85,6 @@ jobs:
|
|||||||
$arguments = @(
|
$arguments = @(
|
||||||
":shared:jvmTest"
|
":shared:jvmTest"
|
||||||
":desktopApp:createReleaseDistributable"
|
":desktopApp:createReleaseDistributable"
|
||||||
"-Pvnidrop.version=${{ steps.version.outputs.app }}"
|
|
||||||
"-Pvnidrop.desktop.rustVariant=release"
|
"-Pvnidrop.desktop.rustVariant=release"
|
||||||
"-Pvnidrop.diagnostics.included=false"
|
"-Pvnidrop.diagnostics.included=false"
|
||||||
"--no-daemon"
|
"--no-daemon"
|
||||||
@@ -131,7 +100,6 @@ jobs:
|
|||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
$arguments = @{
|
$arguments = @{
|
||||||
Version = "${{ steps.version.outputs.app }}"
|
|
||||||
AppImage = ".\desktopApp\build\compose\binaries\main-release\app\VniDrop"
|
AppImage = ".\desktopApp\build\compose\binaries\main-release\app\VniDrop"
|
||||||
OutputDirectory = ".\build\release\windows"
|
OutputDirectory = ".\build\release\windows"
|
||||||
}
|
}
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -24,3 +24,5 @@ config.override.mk
|
|||||||
# Local design export scratch
|
# Local design export scratch
|
||||||
output/
|
output/
|
||||||
.screenshots
|
.screenshots
|
||||||
|
apple/RELEASE-MACOS.md
|
||||||
|
apple/Generated/*.xcconfig
|
||||||
|
|||||||
11
AGENTS.md
11
AGENTS.md
@@ -48,6 +48,15 @@ Domain docs (reference, do not paste into PRs):
|
|||||||
8. **Every bug fix includes a regression test** at the lowest layer that catches it.
|
8. **Every bug fix includes a regression test** at the lowest layer that catches it.
|
||||||
9. After code changes, run the **relevant** checks in [Build and test](#build-and-test)
|
9. After code changes, run the **relevant** checks in [Build and test](#build-and-test)
|
||||||
and fix failures before finishing.
|
and fix failures before finishing.
|
||||||
|
10. **`localization/strings.json` is the single source of truth for all localized
|
||||||
|
strings.** The KMP Compose resources (`shared/src/commonMain/composeResources/
|
||||||
|
values*/strings.xml`) and the Apple catalog + accessors
|
||||||
|
(`apple/VniDrop/Resources/Localizable.xcstrings`, `apple/VniDrop/Generated/
|
||||||
|
L10n.swift`) are **generated** by the loc CLI (`cd localization && bun run
|
||||||
|
src/cli.ts generate`) — never hand-edit them. To add/change a string: edit
|
||||||
|
`strings.json` (set `targets` to `kmp`, `apple`, or omit for both), then
|
||||||
|
regenerate. A key referenced in code but only present in a generated file will
|
||||||
|
be silently dropped the next time generation runs.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -291,6 +300,8 @@ branch from updated `master`.
|
|||||||
- Flaky multi-minute sleeps in tests
|
- Flaky multi-minute sleeps in tests
|
||||||
- Unsigned commits when signing is required
|
- Unsigned commits when signing is required
|
||||||
- Force-push or secret commits without explicit user direction
|
- Force-push or secret commits without explicit user direction
|
||||||
|
- Hand-editing generated localization files (`values*/strings.xml`,
|
||||||
|
`Localizable.xcstrings`, `L10n.swift`) instead of `localization/strings.json`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
389
Cargo.lock
generated
389
Cargo.lock
generated
@@ -61,12 +61,56 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstream"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"anstyle-parse",
|
||||||
|
"anstyle-query",
|
||||||
|
"anstyle-wincon",
|
||||||
|
"colorchoice",
|
||||||
|
"is_terminal_polyfill",
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anstyle"
|
name = "anstyle"
|
||||||
version = "1.0.14"
|
version = "1.0.14"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-parse"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||||
|
dependencies = [
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-query"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-wincon"
|
||||||
|
version = "3.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"once_cell_polyfill",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.103"
|
version = "1.0.103"
|
||||||
@@ -463,9 +507,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg_aliases"
|
name = "cfg_aliases"
|
||||||
version = "0.2.1"
|
version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chacha20"
|
name = "chacha20"
|
||||||
@@ -518,6 +562,7 @@ version = "4.6.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b"
|
checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"anstream",
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"clap_lex",
|
"clap_lex",
|
||||||
"strsim",
|
"strsim",
|
||||||
@@ -556,6 +601,12 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorchoice"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -777,38 +828,17 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling"
|
name = "dashmap"
|
||||||
version = "0.20.11"
|
version = "6.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
|
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling_core",
|
"cfg-if",
|
||||||
"darling_macro",
|
"crossbeam-utils",
|
||||||
]
|
"hashbrown 0.14.5",
|
||||||
|
"lock_api",
|
||||||
[[package]]
|
"once_cell",
|
||||||
name = "darling_core"
|
"parking_lot_core",
|
||||||
version = "0.20.11"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
|
|
||||||
dependencies = [
|
|
||||||
"fnv",
|
|
||||||
"ident_case",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"strsim",
|
|
||||||
"syn 2.0.118",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "darling_macro"
|
|
||||||
version = "0.20.11"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
|
|
||||||
dependencies = [
|
|
||||||
"darling_core",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.118",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -834,7 +864,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090"
|
checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
"syn 2.0.118",
|
"syn 1.0.109",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -879,37 +909,6 @@ version = "0.5.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "derive_builder"
|
|
||||||
version = "0.20.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
|
|
||||||
dependencies = [
|
|
||||||
"derive_builder_macro",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "derive_builder_core"
|
|
||||||
version = "0.20.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
|
|
||||||
dependencies = [
|
|
||||||
"darling",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.118",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "derive_builder_macro"
|
|
||||||
version = "0.20.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
|
|
||||||
dependencies = [
|
|
||||||
"derive_builder_core",
|
|
||||||
"syn 2.0.118",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_more"
|
name = "derive_more"
|
||||||
version = "2.1.1"
|
version = "2.1.1"
|
||||||
@@ -958,6 +957,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer 0.12.1",
|
"block-buffer 0.12.1",
|
||||||
|
"const-oid 0.10.2",
|
||||||
"crypto-common 0.2.2",
|
"crypto-common 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1474,6 +1474,12 @@ dependencies = [
|
|||||||
"byteorder",
|
"byteorder",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.14.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.15.5"
|
version = "0.15.5"
|
||||||
@@ -1861,12 +1867,6 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ident_case"
|
|
||||||
version = "1.0.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "identity-hash"
|
name = "identity-hash"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -1966,9 +1966,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iroh"
|
name = "iroh"
|
||||||
version = "1.0.1"
|
version = "1.0.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1a2e38557969901f8b356d1ebd882253bab98cc81500d53e7bcbf33f56082303"
|
checksum = "460de6bc52163b41b1646931f2897e5ab986f0966ade444467fec25024751a72"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"backon",
|
"backon",
|
||||||
"blake3",
|
"blake3",
|
||||||
@@ -2017,9 +2017,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iroh-base"
|
name = "iroh-base"
|
||||||
version = "1.0.1"
|
version = "1.0.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "61cdf012298adc13f5c2c821ad87214fecc9ac54c751301d45bf62b229a85da1"
|
checksum = "6be73e16ee21c923aca9b3121aaa0db936f7c7ecc156ff47b8dac944c68d59a8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"curve25519-dalek",
|
"curve25519-dalek",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
@@ -2077,9 +2077,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iroh-dns"
|
name = "iroh-dns"
|
||||||
version = "1.0.1"
|
version = "1.0.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4c24b83aae5ed4eced1c3724204c083c28c84c5d225a88e277eceeccce3dc3bd"
|
checksum = "46f6a9b39d18e6345f5c151afd299f2488e2cb5c520fe41b107b6bd3dc4c3349"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
@@ -2118,12 +2118,20 @@ version = "1.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "291065721ad7c477b972e581bbc528df031dc8eb5e39fe1ff3300ae5dfb157ef"
|
checksum = "291065721ad7c477b972e581bbc528df031dc8eb5e39fe1ff3300ae5dfb157ef"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
"iroh-metrics-derive",
|
"iroh-metrics-derive",
|
||||||
"itoa",
|
"itoa",
|
||||||
"n0-error",
|
"n0-error",
|
||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
|
"reqwest",
|
||||||
|
"rustls",
|
||||||
|
"rustls-platform-verifier",
|
||||||
"ryu",
|
"ryu",
|
||||||
"serde",
|
"serde",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2141,13 +2149,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iroh-relay"
|
name = "iroh-relay"
|
||||||
version = "1.0.1"
|
version = "1.0.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f16a5505939f9250297ff2f1210b5142d13618f496eab03ce3f964fc47e2e2b"
|
checksum = "24bd586cf927f7b700f56ec3639b53cb5fa901ce284784051ff71092bfbf8193"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"blake3",
|
"blake3",
|
||||||
"bytes",
|
"bytes",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
|
"clap",
|
||||||
|
"dashmap",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
"derive_more",
|
"derive_more",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
@@ -2168,19 +2178,29 @@ dependencies = [
|
|||||||
"pin-project",
|
"pin-project",
|
||||||
"postcard",
|
"postcard",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
|
"rcgen",
|
||||||
|
"reloadable-state",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rustls",
|
"rustls",
|
||||||
|
"rustls-cert-file-reader",
|
||||||
|
"rustls-cert-reloadable-resolver",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_bytes",
|
"serde_bytes",
|
||||||
|
"serde_json",
|
||||||
|
"sha1 0.11.0",
|
||||||
|
"simdutf8",
|
||||||
"strum",
|
"strum",
|
||||||
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
|
"tokio-rustls-acme",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tokio-websockets",
|
"tokio-websockets",
|
||||||
|
"toml 1.1.2+spec-1.1.0",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
"url",
|
"url",
|
||||||
"vergen-gitcl",
|
|
||||||
"webpki-roots",
|
"webpki-roots",
|
||||||
"ws_stream_wasm",
|
"ws_stream_wasm",
|
||||||
]
|
]
|
||||||
@@ -2264,6 +2284,12 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is_terminal_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.18"
|
version = "1.0.18"
|
||||||
@@ -2714,9 +2740,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "noq"
|
name = "noq"
|
||||||
version = "1.0.1"
|
version = "1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4bf95190af1bd4a00a10e8255ca0c8ddd9e9a9f5e79151d7a7eb6d56aff5dc89"
|
checksum = "e11803df44ac03a30988d61585ea50885d5428e42da944fe1e498799da7886a2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
@@ -2736,9 +2762,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "noq-proto"
|
name = "noq-proto"
|
||||||
version = "1.0.1"
|
version = "1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "aa6c890013591e709a3e45dd53501351b7e27e7ff3c7e9fc3dce43e300e7e9d3"
|
checksum = "334c3c9833f7b2c573cceb9896ddc7aaeb58c8807cbb63211b24d1fe88bf866e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -2765,9 +2791,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "noq-udp"
|
name = "noq-udp"
|
||||||
version = "1.0.1"
|
version = "1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3137a52df66c20090a889828d1c655f21f52294cba64e5c4fbb04fc83eee7c8e"
|
checksum = "bde7a5d5102f1cff03d482240f0ed20551661f63663620f4b26112ed751165e9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -2879,15 +2905,6 @@ dependencies = [
|
|||||||
"syn 2.0.118",
|
"syn 2.0.118",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "num_threads"
|
|
||||||
version = "0.1.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2"
|
name = "objc2"
|
||||||
version = "0.6.4"
|
version = "0.6.4"
|
||||||
@@ -2997,6 +3014,12 @@ dependencies = [
|
|||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opaque-debug"
|
name = "opaque-debug"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -3539,6 +3562,23 @@ version = "0.8.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "reloadable-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1dc20ac1418988b60072d783c9f68e28a173fb63493c127952f6face3b40c6e0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "reloadable-state"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3853ef78d45b50f8b989896304a85239539d39b7f866a000e8846b9b72d74ce8"
|
||||||
|
dependencies = [
|
||||||
|
"arc-swap",
|
||||||
|
"reloadable-core",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.4"
|
version = "0.13.4"
|
||||||
@@ -3562,6 +3602,8 @@ dependencies = [
|
|||||||
"rustls",
|
"rustls",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"rustls-platform-verifier",
|
"rustls-platform-verifier",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
@@ -3668,6 +3710,40 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-cert-file-reader"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8bb47c2a50fdfdaf95b0ac8b12620fc327da1fd4adbb30d0c56d866b005873ff"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-cert-read",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-cert-read"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dd46e8c5ae4de3345c4786a83f99ec7aff287209b9e26fa883c473aeb28f19d5"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-cert-reloadable-resolver"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fe1baa8a3a1f05eaa9fc55aed4342867f70e5c170ea3bfed1b38c51a4857c0c8"
|
||||||
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
|
"reloadable-state",
|
||||||
|
"rustls",
|
||||||
|
"rustls-cert-read",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-native-certs"
|
name = "rustls-native-certs"
|
||||||
version = "0.8.4"
|
version = "0.8.4"
|
||||||
@@ -3898,6 +3974,15 @@ dependencies = [
|
|||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_urlencoded"
|
name = "serde_urlencoded"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
@@ -3931,6 +4016,17 @@ dependencies = [
|
|||||||
"digest 0.10.7",
|
"digest 0.10.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha1"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.3.0",
|
||||||
|
"digest 0.11.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha1_smol"
|
name = "sha1_smol"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
@@ -4234,7 +4330,7 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"rsa",
|
"rsa",
|
||||||
"sha1",
|
"sha1 0.10.6",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"sqlx-core",
|
"sqlx-core",
|
||||||
@@ -4454,7 +4550,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.3.4",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
@@ -4526,9 +4622,7 @@ checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"deranged",
|
"deranged",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"libc",
|
|
||||||
"num-conv",
|
"num-conv",
|
||||||
"num_threads",
|
|
||||||
"powerfmt",
|
"powerfmt",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
"time-core",
|
"time-core",
|
||||||
@@ -4614,6 +4708,34 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-rustls-acme"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1af8573b15fdad8d66da116198cd8fd8d87ff62a67c1c6c3df7f62da1170793f"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"base64",
|
||||||
|
"chrono",
|
||||||
|
"futures",
|
||||||
|
"log",
|
||||||
|
"num-bigint",
|
||||||
|
"pem",
|
||||||
|
"proc-macro2",
|
||||||
|
"rcgen",
|
||||||
|
"reqwest",
|
||||||
|
"ring",
|
||||||
|
"rustls",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"time",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"webpki-roots",
|
||||||
|
"x509-parser",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-stream"
|
name = "tokio-stream"
|
||||||
version = "0.1.18"
|
version = "0.1.18"
|
||||||
@@ -4672,6 +4794,21 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "1.1.2+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"serde_core",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_parser",
|
||||||
|
"toml_writer",
|
||||||
|
"winnow 1.0.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_datetime"
|
name = "toml_datetime"
|
||||||
version = "1.1.1+spec-1.1.0"
|
version = "1.1.1+spec-1.1.0"
|
||||||
@@ -4702,6 +4839,12 @@ dependencies = [
|
|||||||
"winnow 1.0.3",
|
"winnow 1.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_writer"
|
||||||
|
version = "1.1.1+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -4915,7 +5058,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"textwrap",
|
"textwrap",
|
||||||
"toml",
|
"toml 0.5.11",
|
||||||
"uniffi_internal_macros",
|
"uniffi_internal_macros",
|
||||||
"uniffi_meta",
|
"uniffi_meta",
|
||||||
"uniffi_pipeline",
|
"uniffi_pipeline",
|
||||||
@@ -4961,7 +5104,7 @@ dependencies = [
|
|||||||
"quote",
|
"quote",
|
||||||
"serde",
|
"serde",
|
||||||
"syn 2.0.118",
|
"syn 2.0.118",
|
||||||
"toml",
|
"toml 0.5.11",
|
||||||
"uniffi_meta",
|
"uniffi_meta",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -5037,6 +5180,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8parse"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.23.4"
|
version = "1.23.4"
|
||||||
@@ -5061,43 +5210,6 @@ version = "0.2.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "vergen"
|
|
||||||
version = "9.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"derive_builder",
|
|
||||||
"rustversion",
|
|
||||||
"vergen-lib",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "vergen-gitcl"
|
|
||||||
version = "9.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "77ff3b5300a085d6bcd8fc96a507f706a28ae3814693236c9b409db71a1d15b9"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"derive_builder",
|
|
||||||
"rustversion",
|
|
||||||
"time",
|
|
||||||
"vergen",
|
|
||||||
"vergen-lib",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "vergen-lib"
|
|
||||||
version = "9.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"derive_builder",
|
|
||||||
"rustversion",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version_check"
|
name = "version_check"
|
||||||
version = "0.9.5"
|
version = "0.9.5"
|
||||||
@@ -5117,6 +5229,7 @@ dependencies = [
|
|||||||
"futures-lite",
|
"futures-lite",
|
||||||
"iroh",
|
"iroh",
|
||||||
"iroh-blobs",
|
"iroh-blobs",
|
||||||
|
"iroh-relay",
|
||||||
"irpc",
|
"irpc",
|
||||||
"irpc-iroh",
|
"irpc-iroh",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -5329,7 +5442,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.48.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
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.
|
||||||
|
|||||||
45
Makefile
45
Makefile
@@ -12,14 +12,14 @@ include $(ROOT)/make/release.mk
|
|||||||
.PHONY: format test check check-rust audit-rust test-rust test-rust-all
|
.PHONY: format test check check-rust audit-rust test-rust test-rust-all
|
||||||
.PHONY: test-rust-transfer test-rust-approval test-rust-lifecycle test-rust-output-sink
|
.PHONY: test-rust-transfer test-rust-approval test-rust-lifecycle test-rust-output-sink
|
||||||
.PHONY: check-shared test-shared test-android-host check-android verify-android-libs build-android run-desktop
|
.PHONY: check-shared test-shared test-android-host check-android verify-android-libs build-android run-desktop
|
||||||
.PHONY: apple-core apple-project open-apple-project open-apple build-apple-macos build-apple-ios check-apple
|
.PHONY: apple-core apple-version-config apple-app-config apple-project open-apple-project open-apple build-apple-macos build-apple-ios check-apple package-apple-core
|
||||||
.PHONY: check-localization localization localization-migrate
|
.PHONY: prepare-release check-version check-release check-localization localization localization-migrate
|
||||||
.PHONY: check-docs run-docs check-diagnostics run-diagnostics diagnostics-db-local diagnostics-db-remote diagnostics-typegen deploy-diagnostics
|
.PHONY: check-docs run-docs check-diagnostics run-diagnostics diagnostics-db-local diagnostics-db-remote diagnostics-typegen deploy-diagnostics
|
||||||
|
|
||||||
help: ## Show available commands and common configuration variables.
|
help: ## Show available commands and common configuration variables.
|
||||||
@grep -hE '^[A-Za-z0-9_.-]+:.*## ' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*## "} {printf " %-28s %s\n", $$1, $$2}'
|
@grep -hE '^[A-Za-z0-9_.-]+:.*## ' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*## "} {printf " %-28s %s\n", $$1, $$2}'
|
||||||
@printf '\nCommon variables:\n'
|
@printf '\nCommon variables:\n'
|
||||||
@printf ' %-28s %s\n' 'VERSION=x.y.z' 'Package version (default: $(VERSION))'
|
@printf ' %-28s %s\n' 'version.properties' 'Canonical application version ($(VERSION))'
|
||||||
@printf ' %-28s %s\n' 'APPLE_PROFILE=debug|release' 'Rust profile for the Apple XCFramework'
|
@printf ' %-28s %s\n' 'APPLE_PROFILE=debug|release' 'Rust profile for the Apple XCFramework'
|
||||||
@printf ' %-28s %s\n' 'APPLE_CONFIGURATION=...' 'Xcode configuration (default: $(APPLE_CONFIGURATION))'
|
@printf ' %-28s %s\n' 'APPLE_CONFIGURATION=...' 'Xcode configuration (default: $(APPLE_CONFIGURATION))'
|
||||||
@printf ' %-28s %s\n' 'APPLE_DESTINATION=...' 'Optional xcodebuild destination override'
|
@printf ' %-28s %s\n' 'APPLE_DESTINATION=...' 'Optional xcodebuild destination override'
|
||||||
@@ -59,7 +59,27 @@ format: ## Format Rust sources.
|
|||||||
|
|
||||||
test: test-rust test-shared ## Run the main Rust and shared JVM test suites.
|
test: test-rust test-shared ## Run the main Rust and shared JVM test suites.
|
||||||
|
|
||||||
check: check-rust check-shared check-localization check-docs check-diagnostics ## Run portable pre-PR verification.
|
check: check-version check-rust check-shared check-localization check-docs check-diagnostics ## Run portable pre-PR verification.
|
||||||
|
|
||||||
|
prepare-release: ## Update PRODUCT_VERSION and show its derived store versions (RELEASE_VERSION=x.y.z).
|
||||||
|
@test -n "$(RELEASE_VERSION)" || { printf 'Usage: make prepare-release RELEASE_VERSION=x.y.z\n' >&2; exit 1; }
|
||||||
|
cd $(ROOT) && packaging/version/prepare-release.sh "$(RELEASE_VERSION)"
|
||||||
|
|
||||||
|
check-version: ## Validate the canonical version and its platform mappings.
|
||||||
|
cd $(ROOT) && packaging/version/test-version.sh
|
||||||
|
cd $(ROOT) && packaging/version/resolve-version.sh verify
|
||||||
|
cd $(ROOT) && $(GRADLE) verifyVersion $(GRADLE_FLAGS)
|
||||||
|
|
||||||
|
check-release: ## Validate coordinated release scripts and workflow YAML.
|
||||||
|
cd $(ROOT) && bash -n apple/scripts/notarize.sh apple/scripts/sign-exported-app.sh apple/scripts/tests/test-notarize.sh apple/scripts/tests/test-sign-exported-app.sh apple/scripts/generate-appconfig.sh apple/scripts/tests/test-generate-appconfig.sh packaging/android/build-release.sh packaging/android/verify-apk-signature.sh packaging/android/tests/test_verify_apk_signature.sh packaging/release/assemble-release.sh packaging/release/test-assemble-release.sh packaging/release/test-release-config.sh
|
||||||
|
cd $(ROOT) && apple/scripts/tests/test-notarize.sh
|
||||||
|
cd $(ROOT) && apple/scripts/tests/test-generate-appconfig.sh
|
||||||
|
cd $(ROOT) && apple/scripts/tests/test-sign-exported-app.sh
|
||||||
|
cd $(ROOT) && packaging/android/tests/test_verify_apk_signature.sh
|
||||||
|
cd $(ROOT) && packaging/release/test-assemble-release.sh
|
||||||
|
cd $(ROOT) && packaging/release/test-release-config.sh
|
||||||
|
cd $(ROOT) && python3 -m unittest discover -s packaging/android/tests -v
|
||||||
|
cd $(ROOT) && ruby -e 'require "yaml"; ARGV.each { |file| YAML.load_file(file) }' .github/workflows/*.yml
|
||||||
|
|
||||||
check-rust: ## Run Rust formatting, lint, tests, and documentation checks.
|
check-rust: ## Run Rust formatting, lint, tests, and documentation checks.
|
||||||
cd $(ROOT) && $(CARGO) fmt --all -- --check
|
cd $(ROOT) && $(CARGO) fmt --all -- --check
|
||||||
@@ -113,7 +133,13 @@ apple-core: ## Build the Rust XCFramework and generated Swift bindings.
|
|||||||
@test "$(HOST_OS)" = macos || { printf 'Apple builds require macOS.\n' >&2; exit 1; }
|
@test "$(HOST_OS)" = macos || { printf 'Apple builds require macOS.\n' >&2; exit 1; }
|
||||||
cd $(ROOT) && apple/scripts/build-core.sh $(APPLE_PROFILE)
|
cd $(ROOT) && apple/scripts/build-core.sh $(APPLE_PROFILE)
|
||||||
|
|
||||||
apple-project: apple-core ## Generate the native Apple Xcode project.
|
apple-version-config: ## Generate derived Store and Direct Apple build settings.
|
||||||
|
cd $(ROOT) && packaging/version/generate-apple-xcconfig.sh all
|
||||||
|
|
||||||
|
apple-app-config: ## Generate AppConfig.swift from the shared app.properties.
|
||||||
|
cd $(ROOT) && apple/scripts/generate-appconfig.sh
|
||||||
|
|
||||||
|
apple-project: apple-core localization apple-version-config apple-app-config ## Generate the native Apple Xcode project.
|
||||||
cd $(ROOT)/apple && $(XCODEGEN) generate
|
cd $(ROOT)/apple && $(XCODEGEN) generate
|
||||||
|
|
||||||
open-apple-project: apple-project ## Generate and open the native Apple Xcode project.
|
open-apple-project: apple-project ## Generate and open the native Apple Xcode project.
|
||||||
@@ -122,6 +148,15 @@ 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: localization ## Build the signed/notarized direct-download .dmg (see apple/RELEASE-MACOS.md for required env).
|
||||||
|
cd $(ROOT) && apple/scripts/build-dmg.sh
|
||||||
|
|
||||||
|
package-apple-core: ## Zip the prebuilt core (xcframework + bindings) + checksum into apple/dist (build the core first).
|
||||||
|
cd $(ROOT) && apple/scripts/package-core.sh
|
||||||
|
|
||||||
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"
|
||||||
|
|||||||
47
README.md
47
README.md
@@ -50,6 +50,42 @@ mobile networks. If a direct path cannot be established, it can forward the
|
|||||||
same end-to-end encrypted connection through a relay. The relay forwards
|
same end-to-end encrypted connection through a relay. The relay forwards
|
||||||
encrypted packets; it is not a VniDrop file store.
|
encrypted packets; it is not a VniDrop file store.
|
||||||
|
|
||||||
|
### Custom relay servers
|
||||||
|
|
||||||
|
VniDrop uses Iroh's public relay and discovery infrastructure by default. In
|
||||||
|
**Settings → Network**, users can select one of four policies:
|
||||||
|
|
||||||
|
- **Automatic (recommended):** use Iroh's public relays, with direct P2P/LAN
|
||||||
|
connections whenever possible.
|
||||||
|
- **Strict custom:** use only up to eight configured custom HTTPS relays or
|
||||||
|
direct connections. Startup reports an error if none of the custom relays can
|
||||||
|
be established.
|
||||||
|
- **Custom with direct fallback:** prefer the configured custom relays, but
|
||||||
|
continue with direct connections if they are unavailable.
|
||||||
|
- **Local only:** disable every relay and allow direct connections only,
|
||||||
|
primarily for devices on the same network.
|
||||||
|
|
||||||
|
Strict custom, custom with direct fallback, and local only never use public
|
||||||
|
relays or public discovery, including relay addresses advertised by incoming
|
||||||
|
invitations.
|
||||||
|
|
||||||
|
Applying a relay change restarts VniDrop's network engine, so active transfers
|
||||||
|
and shares must be stopped first. The app tests the new configuration and
|
||||||
|
restores the previous one if it cannot connect. Invitations created for an old
|
||||||
|
relay configuration may need to be shared again; stopped shares never expose
|
||||||
|
their stale invitations. If a long relay profile makes an invitation too large
|
||||||
|
for a QR code, use the native share action or export the invitation file.
|
||||||
|
|
||||||
|
Relay credentials embedded in URLs are deliberately rejected and bearer-token
|
||||||
|
authentication is not currently supported. A self-hosted relay must either
|
||||||
|
accept the connecting endpoints or authorize their endpoint IDs independently;
|
||||||
|
the current device ID is shown in **Settings → Network** for this purpose.
|
||||||
|
Configure the same relay profile on participating devices. A custom relay needs
|
||||||
|
a TLS certificate issued by a publicly trusted WebPKI certificate authority;
|
||||||
|
private or enterprise CAs installed only in the operating system are not used
|
||||||
|
in this version. For resilient deployments, configure at least two relays in
|
||||||
|
different failure domains.
|
||||||
|
|
||||||
## Why Iroh and `iroh-blobs`?
|
## Why Iroh and `iroh-blobs`?
|
||||||
|
|
||||||
VniDrop combines a networking layer with its own sharing rules:
|
VniDrop combines a networking layer with its own sharing rules:
|
||||||
@@ -90,18 +126,19 @@ people, especially when using **Anyone with this transfer**.
|
|||||||
- Safe receive destinations that do not silently overwrite existing files
|
- Safe receive destinations that do not silently overwrite existing files
|
||||||
- Native SwiftUI apps on iOS, iPadOS, and macOS; Compose apps on Android,
|
- Native SwiftUI apps on iOS, iPadOS, and macOS; Compose apps on Android,
|
||||||
Windows, and Linux
|
Windows, and Linux
|
||||||
- Opt-in diagnostics with transfer contents, invitations, and file paths
|
- Strict custom HTTPS relay profiles with safe apply and rollback
|
||||||
excluded
|
- Optional user-submitted bug reports with transfer contents, invitations, and
|
||||||
|
file paths excluded
|
||||||
|
|
||||||
## Privacy by design
|
## Privacy by design
|
||||||
|
|
||||||
- **No hosted transfer copy.** VniDrop does not upload file contents to its
|
- **No hosted transfer copy.** VniDrop does not upload file contents to a bug-report
|
||||||
diagnostics service or a VniDrop storage bucket.
|
service or a VniDrop storage bucket.
|
||||||
- **Encrypted in transit.** Iroh connections are authenticated and encrypted
|
- **Encrypted in transit.** Iroh connections are authenticated and encrypted
|
||||||
end to end, including when a relay is needed.
|
end to end, including when a relay is needed.
|
||||||
- **Local control.** Transfer history and sharing state stay on the device.
|
- **Local control.** Transfer history and sharing state stay on the device.
|
||||||
- **Sensitive invitations.** An invitation can grant access, so it is
|
- **Sensitive invitations.** An invitation can grant access, so it is
|
||||||
deliberately excluded from product logs and diagnostics.
|
deliberately excluded from product logs and bug reports.
|
||||||
- **Explicit access.** Approval is required by default, and stopping a share
|
- **Explicit access.** Approval is required by default, and stopping a share
|
||||||
removes access immediately.
|
removes access immediately.
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ abstract class VerifyVnidropLibrariesTask : DefaultTask() {
|
|||||||
archive.getEntry(path)?.size?.takeIf { it > 0L } == null
|
archive.getEntry(path)?.size?.takeIf { it > 0L } == null
|
||||||
}
|
}
|
||||||
check(missing.isEmpty()) {
|
check(missing.isEmpty()) {
|
||||||
"Debug APK has missing or empty VniDrop libraries: ${missing.joinToString()}"
|
"APK has missing or empty VniDrop libraries: ${missing.joinToString()}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,6 +37,22 @@ plugins {
|
|||||||
alias(libs.plugins.composeCompiler)
|
alias(libs.plugins.composeCompiler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val appVersion = rootProject.extra["vnidrop.productVersion"] as String
|
||||||
|
val androidVersionCode = rootProject.extra["vnidrop.androidVersionCode"] as Int
|
||||||
|
val releaseKeystorePath = providers.environmentVariable("VNIDROP_ANDROID_KEYSTORE_PATH").orNull
|
||||||
|
val releaseKeystorePassword = providers.environmentVariable("VNIDROP_ANDROID_KEYSTORE_PASSWORD").orNull
|
||||||
|
val releaseKeyAlias = providers.environmentVariable("VNIDROP_ANDROID_KEY_ALIAS").orNull
|
||||||
|
val releaseKeyPassword = providers.environmentVariable("VNIDROP_ANDROID_KEY_PASSWORD").orNull
|
||||||
|
val releaseSigningValues = listOf(
|
||||||
|
releaseKeystorePath,
|
||||||
|
releaseKeystorePassword,
|
||||||
|
releaseKeyAlias,
|
||||||
|
releaseKeyPassword,
|
||||||
|
)
|
||||||
|
require(releaseSigningValues.all { it == null } || releaseSigningValues.all { it != null }) {
|
||||||
|
"Android release signing requires the keystore path, keystore password, key alias, and key password together"
|
||||||
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
jvmTarget = JvmTarget.JVM_11
|
jvmTarget = JvmTarget.JVM_11
|
||||||
@@ -55,12 +71,26 @@ android {
|
|||||||
namespace = "com.vnidrop.app"
|
namespace = "com.vnidrop.app"
|
||||||
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
if (releaseKeystorePath != null) {
|
||||||
|
create("release") {
|
||||||
|
val keystoreFile = rootProject.file(releaseKeystorePath)
|
||||||
|
.also { require(it.isFile) { "Android release keystore was not found" } }
|
||||||
|
.also { require(it.canRead()) { "Android release keystore is not readable" } }
|
||||||
|
storeFile = keystoreFile
|
||||||
|
storePassword = releaseKeystorePassword
|
||||||
|
keyAlias = releaseKeyAlias
|
||||||
|
keyPassword = releaseKeyPassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.vnidrop.app"
|
applicationId = "com.vnidrop.app"
|
||||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||||
versionCode = 1
|
versionCode = androidVersionCode
|
||||||
versionName = "1.0"
|
versionName = appVersion
|
||||||
}
|
}
|
||||||
packaging {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
@@ -76,6 +106,7 @@ android {
|
|||||||
buildTypes {
|
buildTypes {
|
||||||
getByName("release") {
|
getByName("release") {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
|
signingConfig = signingConfigs.findByName("release")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
@@ -87,6 +118,10 @@ android {
|
|||||||
jniLibs.srcDir(project(":shared").layout.buildDirectory.dir("intermediates/rust/aarch64-linux-android/debug"))
|
jniLibs.srcDir(project(":shared").layout.buildDirectory.dir("intermediates/rust/aarch64-linux-android/debug"))
|
||||||
jniLibs.srcDir(project(":shared").layout.buildDirectory.dir("intermediates/rust/x86_64-linux-android/debug"))
|
jniLibs.srcDir(project(":shared").layout.buildDirectory.dir("intermediates/rust/x86_64-linux-android/debug"))
|
||||||
}
|
}
|
||||||
|
getByName("release") {
|
||||||
|
jniLibs.srcDir(project(":shared").layout.buildDirectory.dir("intermediates/rust/aarch64-linux-android/release"))
|
||||||
|
jniLibs.srcDir(project(":shared").layout.buildDirectory.dir("intermediates/rust/x86_64-linux-android/release"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +132,12 @@ tasks.configureEach {
|
|||||||
":shared:copyAndroidAndroidX64Debug",
|
":shared:copyAndroidAndroidX64Debug",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (name == "mergeReleaseJniLibFolders" || name == "mergeReleaseNativeLibs") {
|
||||||
|
dependsOn(
|
||||||
|
":shared:copyAndroidAndroidArm64Release",
|
||||||
|
":shared:copyAndroidAndroidX64Release",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val verifyDebugVnidropLibraries = tasks.register<VerifyVnidropLibrariesTask>("verifyDebugVnidropLibraries") {
|
val verifyDebugVnidropLibraries = tasks.register<VerifyVnidropLibrariesTask>("verifyDebugVnidropLibraries") {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||||
@@ -38,7 +40,7 @@
|
|||||||
<data android:mimeType="application/vnd.vnidrop.transfer"/>
|
<data android:mimeType="application/vnd.vnidrop.transfer"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<!-- Fallback: .vnd files often arrive as octet-stream / unknown MIME. -->
|
<!-- Fallback: .vnd files often arrive as octet-stream / unknown MIME. -->
|
||||||
<intent-filter>
|
<intent-filter tools:ignore="AppLinkUrlError">
|
||||||
<action android:name="android.intent.action.VIEW"/>
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
<category android:name="android.intent.category.DEFAULT"/>
|
<category android:name="android.intent.category.DEFAULT"/>
|
||||||
<category android:name="android.intent.category.BROWSABLE"/>
|
<category android:name="android.intent.category.BROWSABLE"/>
|
||||||
|
|||||||
4
app.properties
Normal file
4
app.properties
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Public, app-wide configuration shared by every platform (Apple + KMP).
|
||||||
|
# Plain KEY=VALUE so it is parsed identically by shell, Gradle, and codegen.
|
||||||
|
# Injected into the apps at build time — never hardcode these values in app code.
|
||||||
|
PRIVACY_POLICY_URL=https://vnidrop.sudosy.fr/privacy/
|
||||||
8
apple/.gitignore
vendored
8
apple/.gitignore
vendored
@@ -3,6 +3,10 @@
|
|||||||
VnidropCore/vnidrop.xcframework/
|
VnidropCore/vnidrop.xcframework/
|
||||||
VnidropCore/Sources/VnidropCore/Vnidrop.swift
|
VnidropCore/Sources/VnidropCore/Vnidrop.swift
|
||||||
|
|
||||||
|
# Generated from localization/strings.json (cd localization && bun run src/cli.ts generate)
|
||||||
|
VniDrop/Resources/Localizable.xcstrings
|
||||||
|
VniDrop/Generated/
|
||||||
|
|
||||||
# Generated by XcodeGen from project.yml
|
# Generated by XcodeGen from project.yml
|
||||||
VniDrop.xcodeproj/
|
VniDrop.xcodeproj/
|
||||||
|
|
||||||
@@ -14,3 +18,7 @@ Local.xcconfig
|
|||||||
.swiftpm/
|
.swiftpm/
|
||||||
DerivedData/
|
DerivedData/
|
||||||
*.xcuserstate
|
*.xcuserstate
|
||||||
|
|
||||||
|
# Direct-download (.dmg) build outputs — apple/scripts/build-dmg.sh
|
||||||
|
.build-dmg/
|
||||||
|
dist/
|
||||||
|
|||||||
49
apple/.swiftlint.yml
Normal file
49
apple/.swiftlint.yml
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Focused lint for the native app: enforce the typed-resources convention only
|
||||||
|
# (no default style rules, so this stays signal, not noise).
|
||||||
|
only_rules:
|
||||||
|
- custom_rules
|
||||||
|
|
||||||
|
included:
|
||||||
|
- VniDrop
|
||||||
|
|
||||||
|
excluded:
|
||||||
|
- VniDrop/Generated
|
||||||
|
|
||||||
|
custom_rules:
|
||||||
|
raw_localized_string:
|
||||||
|
name: "Raw localized key"
|
||||||
|
regex: 'String\(localized:\s*"'
|
||||||
|
message: "Use a typed L10n.* accessor, not a raw key string."
|
||||||
|
severity: warning
|
||||||
|
raw_localized_string_key:
|
||||||
|
name: "Raw LocalizedStringKey"
|
||||||
|
regex: 'LocalizedStringKey\("'
|
||||||
|
message: "Use a typed L10n.* accessor instead of a raw key."
|
||||||
|
severity: warning
|
||||||
|
raw_sf_symbol:
|
||||||
|
name: "Raw SF Symbol"
|
||||||
|
regex: 'system(Name|Image):\s*"'
|
||||||
|
message: "Use SFSafeSymbols: Image(systemSymbol:) or systemSymbol:."
|
||||||
|
severity: warning
|
||||||
|
raw_swiftui_string_literal:
|
||||||
|
name: "Raw SwiftUI string"
|
||||||
|
# A non-empty string literal as the leading arg of a view initializer is an
|
||||||
|
# implicit LocalizedStringKey. Empty labels (e.g. Picker("", …)) are allowed.
|
||||||
|
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."
|
||||||
|
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,36 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`make apple-project` also generates ignored Store and Direct version xcconfig
|
||||||
|
files. Their `CURRENT_PROJECT_VERSION` values come from the central version
|
||||||
|
resolver as UTC `YYYYMMDD.HHMM.SS` build identifiers. Regenerate the project
|
||||||
|
before creating another App Store archive so it receives a fresh build number;
|
||||||
|
direct DMG builds refresh their own value automatically.
|
||||||
|
|
||||||
|
### 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 # 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
|
||||||
@@ -90,7 +114,7 @@ The Rust core (iroh network stack) links `SystemConfiguration`, `Security`, and
|
|||||||
Screens mirror the Compose UI in `shared/`. Two deliberate simplifications:
|
Screens mirror the Compose UI in `shared/`. Two deliberate simplifications:
|
||||||
- Empty-state Lottie animations are rendered as SF Symbols (no `lottie-ios`
|
- Empty-state Lottie animations are rendered as SF Symbols (no `lottie-ios`
|
||||||
dependency); swap in `lottie-ios` if exact-parity animation is required.
|
dependency); swap in `lottie-ios` if exact-parity animation is required.
|
||||||
- The full diagnostics/telemetry stack (`diagnostics/*`) is stubbed behind
|
- Bug reporting is stubbed behind `BugReportService` (`NoopBugReportService`) and
|
||||||
`BugReportService` / `DiagnosticsBuildConfig` and lands in a later phase; the UI
|
a real transport lands in a later phase. There is no telemetry or crash
|
||||||
hides the diagnostics toggle when not compiled in.
|
auto-reporting.
|
||||||
```
|
```
|
||||||
|
|||||||
39
apple/Tests/AppConfigTests.swift
Normal file
39
apple/Tests/AppConfigTests.swift
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import VniDrop
|
||||||
|
|
||||||
|
/// Verifies the build-time `AppConfig` (generated from the shared `app.properties`)
|
||||||
|
/// exposes the expected, well-formed values to the app.
|
||||||
|
final class AppConfigTests: XCTestCase {
|
||||||
|
func testPrivacyPolicyURLIsTheExpectedHTTPSEndpoint() {
|
||||||
|
let url = AppConfig.privacyPolicyURL
|
||||||
|
XCTAssertEqual(url.scheme, "https", "Privacy policy URL must be https")
|
||||||
|
XCTAssertEqual(url.absoluteString, "https://vnidrop.sudosy.fr/privacy/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPrivacyPolicyURLMatchesTheSharedConfigFile() throws {
|
||||||
|
// Cross-check the generated constant against the single source of truth so a
|
||||||
|
// broken generator (or drift) is caught, not just a hardcoded copy.
|
||||||
|
let expected = try Self.privacyURLFromAppProperties()
|
||||||
|
XCTAssertEqual(AppConfig.privacyPolicyURL.absoluteString, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads `PRIVACY_POLICY_URL` from the repo's `app.properties` by walking up
|
||||||
|
/// from this source file's location to the repository root.
|
||||||
|
private static func privacyURLFromAppProperties() throws -> String {
|
||||||
|
var dir = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
|
||||||
|
for _ in 0..<8 {
|
||||||
|
let candidate = dir.appendingPathComponent("app.properties")
|
||||||
|
if FileManager.default.fileExists(atPath: candidate.path) {
|
||||||
|
let contents = try String(contentsOf: candidate, encoding: .utf8)
|
||||||
|
for line in contents.split(whereSeparator: \.isNewline) {
|
||||||
|
if line.hasPrefix("PRIVACY_POLICY_URL=") {
|
||||||
|
return String(line.dropFirst("PRIVACY_POLICY_URL=".count))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw XCTSkip("PRIVACY_POLICY_URL missing in \(candidate.path)")
|
||||||
|
}
|
||||||
|
dir.deleteLastPathComponent()
|
||||||
|
}
|
||||||
|
throw XCTSkip("app.properties not found from \(#filePath)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,18 @@ final class AppModelTests: XCTestCase {
|
|||||||
_ = makeModel(core, preferences: Fixtures.preferences())
|
_ = makeModel(core, preferences: Fixtures.preferences())
|
||||||
await waitUntil { core.state.isInitialized }
|
await waitUntil { core.state.isInitialized }
|
||||||
XCTAssertTrue(core.state.isInitialized)
|
XCTAssertTrue(core.state.isInitialized)
|
||||||
|
XCTAssertEqual(core.initializedNetworkConfigurations, [.automatic])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInitializesCoreWithSavedCustomRelayConfiguration() async {
|
||||||
|
let core = FakeCoreGateway()
|
||||||
|
let preferences = Fixtures.preferences()
|
||||||
|
let configuration = RelayConfiguration(mode: .strictCustom, relayURLs: ["https://relay.example"])
|
||||||
|
preferences.setRelayConfiguration(configuration)
|
||||||
|
_ = makeModel(core, preferences: preferences)
|
||||||
|
|
||||||
|
await waitUntil { core.state.isInitialized }
|
||||||
|
XCTAssertEqual(core.initializedNetworkConfigurations, [configuration])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSelectDestination() {
|
func testSelectDestination() {
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ final class AppPreferencesRepositoryTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testFallbacksWhenEmpty() {
|
func testMissingRelayProfileDefaultsToAutomatic() {
|
||||||
let repo = AppPreferencesRepository(defaults: defaults(), fallback: fallback())
|
let repo = AppPreferencesRepository(defaults: defaults(), fallback: fallback())
|
||||||
XCTAssertEqual(repo.preferences.username, "Default")
|
XCTAssertEqual(repo.preferences.username, "Default")
|
||||||
XCTAssertEqual(repo.preferences.themeMode, .system)
|
XCTAssertEqual(repo.preferences.themeMode, .system)
|
||||||
XCTAssertFalse(repo.preferences.notificationsEnabled)
|
XCTAssertEqual(repo.preferences.relayConfiguration, .automatic)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testValuesPersistAndReload() {
|
func testValuesPersistAndReload() {
|
||||||
@@ -28,16 +28,52 @@ final class AppPreferencesRepositoryTests: XCTestCase {
|
|||||||
let repo = AppPreferencesRepository(defaults: store, fallback: fb)
|
let repo = AppPreferencesRepository(defaults: store, fallback: fb)
|
||||||
repo.setUsername("Bob")
|
repo.setUsername("Bob")
|
||||||
repo.setThemeMode(.dark)
|
repo.setThemeMode(.dark)
|
||||||
repo.setNotificationsEnabled(true)
|
|
||||||
repo.setReceiveFolder(ReceiveFolder(kind: .iosSecurityScopedUrl, value: "file:///x", displayName: "Custom"))
|
repo.setReceiveFolder(ReceiveFolder(kind: .iosSecurityScopedUrl, value: "file:///x", displayName: "Custom"))
|
||||||
|
repo.setRelayConfiguration(RelayConfiguration(
|
||||||
|
mode: .strictCustom,
|
||||||
|
relayURLs: ["https://relay-one.example", "https://relay-two.example:443"]
|
||||||
|
))
|
||||||
|
|
||||||
// A fresh repository over the same store reflects the persisted values.
|
// A fresh repository over the same store reflects the persisted values.
|
||||||
let reloaded = AppPreferencesRepository(defaults: store, fallback: fb)
|
let reloaded = AppPreferencesRepository(defaults: store, fallback: fb)
|
||||||
XCTAssertEqual(reloaded.preferences.username, "Bob")
|
XCTAssertEqual(reloaded.preferences.username, "Bob")
|
||||||
XCTAssertEqual(reloaded.preferences.themeMode, .dark)
|
XCTAssertEqual(reloaded.preferences.themeMode, .dark)
|
||||||
XCTAssertTrue(reloaded.preferences.notificationsEnabled)
|
|
||||||
XCTAssertEqual(reloaded.preferences.receiveFolder.displayName, "Custom")
|
XCTAssertEqual(reloaded.preferences.receiveFolder.displayName, "Custom")
|
||||||
XCTAssertEqual(reloaded.preferences.receiveFolder.kind, .iosSecurityScopedUrl)
|
XCTAssertEqual(reloaded.preferences.receiveFolder.kind, .iosSecurityScopedUrl)
|
||||||
|
XCTAssertEqual(reloaded.preferences.relayConfiguration, RelayConfiguration(
|
||||||
|
mode: .strictCustom,
|
||||||
|
relayURLs: ["https://relay-one.example", "https://relay-two.example:443"]
|
||||||
|
))
|
||||||
|
XCTAssertNotNil(store.data(forKey: "relay_configuration"))
|
||||||
|
XCTAssertNil(store.object(forKey: "relay_mode"))
|
||||||
|
XCTAssertNil(store.object(forKey: "relay_urls"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCorruptedRelayProfileFailsClosed() {
|
||||||
|
let store = defaults()
|
||||||
|
store.set(Data("{".utf8), forKey: "relay_configuration")
|
||||||
|
|
||||||
|
let repo = AppPreferencesRepository(defaults: store, fallback: fallback())
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
repo.preferences.relayConfiguration,
|
||||||
|
RelayConfiguration(mode: .strictCustom, relayURLs: [])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnknownRelayModeFailsClosed() {
|
||||||
|
let store = defaults()
|
||||||
|
store.set(
|
||||||
|
Data(#"{"mode":"future-mode","relayURLs":["https://relay.example"]}"#.utf8),
|
||||||
|
forKey: "relay_configuration"
|
||||||
|
)
|
||||||
|
|
||||||
|
let repo = AppPreferencesRepository(defaults: store, fallback: fallback())
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
repo.preferences.relayConfiguration,
|
||||||
|
RelayConfiguration(mode: .strictCustom, relayURLs: [])
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testResetReceiveFolderRestoresFallback() {
|
func testResetReceiveFolderRestoresFallback() {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ final class ApprovalCoordinatorTests: XCTestCase {
|
|||||||
private func makeCoordinator(_ core: FakeCoreGateway) -> ApprovalCoordinator {
|
private func makeCoordinator(_ core: FakeCoreGateway) -> ApprovalCoordinator {
|
||||||
ApprovalCoordinator(
|
ApprovalCoordinator(
|
||||||
repository: core,
|
repository: core,
|
||||||
preferences: Fixtures.preferences(),
|
|
||||||
notifications: LocalNotificationService(),
|
notifications: LocalNotificationService(),
|
||||||
visibility: AppVisibility(),
|
visibility: AppVisibility(),
|
||||||
messages: UiMessageController()
|
messages: UiMessageController()
|
||||||
|
|||||||
50
apple/Tests/CoreDispatcherTests.swift
Normal file
50
apple/Tests/CoreDispatcherTests.swift
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import VniDrop
|
||||||
|
|
||||||
|
final class CoreDispatcherTests: XCTestCase {
|
||||||
|
|
||||||
|
/// Regression guard for the receive-cancel deadlock: an interrupt-lane call
|
||||||
|
/// must complete even while the serial lane is occupied by a blocking call.
|
||||||
|
/// With a single shared queue (the old design) the interrupt would be stuck
|
||||||
|
/// behind the blocked `receive`, and this would time out.
|
||||||
|
func testInterruptCompletesWhileSerialLaneIsBlocked() async {
|
||||||
|
let dispatcher = CoreDispatcher()
|
||||||
|
let serialEntered = DispatchSemaphore(value: 0)
|
||||||
|
let releaseSerial = DispatchSemaphore(value: 0)
|
||||||
|
|
||||||
|
// Occupy the serial lane with a call that blocks until we release it.
|
||||||
|
let serialTask = Task {
|
||||||
|
await dispatcher.run {
|
||||||
|
serialEntered.signal()
|
||||||
|
releaseSerial.wait()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
XCTAssertEqual(serialEntered.wait(timeout: .now() + 2), .success, "serial lane never started")
|
||||||
|
|
||||||
|
// The interrupt lane must run despite the serial lane being blocked.
|
||||||
|
let interruptDone = DispatchSemaphore(value: 0)
|
||||||
|
Task.detached {
|
||||||
|
_ = await dispatcher.runInterrupt { 42 }
|
||||||
|
interruptDone.signal()
|
||||||
|
}
|
||||||
|
XCTAssertEqual(
|
||||||
|
interruptDone.wait(timeout: .now() + 2), .success,
|
||||||
|
"interrupt lane was blocked behind the occupied serial lane")
|
||||||
|
|
||||||
|
releaseSerial.signal()
|
||||||
|
_ = await serialTask.value
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRunPropagatesValuesAndErrors() async {
|
||||||
|
let dispatcher = CoreDispatcher()
|
||||||
|
|
||||||
|
let value = await dispatcher.run { 7 }
|
||||||
|
XCTAssertEqual(try? value.get(), 7)
|
||||||
|
|
||||||
|
let failure = await dispatcher.run { () -> Int in throw TestError.unimplemented }
|
||||||
|
switch failure {
|
||||||
|
case .success: XCTFail("expected the thrown error to propagate")
|
||||||
|
case .failure(let error): XCTAssertTrue(error is TestError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
131
apple/Tests/CoreRepositoryLifecycleTests.swift
Normal file
131
apple/Tests/CoreRepositoryLifecycleTests.swift
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@preconcurrency import VnidropCore
|
||||||
|
@testable import VniDrop
|
||||||
|
|
||||||
|
private enum BlockingCoreFactoryError: Error {
|
||||||
|
case stopped
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class BlockingCoreBindingFactory: CoreBindingFactory, @unchecked Sendable {
|
||||||
|
private let release = DispatchSemaphore(value: 0)
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var initializeCallCount = 0
|
||||||
|
private var initializationStarted = false
|
||||||
|
private var startWaiters: [CheckedContinuation<Void, Never>] = []
|
||||||
|
|
||||||
|
var callCount: Int {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return initializeCallCount
|
||||||
|
}
|
||||||
|
|
||||||
|
func initialize(
|
||||||
|
appDataDir: String,
|
||||||
|
eventSink: CoreEventSink,
|
||||||
|
networkConfiguration: RelayConfiguration
|
||||||
|
) throws -> VnidropCore {
|
||||||
|
lock.lock()
|
||||||
|
initializeCallCount += 1
|
||||||
|
let call = initializeCallCount
|
||||||
|
initializationStarted = true
|
||||||
|
let waiters = startWaiters
|
||||||
|
startWaiters.removeAll()
|
||||||
|
lock.unlock()
|
||||||
|
waiters.forEach { $0.resume() }
|
||||||
|
|
||||||
|
if call == 1 {
|
||||||
|
release.wait()
|
||||||
|
}
|
||||||
|
throw BlockingCoreFactoryError.stopped
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitUntilInitializationStarts() async {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
lock.lock()
|
||||||
|
if initializationStarted {
|
||||||
|
lock.unlock()
|
||||||
|
continuation.resume()
|
||||||
|
} else {
|
||||||
|
startWaiters.append(continuation)
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func unblockInitialization() {
|
||||||
|
release.signal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class CoreRepositoryLifecycleTests: XCTestCase {
|
||||||
|
func testIdleRequirementRejectsTransfersAndShares() throws {
|
||||||
|
XCTAssertNoThrow(try CoreNetworkLifecycle.requireIdle(activeTransfers: 0, activeShares: 0))
|
||||||
|
XCTAssertThrowsError(
|
||||||
|
try CoreNetworkLifecycle.requireIdle(activeTransfers: 1, activeShares: 0)
|
||||||
|
) { error in
|
||||||
|
XCTAssertEqual(error as? CoreNetworkLifecycleError, .activeNetworkWork)
|
||||||
|
}
|
||||||
|
XCTAssertThrowsError(
|
||||||
|
try CoreNetworkLifecycle.requireIdle(activeTransfers: 0, activeShares: 1)
|
||||||
|
) { error in
|
||||||
|
XCTAssertEqual(error as? CoreNetworkLifecycleError, .activeNetworkWork)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRestartSerializesInitializationAndRejectsNewNetworkWork() async {
|
||||||
|
let factory = BlockingCoreBindingFactory()
|
||||||
|
let repository = CoreRepository(coreFactory: factory)
|
||||||
|
let firstInitialization = Task {
|
||||||
|
await repository.initialize(appDataDir: "/tmp/first", networkConfiguration: .automatic)
|
||||||
|
}
|
||||||
|
await factory.waitUntilInitializationStarts()
|
||||||
|
|
||||||
|
let safetyRelease = Task.detached {
|
||||||
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
factory.unblockInitialization()
|
||||||
|
}
|
||||||
|
defer {
|
||||||
|
safetyRelease.cancel()
|
||||||
|
factory.unblockInitialization()
|
||||||
|
}
|
||||||
|
|
||||||
|
let concurrentInitialization = await repository.initialize(
|
||||||
|
appDataDir: "/tmp/second",
|
||||||
|
networkConfiguration: RelayConfiguration(mode: .strictCustom, relayURLs: ["https://relay.example"])
|
||||||
|
)
|
||||||
|
assertLifecycleFailure(concurrentInitialization, equals: .transitionInProgress)
|
||||||
|
|
||||||
|
let share = await repository.shareSources(
|
||||||
|
[],
|
||||||
|
transferName: "Blocked",
|
||||||
|
senderName: "Tester",
|
||||||
|
accessPolicy: .requireApproval
|
||||||
|
)
|
||||||
|
assertLifecycleFailure(share, equals: .transitionInProgress)
|
||||||
|
|
||||||
|
let receive = await repository.receive(ticket: "ticket", outputDir: "/tmp", receiverName: "Tester")
|
||||||
|
assertLifecycleFailure(receive, equals: .transitionInProgress)
|
||||||
|
XCTAssertEqual(factory.callCount, 1)
|
||||||
|
|
||||||
|
factory.unblockInitialization()
|
||||||
|
guard case .failure(let error) = await firstInitialization.value else {
|
||||||
|
return XCTFail("The blocking factory should fail the first initialization")
|
||||||
|
}
|
||||||
|
XCTAssertTrue(error is BlockingCoreFactoryError)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func assertLifecycleFailure<T>(
|
||||||
|
_ result: Result<T, Error>,
|
||||||
|
equals expected: CoreNetworkLifecycleError,
|
||||||
|
file: StaticString = #filePath,
|
||||||
|
line: UInt = #line
|
||||||
|
) {
|
||||||
|
guard case .failure(let error) = result else {
|
||||||
|
return XCTFail("Expected lifecycle failure \(expected)", file: file, line: line)
|
||||||
|
}
|
||||||
|
XCTAssertEqual(error as? CoreNetworkLifecycleError, expected, file: file, line: line)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,8 @@ final class FakeCoreGateway: CoreGateway {
|
|||||||
var cancelResult: Result<Void, Error> = .success(())
|
var cancelResult: Result<Void, Error> = .success(())
|
||||||
var deleteResult: Result<Void, Error> = .success(())
|
var deleteResult: Result<Void, Error> = .success(())
|
||||||
var clearReceiveHistoryResult: Result<UInt64, Error> = .success(0)
|
var clearReceiveHistoryResult: Result<UInt64, Error> = .success(0)
|
||||||
|
var initializeResult: Result<Void, Error> = .success(())
|
||||||
|
var initializeResults: [Result<Void, Error>] = []
|
||||||
|
|
||||||
// Recorded calls
|
// Recorded calls
|
||||||
private(set) var responses: [(id: String, accepted: Bool, reason: String?)] = []
|
private(set) var responses: [(id: String, accepted: Bool, reason: String?)] = []
|
||||||
@@ -35,11 +37,18 @@ final class FakeCoreGateway: CoreGateway {
|
|||||||
private(set) var lastReceiveTicket: String?
|
private(set) var lastReceiveTicket: String?
|
||||||
private(set) var lastReceiveReceiverName: String?
|
private(set) var lastReceiveReceiverName: String?
|
||||||
private(set) var lastShareAccessPolicy: ShareAccessPolicy?
|
private(set) var lastShareAccessPolicy: ShareAccessPolicy?
|
||||||
|
private(set) var initializedNetworkConfigurations: [RelayConfiguration] = []
|
||||||
|
|
||||||
func setState(_ state: CoreState) { stateSubject.send(state) }
|
func setState(_ state: CoreState) { stateSubject.send(state) }
|
||||||
func emit(_ signal: CoreSignal) { signalsSubject.send(signal) }
|
func emit(_ signal: CoreSignal) { signalsSubject.send(signal) }
|
||||||
|
|
||||||
func initialize(appDataDir: String) async -> Result<Void, Error> {
|
func initialize(
|
||||||
|
appDataDir: String,
|
||||||
|
networkConfiguration: RelayConfiguration
|
||||||
|
) async -> Result<Void, Error> {
|
||||||
|
initializedNetworkConfigurations.append(networkConfiguration)
|
||||||
|
let result = initializeResults.isEmpty ? initializeResult : initializeResults.removeFirst()
|
||||||
|
guard case .success = result else { return result }
|
||||||
var s = stateSubject.value
|
var s = stateSubject.value
|
||||||
s.isInitialized = true
|
s.isInitialized = true
|
||||||
stateSubject.send(s)
|
stateSubject.send(s)
|
||||||
@@ -62,6 +71,10 @@ final class FakeCoreGateway: CoreGateway {
|
|||||||
func cancel(transferId: UInt64) async -> Result<Void, Error> { cancelledTransfers.append(transferId); return cancelResult }
|
func cancel(transferId: UInt64) async -> Result<Void, Error> { cancelledTransfers.append(transferId); return cancelResult }
|
||||||
func delete(transferId: UInt64) async -> Result<Void, Error> { deletedTransfers.append(transferId); return deleteResult }
|
func delete(transferId: UInt64) async -> Result<Void, Error> { deletedTransfers.append(transferId); return deleteResult }
|
||||||
func clearReceiveHistory() async -> Result<UInt64, Error> { clearReceiveHistoryCount += 1; return clearReceiveHistoryResult }
|
func clearReceiveHistory() async -> Result<UInt64, Error> { clearReceiveHistoryCount += 1; return clearReceiveHistoryResult }
|
||||||
|
func storageUsage() async -> Result<CoreStorageUsageModel, Error> {
|
||||||
|
.success(CoreStorageUsageModel(blobStoreBytes: 0, appDataBytes: 0))
|
||||||
|
}
|
||||||
|
func receivedArtifacts() async -> Result<[ReceivedArtifactModel], Error> { .success([]) }
|
||||||
func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error> { .success(requests[transferId] ?? []) }
|
func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error> { .success(requests[transferId] ?? []) }
|
||||||
func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result<Void, Error> {
|
func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result<Void, Error> {
|
||||||
responses.append((requestId, accepted, reason))
|
responses.append((requestId, accepted, reason))
|
||||||
|
|||||||
@@ -40,14 +40,32 @@ final class ProgressDerivationTests: XCTestCase {
|
|||||||
event(phase: "import", kind: "started", json: "{}"),
|
event(phase: "import", kind: "started", json: "{}"),
|
||||||
]
|
]
|
||||||
let progress = progressForTransfer(events: events, transferId: 1)
|
let progress = progressForTransfer(events: events, transferId: 1)
|
||||||
XCTAssertEqual(progress?.labelKey, "progress_preparing")
|
XCTAssertEqual(progress?.labelKey, L10n.Progress.preparing)
|
||||||
XCTAssertEqual(progress?.progress, 0.3)
|
XCTAssertEqual(progress?.progress, 0.3)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testReceiverCompletionAfterProgressIsTerminal() {
|
||||||
|
let events = [
|
||||||
|
receiverEvent(kind: "completed", json: "{\"connection_id\":1,\"request_id\":1,\"endpoint_id\":\"peer-a\"}"),
|
||||||
|
receiverEvent(kind: "progress", json: "{\"connection_id\":1,\"request_id\":1,\"endpoint_id\":\"peer-a\",\"end_offset\":100}"),
|
||||||
|
receiverEvent(kind: "started", json: "{\"connection_id\":1,\"request_id\":1,\"endpoint_id\":\"peer-a\",\"size\":100}"),
|
||||||
|
]
|
||||||
|
|
||||||
|
let progress = progressForReceiver(
|
||||||
|
events: events,
|
||||||
|
transferId: 1,
|
||||||
|
remoteEndpointId: "peer-a",
|
||||||
|
totalSizeHint: 100
|
||||||
|
)
|
||||||
|
XCTAssertEqual(progress?.kind, .completed)
|
||||||
|
XCTAssertEqual(progress?.labelKey, L10n.Progress.completed)
|
||||||
|
XCTAssertEqual(progress?.progress, 1)
|
||||||
|
}
|
||||||
|
|
||||||
func testStatusLabelKeys() {
|
func testStatusLabelKeys() {
|
||||||
XCTAssertEqual(statusLabelKey(.sharing), "status_available")
|
XCTAssertEqual(statusLabelKey(.sharing), L10n.Status.available)
|
||||||
XCTAssertEqual(statusLabelKey(.receiving), "status_receiving")
|
XCTAssertEqual(statusLabelKey(.receiving), L10n.Status.receiving)
|
||||||
XCTAssertEqual(statusLabelKey(.done), "status_completed")
|
XCTAssertEqual(statusLabelKey(.done), L10n.Status.completed)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func event(phase: String, kind: String, json: String) -> CoreEventModel {
|
private func event(phase: String, kind: String, json: String) -> CoreEventModel {
|
||||||
@@ -56,4 +74,8 @@ final class ProgressDerivationTests: XCTestCase {
|
|||||||
direction: "send", phase: phase, kind: kind, dataJson: json
|
direction: "send", phase: phase, kind: kind, dataJson: json
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func receiverEvent(kind: String, json: String) -> CoreEventModel {
|
||||||
|
event(phase: "transfer", kind: kind, json: json)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ final class ReceiveModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(model.state.historyDeleteTarget, .transfer(transferId: 5))
|
XCTAssertEqual(model.state.historyDeleteTarget, .transfer(transferId: 5))
|
||||||
|
|
||||||
model.confirmHistoryDelete()
|
model.confirmHistoryDelete()
|
||||||
|
// Must close immediately (not after the async delete) so the alert can't
|
||||||
|
// re-present on macOS.
|
||||||
|
XCTAssertNil(model.state.historyDeleteTarget)
|
||||||
await waitUntil { core.deletedTransfers.contains(5) }
|
await waitUntil { core.deletedTransfers.contains(5) }
|
||||||
XCTAssertEqual(core.deletedTransfers, [5])
|
XCTAssertEqual(core.deletedTransfers, [5])
|
||||||
XCTAssertNil(model.state.historyDeleteTarget)
|
XCTAssertNil(model.state.historyDeleteTarget)
|
||||||
|
|||||||
121
apple/Tests/RelayConfigurationTests.swift
Normal file
121
apple/Tests/RelayConfigurationTests.swift
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import VniDrop
|
||||||
|
|
||||||
|
final class RelayConfigurationTests: XCTestCase {
|
||||||
|
func testCustomFallbackValidatesAndPreservesItsMode() throws {
|
||||||
|
let result = try RelayConfigurationValidator.validate(
|
||||||
|
mode: .customWithDirectFallback,
|
||||||
|
relayURLs: ["https://relay.example/"]
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
result,
|
||||||
|
RelayConfiguration(
|
||||||
|
mode: .customWithDirectFallback,
|
||||||
|
relayURLs: ["https://relay.example"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLocalOnlyRetainsPreviouslySavedRelayURLs() throws {
|
||||||
|
let retained = ["https://relay.example"]
|
||||||
|
let result = try RelayConfigurationValidator.validate(
|
||||||
|
mode: .localOnly,
|
||||||
|
relayURLs: ["not a URL"],
|
||||||
|
retainedRelayURLs: retained
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(result, RelayConfiguration(mode: .localOnly, relayURLs: retained))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAutomaticModeIgnoresRelayDrafts() throws {
|
||||||
|
let result = try RelayConfigurationValidator.validate(
|
||||||
|
mode: .automatic,
|
||||||
|
relayURLs: ["not a URL"]
|
||||||
|
)
|
||||||
|
XCTAssertEqual(result, .automatic)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAutomaticModeRetainsPreviouslySavedRelayURLs() throws {
|
||||||
|
let result = try RelayConfigurationValidator.validate(
|
||||||
|
mode: .automatic,
|
||||||
|
relayURLs: ["not a URL"],
|
||||||
|
retainedRelayURLs: ["https://relay.example"]
|
||||||
|
)
|
||||||
|
XCTAssertEqual(result, RelayConfiguration(
|
||||||
|
mode: .automatic,
|
||||||
|
relayURLs: ["https://relay.example"]
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCustomModeTrimsValidHTTPSRelayURLs() throws {
|
||||||
|
let result = try RelayConfigurationValidator.validate(
|
||||||
|
mode: .strictCustom,
|
||||||
|
relayURLs: [" https://relay.example/ ", "https://backup.example:443"]
|
||||||
|
)
|
||||||
|
XCTAssertEqual(result, RelayConfiguration(
|
||||||
|
mode: .strictCustom,
|
||||||
|
relayURLs: ["https://relay.example", "https://backup.example"]
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCustomModeIgnoresEmptyURLRows() throws {
|
||||||
|
let result = try RelayConfigurationValidator.validate(
|
||||||
|
mode: .strictCustom,
|
||||||
|
relayURLs: ["", " ", "https://relay.example"]
|
||||||
|
)
|
||||||
|
XCTAssertEqual(result.relayURLs, ["https://relay.example"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCustomModeRequiresAtLeastOneRelay() {
|
||||||
|
XCTAssertThrowsError(try RelayConfigurationValidator.validate(mode: .strictCustom, relayURLs: [])) { error in
|
||||||
|
XCTAssertEqual(error as? RelayConfigurationValidationError, .missingURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCustomModeRequiresHTTPS() {
|
||||||
|
XCTAssertThrowsError(try RelayConfigurationValidator.validate(
|
||||||
|
mode: .strictCustom,
|
||||||
|
relayURLs: ["http://relay.example"]
|
||||||
|
)) { error in
|
||||||
|
XCTAssertEqual(error as? RelayConfigurationValidationError, .httpsRequired(index: 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCustomModeRejectsCredentialsQueryFragmentAndPath() {
|
||||||
|
let invalidURLs = [
|
||||||
|
"https://user:password@relay.example",
|
||||||
|
"https://relay.example?token=secret",
|
||||||
|
"https://relay.example#fragment",
|
||||||
|
"https://relay.example/custom/path",
|
||||||
|
"https://relay.example:0",
|
||||||
|
"https://relay.example:99999",
|
||||||
|
]
|
||||||
|
for relayURL in invalidURLs {
|
||||||
|
XCTAssertThrowsError(
|
||||||
|
try RelayConfigurationValidator.validate(mode: .strictCustom, relayURLs: [relayURL]),
|
||||||
|
"Expected \(relayURL) to be rejected"
|
||||||
|
) { error in
|
||||||
|
XCTAssertEqual(error as? RelayConfigurationValidationError, .invalidURL(index: 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCustomModeRejectsNormalizedDuplicate() {
|
||||||
|
XCTAssertThrowsError(try RelayConfigurationValidator.validate(
|
||||||
|
mode: .strictCustom,
|
||||||
|
relayURLs: ["https://relay.example", "https://RELAY.example:443/"]
|
||||||
|
)) { error in
|
||||||
|
XCTAssertEqual(error as? RelayConfigurationValidationError, .duplicateURL(index: 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCustomModeRejectsMoreThanEightRelays() {
|
||||||
|
let relayURLs = (0...RelayConfigurationValidator.maximumRelayCount).map {
|
||||||
|
"https://relay-\($0).example"
|
||||||
|
}
|
||||||
|
XCTAssertThrowsError(try RelayConfigurationValidator.validate(mode: .strictCustom, relayURLs: relayURLs)) { error in
|
||||||
|
XCTAssertEqual(error as? RelayConfigurationValidationError, .tooManyURLs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,9 @@ final class SendModelTests: XCTestCase {
|
|||||||
XCTAssertTrue(model.state.isDeleteConfirmationOpen)
|
XCTAssertTrue(model.state.isDeleteConfirmationOpen)
|
||||||
|
|
||||||
model.confirmDeleteTransfer()
|
model.confirmDeleteTransfer()
|
||||||
|
// Must close immediately (not after the async delete) so the alert can't
|
||||||
|
// re-present on macOS.
|
||||||
|
XCTAssertFalse(model.state.isDeleteConfirmationOpen)
|
||||||
await waitUntil { core.deletedTransfers.contains(3) }
|
await waitUntil { core.deletedTransfers.contains(3) }
|
||||||
XCTAssertEqual(core.deletedTransfers, [3])
|
XCTAssertEqual(core.deletedTransfers, [3])
|
||||||
XCTAssertNil(model.state.selectedTransferId)
|
XCTAssertNil(model.state.selectedTransferId)
|
||||||
@@ -55,4 +58,25 @@ final class SendModelTests: XCTestCase {
|
|||||||
let response = core.responses.first { $0.id == "req-1" }
|
let response = core.responses.first { $0.id == "req-1" }
|
||||||
XCTAssertEqual(response?.accepted, false)
|
XCTAssertEqual(response?.accepted, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testOnlyActiveShareExposesStoredInvitationTicket() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
Fixtures.transfer(id: 1, direction: .send, status: .sharing).invitationPresentation,
|
||||||
|
.ready("ticket")
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
Fixtures.transfer(id: 2, direction: .send, status: .importing).invitationPresentation,
|
||||||
|
.preparing
|
||||||
|
)
|
||||||
|
for status in [TransferStatus.stopped, .failed, .cancelled, .done] {
|
||||||
|
XCTAssertEqual(
|
||||||
|
Fixtures.transfer(id: 3, direction: .send, status: status).invitationPresentation,
|
||||||
|
.unavailable
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOversizedInvitationReportsQRCodeUnavailable() {
|
||||||
|
XCTAssertNil(QRCode.generate(from: String(repeating: "x", count: 10_000)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ final class SettingsModelTests: XCTestCase {
|
|||||||
preferences: preferences,
|
preferences: preferences,
|
||||||
notifications: LocalNotificationService(),
|
notifications: LocalNotificationService(),
|
||||||
messages: UiMessageController(),
|
messages: UiMessageController(),
|
||||||
bugReports: NoopBugReportService(),
|
bugReports: NoopBugReportService()
|
||||||
diagnosticsIncluded: false
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,4 +41,107 @@ final class SettingsModelTests: XCTestCase {
|
|||||||
await waitUntil { core.deletedTransfers.count == 2 }
|
await waitUntil { core.deletedTransfers.count == 2 }
|
||||||
XCTAssertEqual(Set(core.deletedTransfers), [2, 3])
|
XCTAssertEqual(Set(core.deletedTransfers), [2, 3])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testNetworkSettingsExposeCurrentEndpointId() {
|
||||||
|
let core = FakeCoreGateway()
|
||||||
|
let model = makeModel(core, preferences: Fixtures.preferences())
|
||||||
|
|
||||||
|
core.setState(CoreState(
|
||||||
|
isInitialized: true,
|
||||||
|
status: CoreStatus(endpointId: "endpoint-for-allowlist", activeTransfers: 0, activeShares: 0)
|
||||||
|
))
|
||||||
|
|
||||||
|
XCTAssertEqual(model.state.endpointId, "endpoint-for-allowlist")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testApplyCustomRelayRestartsCoreThenPersistsConfiguration() async {
|
||||||
|
let core = FakeCoreGateway()
|
||||||
|
let preferences = Fixtures.preferences()
|
||||||
|
let model = makeModel(core, preferences: preferences)
|
||||||
|
model.setRelayMode(.strictCustom)
|
||||||
|
model.setRelayURL(" https://relay.example/ ", at: 0)
|
||||||
|
|
||||||
|
model.applyRelayConfiguration()
|
||||||
|
|
||||||
|
await waitUntil { preferences.preferences.relayConfiguration.mode == .strictCustom }
|
||||||
|
let expected = RelayConfiguration(mode: .strictCustom, relayURLs: ["https://relay.example"])
|
||||||
|
XCTAssertEqual(preferences.preferences.relayConfiguration, expected)
|
||||||
|
XCTAssertEqual(core.initializedNetworkConfigurations, [expected])
|
||||||
|
XCTAssertFalse(model.state.relayConfigurationIsDirty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testApplyingAutomaticRetainsLastCustomRelayURLs() async {
|
||||||
|
let core = FakeCoreGateway()
|
||||||
|
let preferences = Fixtures.preferences()
|
||||||
|
let relayURLs = ["https://relay.example", "https://backup.example"]
|
||||||
|
preferences.setRelayConfiguration(RelayConfiguration(mode: .strictCustom, relayURLs: relayURLs))
|
||||||
|
let model = makeModel(core, preferences: preferences)
|
||||||
|
|
||||||
|
model.setRelayMode(.automatic)
|
||||||
|
model.applyRelayConfiguration()
|
||||||
|
|
||||||
|
await waitUntil { preferences.preferences.relayConfiguration.mode == .automatic }
|
||||||
|
XCTAssertEqual(preferences.preferences.relayConfiguration.relayURLs, relayURLs)
|
||||||
|
XCTAssertEqual(core.initializedNetworkConfigurations, [
|
||||||
|
RelayConfiguration(mode: .automatic, relayURLs: relayURLs),
|
||||||
|
])
|
||||||
|
model.setRelayMode(.strictCustom)
|
||||||
|
XCTAssertEqual(model.state.relayURLs, relayURLs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testApplyRelayIsBlockedWhileShareIsActive() async {
|
||||||
|
let core = FakeCoreGateway()
|
||||||
|
let preferences = Fixtures.preferences()
|
||||||
|
let model = makeModel(core, preferences: preferences)
|
||||||
|
core.setState(CoreState(
|
||||||
|
isInitialized: true,
|
||||||
|
status: CoreStatus(endpointId: "endpoint", activeTransfers: 0, activeShares: 1)
|
||||||
|
))
|
||||||
|
model.setRelayMode(.strictCustom)
|
||||||
|
model.setRelayURL("https://relay.example", at: 0)
|
||||||
|
|
||||||
|
model.applyRelayConfiguration()
|
||||||
|
await Task.yield()
|
||||||
|
|
||||||
|
XCTAssertTrue(core.initializedNetworkConfigurations.isEmpty)
|
||||||
|
XCTAssertEqual(preferences.preferences.relayConfiguration, .automatic)
|
||||||
|
XCTAssertEqual(model.state.relayApplyErrorKey, "relay_apply_active_transfers")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRepositoryActiveWorkRejectionDoesNotAttemptRollback() async {
|
||||||
|
let core = FakeCoreGateway()
|
||||||
|
core.initializeResult = .failure(CoreNetworkLifecycleError.activeNetworkWork)
|
||||||
|
let preferences = Fixtures.preferences()
|
||||||
|
let model = makeModel(core, preferences: preferences)
|
||||||
|
let attempted = RelayConfiguration(mode: .strictCustom, relayURLs: ["https://relay.example"])
|
||||||
|
model.setRelayMode(.strictCustom)
|
||||||
|
model.setRelayURL(attempted.relayURLs[0], at: 0)
|
||||||
|
|
||||||
|
model.applyRelayConfiguration()
|
||||||
|
await waitUntil {
|
||||||
|
core.initializedNetworkConfigurations.count == 1 && !model.state.isApplyingRelayConfiguration
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertEqual(core.initializedNetworkConfigurations, [attempted])
|
||||||
|
XCTAssertEqual(preferences.preferences.relayConfiguration, .automatic)
|
||||||
|
XCTAssertTrue(model.state.hasActiveNetworkWork)
|
||||||
|
XCTAssertEqual(model.state.relayApplyErrorKey, "relay_apply_active_transfers")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFailedRelayApplyRollsBackWithoutPersisting() async {
|
||||||
|
let core = FakeCoreGateway()
|
||||||
|
core.initializeResults = [.failure(TestError.unimplemented), .success(())]
|
||||||
|
let preferences = Fixtures.preferences()
|
||||||
|
let model = makeModel(core, preferences: preferences)
|
||||||
|
let attempted = RelayConfiguration(mode: .strictCustom, relayURLs: ["https://relay.example"])
|
||||||
|
model.setRelayMode(.strictCustom)
|
||||||
|
model.setRelayURL(attempted.relayURLs[0], at: 0)
|
||||||
|
|
||||||
|
model.applyRelayConfiguration()
|
||||||
|
await waitUntil { core.initializedNetworkConfigurations.count == 2 }
|
||||||
|
|
||||||
|
XCTAssertEqual(core.initializedNetworkConfigurations, [attempted, .automatic])
|
||||||
|
XCTAssertEqual(preferences.preferences.relayConfiguration, .automatic)
|
||||||
|
XCTAssertEqual(model.state.relayApplyErrorKey, "relay_apply_failed")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
51
apple/Tests/TransferNotificationTests.swift
Normal file
51
apple/Tests/TransferNotificationTests.swift
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import VniDrop
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class TransferNotificationTests: XCTestCase {
|
||||||
|
|
||||||
|
func testTransferNotificationsFireForTerminalStatesOnly() {
|
||||||
|
let transfers = [
|
||||||
|
Fixtures.transfer(id: 1, direction: .send, status: .failed),
|
||||||
|
Fixtures.transfer(id: 2, direction: .receive, status: .done),
|
||||||
|
Fixtures.transfer(id: 3, direction: .receive, status: .failed),
|
||||||
|
Fixtures.transfer(id: 4, direction: .receive, status: .receiving), // in-flight, ignored
|
||||||
|
Fixtures.transfer(id: 5, direction: .send, status: .sharing), // active share, ignored
|
||||||
|
Fixtures.transfer(id: 6, direction: .send, status: .done), // send-done isn't notified
|
||||||
|
]
|
||||||
|
let planned = plannedTransferNotifications(transfers, published: [])
|
||||||
|
XCTAssertEqual(planned.map(\.kind), [.sendFailed, .receiveCompleted, .receiveFailed])
|
||||||
|
XCTAssertEqual(planned.map(\.id), ["send-failed-1", "receive-completed-2", "receive-failed-3"])
|
||||||
|
XCTAssertEqual(planned.first?.transferName, "Photos")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTransferNotificationsSkipAlreadyPublished() {
|
||||||
|
let transfers = [Fixtures.transfer(id: 2, direction: .receive, status: .done)]
|
||||||
|
XCTAssertTrue(plannedTransferNotifications(transfers, published: ["receive-completed-2"]).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testReceiverNotificationsFireOnlyForCompletedReceivers() {
|
||||||
|
let requests = [
|
||||||
|
Fixtures.request(id: "a", requestedAt: 1, status: .completed),
|
||||||
|
Fixtures.request(id: "b", requestedAt: 2, status: .accepted),
|
||||||
|
Fixtures.request(id: "c", requestedAt: 3, status: .requested),
|
||||||
|
]
|
||||||
|
let planned = plannedReceiverNotifications(requests, published: [])
|
||||||
|
XCTAssertEqual(planned.map(\.id), ["receiver-completed-a"])
|
||||||
|
XCTAssertEqual(planned.first?.kind, .receiverCompleted)
|
||||||
|
XCTAssertEqual(planned.first?.receiver, "Peer")
|
||||||
|
XCTAssertEqual(planned.first?.transferName, "Photos")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testReceiverNotificationsSkipAlreadyPublished() {
|
||||||
|
let requests = [Fixtures.request(id: "a", requestedAt: 1, status: .completed)]
|
||||||
|
XCTAssertTrue(plannedReceiverNotifications(requests, published: ["receiver-completed-a"]).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testReceiverNotificationsFireForFailedReceivers() {
|
||||||
|
let requests = [Fixtures.request(id: "x", requestedAt: 1, status: .failed)]
|
||||||
|
let planned = plannedReceiverNotifications(requests, published: [])
|
||||||
|
XCTAssertEqual(planned.map(\.id), ["receiver-failed-x"])
|
||||||
|
XCTAssertEqual(planned.first?.kind, .receiverFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
import VnidropCore
|
||||||
@testable import VniDrop
|
@testable import VniDrop
|
||||||
|
|
||||||
/// Ports `ui/feedback/UiMessageControllerTest.kt` and `UserFacingErrorTest.kt`.
|
/// Ports `ui/feedback/UiMessageControllerTest.kt` and `UserFacingErrorTest.kt`.
|
||||||
@@ -20,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,19 +36,33 @@ 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("error_permission"))
|
// Typed cases map directly at the UI boundary.
|
||||||
XCTAssertEqual(InvitationError.message("invalid ticket").toUiText(), .resource("error_invalid_ticket"))
|
XCTAssertEqual(InvitationError.shareEmpty.toUiText(), .resource(L10n.Error.shareEmpty))
|
||||||
XCTAssertEqual(InvitationError.message("Select at least one file to share").toUiText(), .resource("error_share_empty"))
|
XCTAssertEqual(InvitationError.cameraUnavailable.toUiText(), .resource(L10n.Error.camera))
|
||||||
XCTAssertEqual(InvitationError.message("Camera access is required").toUiText(), .resource("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("error_generic"))
|
XCTAssertEqual(InvitationError.raw("something entirely unexpected").toUiText(), .resource(L10n.Error.generic))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testToUiTextMapsTypedTransferFailures() {
|
||||||
|
XCTAssertEqual(VnidropError.FilesystemPermission(reason: "read-only folder").toUiText(), .resource(L10n.Error.filesystem))
|
||||||
|
XCTAssertEqual(VnidropError.DestinationExists(reason: "target exists").toUiText(), .resource(L10n.Error.destinationExists))
|
||||||
|
XCTAssertEqual(VnidropError.StorageFull(reason: "disk full").toUiText(), .resource(L10n.Error.storageFull))
|
||||||
|
XCTAssertEqual(VnidropError.Network(reason: "offline").toUiText(), .resource(L10n.Error.network))
|
||||||
|
XCTAssertEqual(VnidropError.InvalidInput(reason: "bad path").toUiText(), .resource(L10n.Error.invalidInput))
|
||||||
|
XCTAssertFalse(VnidropError.FilesystemPermission(reason: "read-only").canRetryWithoutChangingInput)
|
||||||
|
XCTAssertFalse(VnidropError.DestinationExists(reason: "target exists").canRetryWithoutChangingInput)
|
||||||
|
XCTAssertTrue(VnidropError.Network(reason: "offline").canRetryWithoutChangingInput)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ final class AppGraph: ObservableObject {
|
|||||||
let preferencesRepository: AppPreferencesRepository
|
let preferencesRepository: AppPreferencesRepository
|
||||||
let filePreviewRepository: FilePreviewRepository
|
let filePreviewRepository: FilePreviewRepository
|
||||||
let approvalCoordinator: ApprovalCoordinator
|
let approvalCoordinator: ApprovalCoordinator
|
||||||
|
let transferNotificationCoordinator: TransferNotificationCoordinator
|
||||||
|
let backgroundActivity: BackgroundActivityController
|
||||||
|
|
||||||
init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil) {
|
init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil) {
|
||||||
self.dependencies = dependencies
|
self.dependencies = dependencies
|
||||||
@@ -22,18 +24,22 @@ final class AppGraph: ObservableObject {
|
|||||||
fallback: AppPreferencesDefaults(
|
fallback: AppPreferencesDefaults(
|
||||||
username: dependencies.environment.defaultUsername,
|
username: dependencies.environment.defaultUsername,
|
||||||
receiveFolder: dependencies.fileSystemService.defaultReceiveFolder(),
|
receiveFolder: dependencies.fileSystemService.defaultReceiveFolder(),
|
||||||
themeMode: .system,
|
themeMode: .system
|
||||||
notificationsEnabled: false,
|
|
||||||
diagnosticsEnabled: false
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.approvalCoordinator = ApprovalCoordinator(
|
self.approvalCoordinator = ApprovalCoordinator(
|
||||||
repository: coreRepository,
|
repository: coreRepository,
|
||||||
preferences: preferencesRepository,
|
|
||||||
notifications: dependencies.notificationService,
|
notifications: dependencies.notificationService,
|
||||||
visibility: visibility,
|
visibility: visibility,
|
||||||
messages: messages
|
messages: messages
|
||||||
)
|
)
|
||||||
|
self.transferNotificationCoordinator = TransferNotificationCoordinator(
|
||||||
|
repository: coreRepository,
|
||||||
|
notifications: dependencies.notificationService,
|
||||||
|
visibility: visibility,
|
||||||
|
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])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import SFSafeSymbols
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// App root, ported from `App.kt`. Owns the object graph and feature models, wires
|
/// App root, ported from `App.kt`. Owns the object graph and feature models, wires
|
||||||
@@ -8,8 +9,6 @@ struct RootView: View {
|
|||||||
@StateObject private var sendModel: SendModel
|
@StateObject private var sendModel: SendModel
|
||||||
@StateObject private var receiveModel: ReceiveModel
|
@StateObject private var receiveModel: ReceiveModel
|
||||||
@StateObject private var settingsModel: SettingsModel
|
@StateObject private var settingsModel: SettingsModel
|
||||||
@ObservedObject private var messages: UiMessageController
|
|
||||||
@ObservedObject private var approvals: ApprovalCoordinator
|
|
||||||
|
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
@@ -45,8 +44,6 @@ struct RootView: View {
|
|||||||
messages: graph.messages,
|
messages: graph.messages,
|
||||||
bugReports: NoopBugReportService()
|
bugReports: NoopBugReportService()
|
||||||
))
|
))
|
||||||
messages = graph.messages
|
|
||||||
approvals = graph.approvalCoordinator
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -55,13 +52,26 @@ struct RootView: View {
|
|||||||
let isDark = resolveDarkTheme(appModel.themeMode, systemDark: systemDark)
|
let isDark = resolveDarkTheme(appModel.themeMode, systemDark: systemDark)
|
||||||
ZStack {
|
ZStack {
|
||||||
navigation(windowClass: windowClass)
|
navigation(windowClass: windowClass)
|
||||||
SnackbarHost(controller: messages)
|
// Observe the coordinator/messages from the *persisted* `graph`
|
||||||
ApprovalModalHost(
|
// StateObject. Deriving them in `init` bound the view to a throwaway
|
||||||
state: approvals.state,
|
// AppGraph rebuilt on every re-init, whose coordinator never receives
|
||||||
onAccept: approvals.accept,
|
// core events — so the approval modal never appeared.
|
||||||
onRefuse: approvals.refuse
|
ApprovalLayer(
|
||||||
|
approvals: graph.approvalCoordinator,
|
||||||
|
sendModel: sendModel
|
||||||
)
|
)
|
||||||
|
// Top-most so the toast is never covered by the approval overlay's
|
||||||
|
// full-bleed clear layer. Observes the live `graph.messages` directly.
|
||||||
|
SnackbarHost(controller: graph.messages)
|
||||||
}
|
}
|
||||||
|
.overlay {
|
||||||
|
// A small, unobtrusive indicator while the core finishes its async
|
||||||
|
// startup — otherwise the lists look empty and the app feels stalled.
|
||||||
|
if !sendModel.coreState.isInitialized {
|
||||||
|
CoreStartingOverlay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.25), value: sendModel.coreState.isInitialized)
|
||||||
.vniDropTheme(isDark: isDark)
|
.vniDropTheme(isDark: isDark)
|
||||||
.preferredColorScheme(appModel.themeMode.preferredColorScheme)
|
.preferredColorScheme(appModel.themeMode.preferredColorScheme)
|
||||||
.environment(\.vniColors, isDark ? .dark : .light)
|
.environment(\.vniColors, isDark ? .dark : .light)
|
||||||
@@ -72,23 +82,23 @@ 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
|
|
||||||
// (e.g. the Share/QR sheet) so the approval sheet isn't presented under it
|
|
||||||
// on macOS.
|
|
||||||
.onChange(of: approvals.state.current?.id) { _, id in
|
|
||||||
if id != nil { sendModel.closeDetailPanel() }
|
|
||||||
}
|
|
||||||
#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
|
||||||
// drive foreground/background off NSApplication's active state instead —
|
// drive foreground/background off NSApplication's active state instead —
|
||||||
@@ -111,7 +121,7 @@ struct RootView: View {
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
NavigationSplitView {
|
NavigationSplitView {
|
||||||
List(AppDestination.allCases, selection: sidebarBinding) { destination in
|
List(AppDestination.allCases, selection: sidebarBinding) { destination in
|
||||||
Label(LocalizedStringKey(destination.labelKey), systemImage: destination.systemImage)
|
Label(String(localized: destination.labelKey), systemSymbol: destination.systemSymbol)
|
||||||
.tag(destination)
|
.tag(destination)
|
||||||
}
|
}
|
||||||
.navigationSplitViewColumnWidth(min: 180, ideal: 200, max: 260)
|
.navigationSplitViewColumnWidth(min: 180, ideal: 200, max: 260)
|
||||||
@@ -123,7 +133,7 @@ struct RootView: View {
|
|||||||
ForEach(AppDestination.allCases) { destination in
|
ForEach(AppDestination.allCases) { destination in
|
||||||
screen(for: destination, windowClass: windowClass)
|
screen(for: destination, windowClass: windowClass)
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label(LocalizedStringKey(destination.labelKey), systemImage: destination.systemImage)
|
Label(String(localized: destination.labelKey), systemSymbol: destination.systemSymbol)
|
||||||
}
|
}
|
||||||
.tag(destination)
|
.tag(destination)
|
||||||
}
|
}
|
||||||
@@ -182,6 +192,92 @@ struct RootView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hosts the approval modal, observing the coordinator passed in from the persisted
|
||||||
|
/// `AppGraph`. Kept as a child view so the `@ObservedObject` subscription is
|
||||||
|
/// established here (in `body`) against the live instance, rather than in
|
||||||
|
/// `RootView.init` against a throwaway graph.
|
||||||
|
private struct ApprovalLayer: View {
|
||||||
|
@ObservedObject var approvals: ApprovalCoordinator
|
||||||
|
let sendModel: SendModel
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
|
||||||
|
/// macOS-only: an approval arrived while a share/QR sheet was still up. We close
|
||||||
|
/// that sheet and present the approval once its dismissal completes (see
|
||||||
|
/// `sendModel.shareSheetsDismissed`), since macOS drops a sheet shown mid-dismissal.
|
||||||
|
@State private var approvalAwaitingSheetDismiss = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ApprovalModalHost(
|
||||||
|
isPresented: $showApproval,
|
||||||
|
state: approvals.state,
|
||||||
|
onAccept: approvals.accept,
|
||||||
|
onRefuse: approvals.refuse
|
||||||
|
)
|
||||||
|
// A pending approval is a blocking modal. Close any open share/QR sheet first
|
||||||
|
// (the detail-view panel *or* the list-level share sheet), then present the
|
||||||
|
// approval sheet: the approval is presented from the app root and neither
|
||||||
|
// platform reliably stacks it over a sheet owned by the Send screen.
|
||||||
|
.onChange(of: approvals.state.current?.id) { _, id in
|
||||||
|
guard id != nil else {
|
||||||
|
showApproval = false
|
||||||
|
approvalAwaitingSheetDismiss = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let wasShowingSheet = sendModel.state.detailPanel != nil
|
||||||
|
|| sendModel.state.shareTargetId != nil
|
||||||
|
sendModel.dismissShareSheets()
|
||||||
|
#if os(macOS)
|
||||||
|
// macOS silently drops a sheet presented while another is still dismissing,
|
||||||
|
// so wait for that sheet's real dismissal completion before presenting.
|
||||||
|
if wasShowingSheet {
|
||||||
|
approvalAwaitingSheetDismiss = true
|
||||||
|
} else {
|
||||||
|
showApproval = true
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
_ = wasShowingSheet
|
||||||
|
showApproval = true
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
#if os(macOS)
|
||||||
|
.onReceive(sendModel.shareSheetsDismissed) { _ in
|
||||||
|
guard approvalAwaitingSheetDismiss else { return }
|
||||||
|
approvalAwaitingSheetDismiss = false
|
||||||
|
if approvals.state.current != nil { showApproval = true }
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A full-window cover with a centered spinner shown while the core is starting.
|
||||||
|
private struct CoreStartingOverlay: View {
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
backgroundColor.ignoresSafeArea()
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
ProgressView().controlSize(.large)
|
||||||
|
Text(String(localized: L10n.App.starting))
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.transition(.opacity)
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityLabel(Text(String(localized: L10n.App.starting)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var backgroundColor: Color {
|
||||||
|
#if os(iOS)
|
||||||
|
Color(uiColor: .systemBackground)
|
||||||
|
#else
|
||||||
|
Color(nsColor: .windowBackgroundColor)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
import UIKit
|
import UIKit
|
||||||
#else
|
#else
|
||||||
|
|||||||
@@ -1,17 +1,39 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Scene identifier for the single main window.
|
||||||
|
private let mainWindowId = "main"
|
||||||
|
|
||||||
/// Native app entry point for iOS, iPadOS, and macOS.
|
/// Native app entry point for iOS, iPadOS, and macOS.
|
||||||
/// Opens `.vnd` invitations via `onOpenURL` and routes them to the receive flow.
|
/// Opens `.vnd` invitations via `onOpenURL` and routes them to the receive flow.
|
||||||
@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 {
|
||||||
WindowGroup {
|
#if os(macOS)
|
||||||
|
// A single-instance `Window` (not `WindowGroup`): the app must never open a
|
||||||
|
// second window. `Window` also drops the ⌘N "New Window" command.
|
||||||
|
Window(Text(verbatim: "VniDrop"), id: mainWindowId) {
|
||||||
RootView(dependencies: makeAppDependencies(externalInvitations: externalInvitations))
|
RootView(dependencies: makeAppDependencies(externalInvitations: externalInvitations))
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
.onOpenURL(perform: openInvitation)
|
.onOpenURL(perform: openInvitation)
|
||||||
}
|
}
|
||||||
|
#if DIRECT_DISTRIBUTION
|
||||||
|
.commands {
|
||||||
|
UpdatesCommands(controller: updater)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
#else
|
||||||
|
WindowGroup(id: mainWindowId) {
|
||||||
|
RootView(dependencies: makeAppDependencies(externalInvitations: externalInvitations))
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.onOpenURL(perform: openInvitation)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads a `.vnd` invitation document under a security scope, enforcing the
|
/// Reads a `.vnd` invitation document under a security scope, enforcing the
|
||||||
|
|||||||
@@ -19,23 +19,115 @@ enum FolderAccessStatus {
|
|||||||
case unavailable
|
case unavailable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum RelayPreferenceMode: String, Codable, CaseIterable, Sendable {
|
||||||
|
case automatic
|
||||||
|
case strictCustom = "custom"
|
||||||
|
case customWithDirectFallback = "custom-with-direct-fallback"
|
||||||
|
case localOnly = "local-only"
|
||||||
|
|
||||||
|
var usesCustomRelayURLs: Bool {
|
||||||
|
self == .strictCustom || self == .customWithDirectFallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RelayConfiguration: Equatable, Codable, Sendable {
|
||||||
|
var mode: RelayPreferenceMode
|
||||||
|
var relayURLs: [String]
|
||||||
|
|
||||||
|
static let automatic = RelayConfiguration(mode: .automatic, relayURLs: [])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RelayConfigurationValidationError: Error, Equatable, Sendable {
|
||||||
|
case missingURL
|
||||||
|
case tooManyURLs
|
||||||
|
case httpsRequired(index: Int)
|
||||||
|
case invalidURL(index: Int)
|
||||||
|
case duplicateURL(index: Int)
|
||||||
|
|
||||||
|
var urlIndex: Int? {
|
||||||
|
switch self {
|
||||||
|
case .httpsRequired(let index), .invalidURL(let index), .duplicateURL(let index): return index
|
||||||
|
case .missingURL, .tooManyURLs: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RelayConfigurationValidator {
|
||||||
|
static let maximumRelayCount = 8
|
||||||
|
static let maximumRelayURLBytes = 2_048
|
||||||
|
|
||||||
|
static func validate(
|
||||||
|
mode: RelayPreferenceMode,
|
||||||
|
relayURLs: [String],
|
||||||
|
retainedRelayURLs: [String] = []
|
||||||
|
) throws -> RelayConfiguration {
|
||||||
|
guard mode.usesCustomRelayURLs else {
|
||||||
|
return RelayConfiguration(mode: mode, relayURLs: retainedRelayURLs)
|
||||||
|
}
|
||||||
|
|
||||||
|
let relayEntries = relayURLs.enumerated().compactMap { index, value -> (Int, String)? in
|
||||||
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return trimmed.isEmpty ? nil : (index, trimmed)
|
||||||
|
}
|
||||||
|
guard !relayEntries.isEmpty else {
|
||||||
|
throw RelayConfigurationValidationError.missingURL
|
||||||
|
}
|
||||||
|
guard relayEntries.count <= maximumRelayCount else {
|
||||||
|
throw RelayConfigurationValidationError.tooManyURLs
|
||||||
|
}
|
||||||
|
|
||||||
|
var seen = Set<String>()
|
||||||
|
var normalizedURLs: [String] = []
|
||||||
|
for (index, relayURL) in relayEntries {
|
||||||
|
guard relayURL.lengthOfBytes(using: .utf8) <= maximumRelayURLBytes,
|
||||||
|
relayURL.rangeOfCharacter(from: .whitespacesAndNewlines.union(.controlCharacters)) == nil,
|
||||||
|
var components = URLComponents(string: relayURL) else {
|
||||||
|
throw RelayConfigurationValidationError.invalidURL(index: index)
|
||||||
|
}
|
||||||
|
guard components.scheme?.lowercased() == "https" else {
|
||||||
|
throw RelayConfigurationValidationError.httpsRequired(index: index)
|
||||||
|
}
|
||||||
|
guard
|
||||||
|
let host = components.host,
|
||||||
|
!host.isEmpty,
|
||||||
|
components.port.map({ (1...65_535).contains($0) }) ?? true,
|
||||||
|
components.user == nil,
|
||||||
|
components.password == nil,
|
||||||
|
components.query == nil,
|
||||||
|
components.fragment == nil,
|
||||||
|
components.path.isEmpty || components.path == "/"
|
||||||
|
else {
|
||||||
|
throw RelayConfigurationValidationError.invalidURL(index: index)
|
||||||
|
}
|
||||||
|
|
||||||
|
components.scheme = "https"
|
||||||
|
components.host = host.lowercased()
|
||||||
|
if components.port == 443 { components.port = nil }
|
||||||
|
if components.path == "/" { components.path = "" }
|
||||||
|
guard let canonicalURL = components.string, seen.insert(canonicalURL).inserted else {
|
||||||
|
throw RelayConfigurationValidationError.duplicateURL(index: index)
|
||||||
|
}
|
||||||
|
normalizedURLs.append(canonicalURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RelayConfiguration(mode: mode, relayURLs: normalizedURLs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Persisted app preferences, ported from `preferences/AppPreferencesRepository.kt`.
|
/// Persisted app preferences, ported from `preferences/AppPreferencesRepository.kt`.
|
||||||
/// Backed by `UserDefaults` instead of DataStore; keys and semantics match.
|
/// Backed by `UserDefaults` instead of DataStore; keys and semantics match.
|
||||||
struct AppPreferences: Equatable {
|
struct AppPreferences: Equatable {
|
||||||
var username: String
|
var username: String
|
||||||
var receiveFolder: ReceiveFolder
|
var receiveFolder: ReceiveFolder
|
||||||
var themeMode: ThemeMode
|
var themeMode: ThemeMode
|
||||||
var notificationsEnabled: Bool
|
|
||||||
var diagnosticsEnabled: Bool
|
|
||||||
var diagnosticsInstallId: String
|
var diagnosticsInstallId: String
|
||||||
|
var relayConfiguration: RelayConfiguration
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AppPreferencesDefaults {
|
struct AppPreferencesDefaults {
|
||||||
let username: String
|
let username: String
|
||||||
let receiveFolder: ReceiveFolder
|
let receiveFolder: ReceiveFolder
|
||||||
let themeMode: ThemeMode
|
let themeMode: ThemeMode
|
||||||
var notificationsEnabled: Bool = false
|
|
||||||
var diagnosticsEnabled: Bool = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -51,9 +143,8 @@ final class AppPreferencesRepository: ObservableObject {
|
|||||||
static let receiveFolderValue = "receive_folder_value"
|
static let receiveFolderValue = "receive_folder_value"
|
||||||
static let receiveFolderDisplayName = "receive_folder_display_name"
|
static let receiveFolderDisplayName = "receive_folder_display_name"
|
||||||
static let themeMode = "theme_mode"
|
static let themeMode = "theme_mode"
|
||||||
static let notificationsEnabled = "notifications_enabled"
|
|
||||||
static let diagnosticsEnabled = "diagnostics_enabled"
|
|
||||||
static let diagnosticsInstallId = "diagnostics_install_id"
|
static let diagnosticsInstallId = "diagnostics_install_id"
|
||||||
|
static let relayConfiguration = "relay_configuration"
|
||||||
}
|
}
|
||||||
|
|
||||||
init(defaults: UserDefaults = .standard, fallback: AppPreferencesDefaults) {
|
init(defaults: UserDefaults = .standard, fallback: AppPreferencesDefaults) {
|
||||||
@@ -66,19 +157,29 @@ final class AppPreferencesRepository: ObservableObject {
|
|||||||
let username = (defaults.string(forKey: Key.username)).flatMap { $0.isEmpty ? nil : $0 } ?? fallback.username
|
let username = (defaults.string(forKey: Key.username)).flatMap { $0.isEmpty ? nil : $0 } ?? fallback.username
|
||||||
let folder = resolveReceiveFolder(defaults, fallback: fallback.receiveFolder)
|
let folder = resolveReceiveFolder(defaults, fallback: fallback.receiveFolder)
|
||||||
let themeMode = defaults.string(forKey: Key.themeMode).flatMap(ThemeMode.init(rawValue:)) ?? fallback.themeMode
|
let themeMode = defaults.string(forKey: Key.themeMode).flatMap(ThemeMode.init(rawValue:)) ?? fallback.themeMode
|
||||||
let notifications = defaults.object(forKey: Key.notificationsEnabled) as? Bool ?? fallback.notificationsEnabled
|
|
||||||
let diagnostics = defaults.object(forKey: Key.diagnosticsEnabled) as? Bool ?? fallback.diagnosticsEnabled
|
|
||||||
let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? ""
|
let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? ""
|
||||||
return AppPreferences(
|
return AppPreferences(
|
||||||
username: username,
|
username: username,
|
||||||
receiveFolder: folder,
|
receiveFolder: folder,
|
||||||
themeMode: themeMode,
|
themeMode: themeMode,
|
||||||
notificationsEnabled: notifications,
|
diagnosticsInstallId: installId,
|
||||||
diagnosticsEnabled: diagnostics,
|
relayConfiguration: resolveRelayConfiguration(defaults)
|
||||||
diagnosticsInstallId: installId
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func resolveRelayConfiguration(_ defaults: UserDefaults) -> RelayConfiguration {
|
||||||
|
guard defaults.object(forKey: Key.relayConfiguration) != nil else { return .automatic }
|
||||||
|
guard
|
||||||
|
let data = defaults.data(forKey: Key.relayConfiguration),
|
||||||
|
let configuration = try? JSONDecoder().decode(RelayConfiguration.self, from: data)
|
||||||
|
else {
|
||||||
|
// A stored profile must never silently fall back to public relays. Strict
|
||||||
|
// custom with no URLs makes startup fail closed until Settings repairs it.
|
||||||
|
return RelayConfiguration(mode: .strictCustom, relayURLs: [])
|
||||||
|
}
|
||||||
|
return configuration
|
||||||
|
}
|
||||||
|
|
||||||
private static func resolveReceiveFolder(_ defaults: UserDefaults, fallback: ReceiveFolder) -> ReceiveFolder {
|
private static func resolveReceiveFolder(_ defaults: UserDefaults, fallback: ReceiveFolder) -> ReceiveFolder {
|
||||||
let kind = defaults.string(forKey: Key.receiveFolderKind)
|
let kind = defaults.string(forKey: Key.receiveFolderKind)
|
||||||
.flatMap(ReceiveFolderKind.init(rawValue:)) ?? fallback.kind
|
.flatMap(ReceiveFolderKind.init(rawValue:)) ?? fallback.kind
|
||||||
@@ -113,13 +214,9 @@ final class AppPreferencesRepository: ObservableObject {
|
|||||||
reload()
|
reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setNotificationsEnabled(_ enabled: Bool) {
|
func setRelayConfiguration(_ configuration: RelayConfiguration) {
|
||||||
defaults.set(enabled, forKey: Key.notificationsEnabled)
|
guard let encoded = try? JSONEncoder().encode(configuration) else { return }
|
||||||
reload()
|
defaults.set(encoded, forKey: Key.relayConfiguration)
|
||||||
}
|
|
||||||
|
|
||||||
func setDiagnosticsEnabled(_ enabled: Bool) {
|
|
||||||
defaults.set(enabled, forKey: Key.diagnosticsEnabled)
|
|
||||||
reload()
|
reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
41
apple/VniDrop/Core/CoreDispatcher.swift
Normal file
41
apple/VniDrop/Core/CoreDispatcher.swift
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Dispatch-queue labels for the core's serial and interrupt lanes.
|
||||||
|
enum QueueLabel {
|
||||||
|
static let core = "com.vnidrop.core"
|
||||||
|
static let interrupt = "com.vnidrop.core.interrupt"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two-lane dispatcher for blocking core calls.
|
||||||
|
///
|
||||||
|
/// `run` serializes calls on one queue so the core is driven from a single lane.
|
||||||
|
/// `runInterrupt` uses a *separate* concurrent lane, so an interrupt-style call
|
||||||
|
/// (cancel) can reach the core while a blocking call (`receive`) still occupies
|
||||||
|
/// the serial lane. The core is internally synchronized and explicitly supports
|
||||||
|
/// cancel arriving from another thread mid-receive (see VnidropCore.block_on);
|
||||||
|
/// a single shared queue would deadlock it.
|
||||||
|
final class CoreDispatcher: Sendable {
|
||||||
|
private let serialQueue: DispatchQueue
|
||||||
|
private let interruptQueue: DispatchQueue
|
||||||
|
|
||||||
|
init(label: String = QueueLabel.core, interruptLabel: String = QueueLabel.interrupt) {
|
||||||
|
serialQueue = DispatchQueue(label: label, qos: .userInitiated)
|
||||||
|
interruptQueue = DispatchQueue(label: interruptLabel, qos: .userInitiated, attributes: .concurrent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs a blocking core call on the serial lane and hops the result back.
|
||||||
|
func run<T: Sendable>(_ block: @escaping @Sendable () throws -> T) async -> Result<T, Error> {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
serialQueue.async { continuation.resume(returning: Result { try block() }) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `run`, but off the serial lane so it can interrupt a blocking call in
|
||||||
|
/// flight there (e.g. cancel a `receive`). Only use for core calls that are
|
||||||
|
/// safe to run concurrently with another core call.
|
||||||
|
func runInterrupt<T: Sendable>(_ block: @escaping @Sendable () throws -> T) async -> Result<T, Error> {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
interruptQueue.async { continuation.resume(returning: Result { try block() }) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,16 @@ import Foundation
|
|||||||
import Combine
|
import Combine
|
||||||
import VnidropCore
|
import VnidropCore
|
||||||
|
|
||||||
|
struct CoreStorageUsageModel: Sendable {
|
||||||
|
let blobStoreBytes: UInt64
|
||||||
|
let appDataBytes: UInt64
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ReceivedArtifactModel: Sendable {
|
||||||
|
let locator: String
|
||||||
|
let logicalSize: UInt64
|
||||||
|
}
|
||||||
|
|
||||||
/// Seam between the feature models and the Rust core, mirroring `CoreGateway`
|
/// Seam between the feature models and the Rust core, mirroring `CoreGateway`
|
||||||
/// in the KMP `shared` module. `CoreRepository` is the production implementation;
|
/// in the KMP `shared` module. `CoreRepository` is the production implementation;
|
||||||
/// tests substitute a fake so the models can be exercised without the FFI.
|
/// tests substitute a fake so the models can be exercised without the FFI.
|
||||||
@@ -14,7 +24,7 @@ protocol CoreGateway: AnyObject {
|
|||||||
/// Coalesced change hints emitted by the event sink.
|
/// Coalesced change hints emitted by the event sink.
|
||||||
var signals: AnyPublisher<CoreSignal, Never> { get }
|
var signals: AnyPublisher<CoreSignal, Never> { get }
|
||||||
|
|
||||||
func initialize(appDataDir: String) async -> Result<Void, Error>
|
func initialize(appDataDir: String, networkConfiguration: RelayConfiguration) async -> Result<Void, Error>
|
||||||
func shutdown()
|
func shutdown()
|
||||||
func shareSources(
|
func shareSources(
|
||||||
_ sources: [ShareSource],
|
_ sources: [ShareSource],
|
||||||
@@ -32,6 +42,8 @@ protocol CoreGateway: AnyObject {
|
|||||||
func cancel(transferId: UInt64) async -> Result<Void, Error>
|
func cancel(transferId: UInt64) async -> Result<Void, Error>
|
||||||
func delete(transferId: UInt64) async -> Result<Void, Error>
|
func delete(transferId: UInt64) async -> Result<Void, Error>
|
||||||
func clearReceiveHistory() async -> Result<UInt64, Error>
|
func clearReceiveHistory() async -> Result<UInt64, Error>
|
||||||
|
func storageUsage() async -> Result<CoreStorageUsageModel, Error>
|
||||||
|
func receivedArtifacts() async -> Result<[ReceivedArtifactModel], Error>
|
||||||
func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error>
|
func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error>
|
||||||
func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result<Void, Error>
|
func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result<Void, Error>
|
||||||
func refresh() async -> Result<Void, Error>
|
func refresh() async -> Result<Void, Error>
|
||||||
|
|||||||
@@ -15,10 +15,56 @@ struct CoreEventModel: Equatable, Identifiable, Sendable {
|
|||||||
let timestamp: Int64
|
let timestamp: Int64
|
||||||
let scope: String
|
let scope: String
|
||||||
let transferId: UInt64?
|
let transferId: UInt64?
|
||||||
|
/// Raw wire values as emitted by the core. Interpret them through the typed
|
||||||
|
/// `eventDirection` / `eventPhase` / `eventKind` accessors below — logic code
|
||||||
|
/// should never compare these strings directly.
|
||||||
let direction: String?
|
let direction: String?
|
||||||
let phase: String
|
let phase: String
|
||||||
let kind: String
|
let kind: String
|
||||||
let dataJson: String
|
let dataJson: String
|
||||||
|
|
||||||
|
var eventDirection: EventDirection? { direction.flatMap(EventDirection.init(rawValue:)) }
|
||||||
|
var eventPhase: EventPhase? { EventPhase(rawValue: phase) }
|
||||||
|
var eventKind: EventKind? { EventKind(rawValue: kind) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Direction of a core event, matching the wire strings the core emits.
|
||||||
|
enum EventDirection: String, Equatable, Sendable {
|
||||||
|
case send
|
||||||
|
case receive
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phase of a core progress event (the `phase` wire field).
|
||||||
|
enum EventPhase: String, Equatable, Sendable {
|
||||||
|
case importing = "import"
|
||||||
|
case ticket
|
||||||
|
case access
|
||||||
|
case transfer
|
||||||
|
case download
|
||||||
|
case export
|
||||||
|
case lifecycle
|
||||||
|
case network
|
||||||
|
case handshake
|
||||||
|
case error
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kind of a core progress event (the `kind` wire field).
|
||||||
|
enum EventKind: String, Equatable, Sendable {
|
||||||
|
case started
|
||||||
|
case copyProgress = "copy-progress"
|
||||||
|
case copyDone = "copy-done"
|
||||||
|
case outboardProgress = "outboard-progress"
|
||||||
|
case done
|
||||||
|
case created
|
||||||
|
case progress
|
||||||
|
case completed
|
||||||
|
case aborted
|
||||||
|
case failed
|
||||||
|
case connecting
|
||||||
|
case connected
|
||||||
|
case foundCollection = "found-collection"
|
||||||
|
case cancelled
|
||||||
|
case shareStopped = "share-stopped"
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ShareAccessPolicy: Equatable, Sendable {
|
enum ShareAccessPolicy: Equatable, Sendable {
|
||||||
@@ -88,6 +134,7 @@ enum ReceiverDeliveryStatus: Equatable, Sendable {
|
|||||||
case refused
|
case refused
|
||||||
case expired
|
case expired
|
||||||
case completed
|
case completed
|
||||||
|
case failed
|
||||||
case unknown
|
case unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,65 @@ import Foundation
|
|||||||
import Combine
|
import Combine
|
||||||
@preconcurrency import VnidropCore
|
@preconcurrency import VnidropCore
|
||||||
|
|
||||||
|
enum CoreNetworkLifecycleError: Error, Equatable, LocalizedError, Sendable {
|
||||||
|
case transitionInProgress
|
||||||
|
case activeNetworkWork
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .transitionInProgress: return "A network restart is already in progress."
|
||||||
|
case .activeNetworkWork: return "Stop active transfers and shares before restarting the network."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CoreNetworkLifecycle {
|
||||||
|
nonisolated static func requireIdle(activeTransfers: UInt64, activeShares: UInt64) throws {
|
||||||
|
guard activeTransfers == 0, activeShares == 0 else {
|
||||||
|
throw CoreNetworkLifecycleError.activeNetworkWork
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol CoreBindingFactory: Sendable {
|
||||||
|
func initialize(
|
||||||
|
appDataDir: String,
|
||||||
|
eventSink: CoreEventSink,
|
||||||
|
networkConfiguration: RelayConfiguration
|
||||||
|
) throws -> VnidropCore
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NativeCoreBindingFactory: CoreBindingFactory {
|
||||||
|
func initialize(
|
||||||
|
appDataDir: String,
|
||||||
|
eventSink: CoreEventSink,
|
||||||
|
networkConfiguration: RelayConfiguration
|
||||||
|
) throws -> VnidropCore {
|
||||||
|
let nativeConfiguration: CoreNetworkConfig
|
||||||
|
switch networkConfiguration.mode {
|
||||||
|
case .automatic:
|
||||||
|
nativeConfiguration = defaultCoreNetworkConfig()
|
||||||
|
case .strictCustom:
|
||||||
|
nativeConfiguration = CoreNetworkConfig(
|
||||||
|
mode: .strictCustom,
|
||||||
|
relayUrls: networkConfiguration.relayURLs
|
||||||
|
)
|
||||||
|
case .customWithDirectFallback:
|
||||||
|
nativeConfiguration = CoreNetworkConfig(
|
||||||
|
mode: .customWithDirectFallback,
|
||||||
|
relayUrls: networkConfiguration.relayURLs
|
||||||
|
)
|
||||||
|
case .localOnly:
|
||||||
|
nativeConfiguration = CoreNetworkConfig(mode: .localOnly, relayUrls: [])
|
||||||
|
}
|
||||||
|
return try VnidropCore.initializeWithNetworkConfig(
|
||||||
|
appDataDir: appDataDir,
|
||||||
|
eventSink: eventSink,
|
||||||
|
networkConfig: nativeConfiguration
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Swift port of `core/CoreRepository.kt`. Owns the `VnidropCore` handle, maps the
|
/// Swift port of `core/CoreRepository.kt`. Owns the `VnidropCore` handle, maps the
|
||||||
/// generated UniFFI records into app domain models, publishes an observable
|
/// generated UniFFI records into app domain models, publishes an observable
|
||||||
/// `CoreState`, and emits coalesced `CoreSignal`s from the event sink.
|
/// `CoreState`, and emits coalesced `CoreSignal`s from the event sink.
|
||||||
@@ -17,28 +76,65 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
|||||||
/// Coalesced change hints; subscribe to react to approval/history/transfer changes.
|
/// Coalesced change hints; subscribe to react to approval/history/transfer changes.
|
||||||
var signals: AnyPublisher<CoreSignal, Never> { signalsSubject.eraseToAnyPublisher() }
|
var signals: AnyPublisher<CoreSignal, Never> { signalsSubject.eraseToAnyPublisher() }
|
||||||
|
|
||||||
// Set on the main actor (initialize/shutdown) but read from `queue` inside
|
// Initialization swaps happen on `queue`; shutdown and snapshot reads may also
|
||||||
// `runCore`; the underlying core is internally synchronized, so this crossing
|
// access the handle from the main actor. The underlying core is internally
|
||||||
// is safe. `nonisolated(unsafe)` documents that contract for Swift 6.
|
// synchronized, and `nonisolated(unsafe)` documents that crossing for Swift 6.
|
||||||
private nonisolated(unsafe) var core: VnidropCore?
|
private nonisolated(unsafe) var core: VnidropCore?
|
||||||
private let queue = DispatchQueue(label: "com.vnidrop.core", qos: .userInitiated)
|
// Core calls run through `dispatcher` (see runCore/runInterrupt); the factory
|
||||||
|
// and transition flag drive relay-aware (re)initialization.
|
||||||
|
private let dispatcher = CoreDispatcher()
|
||||||
|
private let coreFactory: any CoreBindingFactory
|
||||||
|
private var isNetworkTransitionInProgress = false
|
||||||
private lazy var sink = RepositoryEventSink { [weak self] event in
|
private lazy var sink = RepositoryEventSink { [weak self] event in
|
||||||
Task { @MainActor in self?.handle(event: event) }
|
Task { @MainActor in self?.handle(event: event) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated static let maxEvents = 200
|
private nonisolated static let maxEvents = 200
|
||||||
|
|
||||||
|
init(coreFactory: any CoreBindingFactory = NativeCoreBindingFactory()) {
|
||||||
|
self.coreFactory = coreFactory
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Lifecycle
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
func initialize(appDataDir: String) async -> Result<Void, Error> {
|
func initialize(
|
||||||
await runCore { [sink] in
|
appDataDir: String,
|
||||||
self.core?.shutdown()
|
networkConfiguration: RelayConfiguration
|
||||||
let created = try VnidropCore.initialize(appDataDir: appDataDir, eventSink: sink)
|
) async -> Result<Void, Error> {
|
||||||
return created
|
guard !isNetworkTransitionInProgress else {
|
||||||
}.map { created in
|
return .failure(CoreNetworkLifecycleError.transitionInProgress)
|
||||||
|
}
|
||||||
|
isNetworkTransitionInProgress = true
|
||||||
|
defer { isNetworkTransitionInProgress = false }
|
||||||
|
|
||||||
|
let result = await runCore { [sink] in
|
||||||
|
if let existing = self.core {
|
||||||
|
let status = existing.status()
|
||||||
|
try CoreNetworkLifecycle.requireIdle(
|
||||||
|
activeTransfers: status.activeTransfers,
|
||||||
|
activeShares: status.activeShares
|
||||||
|
)
|
||||||
|
existing.shutdown()
|
||||||
|
self.core = nil
|
||||||
|
}
|
||||||
|
let created = try self.coreFactory.initialize(
|
||||||
|
appDataDir: appDataDir,
|
||||||
|
eventSink: sink,
|
||||||
|
networkConfiguration: networkConfiguration
|
||||||
|
)
|
||||||
self.core = created
|
self.core = created
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
self.refreshSnapshot()
|
self.refreshSnapshot()
|
||||||
self.state.isInitialized = true
|
self.state.isInitialized = true
|
||||||
|
return .success(())
|
||||||
|
case .failure(let error):
|
||||||
|
if error as? CoreNetworkLifecycleError != .activeNetworkWork {
|
||||||
|
self.state = CoreState()
|
||||||
|
}
|
||||||
|
return .failure(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,8 +152,11 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
|||||||
senderName: String,
|
senderName: String,
|
||||||
accessPolicy: ShareAccessPolicy
|
accessPolicy: ShareAccessPolicy
|
||||||
) async -> Result<Share, Error> {
|
) async -> Result<Share, Error> {
|
||||||
|
guard !isNetworkTransitionInProgress else {
|
||||||
|
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(
|
||||||
@@ -89,7 +188,10 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func receive(ticket: String, outputDir: String, receiverName: String) async -> Result<Void, Error> {
|
func receive(ticket: String, outputDir: String, receiverName: String) async -> Result<Void, Error> {
|
||||||
await runCore {
|
guard !isNetworkTransitionInProgress else {
|
||||||
|
return .failure(CoreNetworkLifecycleError.transitionInProgress)
|
||||||
|
}
|
||||||
|
return await runCore {
|
||||||
try self.requireCore().receive(
|
try self.requireCore().receive(
|
||||||
ticket: ticket,
|
ticket: ticket,
|
||||||
outputDir: outputDir,
|
outputDir: outputDir,
|
||||||
@@ -105,7 +207,10 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
|||||||
outputDirectoryUrl: String,
|
outputDirectoryUrl: String,
|
||||||
receiverName: String
|
receiverName: String
|
||||||
) async -> Result<Void, Error> {
|
) async -> Result<Void, Error> {
|
||||||
await runCore {
|
guard !isNetworkTransitionInProgress else {
|
||||||
|
return .failure(CoreNetworkLifecycleError.transitionInProgress)
|
||||||
|
}
|
||||||
|
return await runCore {
|
||||||
try withSecurityScopedAccess(pathOrUrl: outputDirectoryUrl) {
|
try withSecurityScopedAccess(pathOrUrl: outputDirectoryUrl) {
|
||||||
try self.requireCore().receive(
|
try self.requireCore().receive(
|
||||||
ticket: ticket,
|
ticket: ticket,
|
||||||
@@ -119,7 +224,9 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
|||||||
// MARK: - Lifecycle actions
|
// MARK: - Lifecycle actions
|
||||||
|
|
||||||
func cancel(transferId: UInt64) async -> Result<Void, Error> {
|
func cancel(transferId: UInt64) async -> Result<Void, Error> {
|
||||||
await runCore {
|
// Off the serial `queue`: a receive in flight is blocking it, and the
|
||||||
|
// cancel signal must reach the core to unblock that receive.
|
||||||
|
await runInterrupt {
|
||||||
try self.requireCore().cancelTransfer(transferId: transferId)
|
try self.requireCore().cancelTransfer(transferId: transferId)
|
||||||
}.map { self.refreshSnapshot() }
|
}.map { self.refreshSnapshot() }
|
||||||
}
|
}
|
||||||
@@ -143,6 +250,25 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func storageUsage() async -> Result<CoreStorageUsageModel, Error> {
|
||||||
|
await runCore {
|
||||||
|
let usage = try self.requireCore().storageUsage()
|
||||||
|
return CoreStorageUsageModel(
|
||||||
|
blobStoreBytes: usage.blobStoreBytes,
|
||||||
|
appDataBytes: usage.databaseBytes + usage.logsBytes + usage.previewsBytes + usage.otherCoreBytes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func receivedArtifacts() async -> Result<[ReceivedArtifactModel], Error> {
|
||||||
|
await runCore {
|
||||||
|
try self.requireCore().listReceivedArtifacts().compactMap { artifact in
|
||||||
|
guard artifact.locatorKind == .filesystemPath else { return nil }
|
||||||
|
return ReceivedArtifactModel(locator: artifact.locator, logicalSize: artifact.logicalSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error> {
|
func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error> {
|
||||||
await runCore {
|
await runCore {
|
||||||
try self.requireCore().listReceiverRequests(transferId: transferId).map { $0.toModel() }
|
try self.requireCore().listReceiverRequests(transferId: transferId).map { $0.toModel() }
|
||||||
@@ -178,7 +304,7 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
|||||||
|
|
||||||
guard let transferId = model.transferId else { return }
|
guard let transferId = model.transferId else { return }
|
||||||
switch model.phase {
|
switch model.phase {
|
||||||
case "approval": signalsSubject.send(.approvalChanged(transferId: transferId))
|
case "approval", "access": signalsSubject.send(.approvalChanged(transferId: transferId))
|
||||||
case "delivery": signalsSubject.send(.receiverHistoryChanged(transferId: transferId))
|
case "delivery": signalsSubject.send(.receiverHistoryChanged(transferId: transferId))
|
||||||
default: break
|
default: break
|
||||||
}
|
}
|
||||||
@@ -227,24 +353,21 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs a blocking core call off the main actor and hops the result back.
|
/// Runs a blocking core call off the main actor and hops the result back.
|
||||||
private nonisolated func runCore<T: Sendable>(_ block: @escaping @Sendable () throws -> T) async -> Result<T, Error> {
|
private nonisolated func runCore<T: Sendable>(_ block: @escaping @Sendable () throws -> T) async -> Result<T, Error> {
|
||||||
await withCheckedContinuation { continuation in
|
await dispatcher.run(block)
|
||||||
queue.async {
|
}
|
||||||
let result: Result<T, Error>
|
|
||||||
do {
|
/// Like `runCore`, but off the serial lane so it can interrupt a blocking
|
||||||
result = .success(try block())
|
/// call in flight there (e.g. cancel a `receive`). Only use for core calls
|
||||||
} catch {
|
/// that are safe to run concurrently with another core call.
|
||||||
result = .failure(error)
|
private nonisolated func runInterrupt<T: Sendable>(_ block: @escaping @Sendable () throws -> T) async -> Result<T, Error> {
|
||||||
}
|
await dispatcher.runInterrupt(block)
|
||||||
continuation.resume(returning: result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated static func nextTransferId() -> UInt64 {
|
private nonisolated static func nextTransferId() -> UInt64 {
|
||||||
@@ -279,14 +402,15 @@ private extension CoreEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private let refreshPhases: Set<String> = ["lifecycle", "error", "ticket", "import", "download", "export", "handshake"]
|
private let refreshPhases: Set<EventPhase> = [.lifecycle, .error, .ticket, .importing, .download, .export, .handshake]
|
||||||
private let refreshKinds: Set<String> = [
|
private let refreshKinds: Set<EventKind> = [
|
||||||
"started", "done", "created", "failed", "cancelled", "share-stopped", "found-collection", "connected",
|
.started, .done, .created, .failed, .cancelled, .shareStopped, .foundCollection, .connected,
|
||||||
]
|
]
|
||||||
|
|
||||||
private extension CoreEventModel {
|
private extension CoreEventModel {
|
||||||
var shouldRefreshTransfers: Bool {
|
var shouldRefreshTransfers: Bool {
|
||||||
refreshPhases.contains(phase) && refreshKinds.contains(kind)
|
guard let eventPhase, let eventKind else { return false }
|
||||||
|
return refreshPhases.contains(eventPhase) && refreshKinds.contains(eventKind)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,6 +514,7 @@ private extension ReceiverRequest {
|
|||||||
case "refused": return .refused
|
case "refused": return .refused
|
||||||
case "expired": return .expired
|
case "expired": return .expired
|
||||||
case "completed": return .completed
|
case "completed": return .completed
|
||||||
|
case "failed": return .failed
|
||||||
default: return .unknown
|
default: return .unknown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -16,12 +16,63 @@ struct LocalNotification {
|
|||||||
let body: String
|
let body: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Presents notifications even while the app is active. Without a delegate the
|
||||||
|
/// system drops the banner when the app is frontmost — very visible on macOS,
|
||||||
|
/// where the app window is usually open when a transfer completes.
|
||||||
|
///
|
||||||
|
/// `@MainActor` is required, not just convenient: these delegate methods are
|
||||||
|
/// `async`, so their continuation resumes at the return point on whatever executor
|
||||||
|
/// they ran on. When the system hands a notification-tap back to UIKit it performs
|
||||||
|
/// state-restoration/snapshot work synchronously on that thread — which asserts
|
||||||
|
/// "Call must be made on main thread" and crashes if the method returned off-main.
|
||||||
|
/// Main-actor isolation guarantees the return happens on the main thread.
|
||||||
|
// `@preconcurrency` on the conformance: these delegate requirements are nonisolated
|
||||||
|
// with non-Sendable UN* parameters, which strict concurrency won't otherwise let a
|
||||||
|
// main actor-isolated type witness. The main-actor isolation is what fixes the
|
||||||
|
// crash (see the type doc above); the attribute inserts the runtime hop.
|
||||||
|
@MainActor
|
||||||
|
private final class NotificationPresenter: NSObject, @preconcurrency UNUserNotificationCenterDelegate {
|
||||||
|
func userNotificationCenter(
|
||||||
|
_ center: UNUserNotificationCenter,
|
||||||
|
willPresent notification: UNNotification
|
||||||
|
) async -> UNNotificationPresentationOptions {
|
||||||
|
[.banner, .sound, .list]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a notification tap inside the running instance and bring the existing
|
||||||
|
/// window forward, rather than letting the default launch behavior surface (which
|
||||||
|
/// on macOS can spin up a second process). The approval/transfer UI is driven by
|
||||||
|
/// core state, so activating the window is enough to reveal a pending approval.
|
||||||
|
func userNotificationCenter(
|
||||||
|
_ center: UNUserNotificationCenter,
|
||||||
|
didReceive response: UNNotificationResponse
|
||||||
|
) async {
|
||||||
|
#if os(macOS)
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
// Reopen/focus the single main window (activation triggers SwiftUI's
|
||||||
|
// reopen handling when it was closed).
|
||||||
|
for window in NSApp.windows where window.canBecomeMain {
|
||||||
|
window.makeKeyAndOrderFront(nil)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Local notification service backed by `UNUserNotificationCenter`.
|
/// Local notification service backed by `UNUserNotificationCenter`.
|
||||||
@MainActor
|
@MainActor
|
||||||
final class LocalNotificationService: ObservableObject {
|
final class LocalNotificationService: ObservableObject {
|
||||||
@Published private(set) var permission: NotificationPermission = .notDetermined
|
@Published private(set) var permission: NotificationPermission = .notDetermined
|
||||||
|
|
||||||
private let center = UNUserNotificationCenter.current()
|
private let center = UNUserNotificationCenter.current()
|
||||||
|
private let presenter = NotificationPresenter()
|
||||||
|
|
||||||
|
init() {
|
||||||
|
center.delegate = presenter
|
||||||
|
// Seed the permission immediately so gating (approval/lifecycle
|
||||||
|
// notifications) never races a not-yet-refreshed `.notDetermined`.
|
||||||
|
Task { _ = await refreshPermission() }
|
||||||
|
}
|
||||||
|
|
||||||
func refreshPermission() async -> NotificationPermission {
|
func refreshPermission() async -> NotificationPermission {
|
||||||
let settings = await center.notificationSettings()
|
let settings = await center.notificationSettings()
|
||||||
@@ -75,11 +126,6 @@ final class LocalNotificationService: ObservableObject {
|
|||||||
center.removeDeliveredNotifications(withIdentifiers: [id])
|
center.removeDeliveredNotifications(withIdentifiers: [id])
|
||||||
}
|
}
|
||||||
|
|
||||||
func cancelAll() {
|
|
||||||
center.removeAllPendingNotificationRequests()
|
|
||||||
center.removeAllDeliveredNotifications()
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func map(_ status: UNAuthorizationStatus) -> NotificationPermission {
|
private static func map(_ status: UNAuthorizationStatus) -> NotificationPermission {
|
||||||
switch status {
|
switch status {
|
||||||
case .authorized, .provisional, .ephemeral: return .granted
|
case .authorized, .provisional, .ephemeral: return .granted
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ func windowClassFor(width: Double) -> WindowClass {
|
|||||||
/// resolved at the view layer.
|
/// resolved at the view layer.
|
||||||
struct TransferProgress: Equatable {
|
struct TransferProgress: Equatable {
|
||||||
let transferId: UInt64?
|
let transferId: UInt64?
|
||||||
let phase: String
|
let phase: EventPhase
|
||||||
let kind: String
|
let kind: EventKind
|
||||||
let labelKey: String
|
let labelKey: String.LocalizationValue
|
||||||
let progress: Double?
|
let progress: Double?
|
||||||
var detail: String? = nil
|
var detail: String? = nil
|
||||||
/// Pre-resolved label that overrides `labelKey` when set (e.g. "Sending to 2",
|
/// Pre-resolved label that overrides `labelKey` when set (e.g. "Sending to 2",
|
||||||
@@ -28,44 +28,31 @@ struct TransferProgress: Equatable {
|
|||||||
var label: String? = nil
|
var label: String? = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func statusLabelKey(_ status: TransferStatus) -> String {
|
func statusLabelKey(_ status: TransferStatus) -> String.LocalizationValue {
|
||||||
switch status {
|
switch status {
|
||||||
case .importing: return "status_preparing"
|
case .importing: return L10n.Status.preparing
|
||||||
case .sharing: return "status_available"
|
case .sharing: return L10n.Status.available
|
||||||
case .receiving: return "status_receiving"
|
case .receiving: return L10n.Status.receiving
|
||||||
case .done: return "status_completed"
|
case .done: return L10n.Status.completed
|
||||||
case .cancelled: return "status_cancelled"
|
case .cancelled: return L10n.Status.cancelled
|
||||||
case .stopped: return "status_stopped"
|
case .stopped: return L10n.Status.stopped
|
||||||
case .failed: return "status_failed"
|
case .failed: return L10n.Status.failed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private let progressPhases: Set<String> = [
|
/// Latest progress snapshot for a transfer. Events are newest-first. Only events
|
||||||
"import", "ticket", "access", "transfer", "download", "export",
|
/// whose `phase` and `kind` map to known cases participate.
|
||||||
"lifecycle", "network", "handshake", "error",
|
|
||||||
]
|
|
||||||
|
|
||||||
private let progressKinds: Set<String> = [
|
|
||||||
"started", "copy-progress", "copy-done", "outboard-progress", "done",
|
|
||||||
"created", "progress", "completed", "aborted", "failed",
|
|
||||||
"connecting", "connected", "found-collection",
|
|
||||||
"cancelled", "share-stopped",
|
|
||||||
]
|
|
||||||
|
|
||||||
/// Latest progress snapshot for a transfer. Events are newest-first.
|
|
||||||
func progressForTransfer(events: [CoreEventModel], transferId: UInt64) -> TransferProgress? {
|
func progressForTransfer(events: [CoreEventModel], transferId: UInt64) -> TransferProgress? {
|
||||||
let relevant = events.filter { event in
|
let relevant = events.filter { event in
|
||||||
event.transferId == transferId
|
event.transferId == transferId && event.eventPhase != nil && event.eventKind != nil
|
||||||
&& progressPhases.contains(event.phase)
|
|
||||||
&& progressKinds.contains(event.kind)
|
|
||||||
}
|
}
|
||||||
guard let latest = relevant.first else { return nil }
|
guard let latest = relevant.first, let phase = latest.eventPhase, let kind = latest.eventKind else { return nil }
|
||||||
let sizeHint = findKnownSize(events: events, transferId: transferId)
|
let sizeHint = findKnownSize(events: events, transferId: transferId)
|
||||||
return TransferProgress(
|
return TransferProgress(
|
||||||
transferId: transferId,
|
transferId: transferId,
|
||||||
phase: latest.phase,
|
phase: phase,
|
||||||
kind: latest.kind,
|
kind: kind,
|
||||||
labelKey: humanProgressLabel(latest),
|
labelKey: humanProgressLabel(phase: phase, kind: kind),
|
||||||
progress: parseProgress(latest.dataJson, sizeHint: sizeHint),
|
progress: parseProgress(latest.dataJson, sizeHint: sizeHint),
|
||||||
detail: progressDetail(latest)
|
detail: progressDetail(latest)
|
||||||
)
|
)
|
||||||
@@ -80,33 +67,34 @@ func progressForReceiver(
|
|||||||
) -> TransferProgress? {
|
) -> TransferProgress? {
|
||||||
if remoteEndpointId.isEmpty { return nil }
|
if remoteEndpointId.isEmpty { return nil }
|
||||||
let connectionIds = connectionIdsForEndpoint(events: events, remoteEndpointId: remoteEndpointId)
|
let connectionIds = connectionIdsForEndpoint(events: events, remoteEndpointId: remoteEndpointId)
|
||||||
|
let receiverKinds: Set<EventKind> = [.started, .progress, .completed, .aborted]
|
||||||
let transferEvents = events.filter { event in
|
let transferEvents = events.filter { event in
|
||||||
event.transferId == transferId
|
event.transferId == transferId
|
||||||
&& event.direction == "send"
|
&& event.eventDirection == .send
|
||||||
&& event.phase == "transfer"
|
&& event.eventPhase == .transfer
|
||||||
&& ["started", "progress", "completed", "aborted"].contains(event.kind)
|
&& (event.eventKind.map(receiverKinds.contains) ?? false)
|
||||||
&& eventBelongsToReceiver(event, remoteEndpointId: remoteEndpointId, connectionIds: connectionIds)
|
&& eventBelongsToReceiver(event, remoteEndpointId: remoteEndpointId, connectionIds: connectionIds)
|
||||||
}
|
}
|
||||||
if transferEvents.isEmpty { return nil }
|
guard let latest = transferEvents.first, let latestKind = latest.eventKind else { return nil }
|
||||||
|
if latestKind == .aborted {
|
||||||
let latest = transferEvents[0]
|
|
||||||
if latest.kind == "aborted" {
|
|
||||||
return TransferProgress(
|
return TransferProgress(
|
||||||
transferId: transferId, phase: "transfer", kind: "aborted",
|
transferId: transferId, phase: .transfer, kind: .aborted,
|
||||||
labelKey: "progress_interrupted", progress: nil, detail: nil
|
labelKey: L10n.Progress.interrupted, progress: nil, detail: nil
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if latest.kind == "completed" && !transferEvents.contains(where: { $0.kind == "progress" || $0.kind == "started" }) {
|
// Events are newest-first, so a completed latest event is terminal even when
|
||||||
|
// progress/started events precede it — it must show as Completed, not Sending.
|
||||||
|
if latestKind == .completed {
|
||||||
return TransferProgress(
|
return TransferProgress(
|
||||||
transferId: transferId, phase: "transfer", kind: "completed",
|
transferId: transferId, phase: .transfer, kind: .completed,
|
||||||
labelKey: "progress_completed", progress: 1, detail: nil
|
labelKey: L10n.Progress.completed, progress: 1, detail: nil
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let progress = aggregateReceiverProgress(events: transferEvents, totalSizeHint: totalSizeHint)
|
let progress = aggregateReceiverProgress(events: transferEvents, totalSizeHint: totalSizeHint)
|
||||||
|
|
||||||
return TransferProgress(
|
return TransferProgress(
|
||||||
transferId: transferId, phase: "transfer", kind: latest.kind,
|
transferId: transferId, phase: .transfer, kind: latestKind,
|
||||||
labelKey: "progress_sending", progress: progress, detail: progressDetail(latest)
|
labelKey: L10n.Progress.sending, progress: progress, detail: progressDetail(latest)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,26 +115,26 @@ func formatBytes(_ size: UInt64) -> String {
|
|||||||
|
|
||||||
// MARK: - Internals (ported literally from AppUiModels.kt)
|
// MARK: - Internals (ported literally from AppUiModels.kt)
|
||||||
|
|
||||||
private func humanProgressLabel(_ event: CoreEventModel) -> String {
|
private func humanProgressLabel(phase: EventPhase, kind: EventKind) -> String.LocalizationValue {
|
||||||
switch (event.phase, event.kind) {
|
switch (phase, kind) {
|
||||||
case ("import", "copy-progress"), ("import", "outboard-progress"), ("import", "started"):
|
case (.importing, .copyProgress), (.importing, .outboardProgress), (.importing, .started):
|
||||||
return "progress_preparing"
|
return L10n.Progress.preparing
|
||||||
case ("import", "done"): return "progress_ready"
|
case (.importing, .done): return L10n.Progress.ready
|
||||||
case ("ticket", "created"): return "progress_share_ready"
|
case (.ticket, .created): return L10n.Progress.shareReady
|
||||||
case ("network", "connecting"): return "progress_connecting"
|
case (.network, .connecting): return L10n.Progress.connecting
|
||||||
case ("network", "connected"): return "progress_connected"
|
case (.network, .connected): return L10n.Progress.connected
|
||||||
case ("download", "found-collection"): return "progress_getting_ready"
|
case (.download, .foundCollection): return L10n.Progress.gettingReady
|
||||||
case ("download", "progress"): return "progress_downloading"
|
case (.download, .progress): return L10n.Progress.downloading
|
||||||
case ("export", "progress"): return "progress_saving"
|
case (.export, .progress): return L10n.Progress.saving
|
||||||
case ("transfer", "progress"): return "progress_sending"
|
case (.transfer, .progress): return L10n.Progress.sending
|
||||||
case ("transfer", "started"): return "progress_connected"
|
case (.transfer, .started): return L10n.Progress.connected
|
||||||
case ("transfer", "completed"): return "progress_completed"
|
case (.transfer, .completed): return L10n.Progress.completed
|
||||||
case ("lifecycle", "done"): return "progress_completed"
|
case (.lifecycle, .done): return L10n.Progress.completed
|
||||||
case ("lifecycle", "cancelled"): return "progress_cancelled"
|
case (.lifecycle, .cancelled): return L10n.Progress.cancelled
|
||||||
default:
|
default:
|
||||||
if event.phase == "handshake" { return "progress_requesting_access" }
|
if phase == .handshake { return L10n.Progress.requestingAccess }
|
||||||
if event.kind == "failed" { return "progress_failed" }
|
if kind == .failed { return L10n.Progress.failed }
|
||||||
return "progress_working"
|
return L10n.Progress.working
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,14 +205,14 @@ private func aggregateReceiverProgress(events: [CoreEventModel], totalSizeHint:
|
|||||||
order.append(requestKey)
|
order.append(requestKey)
|
||||||
}
|
}
|
||||||
if let size, size > 0 { state.size = size }
|
if let size, size > 0 { state.size = size }
|
||||||
switch event.kind {
|
switch event.eventKind {
|
||||||
case "progress", "started":
|
case .progress, .started:
|
||||||
if let endOffset { state.offset = max(state.offset, endOffset) }
|
if let endOffset { state.offset = max(state.offset, endOffset) }
|
||||||
state.aborted = false
|
state.aborted = false
|
||||||
case "completed":
|
case .completed:
|
||||||
state.completed = true
|
state.completed = true
|
||||||
if let s = state.size { state.offset = s }
|
if let s = state.size { state.offset = s }
|
||||||
case "aborted":
|
case .aborted:
|
||||||
state.aborted = true
|
state.aborted = true
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ final class AppModel: ObservableObject {
|
|||||||
AppLogger.info("lifecycle", "app started", ["platform": environment.name])
|
AppLogger.info("lifecycle", "app started", ["platform": environment.name])
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
let result = await repository.initialize(appDataDir: environment.defaultCoreDataDir)
|
let result = await repository.initialize(
|
||||||
|
appDataDir: environment.defaultCoreDataDir,
|
||||||
|
networkConfiguration: preferences.preferences.relayConfiguration
|
||||||
|
)
|
||||||
if case .failure(let error) = result { messages.error(error) }
|
if case .failure(let error) = result { messages.error(error) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ final class ApprovalCoordinator: ObservableObject {
|
|||||||
@Published private(set) var state = ApprovalState()
|
@Published private(set) var state = ApprovalState()
|
||||||
|
|
||||||
private let repository: CoreGateway
|
private let repository: CoreGateway
|
||||||
private let preferences: AppPreferencesRepository
|
|
||||||
private let notifications: LocalNotificationService
|
private let notifications: LocalNotificationService
|
||||||
private let visibility: AppVisibility
|
private let visibility: AppVisibility
|
||||||
private let messages: UiMessageController
|
private let messages: UiMessageController
|
||||||
@@ -37,13 +36,11 @@ final class ApprovalCoordinator: ObservableObject {
|
|||||||
|
|
||||||
init(
|
init(
|
||||||
repository: CoreGateway,
|
repository: CoreGateway,
|
||||||
preferences: AppPreferencesRepository,
|
|
||||||
notifications: LocalNotificationService,
|
notifications: LocalNotificationService,
|
||||||
visibility: AppVisibility,
|
visibility: AppVisibility,
|
||||||
messages: UiMessageController
|
messages: UiMessageController
|
||||||
) {
|
) {
|
||||||
self.repository = repository
|
self.repository = repository
|
||||||
self.preferences = preferences
|
|
||||||
self.notifications = notifications
|
self.notifications = notifications
|
||||||
self.visibility = visibility
|
self.visibility = visibility
|
||||||
self.messages = messages
|
self.messages = messages
|
||||||
@@ -68,17 +65,15 @@ final class ApprovalCoordinator: ObservableObject {
|
|||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
// Recompute notifications when any input changes.
|
// Recompute notifications when any input changes.
|
||||||
Publishers.CombineLatest4(
|
Publishers.CombineLatest3(
|
||||||
preferences.$preferences,
|
|
||||||
visibility.$isForeground,
|
visibility.$isForeground,
|
||||||
$state,
|
$state,
|
||||||
notifications.$permission
|
notifications.$permission
|
||||||
)
|
)
|
||||||
.sink { [weak self] preferences, foreground, approvalState, permission in
|
.sink { [weak self] foreground, approvalState, permission in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
Task {
|
Task {
|
||||||
await self.synchronizeNotifications(
|
await self.synchronizeNotifications(
|
||||||
enabled: preferences.notificationsEnabled,
|
|
||||||
foreground: foreground,
|
foreground: foreground,
|
||||||
pending: approvalState.pending,
|
pending: approvalState.pending,
|
||||||
permission: permission
|
permission: permission
|
||||||
@@ -137,30 +132,41 @@ final class ApprovalCoordinator: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func synchronizeNotifications(
|
private func synchronizeNotifications(
|
||||||
enabled: Bool,
|
|
||||||
foreground: Bool,
|
foreground: Bool,
|
||||||
pending: [PendingApproval],
|
pending: [PendingApproval],
|
||||||
permission: NotificationPermission
|
permission: NotificationPermission
|
||||||
) async {
|
) async {
|
||||||
if foreground || !enabled || permission != .granted {
|
// iOS suppresses notifications while the user is in the app (the modal shows
|
||||||
notifications.cancelAll()
|
// instead); macOS presents them even when active (the app window is usually
|
||||||
|
// open), relying on the presenter delegate.
|
||||||
|
#if os(iOS)
|
||||||
|
let suppressed = foreground || permission != .granted
|
||||||
|
#else
|
||||||
|
let suppressed = permission != .granted
|
||||||
|
#endif
|
||||||
|
if suppressed {
|
||||||
|
// Cancel only our own approval notifications — other coordinators
|
||||||
|
// (e.g. transfer-lifecycle) manage their own and must not be wiped.
|
||||||
|
for id in publishedNotificationIds { notifications.cancel(id: Self.notificationId(id)) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for request in pending where !publishedNotificationIds.contains(request.id) {
|
for request in pending where !publishedNotificationIds.contains(request.id) {
|
||||||
|
// Reserve the id *before* awaiting: the CombineLatest can fire several
|
||||||
|
// times near-simultaneously, and without this each pass re-adds the same
|
||||||
|
// notification identifier. macOS coalesces a repeated add of an in-flight
|
||||||
|
// id into a silent update and shows no banner.
|
||||||
|
publishedNotificationIds.insert(request.id)
|
||||||
let receiver = request.receiverName
|
let receiver = request.receiverName
|
||||||
?? request.receiverDeviceName
|
?? request.receiverDeviceName
|
||||||
?? String(localized: "approval_nearby_device")
|
?? String(localized: L10n.Approval.nearbyDevice)
|
||||||
let title = String(localized: "approval_connection_request")
|
let title = String(localized: L10n.Approval.connectionRequest)
|
||||||
let body = String(
|
let body = L10n.Approval.requestBody(receiver: receiver, transferName: request.transferName)
|
||||||
format: String(localized: "approval_request_body"),
|
|
||||||
receiver, request.transferName
|
|
||||||
)
|
|
||||||
let result = await notifications.publish(
|
let result = await notifications.publish(
|
||||||
LocalNotification(id: Self.notificationId(request.id), title: title, body: body)
|
LocalNotification(id: Self.notificationId(request.id), title: title, body: body)
|
||||||
)
|
)
|
||||||
switch result {
|
if case .failure(let error) = result {
|
||||||
case .success: publishedNotificationIds.insert(request.id)
|
publishedNotificationIds.remove(request.id)
|
||||||
case .failure(let error): messages.error(error)
|
messages.error(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SFSafeSymbols
|
||||||
|
|
||||||
/// Non-dismissable receiver-approval modal, presented as a native sheet that can't
|
/// Non-dismissable receiver-approval modal, presented as a native sheet that can't
|
||||||
/// 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)
|
||||||
@@ -38,32 +43,32 @@ private struct ApprovalSheet: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let busy = state.respondingIds.contains(request.id)
|
let busy = state.respondingIds.contains(request.id)
|
||||||
let receiver = request.receiverName ?? request.receiverDeviceName ?? String(localized: "approval_nearby_device")
|
let receiver = request.receiverName ?? request.receiverDeviceName ?? String(localized: L10n.Approval.nearbyDevice)
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
Image(systemName: "checkmark.shield.fill")
|
Image(systemSymbol: .checkmarkShieldFill)
|
||||||
.font(.system(size: 44))
|
.font(.system(size: 44))
|
||||||
.foregroundStyle(.tint)
|
.foregroundStyle(.tint)
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
Text(LocalizedStringKey("approval_connection_request"))
|
Text(String(localized: L10n.Approval.connectionRequest))
|
||||||
.font(.title2).fontWeight(.semibold)
|
.font(.title2).fontWeight(.semibold)
|
||||||
Text(String(format: String(localized: "approval_request_body"), receiver, request.transferName))
|
Text(L10n.Approval.requestBody(receiver: receiver, transferName: request.transferName))
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
Text(String(format: String(localized: "approval_endpoint_id"), request.remoteEndpointId))
|
Text(L10n.Approval.endpointId(deviceId: request.remoteEndpointId))
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
if state.pending.count > 1 {
|
if state.pending.count > 1 {
|
||||||
Text(String(format: String(localized: "approval_pending_count"), state.pending.count))
|
Text(L10n.Approval.pendingCount(count: state.pending.count))
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
if busy { ProgressView() }
|
if busy { ProgressView() }
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
Button(action: { onAccept(request.id) }) {
|
Button(action: { onAccept(request.id) }) {
|
||||||
Text(LocalizedStringKey("button_approve")).frame(maxWidth: .infinity)
|
Text(String(localized: L10n.Button.approve)).frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent).controlSize(.large).disabled(busy)
|
.buttonStyle(.borderedProminent).controlSize(.large).disabled(busy)
|
||||||
Button(role: .destructive, action: { onRefuse(request.id) }) {
|
Button(role: .destructive, action: { onRefuse(request.id) }) {
|
||||||
Text(LocalizedStringKey("button_refuse")).frame(maxWidth: .infinity)
|
Text(String(localized: L10n.Button.refuse)).frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered).controlSize(.large).disabled(busy)
|
.buttonStyle(.bordered).controlSize(.large).disabled(busy)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A transfer-lifecycle moment worth a local notification.
|
||||||
|
enum TransferNotificationKind: Equatable {
|
||||||
|
case sendFailed // A share you own failed.
|
||||||
|
case receiveCompleted // An incoming transfer finished downloading.
|
||||||
|
case receiveFailed // An incoming transfer failed.
|
||||||
|
case receiverCompleted // A receiver finished downloading your shared transfer.
|
||||||
|
case receiverFailed // A receiver's download of your shared transfer failed.
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A notification resolved from core state but not yet published. `transferName`
|
||||||
|
/// is the raw name (may be nil); the coordinator localizes and applies fallbacks.
|
||||||
|
struct PlannedNotification: Equatable {
|
||||||
|
let id: String
|
||||||
|
let kind: TransferNotificationKind
|
||||||
|
let transferName: String?
|
||||||
|
let receiver: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure: transfer-status notifications for this snapshot, excluding already-published
|
||||||
|
/// ids. A terminal transfer yields at most one notification, keyed by (kind, id).
|
||||||
|
func plannedTransferNotifications(_ transfers: [Transfer], published: Set<String>) -> [PlannedNotification] {
|
||||||
|
transfers.compactMap { transfer in
|
||||||
|
let kind: TransferNotificationKind
|
||||||
|
switch (transfer.direction, transfer.status) {
|
||||||
|
case (.send, .failed): kind = .sendFailed
|
||||||
|
case (.receive, .done): kind = .receiveCompleted
|
||||||
|
case (.receive, .failed): kind = .receiveFailed
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
let id = transferNotificationId(kind, transferId: transfer.transferId)
|
||||||
|
guard !published.contains(id) else { return nil }
|
||||||
|
return PlannedNotification(id: id, kind: kind, transferName: transfer.transferName, receiver: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure: one notification per receiver that has finished downloading a shared
|
||||||
|
/// transfer, excluding already-published ids.
|
||||||
|
func plannedReceiverNotifications(_ requests: [ReceiverRequestModel], published: Set<String>) -> [PlannedNotification] {
|
||||||
|
requests.compactMap { request in
|
||||||
|
let kind: TransferNotificationKind
|
||||||
|
let idPrefix: String
|
||||||
|
switch request.status {
|
||||||
|
case .completed: kind = .receiverCompleted; idPrefix = "receiver-completed"
|
||||||
|
case .failed: kind = .receiverFailed; idPrefix = "receiver-failed"
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
let id = "\(idPrefix)-\(request.id)"
|
||||||
|
guard !published.contains(id) else { return nil }
|
||||||
|
return PlannedNotification(
|
||||||
|
id: id, kind: kind,
|
||||||
|
transferName: request.transferName,
|
||||||
|
receiver: request.receiverName ?? request.receiverDeviceName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func transferNotificationId(_ kind: TransferNotificationKind, transferId: UInt64) -> String {
|
||||||
|
switch kind {
|
||||||
|
case .sendFailed: return "send-failed-\(transferId)"
|
||||||
|
case .receiveCompleted: return "receive-completed-\(transferId)"
|
||||||
|
case .receiveFailed: return "receive-failed-\(transferId)"
|
||||||
|
case .receiverCompleted: return "receiver-completed-\(transferId)"
|
||||||
|
case .receiverFailed: return "receiver-failed-\(transferId)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fires local notifications for transfer-lifecycle moments (a receive finishing
|
||||||
|
/// or failing, a share failing, a receiver completing), so a user who left the
|
||||||
|
/// app can see the outcome. Approval prompts are handled by `ApprovalCoordinator`.
|
||||||
|
///
|
||||||
|
/// Gated on the OS notification permission (and, on iOS, on being backgrounded).
|
||||||
|
/// Each moment is terminal, so it is marked seen the first time it is observed and
|
||||||
|
/// never re-published. The first state snapshot — which includes existing history
|
||||||
|
/// such as past receives — only primes those ids as seen, so only new transitions
|
||||||
|
/// notify.
|
||||||
|
@MainActor
|
||||||
|
final class TransferNotificationCoordinator: ObservableObject {
|
||||||
|
private let repository: CoreGateway
|
||||||
|
private let notifications: LocalNotificationService
|
||||||
|
private let visibility: AppVisibility
|
||||||
|
private let messages: UiMessageController
|
||||||
|
|
||||||
|
private var published = Set<String>()
|
||||||
|
private var primedTransfers = false
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(
|
||||||
|
repository: CoreGateway,
|
||||||
|
notifications: LocalNotificationService,
|
||||||
|
visibility: AppVisibility,
|
||||||
|
messages: UiMessageController
|
||||||
|
) {
|
||||||
|
self.repository = repository
|
||||||
|
self.notifications = notifications
|
||||||
|
self.visibility = visibility
|
||||||
|
self.messages = messages
|
||||||
|
|
||||||
|
repository.statePublisher
|
||||||
|
.sink { [weak self] core in
|
||||||
|
guard let self, core.isInitialized else { return }
|
||||||
|
Task { await self.syncTransfers(core.transfers) }
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
repository.signals
|
||||||
|
.sink { [weak self] signal in
|
||||||
|
guard let self else { return }
|
||||||
|
switch signal {
|
||||||
|
case .receiverHistoryChanged(let transferId), .transfersChanged(let transferId):
|
||||||
|
Task { await self.syncReceivers(transferId: transferId) }
|
||||||
|
case .approvalChanged:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// iOS suppresses notifications while the user is in the app (the convention);
|
||||||
|
/// macOS presents them even when active (also the convention — the app window
|
||||||
|
/// is usually open), relying on the presenter delegate to show the banner.
|
||||||
|
private var canPublish: Bool {
|
||||||
|
guard notifications.permission == .granted else { return false }
|
||||||
|
#if os(iOS)
|
||||||
|
return !visibility.isForeground
|
||||||
|
#else
|
||||||
|
return true
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func syncTransfers(_ transfers: [Transfer]) async {
|
||||||
|
let planned = plannedTransferNotifications(transfers, published: published)
|
||||||
|
guard primedTransfers else {
|
||||||
|
// The first snapshot includes existing history (e.g. past receives).
|
||||||
|
// Mark those terminal transfers seen without notifying, so only new
|
||||||
|
// transitions notify.
|
||||||
|
primedTransfers = true
|
||||||
|
for plan in planned { published.insert(plan.id) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for plan in planned { await deliver(plan) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func syncReceivers(transferId: UInt64) async {
|
||||||
|
let result = await repository.receiverRequests(transferId: transferId)
|
||||||
|
switch result {
|
||||||
|
case .success(let requests):
|
||||||
|
for plan in plannedReceiverNotifications(requests, published: published) {
|
||||||
|
await deliver(plan)
|
||||||
|
}
|
||||||
|
case .failure(let error):
|
||||||
|
messages.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark seen unconditionally (a terminal moment notifies at most once), then
|
||||||
|
/// publish only when the gate allows.
|
||||||
|
private func deliver(_ plan: PlannedNotification) async {
|
||||||
|
published.insert(plan.id)
|
||||||
|
guard canPublish else { return }
|
||||||
|
let name = plan.transferName ?? String(localized: L10n.Receive.unknownTransfer)
|
||||||
|
let notification: LocalNotification
|
||||||
|
switch plan.kind {
|
||||||
|
case .sendFailed:
|
||||||
|
notification = LocalNotification(
|
||||||
|
id: plan.id,
|
||||||
|
title: String(localized: L10n.Notifications.sendFailedTitle),
|
||||||
|
body: L10n.Notifications.sendFailedBody(transferName: name))
|
||||||
|
case .receiveCompleted:
|
||||||
|
notification = LocalNotification(
|
||||||
|
id: plan.id,
|
||||||
|
title: String(localized: L10n.Notifications.receiveCompletedTitle),
|
||||||
|
body: L10n.Notifications.receiveCompletedBody(transferName: name))
|
||||||
|
case .receiveFailed:
|
||||||
|
notification = LocalNotification(
|
||||||
|
id: plan.id,
|
||||||
|
title: String(localized: L10n.Notifications.receiveFailedTitle),
|
||||||
|
body: L10n.Notifications.receiveFailedBody(transferName: name))
|
||||||
|
case .receiverCompleted:
|
||||||
|
let receiver = plan.receiver ?? String(localized: L10n.Approval.nearbyDevice)
|
||||||
|
notification = LocalNotification(
|
||||||
|
id: plan.id,
|
||||||
|
title: String(localized: L10n.Notifications.receiverCompletedTitle),
|
||||||
|
body: L10n.Notifications.receiverCompletedBody(receiver: receiver, transferName: name))
|
||||||
|
case .receiverFailed:
|
||||||
|
let receiver = plan.receiver ?? String(localized: L10n.Approval.nearbyDevice)
|
||||||
|
notification = LocalNotification(
|
||||||
|
id: plan.id,
|
||||||
|
title: String(localized: L10n.Notifications.receiverFailedTitle),
|
||||||
|
body: L10n.Notifications.receiverFailedBody(receiver: receiver, transferName: name))
|
||||||
|
}
|
||||||
|
if case .failure(let error) = await notifications.publish(notification) {
|
||||||
|
messages.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import SFSafeSymbols
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
enum ReceiveMethodAvailability { case available, unavailable, hidden }
|
enum ReceiveMethodAvailability { case available, unavailable, hidden }
|
||||||
|
|
||||||
/// Invitation acquisition actions shared by the native Apple feature models.
|
/// Invitation acquisition actions, ported from `ReceiveInvitationActions` (iosMain).
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol ReceiveInvitationActions: AnyObject {
|
protocol ReceiveInvitationActions: AnyObject {
|
||||||
var fileAvailability: ReceiveMethodAvailability { get }
|
var fileAvailability: ReceiveMethodAvailability { get }
|
||||||
@@ -23,25 +24,25 @@ struct ReceiveMethodPanel: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
Text(LocalizedStringKey("receive_choose_method_title")).font(VniType.titleLarge)
|
Text(String(localized: L10n.Receive.chooseMethodTitle)).font(VniType.titleLarge)
|
||||||
Text(LocalizedStringKey("receive_choose_method_body")).foregroundStyle(colors.foregroundLighter)
|
Text(String(localized: L10n.Receive.chooseMethodBody)).foregroundStyle(colors.foregroundLighter)
|
||||||
|
|
||||||
MethodRow(
|
MethodRow(
|
||||||
icon: "doc", titleKey: "receive_method_file", descKey: "receive_method_file_description",
|
icon: .doc, titleKey: L10n.Receive.methodFile, descKey: L10n.Receive.methodFileDescription,
|
||||||
availability: actions.fileAvailability
|
availability: actions.fileAvailability
|
||||||
) { actions.pickInvitation { model.onInvitationResult(.invitationFile, $0) } }
|
) { actions.pickInvitation { model.onInvitationResult(.invitationFile, $0) } }
|
||||||
|
|
||||||
if actions.qrAvailability != .hidden {
|
if actions.qrAvailability != .hidden {
|
||||||
MethodRow(
|
MethodRow(
|
||||||
icon: "qrcode.viewfinder", titleKey: "receive_method_scan", descKey: "receive_method_scan_description",
|
icon: .qrcodeViewfinder, titleKey: L10n.Receive.methodScan, descKey: L10n.Receive.methodScanDescription,
|
||||||
availability: actions.qrAvailability
|
availability: actions.qrAvailability
|
||||||
) { actions.scanQrCode { model.onInvitationResult(.qrCode, $0) } }
|
) { actions.scanQrCode { model.onInvitationResult(.qrCode, $0) } }
|
||||||
}
|
}
|
||||||
if actions.nfcAvailability != .hidden {
|
if actions.nfcAvailability != .hidden {
|
||||||
MethodRow(
|
MethodRow(
|
||||||
icon: "wave.3.right",
|
icon: .wave3Right,
|
||||||
titleOverride: model.state.isWaitingForNfc ? String(localized: "receive_nfc_waiting") : nil,
|
titleOverride: model.state.isWaitingForNfc ? String(localized: L10n.Receive.nfcWaiting) : nil,
|
||||||
titleKey: "receive_method_nfc", descKey: "receive_method_nfc_description",
|
titleKey: L10n.Receive.methodNfc, descKey: L10n.Receive.methodNfcDescription,
|
||||||
availability: model.state.isWaitingForNfc ? .unavailable : actions.nfcAvailability
|
availability: model.state.isWaitingForNfc ? .unavailable : actions.nfcAvailability
|
||||||
) {
|
) {
|
||||||
model.setWaitingForNfc(true)
|
model.setWaitingForNfc(true)
|
||||||
@@ -56,10 +57,10 @@ struct ReceiveMethodPanel: View {
|
|||||||
|
|
||||||
private struct MethodRow: View {
|
private struct MethodRow: View {
|
||||||
@Environment(\.vniColors) private var colors
|
@Environment(\.vniColors) private var colors
|
||||||
let icon: String
|
let icon: SFSymbol
|
||||||
var titleOverride: String? = nil
|
var titleOverride: String? = nil
|
||||||
let titleKey: String
|
let titleKey: String.LocalizationValue
|
||||||
let descKey: String
|
let descKey: String.LocalizationValue
|
||||||
let availability: ReceiveMethodAvailability
|
let availability: ReceiveMethodAvailability
|
||||||
let onTap: () -> Void
|
let onTap: () -> Void
|
||||||
|
|
||||||
@@ -67,20 +68,20 @@ private struct MethodRow: View {
|
|||||||
let enabled = availability == .available
|
let enabled = availability == .available
|
||||||
Button(action: onTap) {
|
Button(action: onTap) {
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
Image(systemName: icon).font(.system(size: 22))
|
Image(systemSymbol: icon).font(.system(size: 22))
|
||||||
.foregroundStyle(enabled ? colors.brandLink : colors.foregroundLighter)
|
.foregroundStyle(enabled ? colors.brandLink : colors.foregroundLighter)
|
||||||
.frame(width: 24)
|
.frame(width: 24)
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
if let titleOverride {
|
if let titleOverride {
|
||||||
Text(titleOverride).font(VniType.bodyLarge)
|
Text(titleOverride).font(VniType.bodyLarge)
|
||||||
} else {
|
} else {
|
||||||
Text(LocalizedStringKey(titleKey)).font(VniType.bodyLarge)
|
Text(String(localized: titleKey)).font(VniType.bodyLarge)
|
||||||
}
|
}
|
||||||
Text(LocalizedStringKey(descKey)).font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
Text(String(localized: descKey)).font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if availability == .unavailable {
|
if availability == .unavailable {
|
||||||
Text(LocalizedStringKey("value_unavailable")).font(VniType.labelSmall).foregroundStyle(colors.foregroundLighter)
|
Text(String(localized: L10n.Value.unavailable)).font(VniType.labelSmall).foregroundStyle(colors.foregroundLighter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
@@ -102,7 +103,7 @@ struct InvitationReviewPanel: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
Text(LocalizedStringKey("receive_review_title")).font(VniType.titleLarge)
|
Text(String(localized: L10n.Receive.reviewTitle)).font(VniType.titleLarge)
|
||||||
if state.isInspecting {
|
if state.isInspecting {
|
||||||
ProgressView().frame(maxWidth: .infinity).padding(40)
|
ProgressView().frame(maxWidth: .infinity).padding(40)
|
||||||
}
|
}
|
||||||
@@ -110,28 +111,31 @@ struct InvitationReviewPanel: View {
|
|||||||
let metadata = inspection.metadata
|
let metadata = inspection.metadata
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text(metadata.transferName).font(VniType.bodyLarge).lineLimit(2)
|
Text(metadata.transferName).font(VniType.bodyLarge).lineLimit(2)
|
||||||
Text("\(metadata.fileCount) \(String(localized: "metadata_files").lowercased()) · \(formatBytes(metadata.totalSize))")
|
Text(L10n.Format.separatedTriple(
|
||||||
|
first: "\(metadata.fileCount)",
|
||||||
|
second: String(localized: L10n.Metadata.files).lowercased(),
|
||||||
|
third: formatBytes(metadata.totalSize)))
|
||||||
.foregroundStyle(colors.foregroundLighter)
|
.foregroundStyle(colors.foregroundLighter)
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(colors.backgroundSurface200, in: RoundedRectangle(cornerRadius: 14))
|
.background(colors.backgroundSurface200, in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
|
||||||
Field(label: String(localized: "field_receiver_name"),
|
Field(label: String(localized: L10n.Field.receiverName),
|
||||||
value: Binding(get: { state.receiverName }, set: { model.setReceiverName($0) }))
|
value: Binding(get: { state.receiverName }, set: { model.setReceiverName($0) }))
|
||||||
Text(state.receiveFolder?.displayName ?? String(localized: "value_unavailable"))
|
Text(state.receiveFolder?.displayName ?? String(localized: L10n.Value.unavailable))
|
||||||
.font(VniType.bodySmall)
|
.font(VniType.bodySmall)
|
||||||
.foregroundStyle(state.folderAccessStatus == .writable ? colors.foregroundLight : colors.destructiveDefault)
|
.foregroundStyle(state.folderAccessStatus == .writable ? colors.foregroundLight : colors.destructiveDefault)
|
||||||
|
|
||||||
if state.isReceiving {
|
if state.isReceiving {
|
||||||
let progressId = state.activeReceiveTransferId
|
let progressId = state.activeReceiveTransferId
|
||||||
?? model.coreState.events.first { $0.direction == "receive" && $0.transferId != nil }?.transferId
|
?? model.coreState.events.first { $0.eventDirection == .receive && $0.transferId != nil }?.transferId
|
||||||
let progress = progressId.flatMap { progressForTransfer(events: model.coreState.events, transferId: $0) }
|
let progress = progressId.flatMap { progressForTransfer(events: model.coreState.events, transferId: $0) }
|
||||||
ProgressRow(labelKey: progress?.labelKey ?? "progress_receiving", progress: progress?.progress, detail: progress?.detail)
|
ProgressRow(labelKey: progress?.labelKey ?? L10n.Progress.receiving, progress: progress?.progress, detail: progress?.detail)
|
||||||
SecondaryButton(title: String(localized: "button_cancel_receive"), action: model.cancelActiveReceive)
|
SecondaryButton(title: String(localized: L10n.Button.cancelReceive), action: model.cancelActiveReceive)
|
||||||
} else {
|
} else {
|
||||||
PrimaryButton(
|
PrimaryButton(
|
||||||
title: String(localized: "button_receive"), action: model.receive,
|
title: String(localized: L10n.Button.receive), action: model.receive,
|
||||||
enabled: state.canReceive(coreInitialized: model.coreState.isInitialized)
|
enabled: state.canReceive(coreInitialized: model.coreState.isInitialized)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,10 @@ final class ReceiveModel: ObservableObject {
|
|||||||
func confirmHistoryDelete() {
|
func confirmHistoryDelete() {
|
||||||
guard let target = state.historyDeleteTarget, !state.isDeletingHistory else { return }
|
guard let target = state.historyDeleteTarget, !state.isDeletingHistory else { return }
|
||||||
state.isDeletingHistory = true
|
state.isDeletingHistory = true
|
||||||
|
// Close synchronously: the alert's dismiss binding runs async and no-ops while
|
||||||
|
// `isDeletingHistory`, which would otherwise leave the target set and macOS
|
||||||
|
// re-present it.
|
||||||
|
state.historyDeleteTarget = nil
|
||||||
Task {
|
Task {
|
||||||
let result: Result<Void, Error>
|
let result: Result<Void, Error>
|
||||||
switch target {
|
switch target {
|
||||||
@@ -133,7 +137,7 @@ final class ReceiveModel: ObservableObject {
|
|||||||
case .success:
|
case .success:
|
||||||
state.historyDeleteTarget = nil
|
state.historyDeleteTarget = nil
|
||||||
state.isDeletingHistory = false
|
state.isDeletingHistory = false
|
||||||
let key = target == .all ? "receive_history_cleared" : "transfer_deleted"
|
let key = target == .all ? L10n.Receive.historyCleared : L10n.Transfer.deleted
|
||||||
messages.tryShow(UiMessage(text: .resource(key), tone: .success))
|
messages.tryShow(UiMessage(text: .resource(key), tone: .success))
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
state.isDeletingHistory = false
|
state.isDeletingHistory = false
|
||||||
@@ -173,9 +177,9 @@ final class ReceiveModel: ObservableObject {
|
|||||||
resetAcquisition()
|
resetAcquisition()
|
||||||
let canReveal = fileSystemService.canRevealReceiveFolder(folder)
|
let canReveal = fileSystemService.canRevealReceiveFolder(folder)
|
||||||
messages.tryShow(UiMessage(
|
messages.tryShow(UiMessage(
|
||||||
text: .resource("receive_completed"),
|
text: .resource(L10n.Receive.completed),
|
||||||
tone: .success,
|
tone: .success,
|
||||||
actionLabel: canReveal ? .resource("button_show_in_files") : nil,
|
actionLabel: canReveal ? .resource(L10n.Button.showInFiles) : nil,
|
||||||
onAction: canReveal ? { self.revealReceiveFolder(folder) } : nil
|
onAction: canReveal ? { self.revealReceiveFolder(folder) } : nil
|
||||||
))
|
))
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
@@ -192,7 +196,7 @@ final class ReceiveModel: ObservableObject {
|
|||||||
messages.tryShow(UiMessage(
|
messages.tryShow(UiMessage(
|
||||||
text: uiText,
|
text: uiText,
|
||||||
tone: .error,
|
tone: .error,
|
||||||
actionLabel: .resource("button_retry"),
|
actionLabel: .resource(L10n.Button.retry),
|
||||||
onAction: { self.receive() }
|
onAction: { self.receive() }
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -202,7 +206,7 @@ final class ReceiveModel: ObservableObject {
|
|||||||
func cancelActiveReceive() {
|
func cancelActiveReceive() {
|
||||||
let transferId = state.activeReceiveTransferId
|
let transferId = state.activeReceiveTransferId
|
||||||
?? coreState.transfers.first { $0.direction == .receive && $0.status == .receiving }?.transferId
|
?? coreState.transfers.first { $0.direction == .receive && $0.status == .receiving }?.transferId
|
||||||
?? coreState.events.first { $0.direction == "receive" && $0.transferId != nil }?.transferId
|
?? coreState.events.first { $0.eventDirection == .receive && $0.transferId != nil }?.transferId
|
||||||
guard let transferId else { return }
|
guard let transferId else { return }
|
||||||
Task {
|
Task {
|
||||||
let result = await repository.cancel(transferId: transferId)
|
let result = await repository.cancel(transferId: transferId)
|
||||||
@@ -222,14 +226,14 @@ final class ReceiveModel: ObservableObject {
|
|||||||
Task {
|
Task {
|
||||||
let result = await fileSystemService.revealReceiveFolder(folder)
|
let result = await fileSystemService.revealReceiveFolder(folder)
|
||||||
if case .failure = result {
|
if case .failure = result {
|
||||||
messages.show(UiMessage(text: .resource("receive_open_files_failed"), tone: .error))
|
messages.show(UiMessage(text: .resource(L10n.Receive.openFilesFailed), tone: .error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func inspectInvitation(_ method: ReceiveMethod, _ raw: String) {
|
private func inspectInvitation(_ method: ReceiveMethod, _ raw: String) {
|
||||||
let ticket = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let ticket = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if ticket.isEmpty { return messages.error(.resource("error_invitation_empty")) }
|
if ticket.isEmpty { return messages.error(.resource(L10n.Error.invitationEmpty)) }
|
||||||
state.isAcquisitionOpen = true
|
state.isAcquisitionOpen = true
|
||||||
state.ticket = ticket
|
state.ticket = ticket
|
||||||
state.method = method
|
state.method = method
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SFSafeSymbols
|
||||||
|
|
||||||
/// Receive screen, rebuilt on native SwiftUI. A grouped `List` of received
|
/// Receive screen, rebuilt on native SwiftUI. A grouped `List` of received
|
||||||
/// transfers with swipe-to-delete, and the acquisition flow as a native sheet.
|
/// transfers with swipe-to-delete, and the acquisition flow as a native sheet.
|
||||||
@@ -22,17 +23,17 @@ struct ReceiveScreen: View {
|
|||||||
history
|
history
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(Text(LocalizedStringKey("receive_title")))
|
.navigationTitle(Text(String(localized: L10n.Receive.title)))
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
Button(action: model.openAcquisition) {
|
Button(action: model.openAcquisition) {
|
||||||
Label(String(localized: "button_receive_files"), systemImage: "plus")
|
Label(String(localized: L10n.Button.receiveFiles), systemSymbol: .plus)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !deletable.isEmpty {
|
if !deletable.isEmpty {
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
Button(role: .destructive, action: model.requestClearHistory) {
|
Button(role: .destructive, action: model.requestClearHistory) {
|
||||||
Label(String(localized: "receive_clear_history"), systemImage: "trash")
|
Label(String(localized: L10n.Receive.clearHistory), systemSymbol: .trash)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,11 +51,11 @@ struct ReceiveScreen: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.alert(
|
.alert(
|
||||||
Text(LocalizedStringKey(clearAllPending ? "receive_clear_history_title" : "receive_delete_history_title")),
|
Text(String(localized: clearAllPending ? L10n.Receive.clearHistoryTitle : L10n.Receive.deleteHistoryTitle)),
|
||||||
isPresented: Binding(get: { model.state.historyDeleteTarget != nil }, set: { if !$0 { Task { @MainActor in model.dismissHistoryDelete() } } })
|
isPresented: Binding(get: { model.state.historyDeleteTarget != nil }, set: { if !$0 { Task { @MainActor in model.dismissHistoryDelete() } } })
|
||||||
) {
|
) {
|
||||||
Button(String(localized: "button_cancel"), role: .cancel, action: model.dismissHistoryDelete)
|
Button(String(localized: L10n.Button.cancel), role: .cancel, action: model.dismissHistoryDelete)
|
||||||
Button(String(localized: clearAllPending ? "receive_clear_history" : "button_delete_transfer"),
|
Button(String(localized: clearAllPending ? L10n.Receive.clearHistory : L10n.Button.deleteTransfer),
|
||||||
role: .destructive, action: model.confirmHistoryDelete)
|
role: .destructive, action: model.confirmHistoryDelete)
|
||||||
} message: {
|
} message: {
|
||||||
historyDeleteMessage
|
historyDeleteMessage
|
||||||
@@ -74,27 +75,36 @@ struct ReceiveScreen: View {
|
|||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
model.requestDeleteHistoryItem(transfer.transferId)
|
model.requestDeleteHistoryItem(transfer.transferId)
|
||||||
} label: {
|
} label: {
|
||||||
Label(String(localized: "button_delete_transfer"), systemImage: "trash")
|
Label(String(localized: L10n.Button.deleteTransfer), systemSymbol: .trash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.contextMenu {
|
||||||
|
if transfer.status.isTerminalReceiveHistory {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
model.requestDeleteHistoryItem(transfer.transferId)
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: L10n.Button.deleteTransfer), systemSymbol: .trash)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text(LocalizedStringKey("receive_history_title"))
|
Text(String(localized: L10n.Receive.historyTitle))
|
||||||
} footer: {
|
} footer: {
|
||||||
Text(LocalizedStringKey("receive_new_subtitle"))
|
Text(String(localized: L10n.Receive.newSubtitle))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var emptyState: some View {
|
private var emptyState: some View {
|
||||||
ContentUnavailableView {
|
ContentUnavailableView {
|
||||||
Label(String(localized: "receive_empty_title"), systemImage: "tray.and.arrow.down")
|
Label(String(localized: L10n.Receive.emptyTitle), systemSymbol: .trayAndArrowDown)
|
||||||
} description: {
|
} description: {
|
||||||
Text(LocalizedStringKey("receive_empty_body"))
|
Text(String(localized: L10n.Receive.emptyBody))
|
||||||
} actions: {
|
} actions: {
|
||||||
Button(action: model.openAcquisition) {
|
Button(action: model.openAcquisition) {
|
||||||
Label(String(localized: "button_receive_files"), systemImage: "plus")
|
Label(String(localized: L10n.Button.receiveFiles), systemSymbol: .plus)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.controlSize(.large)
|
.controlSize(.large)
|
||||||
@@ -107,10 +117,10 @@ struct ReceiveScreen: View {
|
|||||||
private var historyDeleteMessage: some View {
|
private var historyDeleteMessage: some View {
|
||||||
if let target = model.state.historyDeleteTarget {
|
if let target = model.state.historyDeleteTarget {
|
||||||
if target == .all {
|
if target == .all {
|
||||||
Text(LocalizedStringKey("receive_clear_history_description"))
|
Text(String(localized: L10n.Receive.clearHistoryDescription))
|
||||||
} else {
|
} else {
|
||||||
Text(String(format: String(localized: "receive_delete_history_description"),
|
Text(L10n.Receive.deleteHistoryDescription(
|
||||||
transferName(for: target) ?? String(localized: "receive_unknown_transfer")))
|
transferName: transferName(for: target) ?? String(localized: L10n.Receive.unknownTransfer)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,14 +139,14 @@ private struct ReceiveTransferRow: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: "doc")
|
Image(systemSymbol: .doc)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.frame(width: 40, height: 40)
|
.frame(width: 40, height: 40)
|
||||||
.background(.quaternary, in: RoundedRectangle(cornerRadius: 9))
|
.background(.quaternary, in: RoundedRectangle(cornerRadius: 9))
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
Text(transfer.transferName ?? String(localized: "receive_unknown_transfer"))
|
Text(transfer.transferName ?? String(localized: L10n.Receive.unknownTransfer))
|
||||||
.font(.body).lineLimit(1)
|
.font(.body).lineLimit(1)
|
||||||
Text("\(formatBytes(transfer.totalSize)) · \(statusLabel(transfer.status))")
|
Text(L10n.Format.separatedPair(first: formatBytes(transfer.totalSize), second: statusLabel(transfer.status)))
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
if transfer.status == .receiving, let progress {
|
if transfer.status == .receiving, let progress {
|
||||||
ProgressRow(labelKey: progress.labelKey, progress: progress.progress, detail: progress.detail)
|
ProgressRow(labelKey: progress.labelKey, progress: progress.progress, detail: progress.detail)
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ struct SendState: Equatable {
|
|||||||
var selectedTransferId: UInt64?
|
var selectedTransferId: UInt64?
|
||||||
var transferThumbnails: [UInt64: Data] = [:]
|
var transferThumbnails: [UInt64: Data] = [:]
|
||||||
var detailPanel: TransferDetailPanel?
|
var detailPanel: TransferDetailPanel?
|
||||||
|
/// Transfer whose share panel is presented inline from the list context menu
|
||||||
|
/// (distinct from `detailPanel == .share`, which shows it from the detail view).
|
||||||
|
/// Held in the model — not `SendScreen` @State — so the approval flow can dismiss
|
||||||
|
/// it centrally before presenting its modal.
|
||||||
|
var shareTargetId: UInt64?
|
||||||
var receiverHistory: [ReceiverRequestModel] = []
|
var receiverHistory: [ReceiverRequestModel] = []
|
||||||
var isLoadingReceivers = false
|
var isLoadingReceivers = false
|
||||||
var isDeleteConfirmationOpen = false
|
var isDeleteConfirmationOpen = false
|
||||||
@@ -56,6 +61,18 @@ final class SendModel: ObservableObject {
|
|||||||
private let messages: UiMessageController
|
private let messages: UiMessageController
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
/// Fires *after* a share/QR sheet (the detail-view panel or the list-level share
|
||||||
|
/// sheet) has finished animating out. The approval flow waits on this to present
|
||||||
|
/// its modal on macOS, where a sheet shown while another is still dismissing is
|
||||||
|
/// dropped — using the real completion instead of a guessed delay.
|
||||||
|
private let shareSheetsDismissedSubject = PassthroughSubject<Void, Never>()
|
||||||
|
var shareSheetsDismissed: AnyPublisher<Void, Never> {
|
||||||
|
shareSheetsDismissedSubject.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invoked by a share sheet's `onDismiss` completion.
|
||||||
|
func shareSheetDidDismiss() { shareSheetsDismissedSubject.send(()) }
|
||||||
|
|
||||||
init(
|
init(
|
||||||
repository: CoreGateway,
|
repository: CoreGateway,
|
||||||
fileSystemService: FileSystemService,
|
fileSystemService: FileSystemService,
|
||||||
@@ -154,7 +171,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() {
|
||||||
@@ -201,12 +218,26 @@ final class SendModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
func closeDetailPanel() { state.detailPanel = nil }
|
func closeDetailPanel() { state.detailPanel = nil }
|
||||||
|
|
||||||
|
func openShareTarget(_ transferId: UInt64) { state.shareTargetId = transferId }
|
||||||
|
func closeShareTarget() { state.shareTargetId = nil }
|
||||||
|
|
||||||
|
/// Dismisses every share/QR surface at once — the detail-view share panel and the
|
||||||
|
/// list-level share sheet. Used before presenting the receiver-approval modal, so
|
||||||
|
/// no competing sheet is left open (macOS drops a sheet shown over another).
|
||||||
|
func dismissShareSheets() {
|
||||||
|
state.detailPanel = nil
|
||||||
|
state.shareTargetId = nil
|
||||||
|
}
|
||||||
|
|
||||||
func requestDeleteTransfer() { state.isDeleteConfirmationOpen = true }
|
func requestDeleteTransfer() { state.isDeleteConfirmationOpen = true }
|
||||||
func dismissDeleteTransfer() { if !state.isDeleting { state.isDeleteConfirmationOpen = false } }
|
func dismissDeleteTransfer() { if !state.isDeleting { state.isDeleteConfirmationOpen = false } }
|
||||||
|
|
||||||
func confirmDeleteTransfer() {
|
func confirmDeleteTransfer() {
|
||||||
guard let transferId = state.selectedTransferId, !state.isDeleting else { return }
|
guard let transferId = state.selectedTransferId, !state.isDeleting else { return }
|
||||||
state.isDeleting = true
|
state.isDeleting = true
|
||||||
|
// Close synchronously: the alert's dismiss binding runs async and no-ops while
|
||||||
|
// `isDeleting`, which would otherwise leave the flag true and macOS re-present it.
|
||||||
|
state.isDeleteConfirmationOpen = false
|
||||||
Task {
|
Task {
|
||||||
let result = await repository.delete(transferId: transferId)
|
let result = await repository.delete(transferId: transferId)
|
||||||
switch result {
|
switch result {
|
||||||
@@ -217,7 +248,32 @@ final class SendModel: ObservableObject {
|
|||||||
state.receiverHistory = []
|
state.receiverHistory = []
|
||||||
state.isDeleteConfirmationOpen = false
|
state.isDeleteConfirmationOpen = false
|
||||||
state.isDeleting = false
|
state.isDeleting = false
|
||||||
messages.tryShow(UiMessage(text: .resource("transfer_deleted"), tone: .success))
|
messages.tryShow(UiMessage(text: .resource(L10n.Transfer.deleted), tone: .success))
|
||||||
|
case .failure(let error):
|
||||||
|
state.isDeleting = false
|
||||||
|
messages.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a transfer by id, independent of the detail selection — used by the
|
||||||
|
/// list context menu so it can act inline without navigating into the detail.
|
||||||
|
func deleteTransfer(id: UInt64) {
|
||||||
|
if state.isDeleting { return }
|
||||||
|
state.isDeleting = true
|
||||||
|
Task {
|
||||||
|
let result = await repository.delete(transferId: id)
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
filePreviewRepository.remove(transferId: id)
|
||||||
|
if state.selectedTransferId == id {
|
||||||
|
state.selectedTransferId = nil
|
||||||
|
state.detailPanel = nil
|
||||||
|
state.receiverHistory = []
|
||||||
|
}
|
||||||
|
state.isDeleting = false
|
||||||
|
_ = await repository.refresh()
|
||||||
|
messages.tryShow(UiMessage(text: .resource(L10n.Transfer.deleted), tone: .success))
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
state.isDeleting = false
|
state.isDeleting = false
|
||||||
messages.error(error)
|
messages.error(error)
|
||||||
@@ -229,8 +285,19 @@ final class SendModel: ObservableObject {
|
|||||||
/// Uses the core's `respondReceiverRequest` (no backend change); applies to
|
/// Uses the core's `respondReceiverRequest` (no backend change); applies to
|
||||||
/// receivers that are still pending or accepted.
|
/// receivers that are still pending or accepted.
|
||||||
func cancelReceiver(requestId: String) {
|
func cancelReceiver(requestId: String) {
|
||||||
|
respondToReceiver(requestId: requestId, accepted: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Approves a single pending receiver by responding to its request positively.
|
||||||
|
/// A fallback for when the approval modal didn't surface — the pending receiver
|
||||||
|
/// can still be accepted from its row in the transfer's receivers panel.
|
||||||
|
func acceptReceiver(requestId: String) {
|
||||||
|
respondToReceiver(requestId: requestId, accepted: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func respondToReceiver(requestId: String, accepted: Bool) {
|
||||||
Task {
|
Task {
|
||||||
let result = await repository.respondReceiverRequest(requestId: requestId, accepted: false, reason: nil)
|
let result = await repository.respondReceiverRequest(requestId: requestId, accepted: accepted, reason: nil)
|
||||||
switch result {
|
switch result {
|
||||||
case .success:
|
case .success:
|
||||||
if let transferId = state.selectedTransferId { refreshReceivers(transferId) }
|
if let transferId = state.selectedTransferId { refreshReceivers(transferId) }
|
||||||
@@ -249,7 +316,7 @@ final class SendModel: ObservableObject {
|
|||||||
switch result {
|
switch result {
|
||||||
case .success:
|
case .success:
|
||||||
_ = await repository.refresh()
|
_ = await repository.refresh()
|
||||||
messages.tryShow(UiMessage(text: .resource("transfer_event_stopped"), tone: .info))
|
messages.tryShow(UiMessage(text: .resource(L10n.Transfer.eventStopped), tone: .info))
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
messages.error(error)
|
messages.error(error)
|
||||||
}
|
}
|
||||||
@@ -261,10 +328,10 @@ final class SendModel: ObservableObject {
|
|||||||
func onInvitationResult(_ action: InvitationAction, _ result: Result<Void, Error>) {
|
func onInvitationResult(_ action: InvitationAction, _ result: Result<Void, Error>) {
|
||||||
switch result {
|
switch result {
|
||||||
case .success:
|
case .success:
|
||||||
let key: String?
|
let key: String.LocalizationValue?
|
||||||
switch action {
|
switch action {
|
||||||
case .export: key = "transfer_invitation_saved"
|
case .export: key = L10n.Transfer.invitationSaved
|
||||||
case .nfc: key = "transfer_nfc_written"
|
case .nfc: key = L10n.Transfer.nfcWritten
|
||||||
case .share: key = nil // system share sheet already confirms
|
case .share: key = nil // system share sheet already confirms
|
||||||
}
|
}
|
||||||
if let key { messages.tryShow(UiMessage(text: .resource(key), tone: .success)) }
|
if let key { messages.tryShow(UiMessage(text: .resource(key), tone: .success)) }
|
||||||
@@ -297,7 +364,14 @@ final class SendModel: ObservableObject {
|
|||||||
state.transferName = ""
|
state.transferName = ""
|
||||||
state.accessPolicy = .requireApproval
|
state.accessPolicy = .requireApproval
|
||||||
state.isSharing = false
|
state.isSharing = false
|
||||||
messages.show(UiMessage(text: .resource("send_transfer_created"), tone: .success))
|
// Jump straight to the new transfer's share panel (QR + delivery) rather
|
||||||
|
// than dropping the user on the list to drill in manually. Refresh first
|
||||||
|
// so the transfer exists in state before it's selected.
|
||||||
|
_ = await repository.refresh()
|
||||||
|
state.selectedTransferId = share.transferId
|
||||||
|
state.detailPanel = .share
|
||||||
|
refreshReceivers(share.transferId)
|
||||||
|
messages.show(UiMessage(text: .resource(L10n.Send.transferCreated), tone: .success))
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
state.isSharing = false
|
state.isSharing = false
|
||||||
messages.error(error)
|
messages.error(error)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SFSafeSymbols
|
||||||
|
|
||||||
/// Send screen, rebuilt on native SwiftUI. A grouped `List` of outgoing transfers,
|
/// Send screen, rebuilt on native SwiftUI. A grouped `List` of outgoing transfers,
|
||||||
/// with the composer and detail panels as native sheets and delete as an alert.
|
/// with the composer and detail panels as native sheets and delete as an alert.
|
||||||
@@ -6,9 +7,18 @@ struct SendScreen: View {
|
|||||||
@ObservedObject var model: SendModel
|
@ObservedObject var model: SendModel
|
||||||
let windowClass: WindowClass
|
let windowClass: WindowClass
|
||||||
|
|
||||||
|
/// Transfer pending an inline (list-level) delete confirmation.
|
||||||
|
@State private var deleteTarget: Transfer?
|
||||||
|
|
||||||
private var outgoing: [Transfer] {
|
private var outgoing: [Transfer] {
|
||||||
model.coreState.transfers.filter { $0.direction == .send }
|
model.coreState.transfers.filter { $0.direction == .send }
|
||||||
}
|
}
|
||||||
|
/// The transfer whose list-level share sheet is open, resolved from the model's
|
||||||
|
/// `shareTargetId` (kept in the model so the approval flow can dismiss it).
|
||||||
|
private var shareTarget: Transfer? {
|
||||||
|
guard let id = model.state.shareTargetId else { return nil }
|
||||||
|
return outgoing.first { $0.transferId == id }
|
||||||
|
}
|
||||||
private var selectedTransfer: Transfer? {
|
private var selectedTransfer: Transfer? {
|
||||||
guard let id = model.state.selectedTransferId else { return nil }
|
guard let id = model.state.selectedTransferId else { return nil }
|
||||||
return outgoing.first { $0.transferId == id }
|
return outgoing.first { $0.transferId == id }
|
||||||
@@ -27,11 +37,11 @@ struct SendScreen: View {
|
|||||||
catalog
|
catalog
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(Text(LocalizedStringKey("send_title")))
|
.navigationTitle(Text(String(localized: L10n.Send.title)))
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
Button(action: model.openComposer) {
|
Button(action: model.openComposer) {
|
||||||
Label(String(localized: "button_create_new_transfer"), systemImage: "plus")
|
Label(String(localized: L10n.Button.createNewTransfer), systemSymbol: .plus)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,6 +50,19 @@ struct SendScreen: View {
|
|||||||
detailView(for: transfer)
|
detailView(for: transfer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Attached inside the NavigationStack (a different sheet host than the
|
||||||
|
// composer drawer on the outer body, so the two don't clash). Opens the
|
||||||
|
// share panel over the list without navigating into the transfer detail.
|
||||||
|
.adaptiveDrawer(
|
||||||
|
isPresented: Binding(get: { shareTarget != nil }, set: { if !$0 { model.closeShareTarget() } }),
|
||||||
|
windowClass: windowClass,
|
||||||
|
onDismiss: model.closeShareTarget,
|
||||||
|
onDismissed: model.shareSheetDidDismiss
|
||||||
|
) {
|
||||||
|
if let shareTarget {
|
||||||
|
TransferSharePanel(model: model, transfer: shareTarget)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.adaptiveDrawer(
|
.adaptiveDrawer(
|
||||||
isPresented: Binding(get: { model.state.isComposerOpen }, set: { _ in }),
|
isPresented: Binding(get: { model.state.isComposerOpen }, set: { _ in }),
|
||||||
@@ -48,8 +71,24 @@ struct SendScreen: View {
|
|||||||
) {
|
) {
|
||||||
TransferComposer(model: model, windowClass: windowClass)
|
TransferComposer(model: model, windowClass: windowClass)
|
||||||
}
|
}
|
||||||
|
.alert(
|
||||||
|
Text(String(localized: L10n.Transfer.deleteTitle)),
|
||||||
|
isPresented: Binding(get: { deleteTarget != nil }, set: { if !$0 { deleteTarget = nil } })
|
||||||
|
) {
|
||||||
|
Button(String(localized: L10n.Button.cancel), role: .cancel) { deleteTarget = nil }
|
||||||
|
Button(String(localized: L10n.Button.deleteTransfer), role: .destructive) {
|
||||||
|
if let target = deleteTarget { model.deleteTransfer(id: target.transferId) }
|
||||||
|
deleteTarget = nil
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
if let target = deleteTarget {
|
||||||
|
Text(L10n.Transfer.deleteDescription(
|
||||||
|
transferName: target.transferName ?? String(localized: L10n.Send.newTransferTitle)))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// The pushed transfer details view, with its detail-panel sheet and delete
|
/// The pushed transfer details view, with its detail-panel sheet and delete
|
||||||
/// alert attached here so they present from the detail's own context (presenting
|
/// alert attached here so they present from the detail's own context (presenting
|
||||||
/// modals from the parent stack while a detail is pushed is unreliable on macOS).
|
/// modals from the parent stack while a detail is pushed is unreliable on macOS).
|
||||||
@@ -58,21 +97,22 @@ struct SendScreen: View {
|
|||||||
.adaptiveDrawer(
|
.adaptiveDrawer(
|
||||||
isPresented: Binding(get: { model.state.detailPanel != nil }, set: { _ in }),
|
isPresented: Binding(get: { model.state.detailPanel != nil }, set: { _ in }),
|
||||||
windowClass: windowClass,
|
windowClass: windowClass,
|
||||||
onDismiss: model.closeDetailPanel
|
onDismiss: model.closeDetailPanel,
|
||||||
|
onDismissed: model.shareSheetDidDismiss
|
||||||
) {
|
) {
|
||||||
if let panel = model.state.detailPanel {
|
if let panel = model.state.detailPanel {
|
||||||
DetailPanelContent(model: model, transfer: transfer, panel: panel)
|
DetailPanelContent(model: model, transfer: transfer, panel: panel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.alert(
|
.alert(
|
||||||
Text(LocalizedStringKey("transfer_delete_title")),
|
Text(String(localized: L10n.Transfer.deleteTitle)),
|
||||||
isPresented: Binding(get: { model.state.isDeleteConfirmationOpen }, set: { if !$0 { Task { @MainActor in model.dismissDeleteTransfer() } } })
|
isPresented: Binding(get: { model.state.isDeleteConfirmationOpen }, set: { if !$0 { Task { @MainActor in model.dismissDeleteTransfer() } } })
|
||||||
) {
|
) {
|
||||||
Button(String(localized: "button_cancel"), role: .cancel, action: model.dismissDeleteTransfer)
|
Button(String(localized: L10n.Button.cancel), role: .cancel, action: model.dismissDeleteTransfer)
|
||||||
Button(String(localized: "button_delete_transfer"), role: .destructive, action: model.confirmDeleteTransfer)
|
Button(String(localized: L10n.Button.deleteTransfer), role: .destructive, action: model.confirmDeleteTransfer)
|
||||||
} message: {
|
} message: {
|
||||||
Text(String(format: String(localized: "transfer_delete_description"),
|
Text(L10n.Transfer.deleteDescription(
|
||||||
transfer.transferName ?? String(localized: "send_new_transfer_title")))
|
transferName: transfer.transferName ?? String(localized: L10n.Send.newTransferTitle)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,23 +130,45 @@ struct SendScreen: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.contextMenu {
|
||||||
|
if transfer.ticket != nil {
|
||||||
|
Button {
|
||||||
|
model.openShareTarget(transfer.transferId)
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: L10n.Transfer.shareTitle), systemSymbol: .squareAndArrowUp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if transfer.status == .sharing {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
model.stopSharing(transferId: transfer.transferId)
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: L10n.Send.stopSharing), systemSymbol: .stopCircle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
Button(role: .destructive) {
|
||||||
|
deleteTarget = transfer
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: L10n.Button.deleteTransfer), systemSymbol: .trash)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text(LocalizedStringKey("send_transfers_title"))
|
Text(String(localized: L10n.Send.transfersTitle))
|
||||||
} footer: {
|
} footer: {
|
||||||
Text(LocalizedStringKey("send_subtitle"))
|
Text(String(localized: L10n.Send.subtitle))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var emptyState: some View {
|
private var emptyState: some View {
|
||||||
ContentUnavailableView {
|
ContentUnavailableView {
|
||||||
Label(String(localized: "send_empty_title"), systemImage: "paperplane")
|
Label(String(localized: L10n.Send.emptyTitle), systemSymbol: .paperplane)
|
||||||
} description: {
|
} description: {
|
||||||
Text(LocalizedStringKey("send_empty_body"))
|
Text(String(localized: L10n.Send.emptyBody))
|
||||||
} actions: {
|
} actions: {
|
||||||
Button(action: model.openComposer) {
|
Button(action: model.openComposer) {
|
||||||
Label(String(localized: "button_create_new_transfer"), systemImage: "plus")
|
Label(String(localized: L10n.Button.createNewTransfer), systemSymbol: .plus)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.controlSize(.large)
|
.controlSize(.large)
|
||||||
@@ -133,12 +195,12 @@ struct SendScreen: View {
|
|||||||
}
|
}
|
||||||
let combined = fractions.isEmpty ? nil : fractions.reduce(0, +) / Double(fractions.count)
|
let combined = fractions.isEmpty ? nil : fractions.reduce(0, +) / Double(fractions.count)
|
||||||
if active.count == 1 {
|
if active.count == 1 {
|
||||||
return TransferProgress(transferId: transfer.transferId, phase: "transfer", kind: "progress",
|
return TransferProgress(transferId: transfer.transferId, phase: .transfer, kind: .progress,
|
||||||
labelKey: "progress_sending", progress: combined)
|
labelKey: L10n.Progress.sending, progress: combined)
|
||||||
}
|
}
|
||||||
return TransferProgress(transferId: transfer.transferId, phase: "transfer", kind: "progress",
|
return TransferProgress(transferId: transfer.transferId, phase: .transfer, kind: .progress,
|
||||||
labelKey: "progress_sending", progress: combined,
|
labelKey: L10n.Progress.sending, progress: combined,
|
||||||
label: String(format: String(localized: "progress_sending_to_count"), active.count))
|
label: L10n.Progress.sendingToCount(count: active.count))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,19 +216,19 @@ private struct TransferListItem: View {
|
|||||||
.background(.quaternary, in: RoundedRectangle(cornerRadius: 9))
|
.background(.quaternary, in: RoundedRectangle(cornerRadius: 9))
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(transfer.transferName ?? String(localized: "send_new_transfer_title"))
|
Text(transfer.transferName ?? String(localized: L10n.Send.newTransferTitle))
|
||||||
.font(.body).lineLimit(1)
|
.font(.body).lineLimit(1)
|
||||||
Spacer()
|
Spacer()
|
||||||
StatusPill(label: statusLabel(transfer.status), tone: transfer.status.pillTone)
|
StatusPill(label: statusLabel(transfer.status), tone: transfer.status.pillTone)
|
||||||
}
|
}
|
||||||
Text("\(formatBytes(transfer.totalSize)) · \(accessPolicyLabel(transfer.accessPolicy))")
|
Text(L10n.Format.separatedPair(first: formatBytes(transfer.totalSize), second: accessPolicyLabel(transfer.accessPolicy)))
|
||||||
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||||
if let progress, transfer.status == .importing || transfer.status == .sharing {
|
if let progress, transfer.status == .importing || transfer.status == .sharing {
|
||||||
ProgressRow(labelKey: progress.labelKey, progress: progress.progress, detail: progress.detail, labelText: progress.label)
|
ProgressRow(labelKey: progress.labelKey, progress: progress.progress, detail: progress.detail, labelText: progress.label)
|
||||||
.padding(.top, 2)
|
.padding(.top, 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Image(systemName: "chevron.forward")
|
Image(systemSymbol: .chevronForward)
|
||||||
.font(.footnote.weight(.semibold)).foregroundStyle(.tertiary)
|
.font(.footnote.weight(.semibold)).foregroundStyle(.tertiary)
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
@@ -181,7 +243,7 @@ struct FileArtwork: View {
|
|||||||
image.resizable().aspectRatio(contentMode: .fill)
|
image.resizable().aspectRatio(contentMode: .fill)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
} else {
|
} else {
|
||||||
Image(systemName: "doc")
|
Image(systemSymbol: .doc)
|
||||||
.font(.system(size: 18))
|
.font(.system(size: 18))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -189,13 +251,13 @@ struct FileArtwork: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func statusLabel(_ status: TransferStatus) -> String {
|
func statusLabel(_ status: TransferStatus) -> String {
|
||||||
String(localized: String.LocalizationValue(statusLabelKey(status)))
|
String(localized: statusLabelKey(status))
|
||||||
}
|
}
|
||||||
|
|
||||||
func accessPolicyLabel(_ policy: ShareAccessPolicy) -> String {
|
func accessPolicyLabel(_ policy: ShareAccessPolicy) -> String {
|
||||||
switch policy {
|
switch policy {
|
||||||
case .requireApproval: return String(localized: "send_access_approval")
|
case .requireApproval: return String(localized: L10n.Send.accessApproval)
|
||||||
case .anyoneWithTransfer: return String(localized: "send_access_anyone")
|
case .anyoneWithTransfer: return String(localized: L10n.Send.accessAnyone)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SFSafeSymbols
|
||||||
|
|
||||||
/// Transfer composer drawer, ported from `feature/send/TransferComposer.kt`.
|
/// Transfer composer drawer, ported from `feature/send/TransferComposer.kt`.
|
||||||
/// Two steps: choose files/folder, then review + name + access policy + share.
|
/// Two steps: choose files/folder, then review + name + access policy + share.
|
||||||
@@ -23,13 +24,13 @@ struct TransferComposer: View {
|
|||||||
|
|
||||||
private var chooseStep: some View {
|
private var chooseStep: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
Text(LocalizedStringKey("send_choose_file_title")).font(.title2).fontWeight(.semibold)
|
Text(String(localized: L10n.Send.chooseFileTitle)).font(.title2).fontWeight(.semibold)
|
||||||
Text(LocalizedStringKey("send_choose_file_body"))
|
Text(String(localized: L10n.Send.chooseFileBody))
|
||||||
.font(.subheadline).foregroundStyle(.secondary)
|
.font(.subheadline).foregroundStyle(.secondary)
|
||||||
VStack(spacing: 14) {
|
VStack(spacing: 14) {
|
||||||
Image(systemName: "doc").font(.system(size: 30)).foregroundStyle(.tint)
|
Image(systemSymbol: .doc).font(.system(size: 30)).foregroundStyle(.tint)
|
||||||
PrimaryButton(title: String(localized: "button_choose_files"), action: model.selectFile).fixedSize()
|
PrimaryButton(title: String(localized: L10n.Button.chooseFiles), action: model.selectFile).fixedSize()
|
||||||
QuietButton(title: String(localized: "button_choose_folder"), action: model.selectFolder)
|
QuietButton(title: String(localized: L10n.Button.chooseFolder), action: model.selectFolder)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(28)
|
.padding(28)
|
||||||
@@ -39,9 +40,9 @@ struct TransferComposer: View {
|
|||||||
|
|
||||||
private var reviewStep: some View {
|
private var reviewStep: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
Text(LocalizedStringKey("send_review_title")).font(.title2).fontWeight(.semibold)
|
Text(String(localized: L10n.Send.reviewTitle)).font(.title2).fontWeight(.semibold)
|
||||||
if state.selectedFiles.count > 1 {
|
if state.selectedFiles.count > 1 {
|
||||||
Text(String(format: String(localized: "send_selected_files_count"), state.selectedFiles.count))
|
Text(L10n.Send.selectedFilesCount(count: state.selectedFiles.count))
|
||||||
.font(.subheadline).foregroundStyle(.secondary)
|
.font(.subheadline).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
ForEach(state.selectedFiles) { file in
|
ForEach(state.selectedFiles) { file in
|
||||||
@@ -51,52 +52,64 @@ struct TransferComposer: View {
|
|||||||
onRemove: { model.removeSelectedFile(file.value) }
|
onRemove: { model.removeSelectedFile(file.value) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Field(label: String(localized: "field_transfer_name"),
|
Field(label: String(localized: L10n.Field.transferName),
|
||||||
value: Binding(get: { state.transferName }, set: { model.setTransferName($0) }))
|
value: Binding(get: { state.transferName }, set: { model.setTransferName($0) }))
|
||||||
Field(label: String(localized: "field_sender_name"),
|
Field(label: String(localized: L10n.Field.senderName),
|
||||||
value: Binding(get: { state.senderName }, set: { model.setSenderName($0) }))
|
value: Binding(get: { state.senderName }, set: { model.setSenderName($0) }))
|
||||||
Text(LocalizedStringKey("send_access_title")).font(.headline)
|
Text(String(localized: L10n.Send.accessTitle)).font(.headline)
|
||||||
PolicyOption(
|
PolicyOption(
|
||||||
icon: "checkmark.shield", titleKey: "send_access_approval", descKey: "send_access_approval_description",
|
icon: .checkmarkShield, titleKey: L10n.Send.accessApproval, descKey: L10n.Send.accessApprovalDescription,
|
||||||
selected: state.accessPolicy == .requireApproval,
|
selected: state.accessPolicy == .requireApproval,
|
||||||
onTap: { model.setAccessPolicy(.requireApproval) }
|
onTap: { model.setAccessPolicy(.requireApproval) }
|
||||||
)
|
)
|
||||||
PolicyOption(
|
PolicyOption(
|
||||||
icon: "globe", titleKey: "send_access_anyone", descKey: "send_access_anyone_description",
|
icon: .globe, titleKey: L10n.Send.accessAnyone, descKey: L10n.Send.accessAnyoneDescription,
|
||||||
selected: state.accessPolicy == .anyoneWithTransfer,
|
selected: state.accessPolicy == .anyoneWithTransfer,
|
||||||
onTap: { model.setAccessPolicy(.anyoneWithTransfer) }
|
onTap: { model.setAccessPolicy(.anyoneWithTransfer) }
|
||||||
)
|
)
|
||||||
if state.accessPolicy == .anyoneWithTransfer {
|
if state.accessPolicy == .anyoneWithTransfer {
|
||||||
Label(String(localized: "send_access_anyone_warning"), systemImage: "exclamationmark.triangle.fill")
|
Label(String(localized: L10n.Send.accessAnyoneWarning), systemSymbol: .exclamationmarkTriangleFill)
|
||||||
.font(.caption).foregroundStyle(.orange)
|
.font(.caption).foregroundStyle(.orange)
|
||||||
}
|
}
|
||||||
actions
|
actions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var actions: some View {
|
private var actions: some View {
|
||||||
let shareTitle = state.isSharing
|
let shareTitle = state.isSharing
|
||||||
? String(localized: "button_sharing_file") : String(localized: "button_share_file")
|
? String(localized: L10n.Button.sharingFile) : String(localized: L10n.Button.shareFile)
|
||||||
let shareButton = PrimaryButton(
|
return VStack(spacing: 10) {
|
||||||
title: shareTitle, action: model.createShare,
|
PrimaryButton(
|
||||||
enabled: state.canCreateShare(coreInitialized: model.coreState.isInitialized)
|
title: shareTitle, action: model.createShare,
|
||||||
)
|
enabled: state.canCreateShare(coreInitialized: model.coreState.isInitialized)
|
||||||
if windowClass == .phone {
|
)
|
||||||
VStack(spacing: 8) {
|
// Secondary source actions as an even row of bordered buttons rather than
|
||||||
shareButton
|
// bare text links, so they read as controls and align with the primary.
|
||||||
QuietButton(title: String(localized: "button_change_files"), action: model.selectFile, enabled: !state.isSharing)
|
HStack(spacing: 10) {
|
||||||
QuietButton(title: String(localized: "button_choose_folder"), action: model.selectFolder, enabled: !state.isSharing)
|
sourceButton(title: L10n.Button.changeFiles, symbol: .docBadgeArrowUp, action: model.selectFile)
|
||||||
}
|
sourceButton(title: L10n.Button.chooseFolder, symbol: .folder, action: model.selectFolder)
|
||||||
} else {
|
if windowClass != .phone {
|
||||||
HStack(spacing: 8) {
|
sourceButton(title: L10n.Button.clear, symbol: .xmark, action: model.clearSelectedSource)
|
||||||
shareButton.fixedSize()
|
}
|
||||||
QuietButton(title: String(localized: "button_change_files"), action: model.selectFile, enabled: !state.isSharing)
|
|
||||||
QuietButton(title: String(localized: "button_choose_folder"), action: model.selectFolder, enabled: !state.isSharing)
|
|
||||||
QuietButton(title: String(localized: "button_clear"), action: model.clearSelectedSource, enabled: !state.isSharing)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func sourceButton(
|
||||||
|
title: String.LocalizationValue, symbol: SFSymbol, action: @escaping () -> Void
|
||||||
|
) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Label(String(localized: title), systemSymbol: symbol)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.85)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(minHeight: 20)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.large)
|
||||||
|
.tint(.secondary)
|
||||||
|
.disabled(state.isSharing)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SelectedFileCard: View {
|
private struct SelectedFileCard: View {
|
||||||
@@ -116,7 +129,7 @@ private struct SelectedFileCard: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
if canRemove {
|
if canRemove {
|
||||||
Button(role: .destructive, action: onRemove) {
|
Button(role: .destructive, action: onRemove) {
|
||||||
Image(systemName: "trash")
|
Image(systemSymbol: .trash)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
.tint(.red)
|
.tint(.red)
|
||||||
@@ -128,32 +141,32 @@ private struct SelectedFileCard: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var subtitle: String {
|
private var subtitle: String {
|
||||||
if file.isDirectory { return String(localized: "send_folder_label") }
|
if file.isDirectory { return String(localized: L10n.Send.folderLabel) }
|
||||||
if let size = file.sizeBytes { return formatBytes(size) }
|
if let size = file.sizeBytes { return formatBytes(size) }
|
||||||
return String(localized: "send_file_size_unknown")
|
return String(localized: L10n.Send.fileSizeUnknown)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct PolicyOption: View {
|
private struct PolicyOption: View {
|
||||||
let icon: String
|
let icon: SFSymbol
|
||||||
let titleKey: String
|
let titleKey: String.LocalizationValue
|
||||||
let descKey: String
|
let descKey: String.LocalizationValue
|
||||||
let selected: Bool
|
let selected: Bool
|
||||||
let onTap: () -> Void
|
let onTap: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: onTap) {
|
Button(action: onTap) {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: icon)
|
Image(systemSymbol: icon)
|
||||||
.font(.system(size: 20))
|
.font(.system(size: 20))
|
||||||
.foregroundStyle(selected ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary))
|
.foregroundStyle(selected ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary))
|
||||||
.frame(width: 22)
|
.frame(width: 22)
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
Text(LocalizedStringKey(titleKey))
|
Text(String(localized: titleKey))
|
||||||
Text(LocalizedStringKey(descKey)).font(.caption).foregroundStyle(.secondary)
|
Text(String(localized: descKey)).font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
|
Image(systemSymbol: selected ? .checkmarkCircleFill : .circle)
|
||||||
.foregroundStyle(selected ? AnyShapeStyle(.tint) : AnyShapeStyle(.tertiary))
|
.foregroundStyle(selected ? AnyShapeStyle(.tint) : AnyShapeStyle(.tertiary))
|
||||||
}
|
}
|
||||||
.padding(14)
|
.padding(14)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SFSafeSymbols
|
||||||
import CoreImage.CIFilterBuiltins
|
import CoreImage.CIFilterBuiltins
|
||||||
|
|
||||||
/// Transfer details + drawer panels, ported from `feature/send/TransferDetails.kt`.
|
/// Transfer details + drawer panels, ported from `feature/send/TransferDetails.kt`.
|
||||||
@@ -23,78 +24,78 @@ struct TransferDetailsView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
Section {
|
Section {
|
||||||
LabeledContent(String(localized: "metadata_status"), value: statusLabel(transfer.status))
|
LabeledContent(String(localized: L10n.Metadata.status), value: statusLabel(transfer.status))
|
||||||
LabeledContent(String(localized: "metadata_size"), value: formatBytes(transfer.totalSize))
|
LabeledContent(String(localized: L10n.Metadata.size), value: formatBytes(transfer.totalSize))
|
||||||
LabeledContent(String(localized: "send_access_title"), value: accessPolicyLabel(transfer.accessPolicy))
|
LabeledContent(String(localized: L10n.Send.accessTitle), value: accessPolicyLabel(transfer.accessPolicy))
|
||||||
} header: {
|
} header: {
|
||||||
Text(transfer.transferName ?? String(localized: "send_new_transfer_title"))
|
Text(transfer.transferName ?? String(localized: L10n.Send.newTransferTitle))
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
DetailDestination(
|
DetailDestination(
|
||||||
title: String(localized: "transfer_activity_title"),
|
title: String(localized: L10n.Transfer.activityTitle),
|
||||||
description: String(localized: "transfer_activity_description"),
|
description: String(localized: L10n.Transfer.activityDescription),
|
||||||
count: events.filter { $0.transferId == transfer.transferId && $0.isMeaningfulActivity }.count,
|
count: events.filter { $0.transferId == transfer.transferId && $0.isMeaningfulActivity }.count,
|
||||||
onTap: model.openActivity
|
onTap: model.openActivity
|
||||||
)
|
)
|
||||||
DetailDestination(
|
DetailDestination(
|
||||||
title: String(localized: "transfer_receivers_title"),
|
title: String(localized: L10n.Transfer.receiversTitle),
|
||||||
description: receiversDescription(pendingReceivers, completedReceivers),
|
description: receiversDescription(pendingReceivers, completedReceivers),
|
||||||
count: pendingReceivers + completedReceivers,
|
count: pendingReceivers + completedReceivers,
|
||||||
onTap: model.openReceivers
|
onTap: model.openReceivers
|
||||||
)
|
)
|
||||||
DetailDestination(
|
|
||||||
title: String(localized: "transfer_share_title"),
|
|
||||||
description: String(localized: "transfer_share_description"),
|
|
||||||
count: 0,
|
|
||||||
onTap: model.openShare
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if isActiveShare {
|
Section {
|
||||||
Section {
|
if isActiveShare {
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
showStopConfirmation = true
|
showStopConfirmation = true
|
||||||
} label: {
|
} label: {
|
||||||
Label(String(localized: "send_stop_sharing"), systemImage: "stop.circle")
|
Label(String(localized: L10n.Send.stopSharing), systemSymbol: .stopCircle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Button(role: .destructive, action: model.requestDeleteTransfer) {
|
||||||
|
Label(String(localized: L10n.Button.deleteTransfer), systemSymbol: .trash)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.navigationTitle(Text(LocalizedStringKey("send_transfer_details_title")))
|
.navigationTitle(Text(String(localized: L10n.Send.transferDetailsTitle)))
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
#endif
|
#endif
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
Button(role: .destructive, action: model.requestDeleteTransfer) {
|
Button(action: model.openShare) {
|
||||||
Image(systemName: "trash")
|
Label(String(localized: L10n.Transfer.shareTitle), systemSymbol: .squareAndArrowUp)
|
||||||
}
|
}
|
||||||
|
.help(String(localized: L10n.Transfer.shareTitle))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
Text(LocalizedStringKey("send_stop_sharing")),
|
Text(String(localized: L10n.Send.stopSharing)),
|
||||||
isPresented: $showStopConfirmation,
|
isPresented: $showStopConfirmation,
|
||||||
titleVisibility: .visible
|
titleVisibility: .visible
|
||||||
) {
|
) {
|
||||||
Button(String(localized: "send_stop_sharing"), role: .destructive) {
|
Button(String(localized: L10n.Send.stopSharing), role: .destructive) {
|
||||||
model.stopSharing(transferId: transfer.transferId)
|
model.stopSharing(transferId: transfer.transferId)
|
||||||
}
|
}
|
||||||
Button(String(localized: "button_cancel"), role: .cancel) {}
|
Button(String(localized: L10n.Button.cancel), role: .cancel) {}
|
||||||
} message: {
|
} message: {
|
||||||
Text(LocalizedStringKey("send_stop_sharing_description"))
|
Text(String(localized: L10n.Send.stopSharingDescription))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func receiversDescription(_ pending: Int, _ completed: Int) -> String {
|
private func receiversDescription(_ pending: Int, _ completed: Int) -> String {
|
||||||
if pending > 0 && completed > 0 {
|
if pending > 0 && completed > 0 {
|
||||||
return "\(String(format: String(localized: "transfer_receivers_pending"), pending)) · \(String(format: String(localized: "transfer_receivers_completed_count"), completed))"
|
return L10n.Format.separatedPair(
|
||||||
|
first: L10n.Transfer.receiversPending(count: pending),
|
||||||
|
second: L10n.Transfer.receiversCompletedCount(count: completed))
|
||||||
}
|
}
|
||||||
if pending > 0 { return String(format: String(localized: "transfer_receivers_pending"), pending) }
|
if pending > 0 { return L10n.Transfer.receiversPending(count: pending) }
|
||||||
if completed > 0 { return String(format: String(localized: "transfer_receivers_completed_count"), completed) }
|
if completed > 0 { return L10n.Transfer.receiversCompletedCount(count: completed) }
|
||||||
return String(localized: "transfer_receivers_description")
|
return String(localized: L10n.Transfer.receiversDescription)
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct DetailDestination: View {
|
private struct DetailDestination: View {
|
||||||
@@ -111,11 +112,11 @@ private struct DetailDestination: View {
|
|||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
Text("\(count)")
|
Text(verbatim: "\(count)")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Image(systemName: "chevron.forward")
|
Image(systemSymbol: .chevronForward)
|
||||||
.font(.footnote.weight(.semibold)).foregroundStyle(.tertiary)
|
.font(.footnote.weight(.semibold)).foregroundStyle(.tertiary)
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
@@ -141,7 +142,8 @@ struct DetailPanelContent: View {
|
|||||||
loading: model.state.isLoadingReceivers,
|
loading: model.state.isLoadingReceivers,
|
||||||
events: model.coreState.events,
|
events: model.coreState.events,
|
||||||
transferTotalSize: transfer.totalSize,
|
transferTotalSize: transfer.totalSize,
|
||||||
onCancel: model.cancelReceiver
|
onCancel: model.cancelReceiver,
|
||||||
|
onAccept: model.acceptReceiver
|
||||||
)
|
)
|
||||||
case .share:
|
case .share:
|
||||||
TransferSharePanel(model: model, transfer: transfer)
|
TransferSharePanel(model: model, transfer: transfer)
|
||||||
@@ -171,13 +173,13 @@ struct TransferActivityPanel: View {
|
|||||||
let visible = events
|
let visible = events
|
||||||
.filter { $0.transferId == transferId && $0.isMeaningfulActivity }
|
.filter { $0.transferId == transferId && $0.isMeaningfulActivity }
|
||||||
.sorted { $0.timestamp > $1.timestamp }
|
.sorted { $0.timestamp > $1.timestamp }
|
||||||
PanelContainer(title: String(localized: "transfer_activity_title")) {
|
PanelContainer(title: String(localized: L10n.Transfer.activityTitle)) {
|
||||||
if visible.isEmpty {
|
if visible.isEmpty {
|
||||||
Text(LocalizedStringKey("transfer_no_activity")).foregroundStyle(colors.foregroundLighter)
|
Text(String(localized: L10n.Transfer.noActivity)).foregroundStyle(colors.foregroundLighter)
|
||||||
} else {
|
} else {
|
||||||
ForEach(Array(visible.enumerated()), id: \.offset) { index, event in
|
ForEach(Array(visible.enumerated()), id: \.offset) { index, event in
|
||||||
if index > 0 { Divider().overlay(colors.borderDefault) }
|
if index > 0 { Divider().overlay(colors.borderDefault) }
|
||||||
Text(LocalizedStringKey(event.activityTitleKey))
|
Text(String(localized: event.activityTitleKey))
|
||||||
.fontWeight(.medium).padding(.vertical, 14)
|
.fontWeight(.medium).padding(.vertical, 14)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,17 +194,23 @@ struct ReceiverHistoryPanel: View {
|
|||||||
let events: [CoreEventModel]
|
let events: [CoreEventModel]
|
||||||
let transferTotalSize: UInt64
|
let transferTotalSize: UInt64
|
||||||
let onCancel: (String) -> Void
|
let onCancel: (String) -> Void
|
||||||
|
let onAccept: (String) -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
PanelContainer(title: String(localized: "transfer_receivers_title")) {
|
PanelContainer(title: String(localized: L10n.Transfer.receiversTitle)) {
|
||||||
if loading {
|
if loading {
|
||||||
ProgressView().frame(maxWidth: .infinity).padding(40)
|
ProgressView().frame(maxWidth: .infinity).padding(40)
|
||||||
} else if receivers.isEmpty {
|
} else if receivers.isEmpty {
|
||||||
Text(LocalizedStringKey("transfer_no_receivers")).foregroundStyle(colors.foregroundLighter)
|
Text(String(localized: L10n.Transfer.noReceivers)).foregroundStyle(colors.foregroundLighter)
|
||||||
} else {
|
} else {
|
||||||
ForEach(Array(receivers.enumerated()), id: \.element.id) { index, receiver in
|
ForEach(Array(receivers.enumerated()), id: \.element.id) { index, receiver in
|
||||||
if index > 0 { Divider().overlay(colors.borderDefault) }
|
if index > 0 { Divider().overlay(colors.borderDefault) }
|
||||||
ReceiverRow(receiver: receiver, sendProgress: sendProgress(for: receiver), onCancel: onCancel)
|
ReceiverRow(
|
||||||
|
receiver: receiver,
|
||||||
|
sendProgress: sendProgress(for: receiver),
|
||||||
|
onCancel: onCancel,
|
||||||
|
onAccept: onAccept
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,6 +231,7 @@ private struct ReceiverRow: View {
|
|||||||
let receiver: ReceiverRequestModel
|
let receiver: ReceiverRequestModel
|
||||||
let sendProgress: TransferProgress?
|
let sendProgress: TransferProgress?
|
||||||
let onCancel: (String) -> Void
|
let onCancel: (String) -> Void
|
||||||
|
let onAccept: (String) -> Void
|
||||||
|
|
||||||
/// Only pending requests can be cancelled per-receiver: the core rejects a
|
/// Only pending requests can be cancelled per-receiver: the core rejects a
|
||||||
/// negative response to an already-accepted request ("...not approved, or it
|
/// negative response to an already-accepted request ("...not approved, or it
|
||||||
@@ -232,9 +241,10 @@ private struct ReceiverRow: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let name = receiver.receiverName ?? receiver.receiverDeviceName ?? String(localized: "transfer_nearby_device")
|
let name = receiver.receiverName ?? receiver.receiverDeviceName ?? String(localized: L10n.Transfer.nearbyDevice)
|
||||||
let showLive = sendProgress != nil && receiver.status != .completed
|
let showLive = sendProgress != nil && receiver.status != .completed
|
||||||
&& receiver.status != .refused && receiver.status != .expired
|
&& receiver.status != .refused && receiver.status != .expired
|
||||||
|
&& receiver.status != .failed
|
||||||
HStack(alignment: .top, spacing: 12) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text(name).font(VniType.bodyLarge).lineLimit(1)
|
Text(name).font(VniType.bodyLarge).lineLimit(1)
|
||||||
@@ -244,24 +254,38 @@ private struct ReceiverRow: View {
|
|||||||
if showLive, let sendProgress {
|
if showLive, let sendProgress {
|
||||||
ProgressRow(labelKey: sendProgress.labelKey, progress: sendProgress.progress, detail: sendProgress.detail, labelText: sendProgress.label)
|
ProgressRow(labelKey: sendProgress.labelKey, progress: sendProgress.progress, detail: sendProgress.detail, labelText: sendProgress.label)
|
||||||
} else {
|
} else {
|
||||||
Text(LocalizedStringKey(receiver.status.statusTextKey))
|
Text(String(localized: receiver.status.statusTextKey))
|
||||||
.font(VniType.bodySmall).fontWeight(.medium)
|
.font(VniType.bodySmall).fontWeight(.medium)
|
||||||
.foregroundStyle(receiver.status.statusColor(colors))
|
.foregroundStyle(receiver.status.statusColor(colors))
|
||||||
}
|
}
|
||||||
if let reason = receiver.reason, !reason.isEmpty {
|
if let reason = receiver.reason, !reason.isEmpty {
|
||||||
Text(reason).font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
Text(receiverReasonUiText(reason).resolved())
|
||||||
|
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
if isCancelable {
|
if isCancelable {
|
||||||
Button(role: .destructive) {
|
VStack(alignment: .trailing, spacing: 8) {
|
||||||
onCancel(receiver.id)
|
Button(role: .destructive) {
|
||||||
} label: {
|
onCancel(receiver.id)
|
||||||
Text(LocalizedStringKey("button_refuse"))
|
} label: {
|
||||||
.font(VniType.bodySmall)
|
Text(String(localized: L10n.Button.refuse))
|
||||||
|
.font(VniType.bodySmall)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.tint(.red)
|
||||||
|
// Fallback approve action, in case the approval modal didn't surface.
|
||||||
|
Button {
|
||||||
|
onAccept(receiver.id)
|
||||||
|
} label: {
|
||||||
|
Text(String(localized: L10n.Button.approve))
|
||||||
|
.font(VniType.bodySmall).fontWeight(.medium)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 16).padding(.vertical, 7)
|
||||||
|
.background(Color.green, in: Capsule())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderless)
|
|
||||||
.tint(.red)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
@@ -275,25 +299,40 @@ struct TransferSharePanel: View {
|
|||||||
let transfer: Transfer
|
let transfer: Transfer
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
PanelContainer(title: String(localized: "transfer_share_title")) {
|
PanelContainer(title: String(localized: L10n.Transfer.shareTitle)) {
|
||||||
if let ticket = transfer.ticket {
|
switch transfer.invitationPresentation {
|
||||||
qrCard(ticket: ticket)
|
case .ready(let ticket):
|
||||||
Text(LocalizedStringKey("transfer_scan_qr"))
|
let qrImage = QRCode.generate(from: ticket)
|
||||||
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
qrCard(image: qrImage)
|
||||||
.frame(maxWidth: .infinity)
|
if qrImage != nil {
|
||||||
|
Text(String(localized: L10n.Transfer.scanQr))
|
||||||
|
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
ShareActionsView(model: model, transfer: transfer, ticket: ticket)
|
ShareActionsView(model: model, transfer: transfer, ticket: ticket)
|
||||||
} else {
|
case .preparing:
|
||||||
Text(LocalizedStringKey("transfer_event_preparing")).foregroundStyle(colors.foregroundLighter)
|
Text(String(localized: L10n.Transfer.eventPreparing)).foregroundStyle(colors.foregroundLighter)
|
||||||
|
case .unavailable:
|
||||||
|
Text(String(localized: transfer.status == .failed ? L10n.Transfer.eventFailed : L10n.Transfer.eventStopped))
|
||||||
|
.foregroundStyle(colors.foregroundLighter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func qrCard(ticket: String) -> some View {
|
private func qrCard(image: Image?) -> some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
if let qr = QRCode.generate(from: ticket) {
|
if let image {
|
||||||
qr.interpolation(.none).resizable().scaledToFit().padding(14)
|
image.interpolation(.none).resizable().scaledToFit().padding(14)
|
||||||
} else {
|
} else {
|
||||||
ProgressView()
|
VStack(spacing: 10) {
|
||||||
|
Image(systemSymbol: .qrcode)
|
||||||
|
.font(.system(size: 36, weight: .medium))
|
||||||
|
Text(String(localized: L10n.Transfer.qrUnavailable))
|
||||||
|
.font(VniType.bodySmall)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.foregroundStyle(.black.opacity(0.72))
|
||||||
|
.padding(22)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: 268, height: 268)
|
.frame(width: 268, height: 268)
|
||||||
@@ -302,6 +341,26 @@ struct TransferSharePanel: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum TransferInvitationPresentation: Equatable {
|
||||||
|
case preparing
|
||||||
|
case ready(String)
|
||||||
|
case unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Transfer {
|
||||||
|
var invitationPresentation: TransferInvitationPresentation {
|
||||||
|
switch status {
|
||||||
|
case .importing:
|
||||||
|
return .preparing
|
||||||
|
case .sharing:
|
||||||
|
guard let ticket, !ticket.isEmpty else { return .preparing }
|
||||||
|
return .ready(ticket)
|
||||||
|
case .receiving, .done, .failed, .cancelled, .stopped:
|
||||||
|
return .unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - QR generation (CoreImage)
|
// MARK: - QR generation (CoreImage)
|
||||||
|
|
||||||
enum QRCode {
|
enum QRCode {
|
||||||
@@ -334,38 +393,39 @@ extension CoreEventModel {
|
|||||||
"receiver-refused", "receiver-completed", "share-stopped", "failed"].contains(kind)
|
"receiver-refused", "receiver-completed", "share-stopped", "failed"].contains(kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
var activityTitleKey: String {
|
var activityTitleKey: String.LocalizationValue {
|
||||||
if phase == "import" && kind == "started" { return "transfer_event_preparing" }
|
if phase == "import" && kind == "started" { return L10n.Transfer.eventPreparing }
|
||||||
if phase == "ticket" && kind == "created" { return "transfer_event_ready" }
|
if phase == "ticket" && kind == "created" { return L10n.Transfer.eventReady }
|
||||||
if phase == "network" { return "transfer_event_connecting" }
|
if phase == "network" { return L10n.Transfer.eventConnecting }
|
||||||
if phase == "download" { return "transfer_event_downloading" }
|
if phase == "download" { return L10n.Transfer.eventDownloading }
|
||||||
if phase == "export" { return "transfer_event_saving" }
|
if phase == "export" { return L10n.Transfer.eventSaving }
|
||||||
if kind == "receiver-requested" { return "transfer_event_requested" }
|
if kind == "receiver-requested" { return L10n.Transfer.eventRequested }
|
||||||
if kind == "receiver-accepted" || kind == "receiver-auto-approved" { return "transfer_event_approved" }
|
if kind == "receiver-accepted" || kind == "receiver-auto-approved" { return L10n.Transfer.eventApproved }
|
||||||
if kind == "receiver-refused" { return "transfer_event_refused" }
|
if kind == "receiver-refused" { return L10n.Transfer.eventRefused }
|
||||||
if kind == "receiver-completed" { return "transfer_event_completed" }
|
if kind == "receiver-completed" { return L10n.Transfer.eventCompleted }
|
||||||
if kind == "share-stopped" || (phase == "lifecycle" && kind == "cancelled") { return "transfer_event_stopped" }
|
if kind == "share-stopped" || (phase == "lifecycle" && kind == "cancelled") { return L10n.Transfer.eventStopped }
|
||||||
if kind == "failed" { return "transfer_event_failed" }
|
if kind == "failed" { return L10n.Transfer.eventFailed }
|
||||||
return "transfer_event_updated"
|
return L10n.Transfer.eventUpdated
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ReceiverDeliveryStatus {
|
extension ReceiverDeliveryStatus {
|
||||||
var statusTextKey: String {
|
var statusTextKey: String.LocalizationValue {
|
||||||
switch self {
|
switch self {
|
||||||
case .requested: return "transfer_receiver_requested"
|
case .requested: return L10n.Transfer.receiverRequested
|
||||||
case .accepted: return "transfer_receiver_accepted"
|
case .accepted: return L10n.Transfer.receiverAccepted
|
||||||
case .refused: return "transfer_receiver_refused"
|
case .refused: return L10n.Transfer.receiverRefused
|
||||||
case .expired: return "transfer_receiver_expired"
|
case .expired: return L10n.Transfer.receiverExpired
|
||||||
case .completed: return "transfer_receiver_completed"
|
case .completed: return L10n.Transfer.receiverCompleted
|
||||||
case .unknown: return "transfer_receiver_unknown"
|
case .failed: return L10n.Transfer.receiverFailed
|
||||||
|
case .unknown: return L10n.Transfer.receiverUnknown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func statusColor(_ colors: VniDropColors) -> Color {
|
func statusColor(_ colors: VniDropColors) -> Color {
|
||||||
switch self {
|
switch self {
|
||||||
case .completed: return colors.brandDefault
|
case .completed: return colors.brandDefault
|
||||||
case .refused, .expired: return colors.destructiveDefault
|
case .refused, .expired, .failed: return colors.destructiveDefault
|
||||||
default: return colors.foregroundLighter
|
default: return colors.foregroundLighter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import SwiftUI
|
|||||||
|
|
||||||
enum NfcShareAvailability { case available, unavailable, hidden }
|
enum NfcShareAvailability { case available, unavailable, hidden }
|
||||||
|
|
||||||
/// Invitation delivery actions shared by the native Apple feature models.
|
/// Invitation delivery actions, ported from `TransferShareActions` (iosMain).
|
||||||
/// Platform implementations perform export, native share, and NFC write.
|
/// Platform implementations perform export, native share, and NFC write.
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol TransferShareActions: AnyObject {
|
protocol TransferShareActions: AnyObject {
|
||||||
@@ -30,7 +30,7 @@ struct ShareActionsView: View {
|
|||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
if actions.nfcAvailability != .hidden {
|
if actions.nfcAvailability != .hidden {
|
||||||
SecondaryButton(
|
SecondaryButton(
|
||||||
title: writingNfc ? String(localized: "transfer_nfc_waiting") : String(localized: "button_write_nfc"),
|
title: writingNfc ? String(localized: L10n.Transfer.nfcWaiting) : String(localized: L10n.Button.writeNfc),
|
||||||
action: {
|
action: {
|
||||||
writingNfc = true
|
writingNfc = true
|
||||||
actions.writeInvitationToNfc(ticket: ticket) { result in
|
actions.writeInvitationToNfc(ticket: ticket) { result in
|
||||||
@@ -41,16 +41,16 @@ struct ShareActionsView: View {
|
|||||||
enabled: actions.nfcAvailability == .available && !writingNfc
|
enabled: actions.nfcAvailability == .available && !writingNfc
|
||||||
)
|
)
|
||||||
if actions.nfcAvailability == .unavailable {
|
if actions.nfcAvailability == .unavailable {
|
||||||
Text(LocalizedStringKey("transfer_nfc_unavailable"))
|
Text(String(localized: L10n.Transfer.nfcUnavailable))
|
||||||
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SecondaryButton(title: String(localized: "button_download_invitation"), action: {
|
SecondaryButton(title: String(localized: L10n.Button.downloadInvitation), action: {
|
||||||
actions.exportInvitation(ticket: ticket, transferName: transfer.transferName ?? "") {
|
actions.exportInvitation(ticket: ticket, transferName: transfer.transferName ?? "") {
|
||||||
model.onInvitationResult(.export, $0)
|
model.onInvitationResult(.export, $0)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
PrimaryButton(title: String(localized: "button_native_share"), action: {
|
PrimaryButton(title: String(localized: L10n.Button.nativeShare), action: {
|
||||||
actions.shareInvitation(ticket: ticket, transferName: transfer.transferName ?? "") {
|
actions.shareInvitation(ticket: ticket, transferName: transfer.transferName ?? "") {
|
||||||
model.onInvitationResult(.share, $0)
|
model.onInvitationResult(.share, $0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,12 +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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the diagnostics stack is compiled in (mirrors DiagnosticsBuildConfig).
|
|
||||||
enum DiagnosticsBuildConfig {
|
|
||||||
static let included = false
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,19 +7,21 @@ enum SettingsSection: Hashable {
|
|||||||
case preferences
|
case preferences
|
||||||
case appearance
|
case appearance
|
||||||
case notifications
|
case notifications
|
||||||
|
case network
|
||||||
case storage
|
case storage
|
||||||
case about
|
case about
|
||||||
case bugReport
|
case bugReport
|
||||||
|
|
||||||
var titleKey: String {
|
var titleKey: String.LocalizationValue {
|
||||||
switch self {
|
switch self {
|
||||||
case .overview: return "settings_title"
|
case .overview: return L10n.Settings.title
|
||||||
case .preferences: return "preferences_title"
|
case .preferences: return L10n.Preferences.title
|
||||||
case .appearance: return "appearance_title"
|
case .appearance: return L10n.Appearance.title
|
||||||
case .notifications: return "notifications_title"
|
case .notifications: return L10n.Notifications.title
|
||||||
case .storage: return "storage_title"
|
case .network: return L10n.Settings.networkTitle
|
||||||
case .about: return "about_title"
|
case .storage: return L10n.Storage.title
|
||||||
case .bugReport: return "about_bug_report"
|
case .about: return L10n.About.title
|
||||||
|
case .bugReport: return L10n.About.bugReport
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,9 +29,10 @@ enum SettingsSection: Hashable {
|
|||||||
/// On-disk usage breakdown for the Storage screen.
|
/// On-disk usage breakdown for the Storage screen.
|
||||||
struct StorageBreakdown: Equatable {
|
struct StorageBreakdown: Equatable {
|
||||||
var receivedFiles: UInt64 = 0
|
var receivedFiles: UInt64 = 0
|
||||||
var transferData: UInt64 = 0
|
var transferCache: UInt64 = 0
|
||||||
|
var appData: UInt64 = 0
|
||||||
var temporary: UInt64 = 0
|
var temporary: UInt64 = 0
|
||||||
var total: UInt64 { receivedFiles + transferData + temporary }
|
var total: UInt64 { receivedFiles + transferCache + appData + temporary }
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SettingsState: Equatable {
|
struct SettingsState: Equatable {
|
||||||
@@ -40,9 +43,15 @@ struct SettingsState: Equatable {
|
|||||||
var isValidatingFolder = false
|
var isValidatingFolder = false
|
||||||
var supportsCustomReceiveFolders = true
|
var supportsCustomReceiveFolders = true
|
||||||
var themeMode: ThemeMode = .system
|
var themeMode: ThemeMode = .system
|
||||||
var notificationsEnabled = false
|
|
||||||
var notificationPermission: NotificationPermission = .notDetermined
|
var notificationPermission: NotificationPermission = .notDetermined
|
||||||
var diagnosticsEnabled = false
|
var relayMode: RelayPreferenceMode = .automatic
|
||||||
|
var relayURLs: [String] = []
|
||||||
|
var relayValidationError: RelayConfigurationValidationError?
|
||||||
|
var relayConfigurationIsDirty = false
|
||||||
|
var isApplyingRelayConfiguration = false
|
||||||
|
var hasActiveNetworkWork = false
|
||||||
|
var endpointId: String?
|
||||||
|
var relayApplyErrorKey: String.LocalizationValue?
|
||||||
var deviceInfo: DeviceInfo?
|
var deviceInfo: DeviceInfo?
|
||||||
var appVersion = ""
|
var appVersion = ""
|
||||||
var isLoadingDeviceInfo = false
|
var isLoadingDeviceInfo = false
|
||||||
@@ -55,23 +64,34 @@ struct SettingsState: Equatable {
|
|||||||
var bugLogPreviewBytes = 0
|
var bugLogPreviewBytes = 0
|
||||||
var storage: StorageBreakdown?
|
var storage: StorageBreakdown?
|
||||||
var isCalculatingStorage = false
|
var isCalculatingStorage = false
|
||||||
|
var storageLoadFailed = false
|
||||||
var isDeletingTransfers = false
|
var isDeletingTransfers = false
|
||||||
|
var isCleaningStorage = false
|
||||||
|
|
||||||
static func == (lhs: SettingsState, rhs: SettingsState) -> Bool {
|
static func == (lhs: SettingsState, rhs: SettingsState) -> Bool {
|
||||||
lhs.selectedSection == rhs.selectedSection && lhs.username == rhs.username
|
lhs.selectedSection == rhs.selectedSection && lhs.username == rhs.username
|
||||||
&& lhs.receiveFolder == rhs.receiveFolder && lhs.folderAccessStatus == rhs.folderAccessStatus
|
&& lhs.receiveFolder == rhs.receiveFolder && lhs.folderAccessStatus == rhs.folderAccessStatus
|
||||||
&& lhs.isValidatingFolder == rhs.isValidatingFolder
|
&& lhs.isValidatingFolder == rhs.isValidatingFolder
|
||||||
&& lhs.supportsCustomReceiveFolders == rhs.supportsCustomReceiveFolders
|
&& lhs.supportsCustomReceiveFolders == rhs.supportsCustomReceiveFolders
|
||||||
&& lhs.themeMode == rhs.themeMode && lhs.notificationsEnabled == rhs.notificationsEnabled
|
&& lhs.themeMode == rhs.themeMode
|
||||||
&& lhs.notificationPermission == rhs.notificationPermission
|
&& lhs.notificationPermission == rhs.notificationPermission
|
||||||
&& lhs.diagnosticsEnabled == rhs.diagnosticsEnabled && lhs.appVersion == rhs.appVersion
|
&& lhs.appVersion == rhs.appVersion
|
||||||
|
&& lhs.relayMode == rhs.relayMode && lhs.relayURLs == rhs.relayURLs
|
||||||
|
&& lhs.relayValidationError == rhs.relayValidationError
|
||||||
|
&& lhs.relayConfigurationIsDirty == rhs.relayConfigurationIsDirty
|
||||||
|
&& lhs.isApplyingRelayConfiguration == rhs.isApplyingRelayConfiguration
|
||||||
|
&& lhs.hasActiveNetworkWork == rhs.hasActiveNetworkWork
|
||||||
|
&& lhs.endpointId == rhs.endpointId
|
||||||
|
&& lhs.relayApplyErrorKey == rhs.relayApplyErrorKey
|
||||||
&& lhs.isLoadingDeviceInfo == rhs.isLoadingDeviceInfo
|
&& lhs.isLoadingDeviceInfo == rhs.isLoadingDeviceInfo
|
||||||
&& lhs.bugWhatHappened == rhs.bugWhatHappened && lhs.bugExpected == rhs.bugExpected
|
&& lhs.bugWhatHappened == rhs.bugWhatHappened && lhs.bugExpected == rhs.bugExpected
|
||||||
&& lhs.bugSteps == rhs.bugSteps && lhs.bugContact == rhs.bugContact
|
&& lhs.bugSteps == rhs.bugSteps && lhs.bugContact == rhs.bugContact
|
||||||
&& lhs.bugIncludeLogs == rhs.bugIncludeLogs && lhs.isSubmittingBugReport == rhs.isSubmittingBugReport
|
&& lhs.bugIncludeLogs == rhs.bugIncludeLogs && lhs.isSubmittingBugReport == rhs.isSubmittingBugReport
|
||||||
&& lhs.bugLogPreviewBytes == rhs.bugLogPreviewBytes
|
&& lhs.bugLogPreviewBytes == rhs.bugLogPreviewBytes
|
||||||
&& lhs.storage == rhs.storage && lhs.isCalculatingStorage == rhs.isCalculatingStorage
|
&& lhs.storage == rhs.storage && lhs.isCalculatingStorage == rhs.isCalculatingStorage
|
||||||
|
&& lhs.storageLoadFailed == rhs.storageLoadFailed
|
||||||
&& lhs.isDeletingTransfers == rhs.isDeletingTransfers
|
&& lhs.isDeletingTransfers == rhs.isDeletingTransfers
|
||||||
|
&& lhs.isCleaningStorage == rhs.isCleaningStorage
|
||||||
&& lhs.deviceInfo?.operatingSystem == rhs.deviceInfo?.operatingSystem
|
&& lhs.deviceInfo?.operatingSystem == rhs.deviceInfo?.operatingSystem
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,11 +110,10 @@ final class SettingsModel: ObservableObject {
|
|||||||
private let notifications: LocalNotificationService
|
private let notifications: LocalNotificationService
|
||||||
private let messages: UiMessageController
|
private let messages: UiMessageController
|
||||||
private let bugReports: BugReportService
|
private let bugReports: BugReportService
|
||||||
private let diagnosticsIncluded: Bool
|
|
||||||
|
|
||||||
private var enableNotificationsAfterSettings = false
|
|
||||||
private var usernamePersistTask: Task<Void, Never>?
|
private var usernamePersistTask: Task<Void, Never>?
|
||||||
private var hasLocalUsernameDraft = false
|
private var hasLocalUsernameDraft = false
|
||||||
|
private var hasRelayConfigurationDraft = false
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
init(
|
init(
|
||||||
@@ -105,8 +124,7 @@ final class SettingsModel: ObservableObject {
|
|||||||
preferences: AppPreferencesRepository,
|
preferences: AppPreferencesRepository,
|
||||||
notifications: LocalNotificationService,
|
notifications: LocalNotificationService,
|
||||||
messages: UiMessageController,
|
messages: UiMessageController,
|
||||||
bugReports: BugReportService,
|
bugReports: BugReportService
|
||||||
diagnosticsIncluded: Bool = DiagnosticsBuildConfig.included
|
|
||||||
) {
|
) {
|
||||||
self.environment = environment
|
self.environment = environment
|
||||||
self.deviceInfoProvider = deviceInfoProvider
|
self.deviceInfoProvider = deviceInfoProvider
|
||||||
@@ -116,7 +134,6 @@ final class SettingsModel: ObservableObject {
|
|||||||
self.notifications = notifications
|
self.notifications = notifications
|
||||||
self.messages = messages
|
self.messages = messages
|
||||||
self.bugReports = bugReports
|
self.bugReports = bugReports
|
||||||
self.diagnosticsIncluded = diagnosticsIncluded
|
|
||||||
self.state = SettingsState(
|
self.state = SettingsState(
|
||||||
supportsCustomReceiveFolders: fileSystemService.supportsCustomReceiveFolders,
|
supportsCustomReceiveFolders: fileSystemService.supportsCustomReceiveFolders,
|
||||||
appVersion: environment.appVersion
|
appVersion: environment.appVersion
|
||||||
@@ -130,12 +147,29 @@ final class SettingsModel: ObservableObject {
|
|||||||
self.state.username = self.hasLocalUsernameDraft ? self.state.username : prefs.username
|
self.state.username = self.hasLocalUsernameDraft ? self.state.username : prefs.username
|
||||||
self.state.receiveFolder = folder
|
self.state.receiveFolder = folder
|
||||||
self.state.themeMode = prefs.themeMode
|
self.state.themeMode = prefs.themeMode
|
||||||
self.state.notificationsEnabled = prefs.notificationsEnabled
|
if !self.hasRelayConfigurationDraft {
|
||||||
self.state.diagnosticsEnabled = prefs.diagnosticsEnabled
|
self.state.relayMode = prefs.relayConfiguration.mode
|
||||||
|
self.state.relayURLs = prefs.relayConfiguration.relayURLs
|
||||||
|
self.state.relayConfigurationIsDirty = false
|
||||||
|
}
|
||||||
if folder != previousFolder { Task { await self.validateFolder(folder) } }
|
if folder != previousFolder { Task { await self.validateFolder(folder) } }
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
repository.statePublisher
|
||||||
|
.sink { [weak self] coreState in
|
||||||
|
guard let self else { return }
|
||||||
|
let hasActiveWork = (coreState.status?.activeTransfers ?? 0) > 0
|
||||||
|
|| (coreState.status?.activeShares ?? 0) > 0
|
||||||
|
|| coreState.transfers.contains(where: { $0.status.isActiveTransfer })
|
||||||
|
self.state.hasActiveNetworkWork = hasActiveWork
|
||||||
|
self.state.endpointId = coreState.status?.endpointId
|
||||||
|
if !hasActiveWork && self.state.relayApplyErrorKey == L10n.Relay.applyActiveTransfers {
|
||||||
|
self.state.relayApplyErrorKey = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
refreshNotificationPermission()
|
refreshNotificationPermission()
|
||||||
loadDeviceInfo()
|
loadDeviceInfo()
|
||||||
}
|
}
|
||||||
@@ -167,42 +201,141 @@ 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() }
|
||||||
|
|
||||||
func setNotificationsEnabled(_ enabled: Bool) {
|
/// Whether the current receive folder is the platform default (so the reset
|
||||||
|
/// action can be hidden when it would be a no-op). Compared by location, not
|
||||||
|
/// display name, which can differ once resolved.
|
||||||
|
var isUsingDefaultReceiveFolder: Bool {
|
||||||
|
guard let folder = state.receiveFolder else { return true }
|
||||||
|
let fallback = fileSystemService.defaultReceiveFolder()
|
||||||
|
return folder.kind == fallback.kind && folder.value == fallback.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the OS for notification permission. This is the only time the app can
|
||||||
|
/// grant it; disabling or fine-tuning afterwards happens in the Settings app.
|
||||||
|
func requestNotifications() {
|
||||||
Task {
|
Task {
|
||||||
if !enabled {
|
|
||||||
preferences.setNotificationsEnabled(false)
|
|
||||||
notifications.cancelAll()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let permission = await notifications.requestPermission()
|
let permission = await notifications.requestPermission()
|
||||||
state.notificationPermission = permission
|
state.notificationPermission = permission
|
||||||
if permission == .granted {
|
if permission == .unsupported {
|
||||||
await enableNotifications()
|
messages.show(UiMessage(text: .resource(L10n.Notifications.unsupported), tone: .warning))
|
||||||
} else {
|
|
||||||
preferences.setNotificationsEnabled(false)
|
|
||||||
let key = permission == .unsupported ? "notifications_unsupported" : "notifications_permission_denied"
|
|
||||||
messages.show(UiMessage(
|
|
||||||
text: .resource(key),
|
|
||||||
tone: .warning,
|
|
||||||
actionLabel: permission == .denied ? .resource("button_open_settings") : nil,
|
|
||||||
onAction: permission == .denied ? { self.openNotificationSettings() } : nil
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setDiagnosticsEnabled(_ enabled: Bool) {
|
// MARK: - Network
|
||||||
if !diagnosticsIncluded { return }
|
|
||||||
Task {
|
func setRelayMode(_ mode: RelayPreferenceMode) {
|
||||||
preferences.setDiagnosticsEnabled(enabled)
|
hasRelayConfigurationDraft = true
|
||||||
messages.show(UiMessage(
|
state.relayMode = mode
|
||||||
text: .resource(enabled ? "diagnostics_enabled_message" : "diagnostics_disabled_message"),
|
if mode.usesCustomRelayURLs && state.relayURLs.isEmpty { state.relayURLs = [""] }
|
||||||
tone: .success
|
updateRelayConfigurationDraft()
|
||||||
))
|
}
|
||||||
|
|
||||||
|
func setRelayURL(_ value: String, at index: Int) {
|
||||||
|
guard state.relayURLs.indices.contains(index) else { return }
|
||||||
|
hasRelayConfigurationDraft = true
|
||||||
|
state.relayURLs[index] = value
|
||||||
|
updateRelayConfigurationDraft()
|
||||||
|
}
|
||||||
|
|
||||||
|
func addRelayURL() {
|
||||||
|
guard state.relayURLs.count < RelayConfigurationValidator.maximumRelayCount else { return }
|
||||||
|
hasRelayConfigurationDraft = true
|
||||||
|
state.relayURLs.append("")
|
||||||
|
updateRelayConfigurationDraft()
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeRelayURL(at index: Int) {
|
||||||
|
guard state.relayURLs.indices.contains(index) else { return }
|
||||||
|
hasRelayConfigurationDraft = true
|
||||||
|
state.relayURLs.remove(at: index)
|
||||||
|
if state.relayURLs.isEmpty { state.relayURLs = [""] }
|
||||||
|
updateRelayConfigurationDraft()
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyRelayConfiguration() {
|
||||||
|
guard !state.isApplyingRelayConfiguration, state.relayConfigurationIsDirty else { return }
|
||||||
|
|
||||||
|
let configuration: RelayConfiguration
|
||||||
|
do {
|
||||||
|
configuration = try RelayConfigurationValidator.validate(
|
||||||
|
mode: state.relayMode,
|
||||||
|
relayURLs: state.relayURLs,
|
||||||
|
retainedRelayURLs: preferences.preferences.relayConfiguration.relayURLs
|
||||||
|
)
|
||||||
|
} catch let error as RelayConfigurationValidationError {
|
||||||
|
state.relayValidationError = error
|
||||||
|
state.relayApplyErrorKey = nil
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let coreState = repository.state
|
||||||
|
let hasActiveWork = (coreState.status?.activeTransfers ?? 0) > 0
|
||||||
|
|| (coreState.status?.activeShares ?? 0) > 0
|
||||||
|
|| coreState.transfers.contains(where: { $0.status.isActiveTransfer })
|
||||||
|
guard !hasActiveWork else {
|
||||||
|
state.hasActiveNetworkWork = true
|
||||||
|
state.relayApplyErrorKey = L10n.Relay.applyActiveTransfers
|
||||||
|
messages.show(UiMessage(text: .resource(L10n.Relay.applyActiveTransfers), tone: .warning))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let previousConfiguration = preferences.preferences.relayConfiguration
|
||||||
|
state.relayValidationError = nil
|
||||||
|
state.relayApplyErrorKey = nil
|
||||||
|
state.isApplyingRelayConfiguration = true
|
||||||
|
Task {
|
||||||
|
let applyResult = await repository.initialize(
|
||||||
|
appDataDir: environment.defaultCoreDataDir,
|
||||||
|
networkConfiguration: configuration
|
||||||
|
)
|
||||||
|
switch applyResult {
|
||||||
|
case .success:
|
||||||
|
hasRelayConfigurationDraft = false
|
||||||
|
preferences.setRelayConfiguration(configuration)
|
||||||
|
state.isApplyingRelayConfiguration = false
|
||||||
|
state.relayConfigurationIsDirty = false
|
||||||
|
messages.show(UiMessage(text: .resource(L10n.Relay.settingsApplied), tone: .success))
|
||||||
|
case .failure(let error):
|
||||||
|
if let lifecycleError = error as? CoreNetworkLifecycleError {
|
||||||
|
state.isApplyingRelayConfiguration = false
|
||||||
|
switch lifecycleError {
|
||||||
|
case .activeNetworkWork:
|
||||||
|
state.hasActiveNetworkWork = true
|
||||||
|
state.relayApplyErrorKey = L10n.Relay.applyActiveTransfers
|
||||||
|
case .transitionInProgress:
|
||||||
|
state.relayApplyErrorKey = L10n.Relay.applyFailed
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let rollbackResult = await repository.initialize(
|
||||||
|
appDataDir: environment.defaultCoreDataDir,
|
||||||
|
networkConfiguration: previousConfiguration
|
||||||
|
)
|
||||||
|
state.isApplyingRelayConfiguration = false
|
||||||
|
if case .success = rollbackResult {
|
||||||
|
state.relayApplyErrorKey = L10n.Relay.applyFailed
|
||||||
|
messages.show(UiMessage(text: .resource(L10n.Relay.applyFailed), tone: .error))
|
||||||
|
} else {
|
||||||
|
state.relayApplyErrorKey = L10n.Relay.restoreFailed
|
||||||
|
messages.show(UiMessage(text: .resource(L10n.Relay.restoreFailed), tone: .error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateRelayConfigurationDraft() {
|
||||||
|
state.relayValidationError = nil
|
||||||
|
state.relayApplyErrorKey = nil
|
||||||
|
let saved = preferences.preferences.relayConfiguration
|
||||||
|
let draftURLs = state.relayMode.usesCustomRelayURLs ? state.relayURLs : saved.relayURLs
|
||||||
|
state.relayConfigurationIsDirty = saved != RelayConfiguration(mode: state.relayMode, relayURLs: draftURLs)
|
||||||
|
hasRelayConfigurationDraft = state.relayConfigurationIsDirty
|
||||||
}
|
}
|
||||||
|
|
||||||
func setBugWhatHappened(_ value: String) { state.bugWhatHappened = value }
|
func setBugWhatHappened(_ value: String) { state.bugWhatHappened = value }
|
||||||
@@ -218,11 +351,11 @@ final class SettingsModel: ObservableObject {
|
|||||||
let what = snapshot.bugWhatHappened.trimmingCharacters(in: .whitespacesAndNewlines)
|
let what = snapshot.bugWhatHappened.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let expected = snapshot.bugExpected.trimmingCharacters(in: .whitespacesAndNewlines)
|
let expected = snapshot.bugExpected.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if what.isEmpty {
|
if what.isEmpty {
|
||||||
messages.show(UiMessage(text: .resource("bug_report_missing_what"), tone: .warning))
|
messages.show(UiMessage(text: .resource(L10n.Bug.reportMissingWhat), tone: .warning))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if expected.isEmpty {
|
if expected.isEmpty {
|
||||||
messages.show(UiMessage(text: .resource("bug_report_missing_expected"), tone: .warning))
|
messages.show(UiMessage(text: .resource(L10n.Bug.reportMissingExpected), tone: .warning))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
state.isSubmittingBugReport = true
|
state.isSubmittingBugReport = true
|
||||||
@@ -241,64 +374,71 @@ final class SettingsModel: ObservableObject {
|
|||||||
state.bugSteps = ""
|
state.bugSteps = ""
|
||||||
state.bugContact = ""
|
state.bugContact = ""
|
||||||
state.bugIncludeLogs = true
|
state.bugIncludeLogs = true
|
||||||
messages.show(UiMessage(text: .resource("bug_report_submitted"), tone: .success))
|
messages.show(UiMessage(text: .resource(L10n.Bug.reportSubmitted), tone: .success))
|
||||||
onSuccess()
|
onSuccess()
|
||||||
case .failure:
|
case .failure:
|
||||||
state.isSubmittingBugReport = false
|
state.isSubmittingBugReport = false
|
||||||
messages.show(UiMessage(text: .resource("bug_report_submit_failed"), tone: .error))
|
messages.show(UiMessage(text: .resource(L10n.Bug.reportSubmitFailed), tone: .error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func openNotificationSettings() {
|
func openNotificationSettings() {
|
||||||
Task {
|
Task {
|
||||||
enableNotificationsAfterSettings = true
|
if case .failure = await notifications.openSettings() {
|
||||||
let result = await notifications.openSettings()
|
messages.show(UiMessage(text: .resource(L10n.Notifications.settingsOpenFailed), tone: .error))
|
||||||
if case .failure = result {
|
|
||||||
enableNotificationsAfterSettings = false
|
|
||||||
messages.show(UiMessage(text: .resource("notifications_settings_open_failed"), tone: .error))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Re-read the OS permission (called on appear and when returning to the
|
||||||
|
/// foreground, e.g. after a trip to Settings) so the toggle stays in sync.
|
||||||
func refreshNotificationPermission() {
|
func refreshNotificationPermission() {
|
||||||
Task {
|
Task {
|
||||||
let permission = await notifications.refreshPermission()
|
state.notificationPermission = await notifications.refreshPermission()
|
||||||
state.notificationPermission = permission
|
|
||||||
if enableNotificationsAfterSettings {
|
|
||||||
enableNotificationsAfterSettings = false
|
|
||||||
if permission == .granted { await enableNotifications() }
|
|
||||||
} else if permission != .granted && state.notificationsEnabled {
|
|
||||||
preferences.setNotificationsEnabled(false)
|
|
||||||
notifications.cancelAll()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func enableNotifications() async {
|
|
||||||
preferences.setNotificationsEnabled(true)
|
|
||||||
messages.show(UiMessage(text: .resource("notifications_enabled_message"), tone: .success))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Storage
|
// MARK: - Storage
|
||||||
|
|
||||||
/// Recomputes the on-disk usage breakdown off the main actor.
|
/// Recomputes the on-disk usage breakdown off the main actor. Safe to call
|
||||||
|
/// before the core is ready: it keeps the spinner up and waits for the core to
|
||||||
|
/// finish initializing (it starts asynchronously at launch) rather than bailing.
|
||||||
func loadStorageUsage() {
|
func loadStorageUsage() {
|
||||||
if state.isCalculatingStorage { return }
|
if state.isCalculatingStorage { return }
|
||||||
state.isCalculatingStorage = true
|
state.isCalculatingStorage = true
|
||||||
let coreDir = environment.defaultCoreDataDir
|
state.storageLoadFailed = false
|
||||||
let receiveDir = state.receiveFolder?.isFileSystemPath == true ? state.receiveFolder?.value : nil
|
|
||||||
let tempDir = NSTemporaryDirectory()
|
let tempDir = NSTemporaryDirectory()
|
||||||
Task.detached {
|
Task {
|
||||||
let breakdown = StorageBreakdown(
|
// The core initializes asynchronously at launch; poll briefly so opening
|
||||||
receivedFiles: receiveDir.map { SettingsModel.directorySize($0) } ?? 0,
|
// Storage early doesn't leave the summary stuck.
|
||||||
transferData: SettingsModel.directorySize(coreDir),
|
var attempts = 0
|
||||||
temporary: SettingsModel.directorySize(tempDir)
|
while !repository.state.isInitialized && attempts < 100 {
|
||||||
)
|
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||||
await MainActor.run { [weak self] in
|
attempts += 1
|
||||||
self?.state.storage = breakdown
|
|
||||||
self?.state.isCalculatingStorage = false
|
|
||||||
}
|
}
|
||||||
|
let coreResult = await repository.storageUsage()
|
||||||
|
let artifactsResult = await repository.receivedArtifacts()
|
||||||
|
guard case .success(let core) = coreResult,
|
||||||
|
case .success(let artifacts) = artifactsResult else {
|
||||||
|
state.isCalculatingStorage = false
|
||||||
|
state.storageLoadFailed = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let diskSizes = await Task.detached {
|
||||||
|
let received = artifacts.reduce(UInt64(0)) { total, artifact in
|
||||||
|
total + SettingsModel.fileSize(artifact.locator)
|
||||||
|
}
|
||||||
|
return (received, SettingsModel.directorySize(tempDir))
|
||||||
|
}.value
|
||||||
|
let breakdown = StorageBreakdown(
|
||||||
|
receivedFiles: diskSizes.0,
|
||||||
|
transferCache: core.blobStoreBytes,
|
||||||
|
appData: core.appDataBytes,
|
||||||
|
temporary: diskSizes.1
|
||||||
|
)
|
||||||
|
state.storage = breakdown
|
||||||
|
state.isCalculatingStorage = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,16 +449,106 @@ final class SettingsModel: ObservableObject {
|
|||||||
if state.isDeletingTransfers { return }
|
if state.isDeletingTransfers { return }
|
||||||
state.isDeletingTransfers = true
|
state.isDeletingTransfers = true
|
||||||
Task {
|
Task {
|
||||||
|
var failures = 0
|
||||||
for id in repository.state.transfers.map(\.transferId) {
|
for id in repository.state.transfers.map(\.transferId) {
|
||||||
_ = await repository.delete(transferId: id)
|
if case .failure = await repository.delete(transferId: id) { failures += 1 }
|
||||||
}
|
}
|
||||||
_ = await repository.refresh()
|
_ = await repository.refresh()
|
||||||
state.isDeletingTransfers = false
|
state.isDeletingTransfers = false
|
||||||
loadStorageUsage()
|
if failures == 0 {
|
||||||
messages.show(UiMessage(text: .resource("storage_transfers_deleted"), tone: .success))
|
loadStorageUsage()
|
||||||
|
messages.show(UiMessage(text: .resource(L10n.Storage.transfersDeleted), tone: .success))
|
||||||
|
} else {
|
||||||
|
messages.error(InvitationError.deleteRecordsFailed)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reclaims disk space the core's transfer deletion doesn't touch: the app's
|
||||||
|
/// temporary directory (leftover picker/staging copies) and any stray `.Trash`
|
||||||
|
/// folders that accumulate inside app-owned directories. Never touches received
|
||||||
|
/// files, the core database, or user-chosen receive folders.
|
||||||
|
func freeUpSpace() {
|
||||||
|
if state.isCleaningStorage { return }
|
||||||
|
// Purging staging while a transfer is mid-flight could break it.
|
||||||
|
let hasActive = repository.state.transfers.contains {
|
||||||
|
$0.status == .sharing || $0.status == .importing || $0.status == .receiving
|
||||||
|
}
|
||||||
|
if hasActive {
|
||||||
|
messages.tryShow(UiMessage(text: .resource(L10n.Storage.cleanupBusy), tone: .warning))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.isCleaningStorage = true
|
||||||
|
let tempDir = NSTemporaryDirectory()
|
||||||
|
let dataDir = environment.defaultCoreDataDir
|
||||||
|
// Only clean the receive folder's trash when it is app-owned (iOS fixed
|
||||||
|
// Documents), never a user-chosen macOS folder like ~/Downloads.
|
||||||
|
let receiveTrashRoot = fileSystemService.supportsCustomReceiveFolders ? nil : state.receiveFolder?.value
|
||||||
|
Task {
|
||||||
|
let freed = await Task.detached {
|
||||||
|
SettingsModel.reclaimJunk(tempDir: tempDir, dataDir: dataDir, receiveTrashRoot: receiveTrashRoot)
|
||||||
|
}.value
|
||||||
|
state.isCleaningStorage = false
|
||||||
|
loadStorageUsage()
|
||||||
|
messages.show(UiMessage(
|
||||||
|
text: .dynamic(L10n.Storage.cleanupFreed(size: formatBytes(freed))),
|
||||||
|
tone: .success
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes temp-directory contents and `.Trash` folders under the given roots,
|
||||||
|
/// returning the number of bytes reclaimed. Runs off the main actor.
|
||||||
|
nonisolated static func reclaimJunk(tempDir: String, dataDir: String, receiveTrashRoot: String?) -> UInt64 {
|
||||||
|
let fm = FileManager.default
|
||||||
|
var freed: UInt64 = 0
|
||||||
|
// Empty the temporary directory.
|
||||||
|
if let entries = try? fm.contentsOfDirectory(atPath: tempDir) {
|
||||||
|
for name in entries {
|
||||||
|
let path = (tempDir as NSString).appendingPathComponent(name)
|
||||||
|
freed += itemSize(path)
|
||||||
|
try? fm.removeItem(atPath: path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Remove stray `.Trash` folders inside app-owned directories.
|
||||||
|
for root in [dataDir, receiveTrashRoot].compactMap({ $0 }) {
|
||||||
|
for trash in trashDirectories(under: root) {
|
||||||
|
freed += directorySize(trash)
|
||||||
|
try? fm.removeItem(atPath: trash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return freed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paths of every directory named `.Trash` under `root` (not descending into them).
|
||||||
|
private nonisolated static func trashDirectories(under root: String) -> [String] {
|
||||||
|
let url = URL(fileURLWithPath: root, isDirectory: true)
|
||||||
|
guard let enumerator = FileManager.default.enumerator(
|
||||||
|
at: url, includingPropertiesForKeys: [.isDirectoryKey]
|
||||||
|
) else { return [] }
|
||||||
|
var result: [String] = []
|
||||||
|
for case let fileURL as URL in enumerator where fileURL.lastPathComponent == ".Trash" {
|
||||||
|
result.append(fileURL.path)
|
||||||
|
enumerator.skipDescendants()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allocated size of a file or directory (0 if missing).
|
||||||
|
private nonisolated static func itemSize(_ path: String) -> UInt64 {
|
||||||
|
var isDirectory: ObjCBool = false
|
||||||
|
guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) else { return 0 }
|
||||||
|
return isDirectory.boolValue ? directorySize(path) : fileSize(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func fileSize(_ path: String) -> UInt64 {
|
||||||
|
let values = try? URL(fileURLWithPath: path).resourceValues(
|
||||||
|
forKeys: [.isRegularFileKey, .totalFileAllocatedSizeKey, .fileSizeKey]
|
||||||
|
)
|
||||||
|
guard values?.isRegularFile == true else { return 0 }
|
||||||
|
return UInt64(values?.totalFileAllocatedSize ?? values?.fileSize ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
/// Recursive size of every regular file under `path` (0 if missing).
|
/// Recursive size of every regular file under `path` (0 if missing).
|
||||||
nonisolated static func directorySize(_ path: String) -> UInt64 {
|
nonisolated static func directorySize(_ path: String) -> UInt64 {
|
||||||
let url = URL(fileURLWithPath: path)
|
let url = URL(fileURLWithPath: path)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SFSafeSymbols
|
||||||
|
|
||||||
/// Settings screen, rebuilt on a native `Form` with `NavigationStack` push
|
/// Settings screen, rebuilt on a native `Form` with `NavigationStack` push
|
||||||
/// navigation. The model stays the source of truth via a derived path binding.
|
/// navigation. The model stays the source of truth via a derived path binding.
|
||||||
@@ -23,28 +24,54 @@ 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: "person.crop.circle", title: String(localized: "preferences_title"), value: model.state.username)
|
SettingsRow(icon: .personCropCircle, title: String(localized: L10n.Preferences.title), value: model.state.username)
|
||||||
}
|
}
|
||||||
NavigationLink(value: SettingsSection.appearance) {
|
NavigationLink(value: SettingsSection.appearance) {
|
||||||
SettingsRow(icon: "sun.max", title: String(localized: "appearance_title"), value: themeModeLabel(model.state.themeMode))
|
SettingsRow(icon: .sunMax, title: String(localized: L10n.Appearance.title), value: themeModeLabel(model.state.themeMode))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Section {
|
Section {
|
||||||
NavigationLink(value: SettingsSection.notifications) {
|
NavigationLink(value: SettingsSection.notifications) {
|
||||||
SettingsRow(icon: "bell", title: String(localized: "notifications_title"), value: nil)
|
SettingsRow(icon: .bell, title: String(localized: L10n.Notifications.title), value: nil)
|
||||||
}
|
}
|
||||||
NavigationLink(value: SettingsSection.storage) {
|
NavigationLink(value: SettingsSection.storage) {
|
||||||
SettingsRow(icon: "internaldrive", title: String(localized: "storage_title"), value: nil)
|
SettingsRow(icon: .internaldrive, title: String(localized: L10n.Storage.title), value: nil)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
Section(String(localized: L10n.Settings.advancedTitle)) {
|
||||||
|
NavigationLink(value: SettingsSection.network) {
|
||||||
|
SettingsRow(
|
||||||
|
icon: .network,
|
||||||
|
title: String(localized: L10n.Settings.networkTitle),
|
||||||
|
value: relayModeLabel(model.state.relayMode)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section {
|
||||||
NavigationLink(value: SettingsSection.about) {
|
NavigationLink(value: SettingsSection.about) {
|
||||||
SettingsRow(icon: "info.circle", title: String(localized: "about_title"), value: nil)
|
SettingsRow(icon: .infoCircle, title: String(localized: L10n.About.title), value: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.navigationTitle(Text(LocalizedStringKey("settings_title")))
|
.navigationTitle(Text(String(localized: L10n.Settings.title)))
|
||||||
.navigationDestination(for: SettingsSection.self) { section in
|
.navigationDestination(for: SettingsSection.self) { section in
|
||||||
sectionForm(section)
|
sectionForm(section)
|
||||||
}
|
}
|
||||||
@@ -57,7 +84,7 @@ struct SettingsScreen: View {
|
|||||||
SettingsSectionContent(model: model, section: section)
|
SettingsSectionContent(model: model, section: section)
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.navigationTitle(Text(LocalizedStringKey(section.titleKey)))
|
.navigationTitle(Text(String(localized: section.titleKey)))
|
||||||
|
|
||||||
if section == .about {
|
if section == .about {
|
||||||
content
|
content
|
||||||
@@ -69,7 +96,7 @@ struct SettingsScreen: View {
|
|||||||
Button {
|
Button {
|
||||||
showBugReport = true
|
showBugReport = true
|
||||||
} label: {
|
} label: {
|
||||||
Label(String(localized: "about_bug_report"), systemImage: "ladybug")
|
Label(String(localized: L10n.About.bugReport), systemSymbol: .ladybug)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,6 +126,8 @@ private struct SettingsSectionContent: View {
|
|||||||
AppearanceSettings(model: model)
|
AppearanceSettings(model: model)
|
||||||
case .notifications:
|
case .notifications:
|
||||||
NotificationSettings(model: model)
|
NotificationSettings(model: model)
|
||||||
|
case .network:
|
||||||
|
NetworkSettings(model: model)
|
||||||
case .storage:
|
case .storage:
|
||||||
StorageSettings(model: model)
|
StorageSettings(model: model)
|
||||||
case .about:
|
case .about:
|
||||||
@@ -109,14 +138,32 @@ private struct SettingsSectionContent: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func relayModeLabel(_ mode: RelayPreferenceMode) -> String {
|
||||||
|
switch mode {
|
||||||
|
case .automatic: return String(localized: L10n.Relay.modeAutomatic)
|
||||||
|
case .strictCustom: return String(localized: L10n.Relay.modeCustom)
|
||||||
|
case .customWithDirectFallback: return String(localized: L10n.Relay.modeCustomDirectFallback)
|
||||||
|
case .localOnly: return String(localized: L10n.Relay.modeLocalOnly)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func relayModeDescription(_ mode: RelayPreferenceMode) -> String.LocalizationValue {
|
||||||
|
switch mode {
|
||||||
|
case .automatic: return L10n.Relay.modeAutomaticDescription
|
||||||
|
case .strictCustom: return L10n.Relay.modeCustomDescription
|
||||||
|
case .customWithDirectFallback: return L10n.Relay.modeCustomDirectFallbackDescription
|
||||||
|
case .localOnly: return L10n.Relay.modeLocalOnlyDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct SettingsRow: View {
|
struct SettingsRow: View {
|
||||||
let icon: String
|
let icon: SFSymbol
|
||||||
let title: String
|
let title: String
|
||||||
let value: String?
|
let value: String?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: icon)
|
Image(systemSymbol: icon)
|
||||||
.foregroundStyle(.tint)
|
.foregroundStyle(.tint)
|
||||||
.frame(width: 26)
|
.frame(width: 26)
|
||||||
Text(title).foregroundStyle(.primary)
|
Text(title).foregroundStyle(.primary)
|
||||||
@@ -130,8 +177,8 @@ struct SettingsRow: View {
|
|||||||
|
|
||||||
func themeModeLabel(_ mode: ThemeMode) -> String {
|
func themeModeLabel(_ mode: ThemeMode) -> String {
|
||||||
switch mode {
|
switch mode {
|
||||||
case .system: return String(localized: "appearance_system_mode")
|
case .system: return String(localized: L10n.Appearance.systemMode)
|
||||||
case .light: return String(localized: "appearance_light_mode")
|
case .light: return String(localized: L10n.Appearance.lightMode)
|
||||||
case .dark: return String(localized: "appearance_dark_mode")
|
case .dark: return String(localized: L10n.Appearance.darkMode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SFSafeSymbols
|
||||||
|
|
||||||
/// Settings section detail views, rebuilt as native `Form` content. Each view is
|
/// Settings section detail views, rebuilt as native `Form` content. Each view is
|
||||||
/// placed inside a parent `Form`, so it returns `Section`s / rows directly.
|
/// placed inside a parent `Form`, so it returns `Section`s / rows directly.
|
||||||
@@ -7,16 +8,26 @@ struct PreferencesSettings: View {
|
|||||||
@ObservedObject var model: SettingsModel
|
@ObservedObject var model: SettingsModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Section(String(localized: "field_username")) {
|
Section(String(localized: L10n.Field.username)) {
|
||||||
TextField(String(localized: "field_username"),
|
TextField(String(localized: L10n.Field.username),
|
||||||
text: Binding(get: { model.state.username }, set: { model.setUsername($0) }))
|
text: Binding(get: { model.state.username }, set: { model.setUsername($0) }))
|
||||||
}
|
}
|
||||||
if model.state.supportsCustomReceiveFolders {
|
if model.state.supportsCustomReceiveFolders {
|
||||||
Section(String(localized: "preferences_receive_folder_title")) {
|
Section(String(localized: L10n.Preferences.receiveFolderTitle)) {
|
||||||
Text(model.state.receiveFolder?.displayName ?? String(localized: "value_unavailable"))
|
LabeledContent {
|
||||||
.foregroundStyle(.secondary)
|
Button(String(localized: L10n.Button.chooseFolder), action: model.chooseReceiveFolder)
|
||||||
Button(String(localized: "button_choose_folder"), action: model.chooseReceiveFolder)
|
} label: {
|
||||||
Button(String(localized: "button_reset_default"), action: model.resetReceiveFolder)
|
Label {
|
||||||
|
Text(model.state.receiveFolder?.displayName ?? String(localized: L10n.Value.unavailable))
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
} icon: {
|
||||||
|
Image(systemSymbol: .folder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !model.isUsingDefaultReceiveFolder {
|
||||||
|
Button(String(localized: L10n.Button.resetDefault), role: .cancel, action: model.resetReceiveFolder)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,7 +38,7 @@ struct AppearanceSettings: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Section {
|
Section {
|
||||||
Picker(String(localized: "appearance_title"),
|
Picker(String(localized: L10n.Appearance.title),
|
||||||
selection: Binding(get: { model.state.themeMode }, set: { model.setThemeMode($0) })) {
|
selection: Binding(get: { model.state.themeMode }, set: { model.setThemeMode($0) })) {
|
||||||
ForEach(ThemeMode.allCases, id: \.self) { mode in
|
ForEach(ThemeMode.allCases, id: \.self) { mode in
|
||||||
Text(themeModeLabel(mode)).tag(mode)
|
Text(themeModeLabel(mode)).tag(mode)
|
||||||
@@ -44,16 +55,189 @@ struct NotificationSettings: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Section {
|
Section {
|
||||||
Toggle(isOn: Binding(
|
Text(String(localized: L10n.Notifications.description)).foregroundStyle(.secondary)
|
||||||
get: { model.state.notificationsEnabled },
|
switch model.state.notificationPermission {
|
||||||
set: { model.setNotificationsEnabled($0) }
|
case .notDetermined:
|
||||||
)) {
|
Button(String(localized: L10n.Notifications.localTitle), action: model.requestNotifications)
|
||||||
Text(LocalizedStringKey("notifications_local_title"))
|
case .granted:
|
||||||
}
|
// Allowed — the OS Settings app is where you disable or fine-tune.
|
||||||
if model.state.notificationPermission == .denied {
|
Text(String(localized: L10n.Notifications.enabledMessage)).foregroundStyle(.secondary)
|
||||||
Button(String(localized: "button_open_settings"), action: model.openNotificationSettings)
|
Button(String(localized: L10n.Button.openSettings), action: model.openNotificationSettings)
|
||||||
|
case .denied:
|
||||||
|
Text(String(localized: L10n.Notifications.permissionDenied)).foregroundStyle(.secondary)
|
||||||
|
Button(String(localized: L10n.Button.openSettings), action: model.openNotificationSettings)
|
||||||
|
case .unsupported:
|
||||||
|
Text(String(localized: L10n.Notifications.unsupported)).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onAppear { model.refreshNotificationPermission() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NetworkSettings: View {
|
||||||
|
@ObservedObject var model: SettingsModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section {
|
||||||
|
Picker(
|
||||||
|
"",
|
||||||
|
selection: Binding(get: { model.state.relayMode }, set: { model.setRelayMode($0) })
|
||||||
|
) {
|
||||||
|
ForEach(RelayPreferenceMode.allCases, id: \.self) { mode in
|
||||||
|
Text(relayModeLabel(mode)).tag(mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.inline)
|
||||||
|
.labelsHidden()
|
||||||
|
.disabled(model.state.isApplyingRelayConfiguration)
|
||||||
|
} header: {
|
||||||
|
Text(String(localized: L10n.Settings.networkTitle))
|
||||||
|
} footer: {
|
||||||
|
Text(String(localized: relayModeDescription(model.state.relayMode)))
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Label {
|
||||||
|
Text(String(localized: L10n.Relay.privacyDescription))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
} icon: {
|
||||||
|
Image(systemSymbol: .lockShield)
|
||||||
|
}
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let endpointId = model.state.endpointId, !endpointId.isEmpty {
|
||||||
|
Section {
|
||||||
|
Text(L10n.Approval.endpointId(deviceId: endpointId))
|
||||||
|
.font(.footnote.monospaced())
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if model.state.relayMode.usesCustomRelayURLs {
|
||||||
|
Section {
|
||||||
|
if model.state.relayMode == .strictCustom {
|
||||||
|
Label {
|
||||||
|
Text(String(localized: L10n.Relay.strictWarning))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
} icon: {
|
||||||
|
Image(systemSymbol: .exclamationmarkShieldFill)
|
||||||
|
}
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(Array(model.state.relayURLs.indices), id: \.self) { index in
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack {
|
||||||
|
TextField(
|
||||||
|
"",
|
||||||
|
text: Binding(
|
||||||
|
get: {
|
||||||
|
model.state.relayURLs.indices.contains(index)
|
||||||
|
? model.state.relayURLs[index]
|
||||||
|
: ""
|
||||||
|
},
|
||||||
|
set: { model.setRelayURL($0, at: index) }
|
||||||
|
),
|
||||||
|
// `Text(verbatim:)` avoids macOS markdown-linkifying the
|
||||||
|
// URL-shaped placeholder into a purple link.
|
||||||
|
prompt: Text(verbatim: "https://relay.example.com")
|
||||||
|
)
|
||||||
|
.labelsHidden()
|
||||||
|
#if os(iOS)
|
||||||
|
.keyboardType(.URL)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
#endif
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.disabled(model.state.isApplyingRelayConfiguration)
|
||||||
|
|
||||||
|
Button(role: .destructive) {
|
||||||
|
model.removeRelayURL(at: index)
|
||||||
|
} label: {
|
||||||
|
Image(systemSymbol: .minusCircleFill)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.accessibilityLabel(Text(String(localized: L10n.Relay.removeUrl)))
|
||||||
|
.disabled(model.state.isApplyingRelayConfiguration)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = model.state.relayValidationError, error.urlIndex == index {
|
||||||
|
Text(relayValidationMessage(error))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: model.addRelayURL) {
|
||||||
|
Label(String(localized: L10n.Relay.addUrl), systemSymbol: .plusCircle)
|
||||||
|
}
|
||||||
|
.disabled(
|
||||||
|
model.state.relayURLs.count >= RelayConfigurationValidator.maximumRelayCount
|
||||||
|
|| model.state.isApplyingRelayConfiguration
|
||||||
|
)
|
||||||
|
} header: {
|
||||||
|
Text(String(localized: L10n.Relay.customUrlsLabel))
|
||||||
|
} footer: {
|
||||||
|
Text(String(localized: L10n.Relay.customUrlsHelp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = model.state.relayValidationError, error.urlIndex == nil {
|
||||||
|
Section {
|
||||||
|
Label {
|
||||||
|
Text(relayValidationMessage(error))
|
||||||
|
} icon: {
|
||||||
|
Image(systemSymbol: .exclamationmarkTriangleFill)
|
||||||
|
}
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if model.state.hasActiveNetworkWork || model.state.relayApplyErrorKey != nil {
|
||||||
|
Section {
|
||||||
|
Label {
|
||||||
|
Text(String(localized: model.state.hasActiveNetworkWork ? L10n.Relay.applyActiveTransfers : (model.state.relayApplyErrorKey ?? L10n.Relay.applyFailed)))
|
||||||
|
} icon: {
|
||||||
|
Image(systemSymbol: .exclamationmarkTriangleFill)
|
||||||
|
}
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button(action: model.applyRelayConfiguration) {
|
||||||
|
HStack {
|
||||||
|
Text(String(localized: model.state.isApplyingRelayConfiguration ? L10n.Relay.applying : L10n.Relay.apply))
|
||||||
|
if model.state.isApplyingRelayConfiguration {
|
||||||
|
Spacer()
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(
|
||||||
|
!model.state.relayConfigurationIsDirty
|
||||||
|
|| model.state.isApplyingRelayConfiguration
|
||||||
|
|| model.state.hasActiveNetworkWork
|
||||||
|
)
|
||||||
|
} footer: {
|
||||||
|
Text(String(localized: L10n.Relay.applyRestartDescription))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func relayValidationMessage(_ error: RelayConfigurationValidationError) -> String {
|
||||||
|
switch error {
|
||||||
|
case .missingURL:
|
||||||
|
return String(localized: L10n.Relay.validationMissingUrl)
|
||||||
|
case .tooManyURLs:
|
||||||
|
return L10n.Relay.validationTooManyUrls(maximum: RelayConfigurationValidator.maximumRelayCount)
|
||||||
|
case .httpsRequired(let index):
|
||||||
|
return L10n.Relay.validationHttpsRequired(line: index + 1)
|
||||||
|
case .invalidURL(let index):
|
||||||
|
return L10n.Relay.validationInvalidUrl(line: index + 1)
|
||||||
|
case .duplicateURL(let index):
|
||||||
|
return L10n.Relay.validationDuplicateUrl(line: index + 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,110 +245,174 @@ struct StorageSettings: View {
|
|||||||
@ObservedObject var model: SettingsModel
|
@ObservedObject var model: SettingsModel
|
||||||
@State private var showDeleteConfirmation = false
|
@State private var showDeleteConfirmation = false
|
||||||
|
|
||||||
|
private var isBusy: Bool {
|
||||||
|
model.state.isCalculatingStorage || model.state.isCleaningStorage || model.state.isDeletingTransfers
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Section {
|
Section {
|
||||||
if let storage = model.state.storage {
|
usageContent
|
||||||
LabeledContent(String(localized: "storage_received_files"), value: formatBytes(storage.receivedFiles))
|
} header: {
|
||||||
LabeledContent(String(localized: "storage_transfer_data"), value: formatBytes(storage.transferData))
|
HStack {
|
||||||
LabeledContent(String(localized: "storage_temporary"), value: formatBytes(storage.temporary))
|
Text(String(localized: L10n.Storage.usageHeader))
|
||||||
LabeledContent(String(localized: "storage_total")) {
|
Spacer()
|
||||||
Text(formatBytes(storage.total)).fontWeight(.semibold)
|
if model.state.isCalculatingStorage {
|
||||||
}
|
ProgressView().controlSize(.small)
|
||||||
} else {
|
} else {
|
||||||
HStack {
|
Button(action: model.loadStorageUsage) {
|
||||||
Text(LocalizedStringKey("storage_calculating")).foregroundStyle(.secondary)
|
Label(String(localized: L10n.Storage.refresh), systemSymbol: .arrowClockwise)
|
||||||
Spacer()
|
.labelStyle(.iconOnly)
|
||||||
ProgressView()
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.disabled(isBusy)
|
||||||
|
.help(String(localized: L10n.Storage.refresh))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} footer: {
|
} footer: {
|
||||||
Text(LocalizedStringKey("storage_footer"))
|
Text(String(localized: L10n.Storage.footer))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reclaim reversible junk (temp + trash) — non-destructive to history.
|
||||||
Section {
|
Section {
|
||||||
Button(role: .destructive) {
|
Button(action: model.freeUpSpace) {
|
||||||
|
actionLabel(
|
||||||
|
title: L10n.Storage.freeUpSpace,
|
||||||
|
busyTitle: L10n.Storage.cleaning,
|
||||||
|
isBusy: model.state.isCleaningStorage,
|
||||||
|
symbol: .sparkles,
|
||||||
|
tint: .accentColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// `.plain` so pressing the row dims the label instead of flipping it to
|
||||||
|
// the white selection-highlight that the default form button style uses.
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(isBusy)
|
||||||
|
} footer: {
|
||||||
|
Text(String(localized: L10n.Storage.freeUpSpaceCaption))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destructive: clears transfer history + cached share content.
|
||||||
|
Section {
|
||||||
|
Button {
|
||||||
showDeleteConfirmation = true
|
showDeleteConfirmation = true
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
actionLabel(
|
||||||
Text(model.state.isDeletingTransfers
|
title: L10n.Storage.deleteTransfers,
|
||||||
? String(localized: "storage_deleting")
|
busyTitle: L10n.Storage.deleting,
|
||||||
: String(localized: "storage_delete_transfers"))
|
isBusy: model.state.isDeletingTransfers,
|
||||||
if model.state.isDeletingTransfers {
|
symbol: .trash,
|
||||||
Spacer()
|
tint: .red
|
||||||
ProgressView()
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.disabled(model.state.isDeletingTransfers)
|
.buttonStyle(.plain)
|
||||||
|
.disabled(isBusy)
|
||||||
|
} footer: {
|
||||||
|
Text(String(localized: L10n.Storage.deleteTransfersCaption))
|
||||||
}
|
}
|
||||||
.onAppear { model.loadStorageUsage() }
|
.task { model.loadStorageUsage() }
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
Text(LocalizedStringKey("storage_delete_transfers")),
|
Text(String(localized: L10n.Storage.deleteTransfers)),
|
||||||
isPresented: $showDeleteConfirmation,
|
isPresented: $showDeleteConfirmation,
|
||||||
titleVisibility: .visible
|
titleVisibility: .visible
|
||||||
) {
|
) {
|
||||||
Button(String(localized: "storage_delete_transfers"), role: .destructive) {
|
Button(String(localized: L10n.Storage.deleteTransfers), role: .destructive) {
|
||||||
model.deleteAllTransfers()
|
model.deleteAllTransfers()
|
||||||
}
|
}
|
||||||
Button(String(localized: "button_cancel"), role: .cancel) {}
|
Button(String(localized: L10n.Button.cancel), role: .cancel) {}
|
||||||
} message: {
|
} message: {
|
||||||
Text(LocalizedStringKey("storage_delete_transfers_description"))
|
Text(String(localized: L10n.Storage.deleteTransfersDescription))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var usageContent: some View {
|
||||||
|
if let storage = model.state.storage {
|
||||||
|
LabeledContent(String(localized: L10n.Storage.receivedFiles), value: formatBytes(storage.receivedFiles))
|
||||||
|
LabeledContent(String(localized: L10n.Storage.transferData), value: formatBytes(storage.transferCache))
|
||||||
|
LabeledContent(String(localized: L10n.Storage.appData), value: formatBytes(storage.appData))
|
||||||
|
LabeledContent(String(localized: L10n.Storage.temporary), value: formatBytes(storage.temporary))
|
||||||
|
LabeledContent(String(localized: L10n.Storage.total)) {
|
||||||
|
Text(formatBytes(storage.total)).fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
} else if model.state.storageLoadFailed {
|
||||||
|
// Genuine failure (core reported an error) — offer a retry.
|
||||||
|
Button(action: model.loadStorageUsage) {
|
||||||
|
Label(String(localized: L10n.Storage.unavailable), systemSymbol: .arrowClockwise)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
} else {
|
||||||
|
// Loading, or waiting for the core to finish starting.
|
||||||
|
HStack {
|
||||||
|
Text(String(localized: L10n.Storage.calculating)).foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tinted, full-width button label with a leading symbol and a trailing
|
||||||
|
/// spinner while busy. `.contentShape` keeps the whole row tappable.
|
||||||
|
private func actionLabel(
|
||||||
|
title: String.LocalizationValue,
|
||||||
|
busyTitle: String.LocalizationValue,
|
||||||
|
isBusy: Bool,
|
||||||
|
symbol: SFSymbol,
|
||||||
|
tint: Color
|
||||||
|
) -> some View {
|
||||||
|
HStack {
|
||||||
|
Label(String(localized: isBusy ? busyTitle : title), systemSymbol: symbol)
|
||||||
|
Spacer()
|
||||||
|
if isBusy {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(tint)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AboutSettings: View {
|
struct AboutSettings: View {
|
||||||
@ObservedObject var model: SettingsModel
|
@ObservedObject var model: SettingsModel
|
||||||
|
|
||||||
private static let privacyPolicyURL = URL(string: "https://github.com/vnidrop/vnidrop")!
|
private static let privacyPolicyURL = AppConfig.privacyPolicyURL
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Section {
|
Section {
|
||||||
Text(LocalizedStringKey("about_tagline")).font(.headline)
|
Text(String(localized: L10n.About.tagline)).font(.headline)
|
||||||
Text(LocalizedStringKey("about_description")).foregroundStyle(.secondary)
|
Text(String(localized: L10n.About.description)).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section(String(localized: "about_is_title")) {
|
Section(String(localized: L10n.About.isTitle)) {
|
||||||
AboutPoint("about_is_direct", "paperplane")
|
AboutPoint(L10n.About.isDirect, .paperplane)
|
||||||
AboutPoint("about_is_no_account", "person.crop.circle.badge.xmark")
|
AboutPoint(L10n.About.isNoAccount, .personCropCircleBadgeXmark)
|
||||||
AboutPoint("about_is_in_control", "checkmark.shield")
|
AboutPoint(L10n.About.isInControl, .checkmarkShield)
|
||||||
AboutPoint("about_is_encrypted", "lock")
|
AboutPoint(L10n.About.isEncrypted, .lock)
|
||||||
AboutPoint("about_is_open", "chevron.left.forwardslash.chevron.right")
|
AboutPoint(L10n.About.isOpen, .chevronLeftForwardslashChevronRight)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section(String(localized: "about_isnt_title")) {
|
Section(String(localized: L10n.About.isntTitle)) {
|
||||||
AboutPoint("about_isnt_cloud", "icloud.slash")
|
AboutPoint(L10n.About.isntCloud, .icloudSlash)
|
||||||
AboutPoint("about_isnt_sync", "arrow.triangle.2.circlepath")
|
AboutPoint(L10n.About.isntSync, .arrowTriangle2Circlepath)
|
||||||
AboutPoint("about_isnt_public", "megaphone")
|
AboutPoint(L10n.About.isntPublic, .megaphone)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section(String(localized: "about_privacy_title")) {
|
Section(String(localized: L10n.About.privacyTitle)) {
|
||||||
AboutPoint("about_privacy_capability", "qrcode")
|
AboutPoint(L10n.About.privacyCapability, .qrcode)
|
||||||
AboutPoint("about_privacy_deny", "hand.raised")
|
AboutPoint(L10n.About.privacyDeny, .handRaised)
|
||||||
AboutPoint("about_privacy_relay", "antenna.radiowaves.left.and.right")
|
AboutPoint(L10n.About.privacyRelay, .antennaRadiowavesLeftAndRight)
|
||||||
AboutPoint("about_privacy_local", "internaldrive")
|
AboutPoint(L10n.About.privacyLocal, .internaldrive)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section(String(localized: "about_title")) {
|
Section(String(localized: L10n.About.title)) {
|
||||||
LabeledContent(String(localized: "version_title"), value: model.state.appVersion)
|
LabeledContent(String(localized: L10n.Version.title), value: model.state.appVersion)
|
||||||
if let device = model.state.deviceInfo {
|
if let device = model.state.deviceInfo {
|
||||||
LabeledContent(String(localized: "device_model_title"), value: device.deviceModel ?? "—")
|
LabeledContent(String(localized: L10n.Device.modelTitle), value: device.deviceModel ?? "—")
|
||||||
LabeledContent(String(localized: "os_version_title"), value: device.operatingSystem)
|
LabeledContent(String(localized: L10n.Os.versionTitle), value: device.operatingSystem)
|
||||||
}
|
}
|
||||||
LabeledContent(String(localized: "about_license_label"), value: "Apache 2.0")
|
LabeledContent(String(localized: L10n.About.licenseLabel), value: "Apache 2.0")
|
||||||
Link(destination: Self.privacyPolicyURL) {
|
Link(destination: Self.privacyPolicyURL) {
|
||||||
Label(String(localized: "about_privacy_policy_label"), systemImage: "hand.raised")
|
Label(String(localized: L10n.About.privacyPolicyLabel), systemSymbol: .handRaised)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if DiagnosticsBuildConfig.included {
|
|
||||||
Section {
|
|
||||||
Toggle(isOn: Binding(
|
|
||||||
get: { model.state.diagnosticsEnabled },
|
|
||||||
set: { model.setDiagnosticsEnabled($0) }
|
|
||||||
)) {
|
|
||||||
Text(LocalizedStringKey("diagnostics_title"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,13 +435,13 @@ struct BugReportSheet: View {
|
|||||||
BugReportSettings(model: model, onSubmitted: { dismiss() })
|
BugReportSettings(model: model, onSubmitted: { dismiss() })
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.navigationTitle(Text(LocalizedStringKey("about_bug_report")))
|
.navigationTitle(Text(String(localized: L10n.About.bugReport)))
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
#endif
|
#endif
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
Button(String(localized: "button_cancel")) { dismiss() }
|
Button(String(localized: L10n.Button.cancel)) { dismiss() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,21 +451,21 @@ struct BugReportSheet: View {
|
|||||||
|
|
||||||
/// A bullet-style informational row with an SF Symbol and wrapping localized text.
|
/// A bullet-style informational row with an SF Symbol and wrapping localized text.
|
||||||
private struct AboutPoint: View {
|
private struct AboutPoint: View {
|
||||||
let key: String
|
let key: String.LocalizationValue
|
||||||
let symbol: String
|
let symbol: SFSymbol
|
||||||
|
|
||||||
init(_ key: String, _ symbol: String) {
|
init(_ key: String.LocalizationValue, _ symbol: SFSymbol) {
|
||||||
self.key = key
|
self.key = key
|
||||||
self.symbol = symbol
|
self.symbol = symbol
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Label {
|
Label {
|
||||||
Text(LocalizedStringKey(key))
|
Text(String(localized: key))
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
} icon: {
|
} icon: {
|
||||||
Image(systemName: symbol).foregroundStyle(.tint)
|
Image(systemSymbol: symbol).foregroundStyle(.tint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -227,36 +475,36 @@ struct BugReportSettings: View {
|
|||||||
var onSubmitted: () -> Void = {}
|
var onSubmitted: () -> Void = {}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Section(String(localized: "bug_report_what_label")) {
|
Section(String(localized: L10n.Bug.reportWhatLabel)) {
|
||||||
TextField("", text: Binding(get: { model.state.bugWhatHappened }, set: { model.setBugWhatHappened($0) }),
|
TextField("", text: Binding(get: { model.state.bugWhatHappened }, set: { model.setBugWhatHappened($0) }),
|
||||||
prompt: Text(LocalizedStringKey("bug_report_what_hint")), axis: .vertical)
|
prompt: Text(String(localized: L10n.Bug.reportWhatHint)), axis: .vertical)
|
||||||
.lineLimit(3, reservesSpace: true)
|
.lineLimit(3, reservesSpace: true)
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
}
|
}
|
||||||
Section(String(localized: "bug_report_expected_label")) {
|
Section(String(localized: L10n.Bug.reportExpectedLabel)) {
|
||||||
TextField("", text: Binding(get: { model.state.bugExpected }, set: { model.setBugExpected($0) }),
|
TextField("", text: Binding(get: { model.state.bugExpected }, set: { model.setBugExpected($0) }),
|
||||||
prompt: Text(LocalizedStringKey("bug_report_expected_hint")), axis: .vertical)
|
prompt: Text(String(localized: L10n.Bug.reportExpectedHint)), axis: .vertical)
|
||||||
.lineLimit(3, reservesSpace: true)
|
.lineLimit(3, reservesSpace: true)
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
}
|
}
|
||||||
Section(String(localized: "bug_report_steps_label")) {
|
Section(String(localized: L10n.Bug.reportStepsLabel)) {
|
||||||
TextField("", text: Binding(get: { model.state.bugSteps }, set: { model.setBugSteps($0) }),
|
TextField("", text: Binding(get: { model.state.bugSteps }, set: { model.setBugSteps($0) }),
|
||||||
prompt: Text(LocalizedStringKey("bug_report_steps_hint")), axis: .vertical)
|
prompt: Text(String(localized: L10n.Bug.reportStepsHint)), axis: .vertical)
|
||||||
.lineLimit(3, reservesSpace: true)
|
.lineLimit(3, reservesSpace: true)
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
}
|
}
|
||||||
Section(String(localized: "bug_report_contact_label")) {
|
Section(String(localized: L10n.Bug.reportContactLabel)) {
|
||||||
TextField("", text: Binding(get: { model.state.bugContact }, set: { model.setBugContact($0) }),
|
TextField("", text: Binding(get: { model.state.bugContact }, set: { model.setBugContact($0) }),
|
||||||
prompt: Text(LocalizedStringKey("bug_report_contact_hint")))
|
prompt: Text(String(localized: L10n.Bug.reportContactHint)))
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
}
|
}
|
||||||
Section {
|
Section {
|
||||||
Toggle(isOn: Binding(get: { model.state.bugIncludeLogs }, set: { model.setBugIncludeLogs($0) })) {
|
Toggle(isOn: Binding(get: { model.state.bugIncludeLogs }, set: { model.setBugIncludeLogs($0) })) {
|
||||||
Text(LocalizedStringKey("bug_report_include_logs"))
|
Text(String(localized: L10n.Bug.reportIncludeLogs))
|
||||||
}
|
}
|
||||||
Button(action: { model.submitBugReport(onSuccess: onSubmitted) }) {
|
Button(action: { model.submitBugReport(onSuccess: onSubmitted) }) {
|
||||||
Text(model.state.isSubmittingBugReport
|
Text(model.state.isSubmittingBugReport
|
||||||
? String(localized: "bug_report_submitting") : String(localized: "bug_report_submit"))
|
? String(localized: L10n.Bug.reportSubmitting) : String(localized: L10n.Bug.reportSubmit))
|
||||||
}
|
}
|
||||||
.disabled(model.state.isSubmittingBugReport)
|
.disabled(model.state.isSubmittingBugReport)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import UIKit
|
|||||||
@MainActor
|
@MainActor
|
||||||
func makeAppDependencies(externalInvitations: ExternalInvitationController) -> AppDependencies {
|
func makeAppDependencies(externalInvitations: ExternalInvitationController) -> AppDependencies {
|
||||||
let device = UIDevice.current
|
let device = UIDevice.current
|
||||||
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.1.0"
|
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
|
||||||
let env = PlatformEnvironment(
|
let env = PlatformEnvironment(
|
||||||
name: "\(device.systemName) \(device.systemVersion)",
|
name: "\(device.systemName) \(device.systemVersion)",
|
||||||
appVersion: version,
|
appVersion: version,
|
||||||
@@ -36,7 +36,7 @@ private struct IosDeviceInfoProvider: DeviceInfoProvider {
|
|||||||
device.isBatteryMonitoringEnabled = true
|
device.isBatteryMonitoringEnabled = true
|
||||||
defer { device.isBatteryMonitoringEnabled = wasMonitoring }
|
defer { device.isBatteryMonitoringEnabled = wasMonitoring }
|
||||||
let level = device.batteryLevel
|
let level = device.batteryLevel
|
||||||
return level >= 0 ? "\(Int(level * 100))%" : nil
|
return level >= 0 ? L10n.Battery.levelValue(level: "\(Int(level * 100))") : nil
|
||||||
}()
|
}()
|
||||||
return DeviceInfo(
|
return DeviceInfo(
|
||||||
deviceName: device.name,
|
deviceName: device.name,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import AppKit
|
|||||||
/// Builds the macOS dependency graph, mirroring `rememberIosAppDependencies`.
|
/// Builds the macOS dependency graph, mirroring `rememberIosAppDependencies`.
|
||||||
@MainActor
|
@MainActor
|
||||||
func makeAppDependencies(externalInvitations: ExternalInvitationController) -> AppDependencies {
|
func makeAppDependencies(externalInvitations: ExternalInvitationController) -> AppDependencies {
|
||||||
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.1.0"
|
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
|
||||||
let host = Host.current().localizedName ?? "Mac"
|
let host = Host.current().localizedName ?? "Mac"
|
||||||
let env = PlatformEnvironment(
|
let env = PlatformEnvironment(
|
||||||
name: "macOS " + ProcessInfo.processInfo.operatingSystemVersionString,
|
name: "macOS " + ProcessInfo.processInfo.operatingSystemVersionString,
|
||||||
|
|||||||
@@ -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 |
57
apple/VniDrop/Resources/AppIcon.icon/icon.json
Normal file
57
apple/VniDrop/Resources/AppIcon.icon/icon.json
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"fill" : {
|
||||||
|
"linear-gradient" : [
|
||||||
|
"extended-gray:1.00000,1.00000",
|
||||||
|
"srgb:0.84942,0.81480,0.95401,1.00000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"groups" : [
|
||||||
|
{
|
||||||
|
"blend-mode" : "normal",
|
||||||
|
"blur-material" : null,
|
||||||
|
"layers" : [
|
||||||
|
{
|
||||||
|
"image-name" : "Mask.svg",
|
||||||
|
"name" : "Mask"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lighting" : "individual",
|
||||||
|
"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" : "layer-color",
|
||||||
|
"opacity" : 0.8
|
||||||
|
},
|
||||||
|
"translucency" : {
|
||||||
|
"enabled" : true,
|
||||||
|
"value" : 0.4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"supported-platforms" : {
|
||||||
|
"circles" : [
|
||||||
|
"watchOS"
|
||||||
|
],
|
||||||
|
"squares" : "shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.9685",
|
||||||
|
"green" : "0.3315",
|
||||||
|
"red" : "0.6606"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,12 @@
|
|||||||
<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
|
||||||
|
instead of spawning another copy. -->
|
||||||
|
<key>LSMultipleInstancesProhibited</key>
|
||||||
|
<true/>
|
||||||
<key>CFBundleDocumentTypes</key>
|
<key>CFBundleDocumentTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
@@ -51,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>
|
||||||
@@ -63,10 +83,12 @@
|
|||||||
<string>VniDrop uses the camera to scan transfer QR codes.</string>
|
<string>VniDrop uses the camera to scan transfer QR codes.</string>
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
<string>VniDrop needs local network access to send to other local devices if needed.</string>
|
<string>VniDrop needs local network access to send to other local devices if needed.</string>
|
||||||
|
<!-- iPadOS: a single scene only — no second window via Stage Manager / split
|
||||||
|
view. Mirrors the single-window macOS behavior. -->
|
||||||
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
<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>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
@@ -7,11 +7,16 @@ struct AdaptiveDrawer<DrawerContent: View>: ViewModifier {
|
|||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
let windowClass: WindowClass
|
let windowClass: WindowClass
|
||||||
let onDismiss: () -> Void
|
let onDismiss: () -> Void
|
||||||
|
/// Fired after the sheet's dismissal animation completes (as opposed to
|
||||||
|
/// `onDismiss`, which requests the close). Lets callers serialize a follow-up
|
||||||
|
/// sheet against this one's actual teardown instead of guessing a delay.
|
||||||
|
let onDismissed: (() -> Void)?
|
||||||
@ViewBuilder let drawerContent: () -> DrawerContent
|
@ViewBuilder let drawerContent: () -> DrawerContent
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
content.sheet(
|
content.sheet(
|
||||||
isPresented: Binding(get: { isPresented }, set: { if !$0 { onDismiss() } })
|
isPresented: Binding(get: { isPresented }, set: { if !$0 { onDismiss() } }),
|
||||||
|
onDismiss: onDismissed
|
||||||
) {
|
) {
|
||||||
SheetChrome(onClose: onDismiss) { drawerContent() }
|
SheetChrome(onClose: onDismiss) { drawerContent() }
|
||||||
.modifier(PhoneDetents(enabled: windowClass == .phone))
|
.modifier(PhoneDetents(enabled: windowClass == .phone))
|
||||||
@@ -41,7 +46,7 @@ private struct SheetChrome<Content: View>: View {
|
|||||||
ScrollView { content().padding(.top, 4) }
|
ScrollView { content().padding(.top, 4) }
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
Button(String(localized: "button_close"), action: onClose)
|
Button(String(localized: L10n.Button.close), action: onClose)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -56,11 +61,12 @@ extension View {
|
|||||||
isPresented: Binding<Bool>,
|
isPresented: Binding<Bool>,
|
||||||
windowClass: WindowClass,
|
windowClass: WindowClass,
|
||||||
onDismiss: @escaping () -> Void,
|
onDismiss: @escaping () -> Void,
|
||||||
|
onDismissed: (() -> Void)? = nil,
|
||||||
@ViewBuilder content: @escaping () -> DrawerContent
|
@ViewBuilder content: @escaping () -> DrawerContent
|
||||||
) -> some View {
|
) -> some View {
|
||||||
modifier(AdaptiveDrawer(
|
modifier(AdaptiveDrawer(
|
||||||
isPresented: isPresented, windowClass: windowClass,
|
isPresented: isPresented, windowClass: windowClass,
|
||||||
onDismiss: onDismiss, drawerContent: content
|
onDismiss: onDismiss, onDismissed: onDismissed, drawerContent: content
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ struct StatusPill: View {
|
|||||||
// MARK: - ProgressRow
|
// MARK: - ProgressRow
|
||||||
|
|
||||||
struct ProgressRow: View {
|
struct ProgressRow: View {
|
||||||
let labelKey: String
|
let labelKey: String.LocalizationValue
|
||||||
let progress: Double?
|
let progress: Double?
|
||||||
var detail: String? = nil
|
var detail: String? = nil
|
||||||
/// Pre-resolved label; when set it overrides `labelKey`.
|
/// Pre-resolved label; when set it overrides `labelKey`.
|
||||||
@@ -42,7 +42,7 @@ struct ProgressRow: View {
|
|||||||
label.font(.subheadline).lineLimit(1)
|
label.font(.subheadline).lineLimit(1)
|
||||||
Spacer()
|
Spacer()
|
||||||
if let progress {
|
if let progress {
|
||||||
Text("\(Int(progress * 100))%").font(.caption).foregroundStyle(.secondary)
|
Text(verbatim: "\(Int(progress * 100))%").font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let detail {
|
if let detail {
|
||||||
@@ -62,7 +62,7 @@ struct ProgressRow: View {
|
|||||||
if let labelText {
|
if let labelText {
|
||||||
Text(labelText)
|
Text(labelText)
|
||||||
} else {
|
} else {
|
||||||
Text(LocalizedStringKey(labelKey))
|
Text(String(localized: labelKey))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SFSafeSymbols
|
||||||
|
|
||||||
/// Bottom toast host driven by `UiMessageController`, ported from
|
/// Bottom toast host driven by `UiMessageController`, ported from
|
||||||
/// `ui/feedback/VniDropSnackbarHost.kt`. Tone drives the accent color; errors get
|
/// `ui/feedback/VniDropSnackbarHost.kt`. Tone drives the accent color; errors get
|
||||||
@@ -52,7 +53,7 @@ struct SnackbarHost: View {
|
|||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
}
|
}
|
||||||
Button(action: dismiss) {
|
Button(action: dismiss) {
|
||||||
Image(systemName: "xmark")
|
Image(systemSymbol: .xmark)
|
||||||
.font(.footnote.weight(.semibold))
|
.font(.footnote.weight(.semibold))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.frame(width: 36, height: 36)
|
.frame(width: 36, height: 36)
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import Combine
|
|||||||
/// A localizable UI string: either a catalog key or dynamic text, ported from
|
/// A localizable UI string: either a catalog key or dynamic text, ported from
|
||||||
/// `UiText` in `ui/feedback/UiMessageController.kt`.
|
/// `UiText` in `ui/feedback/UiMessageController.kt`.
|
||||||
enum UiText: Equatable {
|
enum UiText: Equatable {
|
||||||
case resource(String) // Localizable.xcstrings key
|
case resource(String.LocalizationValue) // Localizable.xcstrings key (use L10n.*)
|
||||||
case dynamic(String)
|
case dynamic(String)
|
||||||
|
|
||||||
/// Resolves to display text. Keys go through the string catalog.
|
/// Resolves to display text. Keys go through the string catalog.
|
||||||
func resolved() -> String {
|
func resolved() -> String {
|
||||||
switch self {
|
switch self {
|
||||||
case .dynamic(let value): return value
|
case .dynamic(let value): return value
|
||||||
case .resource(let key): return String(localized: String.LocalizationValue(key))
|
case .resource(let value): return String(localized: value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,29 +5,46 @@ 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:
|
||||||
return .resource("error_invalid_ticket")
|
return .resource(L10n.Error.invalidTicket)
|
||||||
case .Permission:
|
case .Permission:
|
||||||
return .resource("error_permission")
|
return .resource(L10n.Error.permission)
|
||||||
case .Filesystem:
|
case .Filesystem:
|
||||||
return .resource("error_filesystem")
|
return .resource(L10n.Error.filesystem)
|
||||||
|
case .FilesystemPermission:
|
||||||
|
return .resource(L10n.Error.filesystem)
|
||||||
|
case .DestinationExists:
|
||||||
|
return .resource(L10n.Error.destinationExists)
|
||||||
|
case .StorageFull:
|
||||||
|
return .resource(L10n.Error.storageFull)
|
||||||
|
case .Network:
|
||||||
|
return .resource(L10n.Error.network)
|
||||||
case .Transfer(let reason):
|
case .Transfer(let reason):
|
||||||
return transferUiText(reason)
|
return transferUiText(reason)
|
||||||
case .Repository:
|
case .Repository:
|
||||||
return .resource("error_repository")
|
return .resource(L10n.Error.repository)
|
||||||
|
case .Cancelled:
|
||||||
|
return .resource(L10n.Error.generic)
|
||||||
|
case .InvalidInput:
|
||||||
|
return .resource(L10n.Error.invalidInput)
|
||||||
case .Initialization(let reason):
|
case .Initialization(let reason):
|
||||||
return initializationUiText(reason)
|
return initializationUiText(reason)
|
||||||
case .Internal(let reason):
|
case .Internal(let reason):
|
||||||
return reasonHints(reason) ?? .resource("error_generic")
|
return reasonHints(reason) ?? .resource(L10n.Error.generic)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return reasonHints(technicalDetail) ?? .resource("error_generic")
|
return reasonHints(technicalDetail) ?? .resource(L10n.Error.generic)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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 }
|
||||||
let haystack = technicalDetail.lowercased()
|
let haystack = technicalDetail.lowercased()
|
||||||
if haystack.isEmpty {
|
if haystack.isEmpty {
|
||||||
// URLError / CocoaError cancellation without a message.
|
// URLError / CocoaError cancellation without a message.
|
||||||
@@ -44,32 +61,108 @@ extension Error {
|
|||||||
var technicalDetail: String {
|
var technicalDetail: String {
|
||||||
if let vni = self as? VnidropError {
|
if let vni = self as? VnidropError {
|
||||||
switch vni {
|
switch vni {
|
||||||
case .Initialization(let r), .Ticket(let r), .Filesystem(let r),
|
case .Initialization(let r), .Ticket(let r), .Filesystem(let r), .FilesystemPermission(let r),
|
||||||
.Transfer(let r), .Permission(let r), .Repository(let r), .Internal(let r):
|
.DestinationExists(let r), .StorageFull(let r), .Network(let r),
|
||||||
|
.Transfer(let r), .Permission(let r), .Repository(let r), .Cancelled(let r),
|
||||||
|
.InvalidInput(let r), .Internal(let r):
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (self as? LocalizedError)?.errorDescription ?? (self as NSError).localizedDescription
|
return (self as? LocalizedError)?.errorDescription ?? (self as NSError).localizedDescription
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var canRetryWithoutChangingInput: Bool {
|
||||||
|
guard let vni = self as? VnidropError else { return true }
|
||||||
|
switch vni {
|
||||||
|
case .FilesystemPermission, .DestinationExists, .InvalidInput: return false
|
||||||
|
default: return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// surfacing the raw core code (e.g. `destination_exists`). Unknown codes fall back
|
||||||
|
/// to the substring hints, then a generic message.
|
||||||
|
func receiverReasonUiText(_ reason: String) -> UiText {
|
||||||
|
switch reason {
|
||||||
|
case "destination_exists":
|
||||||
|
return .resource(L10n.Error.destinationExists)
|
||||||
|
case "filesystem", "filesystem_permission_denied":
|
||||||
|
return .resource(L10n.Error.filesystem)
|
||||||
|
case "permission_denied", "approval-required", "approval-expired",
|
||||||
|
"unknown-transfer", "missing-endpoint-id", "invalid-receipt":
|
||||||
|
return .resource(L10n.Error.permission)
|
||||||
|
case "storage_full":
|
||||||
|
return .resource(L10n.Error.storageFull)
|
||||||
|
case "network":
|
||||||
|
return .resource(L10n.Error.network)
|
||||||
|
case "invalid_ticket":
|
||||||
|
return .resource(L10n.Error.invalidTicket)
|
||||||
|
case "transfer":
|
||||||
|
return .resource(L10n.Error.transfer)
|
||||||
|
case "repository", "repository-error":
|
||||||
|
return .resource(L10n.Error.repository)
|
||||||
|
case "invalid_input":
|
||||||
|
return .resource(L10n.Error.invalidInput)
|
||||||
|
case "initialization":
|
||||||
|
return .resource(L10n.Error.initialization)
|
||||||
|
case "cancelled", "internal":
|
||||||
|
return .resource(L10n.Error.generic)
|
||||||
|
default:
|
||||||
|
return reasonHints(reason) ?? .resource(L10n.Error.generic)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func transferUiText(_ reason: String) -> UiText {
|
private func transferUiText(_ reason: String) -> UiText {
|
||||||
let detail = reason.lowercased()
|
let detail = reason.lowercased()
|
||||||
if detail.contains("refused") || detail.contains("denied") || detail.contains("not approved") {
|
if detail.contains("refused") || detail.contains("denied") || detail.contains("not approved") {
|
||||||
return .resource("error_permission")
|
return .resource(L10n.Error.permission)
|
||||||
}
|
}
|
||||||
return .resource("error_transfer")
|
return .resource(L10n.Error.transfer)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func initializationUiText(_ reason: String) -> UiText {
|
private func initializationUiText(_ reason: String) -> UiText {
|
||||||
let detail = reason.lowercased()
|
let detail = reason.lowercased()
|
||||||
if detail.contains("native") && detail.contains("library") {
|
if detail.contains("native") && detail.contains("library") {
|
||||||
return .resource("error_missing_native_library")
|
return .resource(L10n.Error.missingNativeLibrary)
|
||||||
}
|
}
|
||||||
if detail.contains("socket") || detail.contains("bind") {
|
if detail.contains("socket") || detail.contains("bind") {
|
||||||
return .resource("error_socket_bind")
|
return .resource(L10n.Error.socketBind)
|
||||||
}
|
}
|
||||||
return .resource("error_initialization")
|
return .resource(L10n.Error.initialization)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reasonHints(_ detailRaw: String) -> UiText? {
|
private func reasonHints(_ detailRaw: String) -> UiText? {
|
||||||
@@ -77,50 +170,50 @@ private func reasonHints(_ detailRaw: String) -> UiText? {
|
|||||||
if detail.isEmpty { return nil }
|
if detail.isEmpty { return nil }
|
||||||
|
|
||||||
if detail.contains("still starting") || detail.contains("starting up") {
|
if detail.contains("still starting") || detail.contains("starting up") {
|
||||||
return .resource("error_starting_up")
|
return .resource(L10n.Error.startingUp)
|
||||||
}
|
}
|
||||||
if detail.contains("empty") && (detail.contains("invitation") || detail.contains("ticket") || detail.contains("qr")) {
|
if detail.contains("empty") && (detail.contains("invitation") || detail.contains("ticket") || detail.contains("qr")) {
|
||||||
return .resource("error_invitation_empty")
|
return .resource(L10n.Error.invitationEmpty)
|
||||||
}
|
}
|
||||||
if detail.contains("select at least one") || detail.contains("no files found") {
|
if detail.contains("select at least one") || detail.contains("no files found") {
|
||||||
return .resource("error_share_empty")
|
return .resource(L10n.Error.shareEmpty)
|
||||||
}
|
}
|
||||||
if detail.contains("camera") {
|
if detail.contains("camera") {
|
||||||
return .resource("error_camera")
|
return .resource(L10n.Error.camera)
|
||||||
}
|
}
|
||||||
if detail.contains("nfc") || detail.contains("ndef")
|
if detail.contains("nfc") || detail.contains("ndef")
|
||||||
|| (detail.contains("read-only") && detail.contains("tag"))
|
|| (detail.contains("read-only") && detail.contains("tag"))
|
||||||
|| detail.contains("tag is too small") || detail.contains("no nfc tag") {
|
|| detail.contains("tag is too small") || detail.contains("no nfc tag") {
|
||||||
return .resource("error_nfc")
|
return .resource(L10n.Error.nfc)
|
||||||
}
|
}
|
||||||
if detail.contains("native") && detail.contains("library") {
|
if detail.contains("native") && detail.contains("library") {
|
||||||
return .resource("error_missing_native_library")
|
return .resource(L10n.Error.missingNativeLibrary)
|
||||||
}
|
}
|
||||||
if detail.contains("socket") || detail.contains("bind") {
|
if detail.contains("socket") || detail.contains("bind") {
|
||||||
return .resource("error_socket_bind")
|
return .resource(L10n.Error.socketBind)
|
||||||
}
|
}
|
||||||
if detail.contains("device information") || detail.contains("device info") {
|
if detail.contains("device information") || detail.contains("device info") {
|
||||||
return .resource("error_device_info")
|
return .resource(L10n.Error.deviceInfo)
|
||||||
}
|
}
|
||||||
if detail.contains("refused") || detail.contains("denied") || detail.contains("permission")
|
if detail.contains("refused") || detail.contains("denied") || detail.contains("permission")
|
||||||
|| detail.contains("not approved") || detail.contains("waiting for approval") {
|
|| detail.contains("not approved") || detail.contains("waiting for approval") {
|
||||||
return .resource("error_permission")
|
return .resource(L10n.Error.permission)
|
||||||
}
|
}
|
||||||
if detail.contains("invalid ticket") || detail.contains("ticket error")
|
if detail.contains("invalid ticket") || detail.contains("ticket error")
|
||||||
|| detail.contains("could not be read") || detail.contains("malformed")
|
|| detail.contains("could not be read") || detail.contains("malformed")
|
||||||
|| detail.contains("invitation could not be opened") {
|
|| detail.contains("invitation could not be opened") {
|
||||||
return .resource("error_invalid_ticket")
|
return .resource(L10n.Error.invalidTicket)
|
||||||
}
|
}
|
||||||
if detail.contains("selected")
|
if detail.contains("selected")
|
||||||
&& (detail.contains("file") || detail.contains("folder") || detail.contains("document") || detail.contains("open")) {
|
&& (detail.contains("file") || detail.contains("folder") || detail.contains("document") || detail.contains("open")) {
|
||||||
return .resource("error_selection_failed")
|
return .resource(L10n.Error.selectionFailed)
|
||||||
}
|
}
|
||||||
if detail.contains("could not open the selected") || detail.contains("could not open selected") {
|
if detail.contains("could not open the selected") || detail.contains("could not open selected") {
|
||||||
return .resource("error_selection_failed")
|
return .resource(L10n.Error.selectionFailed)
|
||||||
}
|
}
|
||||||
if detail.contains("document picker") || detail.contains("folder picker") || detail.contains("file descriptor")
|
if detail.contains("document picker") || detail.contains("folder picker") || detail.contains("file descriptor")
|
||||||
|| detail.contains("view controller") {
|
|| detail.contains("view controller") {
|
||||||
return .resource("error_selection_failed")
|
return .resource(L10n.Error.selectionFailed)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import SFSafeSymbols
|
||||||
|
|
||||||
/// Top-level destinations, ported from `ui/navigation/AppDestination.kt`.
|
/// Top-level destinations, ported from `ui/navigation/AppDestination.kt`.
|
||||||
enum AppDestination: String, CaseIterable, Identifiable {
|
enum AppDestination: String, CaseIterable, Identifiable {
|
||||||
@@ -8,20 +9,20 @@ enum AppDestination: String, CaseIterable, Identifiable {
|
|||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
var labelKey: String {
|
var labelKey: String.LocalizationValue {
|
||||||
switch self {
|
switch self {
|
||||||
case .send: return "nav_send"
|
case .send: return L10n.Nav.send
|
||||||
case .receive: return "nav_receive"
|
case .receive: return L10n.Nav.receive
|
||||||
case .settings: return "nav_settings"
|
case .settings: return L10n.Nav.settings
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// SF Symbol approximating the Compose line icon.
|
/// SF Symbol approximating the Compose line icon.
|
||||||
var systemImage: String {
|
var systemSymbol: SFSymbol {
|
||||||
switch self {
|
switch self {
|
||||||
case .send: return "paperplane"
|
case .send: return .paperplane
|
||||||
case .receive: return "tray.and.arrow.down"
|
case .receive: return .trayAndArrowDown
|
||||||
case .settings: return "gearshape"
|
case .settings: return .gearshape
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ struct VniDropColors {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension VniDropColors {
|
extension VniDropColors {
|
||||||
/// The single brand accent used app-wide as the SwiftUI tint.
|
/// The single brand accent used app-wide as the SwiftUI tint. Mirrored by the
|
||||||
|
/// `AccentColor` asset (the OS-level global accent for the macOS sidebar etc.);
|
||||||
|
/// keep the two in sync.
|
||||||
static let brandPurple = Color.hsl(271, 91, 65)
|
static let brandPurple = Color.hsl(271, 91, 65)
|
||||||
|
|
||||||
static let light = VniDropColors(
|
static let light = VniDropColors(
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
# XcodeGen spec for the native SwiftUI VniDrop app (iOS/iPadOS/macOS).
|
# XcodeGen spec for the native SwiftUI VniDrop app (iOS/iPadOS/macOS).
|
||||||
# Regenerate the project with: xcodegen generate (run from apple/)
|
# Regenerate the project with: xcodegen generate (run from apple/)
|
||||||
# Requires the Rust core first: apple/scripts/build-core.sh debug
|
# Requires three generated inputs first (all gitignored), before xcodegen:
|
||||||
|
# - Rust core: apple/scripts/build-core.sh debug
|
||||||
|
# - Localization: (cd localization && bun run src/cli.ts generate)
|
||||||
|
# -> VniDrop/Resources/Localizable.xcstrings, VniDrop/Generated/L10n.swift
|
||||||
|
# - Versions: packaging/version/generate-apple-xcconfig.sh all
|
||||||
|
# -> Generated/StoreVersion.xcconfig, Generated/DirectVersion.xcconfig
|
||||||
name: VniDrop
|
name: VniDrop
|
||||||
options:
|
options:
|
||||||
bundleIdPrefix: com.vnidrop
|
bundleIdPrefix: com.vnidrop
|
||||||
@@ -9,30 +14,63 @@ 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).
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
# Apple Silicon only. Intel Macs are unsupported (going EOL with macOS 28), and
|
||||||
|
# the Rust core's macOS slice (vnidrop.xcframework) is built arm64-only, so a
|
||||||
|
# universal link would fail looking for x86_64 symbols anyway.
|
||||||
|
ARCHS: arm64
|
||||||
|
# Strip unreachable code from release binaries.
|
||||||
|
DEAD_CODE_STRIPPING: YES
|
||||||
|
# Flag user-facing strings that aren't localized (the app ships 9 languages).
|
||||||
|
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED: YES
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
VnidropCore:
|
VnidropCore:
|
||||||
path: VnidropCore
|
path: VnidropCore
|
||||||
|
SFSafeSymbols:
|
||||||
|
url: https://github.com/SFSafeSymbols/SFSafeSymbols
|
||||||
|
from: "5.3.0"
|
||||||
|
# Sparkle powers in-app auto-updates for the direct-download (.dmg) build only.
|
||||||
|
# It is linked exclusively by the VniDropDirect target — SwiftPM links products
|
||||||
|
# per target, not per config, so keeping it off the App Store target is what
|
||||||
|
# guarantees the store binary never bundles a self-updater (App Store forbids it).
|
||||||
|
Sparkle:
|
||||||
|
url: https://github.com/sparkle-project/Sparkle
|
||||||
|
from: "2.9.4"
|
||||||
|
|
||||||
targets:
|
# 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:
|
|
||||||
Debug: Signing.xcconfig
|
|
||||||
Release: 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: "$(PRODUCT_VERSION)"
|
||||||
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
|
||||||
@@ -41,16 +79,68 @@ targets:
|
|||||||
SWIFT_STRICT_CONCURRENCY: complete
|
SWIFT_STRICT_CONCURRENCY: complete
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING: NO
|
ENABLE_USER_SCRIPT_SANDBOXING: NO
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
|
# App-wide accent (macOS sidebar selection, default control tints). The
|
||||||
|
# AccentColor asset mirrors VniDropColors.brandPurple — keep them in sync.
|
||||||
|
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
|
||||||
- sdk: SystemConfiguration.framework
|
- sdk: SystemConfiguration.framework
|
||||||
- sdk: Security.framework
|
- sdk: Security.framework
|
||||||
- sdk: libresolv.tbd
|
- sdk: libresolv.tbd
|
||||||
|
preBuildScripts:
|
||||||
|
# Enforce the typed-resources convention (see .swiftlint.yml). Required: fails
|
||||||
|
# the build if SwiftLint is missing so the rules can't be silently bypassed.
|
||||||
|
- name: SwiftLint (typed resources)
|
||||||
|
basedOnDependencyAnalysis: false
|
||||||
|
script: |
|
||||||
|
# Xcode runs build phases with a minimal PATH that omits Homebrew, so add
|
||||||
|
# the common Homebrew bin dirs (Apple Silicon + Intel) before resolving it.
|
||||||
|
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
|
||||||
|
if which swiftlint >/dev/null; then
|
||||||
|
swiftlint lint --config "${SRCROOT}/.swiftlint.yml"
|
||||||
|
else
|
||||||
|
echo "error: SwiftLint not installed — run 'brew install swiftlint'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
targets:
|
||||||
|
# App Store / TestFlight target. iOS + macOS, sandboxed, no self-updater.
|
||||||
|
VniDrop:
|
||||||
|
templates: [AppBase]
|
||||||
|
supportedDestinations: [iOS, macOS]
|
||||||
|
configFiles:
|
||||||
|
Debug: Generated/StoreVersion.xcconfig
|
||||||
|
Release: Generated/StoreVersion.xcconfig
|
||||||
|
Release-Direct: Generated/StoreVersion.xcconfig
|
||||||
|
|
||||||
|
# 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]
|
||||||
|
configFiles:
|
||||||
|
Debug: Generated/DirectVersion.xcconfig
|
||||||
|
Release: Generated/DirectVersion.xcconfig
|
||||||
|
Release-Direct: Generated/DirectVersion.xcconfig
|
||||||
|
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
|
||||||
|
dependencies:
|
||||||
|
- package: Sparkle
|
||||||
|
|
||||||
VniDropTests:
|
VniDropTests:
|
||||||
type: bundle.unit-test
|
type: bundle.unit-test
|
||||||
@@ -58,6 +148,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:
|
||||||
@@ -79,3 +170,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>
|
||||||
@@ -44,6 +44,13 @@ export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-15.0}"
|
|||||||
# This never touches the Rust crate — it only changes how the build is invoked.
|
# This never touches the Rust crate — it only changes how the build is invoked.
|
||||||
export CARGO_PROFILE_DEV_STRIP=none
|
export CARGO_PROFILE_DEV_STRIP=none
|
||||||
|
|
||||||
|
# The workspace `[profile.release] lto = "thin"` corrupts host proc-macro / build
|
||||||
|
# script dylibs when cross-compiling ("mis-aligned LINKEDIT string pool"). Cargo
|
||||||
|
# forbids overriding `lto` per build-override, so disable thin LTO for the whole
|
||||||
|
# release build here — the crate is still fully optimized (opt-level 3, debuginfo
|
||||||
|
# stripped), which is what shrinks the static lib. This never edits the Cargo crate.
|
||||||
|
export CARGO_PROFILE_RELEASE_LTO=false
|
||||||
|
|
||||||
IOS_TARGET="aarch64-apple-ios"
|
IOS_TARGET="aarch64-apple-ios"
|
||||||
SIM_ARM_TARGET="aarch64-apple-ios-sim"
|
SIM_ARM_TARGET="aarch64-apple-ios-sim"
|
||||||
SIM_X64_TARGET="x86_64-apple-ios"
|
SIM_X64_TARGET="x86_64-apple-ios"
|
||||||
|
|||||||
173
apple/scripts/build-dmg.sh
Executable file
173
apple/scripts/build-dmg.sh
Executable file
@@ -0,0 +1,173 @@
|
|||||||
|
#!/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
|
||||||
|
#
|
||||||
|
# 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"
|
||||||
|
VERSION_RESOLVER="$REPO_ROOT/packaging/version/resolve-version.sh"
|
||||||
|
VERSION_CONFIG_GENERATOR="$REPO_ROOT/packaging/version/generate-apple-xcconfig.sh"
|
||||||
|
|
||||||
|
VERSION="$("$VERSION_RESOLVER" product)"
|
||||||
|
export VNIDROP_BUILD_TIME_UTC="${VNIDROP_BUILD_TIME_UTC:-$(date -u +%Y%m%d%H%M%S)}"
|
||||||
|
BUILD_NUMBER="$("$VERSION_RESOLVER" apple-direct-build)"
|
||||||
|
"$VERSION_RESOLVER" verify >/dev/null
|
||||||
|
BUILD_METADATA="$DIST_DIR/$APP_NAME-$VERSION.build-info.json"
|
||||||
|
|
||||||
|
# --- 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"
|
||||||
|
"$VERSION_CONFIG_GENERATOR" all
|
||||||
|
( 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; }
|
||||||
|
ACTUAL_VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' \
|
||||||
|
"$APP/Contents/Info.plist")"
|
||||||
|
ACTUAL_BUILD="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' \
|
||||||
|
"$APP/Contents/Info.plist")"
|
||||||
|
[ "$ACTUAL_VERSION" = "$VERSION" ] || {
|
||||||
|
echo "error: exported app version $ACTUAL_VERSION does not match $VERSION" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[ "$ACTUAL_BUILD" = "$BUILD_NUMBER" ] || {
|
||||||
|
echo "error: exported app build $ACTUAL_BUILD does not match $BUILD_NUMBER" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "==> Enforcing hardened-runtime signature"
|
||||||
|
"$SCRIPT_DIR/sign-exported-app.sh" \
|
||||||
|
"$APP" \
|
||||||
|
"$DEVELOPER_ID_APP" \
|
||||||
|
"$APPLE_DIR/VniDrop/Resources/VniDropDirect.entitlements"
|
||||||
|
|
||||||
|
# --- 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)"
|
||||||
|
NOTARY_LOG="$DIST_DIR/$APP_NAME-$VERSION.notary-log.json"
|
||||||
|
"$SCRIPT_DIR/notarize.sh" "$DMG" "$NOTARY_PROFILE" "$NOTARY_LOG"
|
||||||
|
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")"
|
||||||
|
jq -n \
|
||||||
|
--arg productVersion "$VERSION" \
|
||||||
|
--arg directBuildNumber "$BUILD_NUMBER" \
|
||||||
|
--arg artifact "$(basename "$DMG")" \
|
||||||
|
'{
|
||||||
|
productVersion: $productVersion,
|
||||||
|
directBuildNumber: $directBuildNumber,
|
||||||
|
distribution: "direct",
|
||||||
|
artifact: $artifact
|
||||||
|
}' > "$BUILD_METADATA"
|
||||||
|
echo "==> Done."
|
||||||
|
echo " dmg: $DMG"
|
||||||
|
echo " version: $VERSION"
|
||||||
|
echo " build: $BUILD_NUMBER"
|
||||||
|
echo " size: $SIZE bytes"
|
||||||
66
apple/scripts/generate-appcast.sh
Executable file
66
apple/scripts/generate-appcast.sh
Executable file
@@ -0,0 +1,66 @@
|
|||||||
|
#!/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
|
||||||
|
#
|
||||||
|
# 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_RESOLVER="$REPO_ROOT/packaging/version/resolve-version.sh"
|
||||||
|
|
||||||
|
VERSION="$("$VERSION_RESOLVER" product)"
|
||||||
|
"$VERSION_RESOLVER" verify >/dev/null
|
||||||
|
|
||||||
|
# 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/"
|
||||||
44
apple/scripts/generate-appconfig.sh
Executable file
44
apple/scripts/generate-appconfig.sh
Executable file
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Generates apple/VniDrop/Generated/AppConfig.swift from the shared app.properties
|
||||||
|
# so app-wide constants (privacy policy URL, …) have a single source of truth
|
||||||
|
# across Apple and KMP. Regenerate instead of editing the output.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
repo_root="$(cd "$script_dir/../.." && pwd)"
|
||||||
|
config_file="${VNIDROP_APP_PROPERTIES:-$repo_root/app.properties}"
|
||||||
|
output_dir="${VNIDROP_APPLE_GENERATED_DIR:-$repo_root/apple/VniDrop/Generated}"
|
||||||
|
|
||||||
|
read_property() {
|
||||||
|
local key=$1
|
||||||
|
local value
|
||||||
|
value="$(sed -n "s/^${key}=//p" "$config_file")"
|
||||||
|
[[ -n "$value" ]] || { printf 'Missing %s in %s\n' "$key" "$config_file" >&2; exit 1; }
|
||||||
|
[[ $(printf '%s\n' "$value" | wc -l | tr -d ' ') == 1 ]] ||
|
||||||
|
{ printf 'Duplicate %s in %s\n' "$key" "$config_file" >&2; exit 1; }
|
||||||
|
printf '%s' "$value"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Escape for a Swift string literal.
|
||||||
|
swift_escape() {
|
||||||
|
printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'
|
||||||
|
}
|
||||||
|
|
||||||
|
privacy_url="$(read_property PRIVACY_POLICY_URL)"
|
||||||
|
|
||||||
|
mkdir -p "$output_dir"
|
||||||
|
tmp="$(mktemp "$output_dir/.AppConfig.swift.XXXXXX")"
|
||||||
|
cat > "$tmp" <<EOF
|
||||||
|
// Generated by apple/scripts/generate-appconfig.sh from app.properties.
|
||||||
|
// Regenerate this file instead of editing it.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// App-wide constants injected at build time from the shared \`app.properties\`.
|
||||||
|
enum AppConfig {
|
||||||
|
static let privacyPolicyURL = URL(string: "$(swift_escape "$privacy_url")")!
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
mv "$tmp" "$output_dir/AppConfig.swift"
|
||||||
67
apple/scripts/notarize.sh
Executable file
67
apple/scripts/notarize.sh
Executable file
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ $# -ne 3 ]]; then
|
||||||
|
printf 'Usage: %s <artifact> <keychain-profile> <log-output>\n' "$0" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
artifact=$1
|
||||||
|
keychain_profile=$2
|
||||||
|
log_output=$3
|
||||||
|
|
||||||
|
[[ -s $artifact ]] || {
|
||||||
|
printf 'error: notarization artifact is missing or empty: %s\n' "$artifact" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[[ -n $keychain_profile ]] || {
|
||||||
|
printf 'error: notarization keychain profile is empty\n' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[[ -n $log_output ]] || {
|
||||||
|
printf 'error: notarization log output path is empty\n' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
rm -f "$log_output"
|
||||||
|
set +e
|
||||||
|
response="$(
|
||||||
|
xcrun notarytool submit "$artifact" \
|
||||||
|
--keychain-profile "$keychain_profile" \
|
||||||
|
--wait \
|
||||||
|
--output-format json
|
||||||
|
)"
|
||||||
|
submit_exit=$?
|
||||||
|
set -e
|
||||||
|
printf '%s\n' "$response"
|
||||||
|
|
||||||
|
submission_id="$(
|
||||||
|
printf '%s\n' "$response" |
|
||||||
|
jq -r '.id // empty' 2>/dev/null ||
|
||||||
|
true
|
||||||
|
)"
|
||||||
|
status="$(
|
||||||
|
printf '%s\n' "$response" |
|
||||||
|
jq -r '.status // empty' 2>/dev/null ||
|
||||||
|
true
|
||||||
|
)"
|
||||||
|
|
||||||
|
if [[ $submit_exit -eq 0 && $status == Accepted && -n $submission_id ]]; then
|
||||||
|
printf 'Notarization accepted (submission %s)\n' "$submission_id"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'error: notarization was not accepted (status: %s, submission: %s)\n' \
|
||||||
|
"${status:-unknown}" "${submission_id:-unknown}" >&2
|
||||||
|
if [[ -n $submission_id ]]; then
|
||||||
|
mkdir -p "$(dirname "$log_output")"
|
||||||
|
if xcrun notarytool log "$submission_id" "$log_output" \
|
||||||
|
--keychain-profile "$keychain_profile"; then
|
||||||
|
printf '%s\n' 'Apple notarization log:' >&2
|
||||||
|
cat "$log_output" >&2
|
||||||
|
else
|
||||||
|
printf 'error: could not retrieve the Apple notarization log\n' >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user