mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-07 11:19:58 +02:00
Compare commits
70 Commits
31ba3f40b2
...
feat/devic
| Author | SHA1 | Date | |
|---|---|---|---|
| c852a68f28 | |||
| 225ff9ad22 | |||
| 677fc3c6d5 | |||
| 3441280599 | |||
| d8587851a3 | |||
| cba51ad504 | |||
| 0f70663263 | |||
| 94a8ba2103 | |||
| 268fbf161d | |||
| dc23e87c56 | |||
| 1369df4578 | |||
| 256ff89423 | |||
| 3c89c34c6e | |||
| 178def0629 | |||
| 5f5f7e0515 | |||
| e041fbeda2 | |||
| 7aa99304b2 | |||
| 9fbcf653e8 | |||
| 4cfee786fc | |||
| 0388422318 | |||
| 7afc7d0892 | |||
|
|
e8c3eadfc8 | ||
| 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 | |||
| f3124371ee | |||
| ea2f8b1cc7 | |||
| 9b8d66f97d | |||
| a8a168ffde | |||
| 516c4ace84 |
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(swift test *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
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
|
||||
9
.github/workflows/apple.yml
vendored
9
.github/workflows/apple.yml
vendored
@@ -4,6 +4,8 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "apple/**"
|
||||
- "version.properties"
|
||||
- "packaging/version/**"
|
||||
- "crates/vnidrop/**"
|
||||
- "crates/uniffi-bindgen/**"
|
||||
- "Cargo.toml"
|
||||
@@ -18,6 +20,8 @@ on:
|
||||
- master
|
||||
paths:
|
||||
- "apple/**"
|
||||
- "version.properties"
|
||||
- "packaging/version/**"
|
||||
- "crates/vnidrop/**"
|
||||
- "crates/uniffi-bindgen/**"
|
||||
- "Cargo.toml"
|
||||
@@ -76,3 +80,8 @@ jobs:
|
||||
|
||||
- name: Build and test Apple app
|
||||
run: make check-apple
|
||||
|
||||
- name: Build direct-download macOS target (Sparkle, unsigned)
|
||||
# Keeps the VniDropDirect (.dmg/Sparkle) target compiling; signing and
|
||||
# notarization happen only in apple-release.yml on a tag.
|
||||
run: make build-apple-macos-direct
|
||||
|
||||
99
.github/workflows/linux-packages.yml
vendored
99
.github/workflows/linux-packages.yml
vendored
@@ -5,6 +5,8 @@ on:
|
||||
paths:
|
||||
- ".github/workflows/linux-packages.yml"
|
||||
- "packaging/linux/**"
|
||||
- "packaging/version/**"
|
||||
- "version.properties"
|
||||
- "assets/linux/**"
|
||||
- "desktopApp/**"
|
||||
- "shared/**"
|
||||
@@ -20,16 +22,8 @@ on:
|
||||
- "Makefile"
|
||||
- "config.mk"
|
||||
- "make/**"
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Release version in MAJOR.MINOR.PATCH form
|
||||
required: true
|
||||
default: "1.0.0"
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -88,16 +82,14 @@ jobs:
|
||||
restore-keys: |
|
||||
linux-deb-x64-cargo-1.91.0-
|
||||
|
||||
- name: Resolve version
|
||||
- name: Resolve canonical version
|
||||
id: version
|
||||
env:
|
||||
REQUESTED_VERSION: ${{ inputs.version || '1.0.0' }}
|
||||
run: |
|
||||
version=$(packaging/linux/resolve-version.sh "$REQUESTED_VERSION")
|
||||
version=$(packaging/linux/resolve-version.sh)
|
||||
echo "app=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Test and build Debian package
|
||||
run: make package-deb VERSION=${{ steps.version.outputs.app }}
|
||||
run: make package-deb
|
||||
|
||||
- name: Upload Debian artifact
|
||||
if: github.event_name != 'pull_request'
|
||||
@@ -193,16 +185,14 @@ jobs:
|
||||
restore-keys: |
|
||||
linux-rpm-x64-cargo-1.91.0-
|
||||
|
||||
- name: Resolve version
|
||||
- name: Resolve canonical version
|
||||
id: version
|
||||
env:
|
||||
REQUESTED_VERSION: ${{ inputs.version || '1.0.0' }}
|
||||
run: |
|
||||
version=$(packaging/linux/resolve-version.sh "$REQUESTED_VERSION")
|
||||
version=$(packaging/linux/resolve-version.sh)
|
||||
echo "app=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build RPM package
|
||||
run: make package-rpm VERSION=${{ steps.version.outputs.app }}
|
||||
run: make package-rpm
|
||||
|
||||
- name: Upload RPM artifact
|
||||
if: github.event_name != 'pull_request'
|
||||
@@ -220,74 +210,3 @@ jobs:
|
||||
echo "- Version: ${{ steps.version.outputs.app }}-1" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Architecture: x86_64" >> "$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.lock"
|
||||
- "crates/vnidrop/**"
|
||||
- "version.properties"
|
||||
- "packaging/version/**"
|
||||
- "Makefile"
|
||||
- "config.mk"
|
||||
- "make/**"
|
||||
@@ -19,6 +21,8 @@ on:
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/vnidrop/**"
|
||||
- "version.properties"
|
||||
- "packaging/version/**"
|
||||
- "Makefile"
|
||||
- "config.mk"
|
||||
- "make/**"
|
||||
|
||||
4
.github/workflows/shared-kmp.yml
vendored
4
.github/workflows/shared-kmp.yml
vendored
@@ -4,6 +4,8 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "shared/**"
|
||||
- "version.properties"
|
||||
- "packaging/version/**"
|
||||
- "crates/vnidrop/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
@@ -24,6 +26,8 @@ on:
|
||||
- master
|
||||
paths:
|
||||
- "shared/**"
|
||||
- "version.properties"
|
||||
- "packaging/version/**"
|
||||
- "crates/vnidrop/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
|
||||
46
.github/workflows/windows-store.yml
vendored
46
.github/workflows/windows-store.yml
vendored
@@ -5,6 +5,8 @@ on:
|
||||
paths:
|
||||
- ".github/workflows/windows-store.yml"
|
||||
- "packaging/windows/**"
|
||||
- "packaging/version/**"
|
||||
- "version.properties"
|
||||
- "assets/windows/**"
|
||||
- "desktopApp/**"
|
||||
- "shared/**"
|
||||
@@ -17,16 +19,8 @@ on:
|
||||
- "gradle/**"
|
||||
- "gradlew"
|
||||
- "gradlew.bat"
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Release version in MAJOR.MINOR.PATCH form
|
||||
required: true
|
||||
default: "1.0.0"
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -77,37 +71,13 @@ jobs:
|
||||
restore-keys: |
|
||||
windows-x64-cargo-1.91.0-
|
||||
|
||||
- name: Resolve Store version
|
||||
- name: Resolve canonical version
|
||||
id: version
|
||||
shell: pwsh
|
||||
env:
|
||||
REQUESTED_VERSION: ${{ inputs.version || '1.0.0' }}
|
||||
run: |
|
||||
$version = $env:REQUESTED_VERSION
|
||||
if ($env:GITHUB_REF_TYPE -eq "tag") {
|
||||
if ($env:GITHUB_REF_NAME -notmatch "^v[0-9]+\.[0-9]+\.[0-9]+$") {
|
||||
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
|
||||
$version = .\packaging\version\resolve-version.ps1 -Field Json -VerifyTag | ConvertFrom-Json
|
||||
"app=$($version.productVersion)" >> $env:GITHUB_OUTPUT
|
||||
"package=$($version.windowsPackageVersion)" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Test and build release app image
|
||||
shell: pwsh
|
||||
@@ -115,7 +85,6 @@ jobs:
|
||||
$arguments = @(
|
||||
":shared:jvmTest"
|
||||
":desktopApp:createReleaseDistributable"
|
||||
"-Pvnidrop.version=${{ steps.version.outputs.app }}"
|
||||
"-Pvnidrop.desktop.rustVariant=release"
|
||||
"-Pvnidrop.diagnostics.included=false"
|
||||
"--no-daemon"
|
||||
@@ -131,7 +100,6 @@ jobs:
|
||||
shell: pwsh
|
||||
run: |
|
||||
$arguments = @{
|
||||
Version = "${{ steps.version.outputs.app }}"
|
||||
AppImage = ".\desktopApp\build\compose\binaries\main-release\app\VniDrop"
|
||||
OutputDirectory = ".\build\release\windows"
|
||||
}
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -24,3 +24,5 @@ config.override.mk
|
||||
# Local design export scratch
|
||||
output/
|
||||
.screenshots
|
||||
apple/RELEASE-MACOS.md
|
||||
apple/Generated/*.xcconfig
|
||||
|
||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -5227,6 +5227,7 @@ dependencies = [
|
||||
"data-encoding",
|
||||
"futures",
|
||||
"futures-lite",
|
||||
"getrandom 0.3.4",
|
||||
"iroh",
|
||||
"iroh-blobs",
|
||||
"iroh-relay",
|
||||
|
||||
487
DESIGN-DEVICE-HISTORY.md
Normal file
487
DESIGN-DEVICE-HISTORY.md
Normal file
@@ -0,0 +1,487 @@
|
||||
# Design — Device history and direct offers
|
||||
|
||||
Status: **implemented** in the Rust core and the SwiftUI app. The KMP/Compose
|
||||
app has not been built yet; the UniFFI surface is additive, so `shared/` still
|
||||
compiles untouched and the Compose string resources are already generated.
|
||||
|
||||
Where the build deviates from what was specified here, the section says so.
|
||||
|
||||
Lets a user send to a device they have already transferred with, without
|
||||
creating and sharing a new invitation. Both sides opt in to being remembered,
|
||||
and either side can end the relationship later and have that actually take
|
||||
effect on the other device.
|
||||
|
||||
Local network discovery was considered and deliberately dropped. See
|
||||
[Appendix A](#appendix-a--deferred-local-network-discovery).
|
||||
|
||||
---
|
||||
|
||||
## 1. Goals and non-goals
|
||||
|
||||
### Goals
|
||||
|
||||
- Send to a previously used device with no new invitation, QR code, or NFC tap.
|
||||
- Let each side independently decide whether to be remembered after a transfer.
|
||||
- Let either side revoke that relationship unilaterally, with real effect.
|
||||
- Keep the receiving side's confirmation mandatory for every transfer that
|
||||
arrives this way.
|
||||
|
||||
### Non-goals
|
||||
|
||||
- No automatic acceptance of transfers, under any configuration.
|
||||
- No server-side store-and-forward and no push infrastructure. An offer to an
|
||||
unreachable device is held **on the sender's own device** or fails; nothing is
|
||||
uploaded anywhere. See §11 for what this means in practice.
|
||||
- No presence or "who is online" indicator. Knowing it requires probing, and
|
||||
probing tells every contact when you opened your list. Reachability is
|
||||
resolved lazily, at send time.
|
||||
- No change to the invitation (QR / NFC / `.vnd`) flow, which remains how a
|
||||
first contact is made and how an unpaired device is reached.
|
||||
|
||||
### Relationship to the existing flow
|
||||
|
||||
First contact is unchanged: an invitation, a transfer, a receiver confirmation.
|
||||
This feature only removes the invitation step from the *second* and subsequent
|
||||
transfers between the same two devices.
|
||||
|
||||
---
|
||||
|
||||
## 2. Threat model
|
||||
|
||||
Assume an attacker who can run a modified VniDrop client, choose any display
|
||||
name, and reach the target over the network.
|
||||
|
||||
| Property | Mechanism |
|
||||
|---|---|
|
||||
| A stranger cannot send an unsolicited transfer prompt | The offer protocol requires a valid grant (§3) |
|
||||
| A stranger cannot impersonate a known device | Identity is the iroh endpoint key; display names are untrusted data |
|
||||
| Being remembered requires consent from the remembered party | Grants are minted by the party being remembered (§3.4) |
|
||||
| A user can end a relationship unilaterally | A grant is validated only by its issuer (§3.3) |
|
||||
| A revoked peer cannot quietly regain access | Revocation is local and immediate; no cooperation required |
|
||||
|
||||
Explicit non-property: we cannot erase data from a device we do not control. A
|
||||
revoked peer's app may still hold a name string on disk. What is guaranteed is
|
||||
that the entry stops **functioning** — see §3.3.
|
||||
|
||||
The app broadcasts nothing and advertises nothing. There is no passive network
|
||||
surface introduced by this feature at all.
|
||||
|
||||
---
|
||||
|
||||
## 3. Grants: the core primitive
|
||||
|
||||
A history entry is **not** "I remember this device's endpoint ID". It is "this
|
||||
device issued me a capability to reach it". This is what makes both consent and
|
||||
revocation real rather than promised, and it is the reason a grant-based design
|
||||
is worth the modest extra complexity over storing a public key.
|
||||
|
||||
### 3.1 Shape
|
||||
|
||||
A grant is directional. If Alice wants Bob to be able to reach her, *Alice*
|
||||
mints the grant and gives it to Bob:
|
||||
|
||||
- `grant_id` — 128-bit random, opaque.
|
||||
- `grant_secret` — 256-bit random.
|
||||
- Bound to Bob's endpoint ID at issue time.
|
||||
- `expires_at` — an **idle** expiry, renewed on use (§3.5).
|
||||
|
||||
Alice keeps `(grant_id, grant_secret, bob_endpoint_id, expires_at, revoked_at)`
|
||||
in her **issued** table. Bob keeps `(grant_id, grant_secret, alice_endpoint_id,
|
||||
display_name, …)` in his **held** table, which is what his history UI lists.
|
||||
|
||||
A mutual relationship is two independent grants. Either side can revoke its own
|
||||
without affecting the other direction, which is the correct semantics: "you may
|
||||
no longer reach me" is separable from "I may no longer reach you".
|
||||
|
||||
### 3.2 Proving a grant
|
||||
|
||||
iroh already provides a mutually authenticated, encrypted QUIC connection, so
|
||||
both endpoint IDs are known and trustworthy at the transport layer. On top of
|
||||
that, challenge–response proves possession of the grant without ever
|
||||
transmitting it:
|
||||
|
||||
1. Alice (the accepting side) sends a 32-byte random `challenge`.
|
||||
2. Bob replies with `grant_id` and
|
||||
`HMAC(grant_secret, "vnidrop-grant-v1" ‖ challenge ‖ alice_endpoint_id ‖ bob_endpoint_id)`.
|
||||
3. Alice looks up `grant_id`, checks it is neither revoked nor expired, checks
|
||||
that the connection's remote endpoint ID equals the endpoint the grant was
|
||||
issued to, and verifies the HMAC in constant time.
|
||||
|
||||
Binding to the issued-to endpoint means Bob cannot lend his grant to a third
|
||||
party. Binding to the challenge means a captured proof cannot be replayed.
|
||||
|
||||
### 3.3 Revocation
|
||||
|
||||
Alice deletes (or tombstones) the grant in her issued table. That is the whole
|
||||
mechanism, and it is sufficient: hers is the **only** device that can validate
|
||||
it. Bob's next attempt presents an unknown `grant_id`, is refused, and his
|
||||
client deletes the dead entry.
|
||||
|
||||
The refusal is **explicit**: ordinary revocation returns a distinct `Revoked`
|
||||
status so Bob's client can remove the entry immediately and tell him the device
|
||||
is no longer available. Silence would leave a zombie entry, and Bob can infer
|
||||
what happened regardless, so the deniability is not worth the worse behavior.
|
||||
|
||||
The hard block list is the exception: a blocked endpoint receives a response
|
||||
indistinguishable from an expired or unknown grant, so blocking cannot be
|
||||
detected by probing.
|
||||
|
||||
Additionally, when Alice revokes while Bob is reachable, she sends a best-effort
|
||||
`RevokeGrant { grant_id }` so his entry disappears promptly rather than at his
|
||||
next attempt. Best-effort only — correctness never depends on it arriving.
|
||||
|
||||
Two things revocation deliberately is **not**:
|
||||
|
||||
- **Not retroactive.** Files already sent stay sent. UI copy must say so.
|
||||
- **Not a block.** Bob can still reach Alice with a QR invitation like any
|
||||
stranger. A separate hard block list refuses a given endpoint ID at the offer
|
||||
and handshake layers.
|
||||
|
||||
### 3.4 Consent to be remembered
|
||||
|
||||
After a completed transfer, each side is asked independently whether to remember
|
||||
the other. If Alice declines, no grant is minted, so Bob has nothing functional
|
||||
to store and his UI must not offer to save the device. Bob cannot override
|
||||
Alice's choice, because the useful half of the entry is hers to issue.
|
||||
|
||||
The prompt is per-transfer and must be dismissible without a choice, defaulting
|
||||
to "no". A user who never engages with it is never added to anyone's history.
|
||||
|
||||
### 3.5 Grant lifetime
|
||||
|
||||
Grants expire on **idleness, not age**. Each successful offer renews the
|
||||
issuer's `expires_at`, so a relationship in regular use never lapses, while one
|
||||
that is forgotten cleans itself up.
|
||||
|
||||
Default idle lifetime: **90 days**, configurable per device in settings
|
||||
(30 / 90 / 365 days / never) and applied at issue time. Changing the setting
|
||||
affects newly minted grants; existing ones keep the lifetime they were issued
|
||||
with until renewed.
|
||||
|
||||
Renewal is issuer-side only and needs no protocol message: Alice extends the
|
||||
grant when she validates a proof from Bob. An expired grant behaves exactly like
|
||||
a revoked one from Bob's side, except that the UI explains it as inactivity and
|
||||
offers to pair again rather than presenting it as a deliberate removal.
|
||||
|
||||
This bounds the blast radius of a pairing the user has forgotten about, and it
|
||||
softens the reinstall problem in §5.1: dead entries pointing at a regenerated
|
||||
`iroh.secret` eventually disappear on their own.
|
||||
|
||||
---
|
||||
|
||||
## 4. The offer protocol
|
||||
|
||||
Today the protocol is strictly receiver-pull: the sender never initiates. An
|
||||
offer inverts only the *delivery of the ticket*, not the transfer itself.
|
||||
|
||||
New ALPN: `/vnidrop/offer/1`.
|
||||
|
||||
1. Sender picks a contact from history.
|
||||
2. Sender creates the share exactly as today (`share_files`). The share is
|
||||
`ApprovalRequired`; an offer-created share may **never** be `Public`
|
||||
(invariant, enforced in `access_policy`).
|
||||
3. Sender pre-authorizes the target endpoint for that `transfer_id` via the
|
||||
existing `AccessPolicy::approve_endpoint_until`, so the sender is not later
|
||||
prompted to approve a transfer they themselves initiated.
|
||||
4. Sender dials the target's offer ALPN, completes the grant challenge–response
|
||||
(§3.2), and sends
|
||||
`Offer { ticket, sender_display_name, file_count, total_bytes }`.
|
||||
5. **The receiver is prompted.** This is the mandatory confirmation and it has
|
||||
no bypass.
|
||||
6. On accept, the receiver calls the existing `receive(ticket, output_dir,
|
||||
receiver_name)` — completely unchanged. It dials the sender's existing
|
||||
`/vnidrop/handshake/2`, where the pre-authorization from step 3 is already in
|
||||
place, so exactly one human is prompted for the whole flow.
|
||||
7. On decline, the sender receives `Declined` and stops the share.
|
||||
|
||||
The ticket must satisfy the receiver's relay profile, so the existing
|
||||
`ticket_matches_relay_profile` check applies unchanged: a contact on a
|
||||
strict-custom profile will refuse an offer whose ticket advertises public
|
||||
relays, and the UI must explain that rather than failing opaquely.
|
||||
|
||||
### 4.1 Identity display
|
||||
|
||||
Display names are attacker-chosen data — the existing handshake already treats
|
||||
`receiver_name` that way, and the same rule applies here. The endpoint ID is the
|
||||
only real identity. Therefore:
|
||||
|
||||
- A contact's local label is set by the local user and is **never** silently
|
||||
overwritten by a name the remote later claims. A changed remote name is shown
|
||||
as a distinct, dismissible signal.
|
||||
- A short fingerprint derived from the endpoint ID is available in the contact
|
||||
detail view, for out-of-band verification.
|
||||
|
||||
---
|
||||
|
||||
## 5. Address resolution and reachability
|
||||
|
||||
A contact stores an endpoint ID, but iroh needs an address to dial. Without
|
||||
local discovery, resolution depends on the relay profile:
|
||||
|
||||
| Relay mode | Resolution |
|
||||
|---|---|
|
||||
| `Automatic` | Public discovery resolves the endpoint ID anywhere |
|
||||
| `StrictCustom` / `CustomWithDirectFallback` | Reachable through the configured relay, whose URL is stable |
|
||||
| `LocalOnly` | Only while the cached direct address is still valid |
|
||||
|
||||
`presets::Minimal` deliberately leaves address lookup empty for the restricted
|
||||
modes (see the comment at `runtime/mod.rs:154`), so those modes cannot fall back
|
||||
to public resolution — by design.
|
||||
|
||||
**Mitigation: cache the peer's last-known `EndpointAddr` on the contact and
|
||||
refresh it after every successful connection.** The repository already persists
|
||||
sender addresses this way for receive rows —
|
||||
`encode_persisted_sender_address` / `parse_persisted_sender_address` in
|
||||
`ticket.rs:72` — so this reuses an established pattern rather than inventing
|
||||
one.
|
||||
|
||||
This covers relay modes fully, and covers `LocalOnly` for as long as the peer's
|
||||
address is unchanged. When it is not, the send fails and the user falls back to
|
||||
a QR invitation: no regression against today's behavior, but the UI must say so
|
||||
plainly rather than presenting an opaque failure. Local-only users in particular
|
||||
should be told that contacts depend on a cached address.
|
||||
|
||||
Reachability is never polled in the background. It is determined when the user
|
||||
actually sends — and, for incoming offers, when the app next comes to the
|
||||
foreground (§11).
|
||||
|
||||
### 5.1 Identity lifetime
|
||||
|
||||
Reinstalling the app regenerates `iroh.secret`, so every grant referencing the
|
||||
old endpoint dies. The UI needs an explicit "this device is no longer
|
||||
recognized, pair again" state rather than a silent failure.
|
||||
|
||||
---
|
||||
|
||||
## 6. Data model
|
||||
|
||||
New tables in the existing SQLite repository, with a schema migration:
|
||||
|
||||
| Table | Columns (sketch) |
|
||||
|---|---|
|
||||
| `contacts` | `id`, `endpoint_id` (unique), `local_label`, `remote_display_name`, `last_known_addr`, `created_at`, `last_transfer_at` |
|
||||
| `grants_issued` | `grant_id`, `grant_secret`, `issued_to_endpoint_id`, `created_at`, `expires_at` (idle, renewed on use), `revoked_at` |
|
||||
| `grants_held` | `grant_id`, `grant_secret`, `peer_endpoint_id`, `created_at`, `expires_at` (advisory copy) |
|
||||
| `blocked_endpoints` | `endpoint_id`, `created_at` |
|
||||
|
||||
`grant_secret` is **key material**. It follows the same rule as tickets: never
|
||||
in events, never in logs, never in bug reports, never in a UniFFI return value.
|
||||
The existing "tickets are capabilities" discipline extends verbatim.
|
||||
|
||||
A contact list is itself a privacy artifact — it names the people someone
|
||||
exchanges files with. It must be deletable per-entry and wholesale, and the
|
||||
wholesale delete must be reachable from the same place as the existing
|
||||
transfer-history and cache clearing actions.
|
||||
|
||||
Deleting a contact deletes both directions' grants for that peer and, for the
|
||||
issued side, triggers the best-effort revoke message.
|
||||
|
||||
---
|
||||
|
||||
## 7. Abuse and resource limits
|
||||
|
||||
Extend `CoreLimits` rather than inventing a parallel mechanism:
|
||||
|
||||
- `max_contacts`.
|
||||
- `max_pending_offers`, mirroring the existing `max_pending_approvals`.
|
||||
- Per-endpoint offer rate limiting, with a cooldown after repeated declines.
|
||||
- Blocked endpoints are refused at the offer ALPN before any user-visible
|
||||
prompt.
|
||||
|
||||
Because an offer already requires a valid grant, the spam surface is limited to
|
||||
devices the user deliberately chose to be reachable by, and the remedy — revoke
|
||||
— is one tap.
|
||||
|
||||
---
|
||||
|
||||
## 8. Surfaces to build
|
||||
|
||||
- **Rust core:** offer ALPN and handler, grant minting/proof/revocation,
|
||||
contacts and grants repository with migration, address caching, new limits,
|
||||
block list.
|
||||
- **UniFFI:** additive API — list/rename/delete contacts, send-to-contact,
|
||||
revoke, block/unblock, respond to an incoming offer, plus the corresponding
|
||||
events. Additive changes do not break existing Kotlin or Swift call sites, but
|
||||
both must be updated to use them.
|
||||
- **Compose (`shared/`)** and **SwiftUI (`apple/`)**: a contacts list and detail
|
||||
view, the post-transfer "remember this device?" prompt, the incoming-offer
|
||||
confirmation, a send-to-contact entry point in the send flow, and settings for
|
||||
the feature toggle, the grant idle lifetime (30 / 90 / 365 days / never,
|
||||
default 90), and blocked devices.
|
||||
- **Localization:** all new strings go in `localization/strings.json` and are
|
||||
generated; the platform catalogs are never hand-edited.
|
||||
|
||||
No new OS permissions, entitlements, or platform bridges are required.
|
||||
|
||||
---
|
||||
|
||||
## 9. Testing
|
||||
|
||||
- **Grant crypto:** fixed vectors for the HMAC proof; expiry, revocation,
|
||||
wrong-endpoint binding, and replay rejection.
|
||||
- **Grant lifetime:** a successful proof renews `expires_at`; an idle grant
|
||||
lapses at the configured boundary; a renewed grant survives past its original
|
||||
expiry. Assert the revoked and blocked responses are distinguishable from each
|
||||
other and that blocked is indistinguishable from expired/unknown.
|
||||
- **Offer protocol:** two in-process nodes using the existing
|
||||
`crates/vnidrop/tests/support` harness — accept, decline, revoked grant,
|
||||
expired grant, blocked endpoint, relay-profile mismatch, and the invariant
|
||||
that an offer-created share is never `Public`.
|
||||
- **Pre-authorization:** assert the sender is prompted exactly zero times and
|
||||
the receiver exactly once, for a full offer → accept → transfer round trip.
|
||||
- **Consent:** assert that declining to be remembered leaves the peer with no
|
||||
usable grant, and that a subsequent offer from that peer is refused.
|
||||
- **Address caching:** a contact whose cached address is stale falls back
|
||||
cleanly and reports an actionable error, rather than hanging.
|
||||
- **Persistence:** grants and contacts survive a core shutdown and reopen of the
|
||||
same data dir, following the existing recovery-test pattern.
|
||||
- **Sender-held offers (§11):** an offer to an unreachable contact is retained,
|
||||
is cancellable, is collected on the receiver's next pull, and is not
|
||||
double-delivered if the receiver pulls twice.
|
||||
- Per `AGENTS.md`, any bug found gets a regression test at the lowest layer.
|
||||
|
||||
---
|
||||
|
||||
## 10. Settled decisions
|
||||
|
||||
Both previously open questions are decided and specified above; recorded here
|
||||
with their rationale so the reasoning is not lost.
|
||||
|
||||
1. **Revocation is reported explicitly** (§3.3). A revoked peer's client
|
||||
receives a distinct status and removes the dead entry immediately. The
|
||||
alternative — silence — leaves a zombie entry, and the revocation is
|
||||
inferable from the failure anyway, so the deniability is illusory.
|
||||
Indistinguishable silence is reserved for the hard block list, where
|
||||
undetectability is the point.
|
||||
2. **Grants expire on idleness, renewed on use, defaulting to 90 days** (§3.5),
|
||||
configurable to 30 / 90 / 365 days or never. Relationships in regular use
|
||||
never lapse; forgotten ones clean themselves up, which bounds the blast
|
||||
radius of a stale pairing and quietly disposes of entries orphaned by a
|
||||
reinstall.
|
||||
|
||||
---
|
||||
|
||||
## 11. Delivery when the recipient is not running
|
||||
|
||||
An offer is a live connection to a running app. This section states plainly what
|
||||
that costs and how far it is mitigated.
|
||||
|
||||
### 11.1 The constraint
|
||||
|
||||
Notifying the user is not the problem — `LocalNotificationService` and the
|
||||
existing `ApprovalCoordinator` already turn an incoming approval request into a
|
||||
user-visible prompt, and an incoming offer reuses that path unchanged.
|
||||
|
||||
*Receiving* the request is the problem. `BackgroundActivityController` holds an
|
||||
iOS background assertion only while there is active work and releases it as soon
|
||||
as that drains, so a suspended app has no listening socket: the sender's dial
|
||||
fails and there is nothing to notify about.
|
||||
|
||||
Waking a suspended iOS app from the network requires a remote push through APNs,
|
||||
which means a server holding device tokens and observing who contacts whom. That
|
||||
is infrastructure plus a metadata leak, both of which contradict the product's
|
||||
no-cloud posture. **APNs is out of scope.** (This is also why AirDrop can do it
|
||||
and a third-party app cannot: AirDrop is an OS daemon, not an app.)
|
||||
|
||||
### 11.2 Sender-held offers with a foreground pull
|
||||
|
||||
When the target is unreachable, the sender holds the offer **locally** — the
|
||||
share stays on the sender's disk exactly as today, with no copy anywhere else —
|
||||
and the receiver collects it when its app next comes to the foreground, raising
|
||||
a local notification at that point.
|
||||
|
||||
Resulting coverage:
|
||||
|
||||
| Scenario | Result |
|
||||
|---|---|
|
||||
| Phone → always-on desktop | Immediate; the desktop is listening |
|
||||
| Desktop → phone, app closed | Delivered on the phone's next launch |
|
||||
| Phone → phone, both apps closed | **Not supported** |
|
||||
|
||||
Desktop platforms are unaffected by any of this and are always reachable while
|
||||
the app runs.
|
||||
|
||||
### 11.3 The presence cost of pulling
|
||||
|
||||
Dialing contacts on launch tells them when the app was opened and reveals the
|
||||
device's address to them — precisely the leak §1 avoids by refusing background
|
||||
presence polling. The pull is therefore bounded rather than automatic:
|
||||
|
||||
- It is **off by default**, behind a single setting whose own footer states the
|
||||
cost, plus an explicit "Check now" action that works regardless.
|
||||
- It never runs in the background, only on an actual foreground transition.
|
||||
- It is rate-limited per contact (5 minutes), so repeated app switching does not
|
||||
turn into a presence beacon.
|
||||
|
||||
**Deviation from the original draft, as built.** This specified a *per-contact*
|
||||
opt-in. What shipped is one global toggle, which is coarser: enabling it polls
|
||||
every contact rather than a chosen few. Per-contact control needs a schema
|
||||
column and a control on each device's detail screen, and the global switch with
|
||||
an honest footer covers the same threat — the user still decides whether their
|
||||
app-open times are revealed at all. Worth revisiting if anyone keeps contacts
|
||||
they would rather not signal to.
|
||||
|
||||
### 11.4 What the sender sees
|
||||
|
||||
A held offer is listed on the sender's device with its target, and withdrawing
|
||||
it is cancelling the transfer — stopping the share deletes the waiting ticket,
|
||||
so a cancelled transfer can never be collected afterwards.
|
||||
|
||||
### 11.5 Scope statement for the UI
|
||||
|
||||
Mobile-to-mobile transfer with both apps closed is not supported and must not be
|
||||
implied. The contact list distinguishes "reachable now" from "will be delivered
|
||||
when they next open VniDrop", and an offer awaiting pickup is visible and
|
||||
cancellable on the sender's side.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — Deferred: local network discovery
|
||||
|
||||
An earlier draft specified AirDrop-style discovery: three visibility tiers
|
||||
(invisible / paired-only / a time-boxed pairing window), private per-grant mDNS
|
||||
beacons using rotating per-epoch AEAD entries so only grant holders could
|
||||
recognize a device, and a short-authentication-string pairing flow. It was
|
||||
dropped, because once first contact requires a completed transfer anyway,
|
||||
discovery adds far less than it costs.
|
||||
|
||||
**What it would have added:** camera-free pairing (QR pairing already works),
|
||||
live presence (which requires probing, and probing leaks when a user opens their
|
||||
contact list), and address resolution on a network with no public discovery —
|
||||
the only substantive one, and largely handled by the address caching in §5.
|
||||
|
||||
**What dropping it avoids:**
|
||||
|
||||
- The `com.apple.developer.networking.multicast` entitlement risk. iroh's
|
||||
local-network discovery uses raw multicast sockets rather than Bonjour, and
|
||||
that entitlement requires a special request to Apple that is frequently
|
||||
refused. This was the single largest threat to shipping.
|
||||
- Local network permission prompts on iOS/macOS, an Android multicast lock and
|
||||
`NEARBY_WIFI_DEVICES`, a Windows firewall prompt, and avahi coexistence on UDP
|
||||
5353.
|
||||
- A per-platform discovery bridge, including a native `NWBrowser`/`NWListener`
|
||||
implementation in Swift.
|
||||
- Beacon crypto, epoch/clock-skew handling, and a hard cap of roughly 24–28
|
||||
advertised contacts imposed by the mDNS packet budget.
|
||||
- A contradiction with the README's promise that the restricted relay modes
|
||||
never use "public discovery".
|
||||
- Visibility-tier settings, which are difficult to explain and easy to
|
||||
misconfigure.
|
||||
|
||||
It also *improves* the privacy posture: the app broadcasts nothing at all, which
|
||||
is a stronger and far more explainable claim than any beacon scheme, including
|
||||
in an App Store review.
|
||||
|
||||
**Network-trust detection was rejected separately and stays rejected.** Deciding
|
||||
what to expose based on whether a network looks "public" is unreliable — macOS
|
||||
has no such concept, Android needs `ACCESS_FINE_LOCATION` to read an SSID, and
|
||||
iOS cannot identify the current network at all without
|
||||
`com.apple.developer.networking.wifi-info` plus location permission. It is also
|
||||
spoofable, since an attacker can clone an SSID and choose a gateway MAC.
|
||||
|
||||
**If it is ever revisited**, the beacon scheme was deliberately keyed off grants,
|
||||
so it layers onto the tables in §6 with no change to the offer protocol or the
|
||||
data model. Nothing in this design forecloses it. One unrelated cleanup noted
|
||||
along the way: `apple/VniDrop/Resources/Info.plist:78` declares
|
||||
`NSBonjourServices` with a single empty-string entry, which is meaningless and
|
||||
should be removed or given a real service type.
|
||||
3
LICENSE
3
LICENSE
@@ -187,7 +187,8 @@
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
Copyright 2026 VniDrop
|
||||
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (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: 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: apple-core apple-project open-apple-project open-apple build-apple-macos build-apple-ios check-apple
|
||||
.PHONY: check-localization localization localization-migrate
|
||||
.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: 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
|
||||
|
||||
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}'
|
||||
@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_CONFIGURATION=...' 'Xcode configuration (default: $(APPLE_CONFIGURATION))'
|
||||
@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.
|
||||
|
||||
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.
|
||||
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; }
|
||||
cd $(ROOT) && apple/scripts/build-core.sh $(APPLE_PROFILE)
|
||||
|
||||
apple-project: apple-core localization ## 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
|
||||
|
||||
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).
|
||||
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.
|
||||
@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"
|
||||
|
||||
10
README.md
10
README.md
@@ -127,18 +127,18 @@ people, especially when using **Anyone with this transfer**.
|
||||
- Native SwiftUI apps on iOS, iPadOS, and macOS; Compose apps on Android,
|
||||
Windows, and Linux
|
||||
- Strict custom HTTPS relay profiles with safe apply and rollback
|
||||
- Opt-in diagnostics with transfer contents, invitations, and file paths
|
||||
excluded
|
||||
- Optional user-submitted bug reports with transfer contents, invitations, and
|
||||
file paths excluded
|
||||
|
||||
## Privacy by design
|
||||
|
||||
- **No hosted transfer copy.** VniDrop does not upload file contents to its
|
||||
diagnostics service or a VniDrop storage bucket.
|
||||
- **No hosted transfer copy.** VniDrop does not upload file contents to a bug-report
|
||||
service or a VniDrop storage bucket.
|
||||
- **Encrypted in transit.** Iroh connections are authenticated and encrypted
|
||||
end to end, including when a relay is needed.
|
||||
- **Local control.** Transfer history and sharing state stay on the device.
|
||||
- **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
|
||||
removes access immediately.
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ abstract class VerifyVnidropLibrariesTask : DefaultTask() {
|
||||
archive.getEntry(path)?.size?.takeIf { it > 0L } == null
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
compilerOptions {
|
||||
jvmTarget = JvmTarget.JVM_11
|
||||
@@ -55,12 +71,26 @@ android {
|
||||
namespace = "com.vnidrop.app"
|
||||
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 {
|
||||
applicationId = "com.vnidrop.app"
|
||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
versionCode = androidVersionCode
|
||||
versionName = appVersion
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
@@ -76,6 +106,7 @@ android {
|
||||
buildTypes {
|
||||
getByName("release") {
|
||||
isMinifyEnabled = false
|
||||
signingConfig = signingConfigs.findByName("release")
|
||||
}
|
||||
}
|
||||
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/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",
|
||||
)
|
||||
}
|
||||
if (name == "mergeReleaseJniLibFolders" || name == "mergeReleaseNativeLibs") {
|
||||
dependsOn(
|
||||
":shared:copyAndroidAndroidArm64Release",
|
||||
":shared:copyAndroidAndroidX64Release",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val verifyDebugVnidropLibraries = tasks.register<VerifyVnidropLibrariesTask>("verifyDebugVnidropLibraries") {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?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.ACCESS_NETWORK_STATE"/>
|
||||
@@ -38,7 +40,7 @@
|
||||
<data android:mimeType="application/vnd.vnidrop.transfer"/>
|
||||
</intent-filter>
|
||||
<!-- Fallback: .vnd files often arrive as octet-stream / unknown MIME. -->
|
||||
<intent-filter>
|
||||
<intent-filter tools:ignore="AppLinkUrlError">
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<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/
|
||||
4
apple/.gitignore
vendored
4
apple/.gitignore
vendored
@@ -18,3 +18,7 @@ Local.xcconfig
|
||||
.swiftpm/
|
||||
DerivedData/
|
||||
*.xcuserstate
|
||||
|
||||
# Direct-download (.dmg) build outputs — apple/scripts/build-dmg.sh
|
||||
.build-dmg/
|
||||
dist/
|
||||
|
||||
@@ -32,3 +32,18 @@ custom_rules:
|
||||
regex: '\b(Text|Label|Button|Toggle|Link|NavigationLink|Section|Picker|Stepper|TextField|SecureField|DisclosureGroup|Menu|GroupBox)\("[^"]'
|
||||
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
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
// swift-tools-version:5.9
|
||||
import PackageDescription
|
||||
|
||||
// Core/UI Swift sources built as a library so the shared logic can be typechecked
|
||||
// and unit-tested from the command line (macOS). The iOS/macOS app target in the
|
||||
// Xcode project links the same sources plus the app entry point.
|
||||
let package = Package(
|
||||
name: "VniDropApp",
|
||||
defaultLocalization: "en",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
.macOS(.v13),
|
||||
],
|
||||
products: [
|
||||
.library(name: "VniDropApp", targets: ["VniDropApp"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "VnidropCore"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "VniDropApp",
|
||||
dependencies: [.product(name: "VnidropCore", package: "VnidropCore")],
|
||||
path: "VniDrop",
|
||||
// The @main entry belongs to the Xcode app target only; excluding it
|
||||
// keeps this library free of a conflicting `_main` symbol for tests.
|
||||
exclude: ["Resources", "App/VniDropApp.swift"],
|
||||
// The Rust core (iroh network stack) links these system libraries. The
|
||||
// Xcode app target must add the same frameworks under "Link Binary With
|
||||
// Libraries" (SystemConfiguration, Security, libresolv).
|
||||
linkerSettings: [
|
||||
.linkedFramework("SystemConfiguration"),
|
||||
.linkedFramework("Security"),
|
||||
.linkedLibrary("resolv"),
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "VniDropAppTests",
|
||||
dependencies: ["VniDropApp"],
|
||||
path: "Tests"
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -18,9 +18,8 @@ apple/
|
||||
UI/Theme|Components|Navigation|Feedback|Shell/
|
||||
Platform/ # pickers, QR, NFC, share/export, per-OS file services
|
||||
Resources/ # Localizable.xcstrings, Info.plist, entitlements, assets
|
||||
Tests/ # XCTest (ported progress-derivation assertions)
|
||||
Package.swift # builds VniDrop/ as a library for CLI build/test
|
||||
project.yml # XcodeGen spec for the iOS/macOS app target
|
||||
Tests/ # XCTest bundle (VniDropTests target)
|
||||
project.yml # XcodeGen spec for the iOS/macOS app and test targets
|
||||
```
|
||||
|
||||
## Build & run
|
||||
@@ -34,12 +33,36 @@ Prerequisites: Xcode, Rust with the Apple targets
|
||||
make apple-core # Rust core, Swift bindings, and XCFramework
|
||||
make apple-project # generate apple/VniDrop.xcodeproj
|
||||
make open-apple-project # generate and open the project in Xcode
|
||||
make build-apple-macos # unsigned macOS build
|
||||
make build-apple-macos # unsigned macOS build (App Store target)
|
||||
make open-apple # build and launch the macOS app
|
||||
make build-apple-ios # unsigned iOS simulator build
|
||||
make build-apple-ios # unsigned iOS simulator app
|
||||
make check-apple # iOS simulator tests
|
||||
```
|
||||
|
||||
`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
|
||||
`APPLE_DESTINATION` to override the automatically selected iOS simulator.
|
||||
Code signing is disabled for the app and test targets; local and CI builds do
|
||||
@@ -48,19 +71,22 @@ opt in with `APPLE_CODE_SIGNING=YES`. For signed builds from Xcode, create the
|
||||
ignored `apple/Local.xcconfig` and override the signing settings there, including
|
||||
the development team.
|
||||
|
||||
## Command-line typecheck & tests
|
||||
## Typecheck & tests
|
||||
|
||||
`Package.swift` builds the same sources as a library (minus the `@main` entry),
|
||||
so the shared logic can be checked and unit-tested without Xcode:
|
||||
The Xcode project is the only build definition: it owns the UI, its package
|
||||
dependencies, and the `VniDropTests` bundle (module `VniDrop`, which is what the
|
||||
tests import). Everything runs through `xcodebuild`:
|
||||
|
||||
```bash
|
||||
cd apple
|
||||
swift build # macOS
|
||||
swift test # runs Tests/ (ported progress-derivation assertions)
|
||||
# iOS typecheck:
|
||||
swift build --triple arm64-apple-ios16.0-simulator --sdk "$(xcrun --sdk iphonesimulator --show-sdk-path)"
|
||||
make check-apple # iOS simulator unit tests
|
||||
make build-apple-macos # unsigned macOS build (typecheck)
|
||||
```
|
||||
|
||||
There is deliberately no SwiftPM manifest for the app. A second build definition
|
||||
would duplicate the target's package dependencies, and the previous one had
|
||||
already drifted out of sync with `project.yml` badly enough that neither
|
||||
`swift build` nor `swift test` worked.
|
||||
|
||||
## Generated / ignored artifacts
|
||||
|
||||
`build-core.sh` produces build outputs that are gitignored (see `apple/.gitignore`):
|
||||
@@ -82,15 +108,14 @@ Rust crate itself is never changed.
|
||||
## System frameworks
|
||||
|
||||
The Rust core (iroh network stack) links `SystemConfiguration`, `Security`, and
|
||||
`libresolv`. These are declared in both `Package.swift` (for CLI build/test) and
|
||||
`project.yml` (for the app target).
|
||||
`libresolv`. These are declared in `project.yml` for the app target.
|
||||
|
||||
## Parity & scope
|
||||
|
||||
Screens mirror the Compose UI in `shared/`. Two deliberate simplifications:
|
||||
- Empty-state Lottie animations are rendered as SF Symbols (no `lottie-ios`
|
||||
dependency); swap in `lottie-ios` if exact-parity animation is required.
|
||||
- The full diagnostics/telemetry stack (`diagnostics/*`) is stubbed behind
|
||||
`BugReportService` / `DiagnosticsBuildConfig` and lands in a later phase; the UI
|
||||
hides the diagnostics toggle when not compiled in.
|
||||
- Bug reporting is stubbed behind `BugReportService` (`NoopBugReportService`) and
|
||||
a real transport lands in a later phase. There is no telemetry or crash
|
||||
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)")
|
||||
}
|
||||
}
|
||||
640
apple/Tests/ContactsModelTests.swift
Normal file
640
apple/Tests/ContactsModelTests.swift
Normal file
@@ -0,0 +1,640 @@
|
||||
import XCTest
|
||||
@testable import VniDrop
|
||||
|
||||
@MainActor
|
||||
final class ContactsModelTests: XCTestCase {
|
||||
private func makeModel(
|
||||
_ gateway: FakeCoreGateway
|
||||
) -> (ContactsModel, AppPreferencesRepository) {
|
||||
let defaults = UserDefaults(suiteName: "contacts-tests-\(UUID().uuidString)")!
|
||||
let preferences = AppPreferencesRepository(
|
||||
defaults: defaults,
|
||||
fallback: AppPreferencesDefaults(
|
||||
username: "tester",
|
||||
receiveFolder: ReceiveFolder(
|
||||
kind: .fileSystemPath,
|
||||
value: "/tmp",
|
||||
displayName: "Downloads"
|
||||
),
|
||||
themeMode: .system
|
||||
)
|
||||
)
|
||||
let model = ContactsModel(
|
||||
repository: gateway,
|
||||
messages: UiMessageController(),
|
||||
preferences: preferences,
|
||||
fileSystemService: FakeFileSystemService()
|
||||
)
|
||||
return (model, preferences)
|
||||
}
|
||||
|
||||
private func contact(
|
||||
_ endpointId: String,
|
||||
label: String? = nil,
|
||||
remoteName: String? = nil,
|
||||
canSend: Bool = true
|
||||
) -> DeviceContact {
|
||||
DeviceContact(
|
||||
endpointId: endpointId,
|
||||
localLabel: label,
|
||||
remoteDisplayName: remoteName,
|
||||
lastTransferAt: nil,
|
||||
createdAt: 0,
|
||||
canSend: canSend
|
||||
)
|
||||
}
|
||||
|
||||
private func offer(_ offerId: String, from endpointId: String = "peer") -> IncomingOfferModel {
|
||||
IncomingOfferModel(
|
||||
offerId: offerId,
|
||||
fromEndpointId: endpointId,
|
||||
senderDisplayName: "Peer",
|
||||
transferName: "photos",
|
||||
fileCount: 2,
|
||||
totalBytes: 1_024,
|
||||
receivedAt: 0
|
||||
)
|
||||
}
|
||||
|
||||
func testRefreshLoadsContactsBlocksAndPrompts() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.contactsResult = .success([contact("a"), contact("b")])
|
||||
gateway.blockedResult = .success(["blocked-one"])
|
||||
gateway.pairings = [PendingPairingModel(endpointId: "c", displayName: "Laptop", receivedAt: 0)]
|
||||
gateway.offers = [offer("offer-1")]
|
||||
let (model, _) = makeModel(gateway)
|
||||
|
||||
await model.refresh()
|
||||
|
||||
XCTAssertEqual(model.state.contacts.count, 2)
|
||||
XCTAssertEqual(model.state.blocked, ["blocked-one"])
|
||||
XCTAssertEqual(model.state.currentPairing?.endpointId, "c")
|
||||
XCTAssertEqual(model.state.currentOffer?.offerId, "offer-1")
|
||||
XCTAssertFalse(model.state.isLoading)
|
||||
}
|
||||
|
||||
/// Accepting an offer is the only path that yields a ticket; the caller needs
|
||||
/// it to run the receive with its own destination.
|
||||
func testAcceptingAnOfferReturnsTheTicket() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.offers = [offer("offer-1")]
|
||||
gateway.offerTicket = "vnd1:abc"
|
||||
let (model, _) = makeModel(gateway)
|
||||
await model.refresh()
|
||||
|
||||
let ticket = await model.respondToOffer(offerId: "offer-1", accepted: true)
|
||||
|
||||
XCTAssertEqual(ticket, "vnd1:abc")
|
||||
XCTAssertTrue(model.state.pendingOffers.isEmpty)
|
||||
XCTAssertEqual(gateway.offerResponses.map(\.accepted), [true])
|
||||
}
|
||||
|
||||
func testDecliningAnOfferYieldsNoTicketAndClearsThePrompt() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.offers = [offer("offer-1")]
|
||||
let (model, _) = makeModel(gateway)
|
||||
await model.refresh()
|
||||
|
||||
let ticket = await model.respondToOffer(offerId: "offer-1", accepted: false)
|
||||
|
||||
XCTAssertNil(ticket, "a declined offer must not hand over a capability")
|
||||
XCTAssertTrue(model.state.pendingOffers.isEmpty)
|
||||
}
|
||||
|
||||
/// Declining to be remembered must leave nothing behind for the peer.
|
||||
func testDecliningPairingClearsThePromptWithoutAddingAContact() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.pairings = [PendingPairingModel(endpointId: "peer", displayName: nil, receivedAt: 0)]
|
||||
let (model, _) = makeModel(gateway)
|
||||
await model.refresh()
|
||||
|
||||
await model.respondToPairing(endpointId: "peer", accepted: false)
|
||||
|
||||
XCTAssertTrue(model.state.pendingPairings.isEmpty)
|
||||
XCTAssertTrue(model.state.contacts.isEmpty)
|
||||
XCTAssertEqual(gateway.pairingResponses.map(\.accepted), [false])
|
||||
}
|
||||
|
||||
func testAcceptingPairingAddsTheContact() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.pairings = [PendingPairingModel(endpointId: "peer", displayName: "Laptop", receivedAt: 0)]
|
||||
let (model, _) = makeModel(gateway)
|
||||
await model.refresh()
|
||||
gateway.contactsResult = .success([contact("peer", remoteName: "Laptop")])
|
||||
|
||||
await model.respondToPairing(endpointId: "peer", accepted: true)
|
||||
|
||||
XCTAssertTrue(model.state.pendingPairings.isEmpty)
|
||||
XCTAssertEqual(model.state.contacts.map(\.endpointId), ["peer"])
|
||||
}
|
||||
|
||||
func testForgettingClearsTheSelectionAndReloads() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.contactsResult = .success([contact("peer")])
|
||||
let (model, _) = makeModel(gateway)
|
||||
await model.refresh()
|
||||
model.select("peer")
|
||||
|
||||
gateway.contactsResult = .success([])
|
||||
await model.forget(endpointId: "peer")
|
||||
|
||||
XCTAssertEqual(gateway.forgottenContacts, ["peer"])
|
||||
XCTAssertNil(model.state.selectedEndpointId)
|
||||
XCTAssertTrue(model.state.contacts.isEmpty)
|
||||
}
|
||||
|
||||
func testBlockingRemovesTheContactAndKeepsItListedAsBlocked() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.contactsResult = .success([contact("peer")])
|
||||
let (model, _) = makeModel(gateway)
|
||||
await model.refresh()
|
||||
model.select("peer")
|
||||
|
||||
gateway.contactsResult = .success([])
|
||||
gateway.blockedResult = .success(["peer"])
|
||||
await model.block(endpointId: "peer")
|
||||
|
||||
XCTAssertEqual(gateway.blockedContactIds, ["peer"])
|
||||
XCTAssertNil(model.state.selectedEndpointId)
|
||||
XCTAssertEqual(model.state.blocked, ["peer"])
|
||||
}
|
||||
|
||||
/// An empty label clears the override rather than storing whitespace, so the
|
||||
/// row falls back to the name the device reports.
|
||||
func testBlankLabelClearsTheLocalName() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
let (model, _) = makeModel(gateway)
|
||||
|
||||
await model.setLabel(endpointId: "peer", label: " ")
|
||||
|
||||
XCTAssertEqual(gateway.contactLabels.count, 1)
|
||||
XCTAssertNil(gateway.contactLabels[0].label)
|
||||
}
|
||||
|
||||
func testLabelIsTrimmedBeforeStoring() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
let (model, _) = makeModel(gateway)
|
||||
|
||||
await model.setLabel(endpointId: "peer", label: " Work Mac ")
|
||||
|
||||
XCTAssertEqual(gateway.contactLabels[0].label, "Work Mac")
|
||||
}
|
||||
|
||||
/// The core holds the lifetime in memory only, so the stored preference is
|
||||
/// the durable copy and both have to move together.
|
||||
func testGrantLifetimeIsPersistedAndPushedToTheCore() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
let (model, preferences) = makeModel(gateway)
|
||||
|
||||
model.setGrantLifetime(.days365)
|
||||
await Task.yield()
|
||||
|
||||
XCTAssertEqual(model.state.grantLifetime, .days365)
|
||||
XCTAssertEqual(preferences.preferences.grantLifetime, .days365)
|
||||
XCTAssertEqual(gateway.grantLifetimes.last, .days365)
|
||||
}
|
||||
|
||||
func testDefaultGrantLifetimeIsNinetyDays() {
|
||||
let gateway = FakeCoreGateway()
|
||||
let (model, _) = makeModel(gateway)
|
||||
|
||||
XCTAssertEqual(model.state.grantLifetime, .days90)
|
||||
}
|
||||
|
||||
/// The local label wins over whatever the peer calls itself.
|
||||
func testDisplayNamePrefersTheLocalLabel() {
|
||||
let subject = contact("peer", label: "Work Mac", remoteName: "Totally Not Evil")
|
||||
|
||||
XCTAssertEqual(subject.displayName, "Work Mac")
|
||||
}
|
||||
|
||||
func testDisplayNameFallsBackToTheReportedName() {
|
||||
let subject = contact("peer", remoteName: "Laptop")
|
||||
|
||||
XCTAssertEqual(subject.displayName, "Laptop")
|
||||
}
|
||||
|
||||
/// Files picked for a device go out as an offer, never as an invitation
|
||||
/// anyone holding the ticket could use.
|
||||
func testSendingToAContactUsesTheContactDestination() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
let files = FakeFileSystemService()
|
||||
let defaults = UserDefaults(suiteName: "contacts-send-\(UUID().uuidString)")!
|
||||
let preferences = AppPreferencesRepository(
|
||||
defaults: defaults,
|
||||
fallback: AppPreferencesDefaults(
|
||||
username: "tester",
|
||||
receiveFolder: ReceiveFolder(kind: .fileSystemPath, value: "/tmp", displayName: "Downloads"),
|
||||
themeMode: .system
|
||||
)
|
||||
)
|
||||
let model = ContactsModel(
|
||||
repository: gateway,
|
||||
messages: UiMessageController(),
|
||||
preferences: preferences,
|
||||
fileSystemService: files
|
||||
)
|
||||
gateway.sendToContactResult = .success(
|
||||
ContactSendOutcome(
|
||||
share: Share(
|
||||
transferId: 1, ticket: "vnd1:x", transferName: "doc",
|
||||
contentHash: "h", fileCount: 1, totalSize: 2
|
||||
),
|
||||
delivered: true
|
||||
)
|
||||
)
|
||||
|
||||
model.chooseFilesToSend(to: "peer")
|
||||
XCTAssertTrue(model.pendingFilePick)
|
||||
await model.onFilesPicked([
|
||||
PickedShareFile(value: "/tmp/doc.txt", displayName: "doc.txt", isDirectory: false)
|
||||
])
|
||||
|
||||
XCTAssertEqual(files.shareDestinations, [.contact(endpointId: "peer")])
|
||||
XCTAssertEqual(gateway.sentToContacts, ["peer"])
|
||||
}
|
||||
|
||||
/// A pick that arrives with no target must not be sent anywhere.
|
||||
func testPickedFilesWithoutATargetAreIgnored() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
let files = FakeFileSystemService()
|
||||
let defaults = UserDefaults(suiteName: "contacts-send-\(UUID().uuidString)")!
|
||||
let preferences = AppPreferencesRepository(
|
||||
defaults: defaults,
|
||||
fallback: AppPreferencesDefaults(
|
||||
username: "tester",
|
||||
receiveFolder: ReceiveFolder(kind: .fileSystemPath, value: "/tmp", displayName: "Downloads"),
|
||||
themeMode: .system
|
||||
)
|
||||
)
|
||||
let model = ContactsModel(
|
||||
repository: gateway,
|
||||
messages: UiMessageController(),
|
||||
preferences: preferences,
|
||||
fileSystemService: files
|
||||
)
|
||||
|
||||
await model.onFilesPicked([
|
||||
PickedShareFile(value: "/tmp/doc.txt", displayName: "doc.txt", isDirectory: false)
|
||||
])
|
||||
|
||||
XCTAssertTrue(files.shareDestinations.isEmpty)
|
||||
XCTAssertTrue(gateway.sentToContacts.isEmpty)
|
||||
}
|
||||
|
||||
/// Polling is opt-in: it tells every contact the app was opened.
|
||||
func testForegroundCheckIsSkippedUnlessEnabled() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
let (model, _) = makeModel(gateway)
|
||||
|
||||
await model.checkForOffersOnForeground()
|
||||
|
||||
XCTAssertEqual(gateway.pollCount, 0)
|
||||
}
|
||||
|
||||
func testForegroundCheckRunsOnceEnabled() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
let (model, preferences) = makeModel(gateway)
|
||||
|
||||
model.setCheckForOffersOnOpen(true)
|
||||
await model.checkForOffersOnForeground()
|
||||
|
||||
XCTAssertEqual(gateway.pollCount, 1)
|
||||
XCTAssertTrue(preferences.preferences.checkForOffersOnOpen)
|
||||
}
|
||||
|
||||
/// The explicit "check now" ignores the setting: the user just asked.
|
||||
func testExplicitCheckRunsEvenWhenTheSettingIsOff() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.pollResult = .success(2)
|
||||
let (model, _) = makeModel(gateway)
|
||||
|
||||
let collected = await model.collectWaitingOffers()
|
||||
|
||||
XCTAssertEqual(collected, 2)
|
||||
XCTAssertEqual(gateway.pollCount, 1)
|
||||
}
|
||||
|
||||
/// A transfer that could not be delivered is reported as waiting, not as a
|
||||
/// success nobody has received.
|
||||
func testAnUndeliveredSendIsReportedAsWaiting() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
let files = FakeFileSystemService()
|
||||
let defaults = UserDefaults(suiteName: "contacts-held-\(UUID().uuidString)")!
|
||||
let preferences = AppPreferencesRepository(
|
||||
defaults: defaults,
|
||||
fallback: AppPreferencesDefaults(
|
||||
username: "tester",
|
||||
receiveFolder: ReceiveFolder(kind: .fileSystemPath, value: "/tmp", displayName: "Downloads"),
|
||||
themeMode: .system
|
||||
)
|
||||
)
|
||||
let messages = UiMessageController()
|
||||
let model = ContactsModel(
|
||||
repository: gateway,
|
||||
messages: messages,
|
||||
preferences: preferences,
|
||||
fileSystemService: files
|
||||
)
|
||||
gateway.sendToContactResult = .success(
|
||||
ContactSendOutcome(
|
||||
share: Share(
|
||||
transferId: 1, ticket: "vnd1:x", transferName: "doc",
|
||||
contentHash: "h", fileCount: 1, totalSize: 2
|
||||
),
|
||||
delivered: false
|
||||
)
|
||||
)
|
||||
|
||||
model.chooseFilesToSend(to: "peer")
|
||||
await model.onFilesPicked([
|
||||
PickedShareFile(value: "/tmp/doc.txt", displayName: "doc.txt", isDirectory: false)
|
||||
])
|
||||
|
||||
XCTAssertEqual(messages.current?.tone, .info)
|
||||
}
|
||||
|
||||
func testHeldOffersAreLoadedForDisplay() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.heldOffersResult = .success([
|
||||
HeldOfferModel(
|
||||
offerId: "held-1",
|
||||
endpointId: "peer",
|
||||
transferId: 1,
|
||||
transferName: "doc",
|
||||
fileCount: 1,
|
||||
totalBytes: 2,
|
||||
createdAt: 0
|
||||
)
|
||||
])
|
||||
let (model, _) = makeModel(gateway)
|
||||
|
||||
await model.refresh()
|
||||
|
||||
XCTAssertEqual(model.state.heldOffers.map(\.offerId), ["held-1"])
|
||||
}
|
||||
|
||||
/// Offering an existing transfer reuses it rather than creating another.
|
||||
func testOfferingAnExistingTransferReportsAcceptance() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.offerTransferResult = .success(
|
||||
ContactSendOutcome(
|
||||
share: Share(
|
||||
transferId: 7, ticket: "vnd1:x", transferName: "doc",
|
||||
contentHash: "h", fileCount: 1, totalSize: 2
|
||||
),
|
||||
delivered: true
|
||||
)
|
||||
)
|
||||
let (model, _) = makeModel(gateway)
|
||||
|
||||
let delivered = await model.offerTransfer(transferId: 7, to: contact("peer"))
|
||||
|
||||
XCTAssertTrue(delivered)
|
||||
XCTAssertEqual(gateway.offeredTransfers.map(\.transferId), [7])
|
||||
XCTAssertEqual(gateway.offeredTransfers.map(\.endpointId), ["peer"])
|
||||
}
|
||||
|
||||
/// An offer to a closed device is reported as waiting, not accepted.
|
||||
func testOfferingToAClosedDeviceReportsItAsWaiting() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.offerTransferResult = .success(
|
||||
ContactSendOutcome(
|
||||
share: Share(
|
||||
transferId: 7, ticket: "vnd1:x", transferName: "doc",
|
||||
contentHash: "h", fileCount: 1, totalSize: 2
|
||||
),
|
||||
delivered: false
|
||||
)
|
||||
)
|
||||
let (model, _) = makeModel(gateway)
|
||||
|
||||
let delivered = await model.offerTransfer(transferId: 7, to: contact("peer"))
|
||||
|
||||
XCTAssertFalse(delivered)
|
||||
}
|
||||
|
||||
/// A refusal by the person on the other device is information, not an error.
|
||||
func testADeclinedOfferIsReportedWithoutAnErrorTone() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.offerTransferResult = .failure(
|
||||
InvitationError.raw("permission error: device did not accept the transfer: receiver-declined")
|
||||
)
|
||||
let defaults = UserDefaults(suiteName: "contacts-declined-\(UUID().uuidString)")!
|
||||
let preferences = AppPreferencesRepository(
|
||||
defaults: defaults,
|
||||
fallback: AppPreferencesDefaults(
|
||||
username: "tester",
|
||||
receiveFolder: ReceiveFolder(kind: .fileSystemPath, value: "/tmp", displayName: "Downloads"),
|
||||
themeMode: .system
|
||||
)
|
||||
)
|
||||
let messages = UiMessageController()
|
||||
let model = ContactsModel(
|
||||
repository: gateway,
|
||||
messages: messages,
|
||||
preferences: preferences,
|
||||
fileSystemService: FakeFileSystemService()
|
||||
)
|
||||
|
||||
let delivered = await model.offerTransfer(transferId: 7, to: contact("peer"))
|
||||
|
||||
XCTAssertFalse(delivered)
|
||||
XCTAssertEqual(messages.current?.tone, .info)
|
||||
}
|
||||
|
||||
func testUnreachableContactIsSurfacedForRepairing() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.contactsResult = .success([contact("peer", canSend: false)])
|
||||
let (model, _) = makeModel(gateway)
|
||||
|
||||
await model.refresh()
|
||||
|
||||
XCTAssertEqual(model.state.contacts.first?.canSend, false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Post-transfer suggestions
|
||||
|
||||
@MainActor
|
||||
final class PairingSuggestionTests: XCTestCase {
|
||||
private func makeModel(
|
||||
_ gateway: FakeCoreGateway,
|
||||
defaults: UserDefaults
|
||||
) -> (ContactsModel, AppPreferencesRepository) {
|
||||
let preferences = AppPreferencesRepository(
|
||||
defaults: defaults,
|
||||
fallback: AppPreferencesDefaults(
|
||||
username: "tester",
|
||||
receiveFolder: ReceiveFolder(
|
||||
kind: .fileSystemPath,
|
||||
value: "/tmp",
|
||||
displayName: "Downloads"
|
||||
),
|
||||
themeMode: .system
|
||||
)
|
||||
)
|
||||
let model = ContactsModel(
|
||||
repository: gateway,
|
||||
messages: UiMessageController(),
|
||||
preferences: preferences,
|
||||
fileSystemService: FakeFileSystemService()
|
||||
)
|
||||
return (model, preferences)
|
||||
}
|
||||
|
||||
private func newDefaults() -> UserDefaults {
|
||||
UserDefaults(suiteName: "suggestion-tests-\(UUID().uuidString)")!
|
||||
}
|
||||
|
||||
private func completedReceive(from peerId: String?) -> Transfer {
|
||||
Transfer(
|
||||
localId: "local-1",
|
||||
transferId: 1,
|
||||
direction: .receive,
|
||||
status: .done,
|
||||
peerId: peerId,
|
||||
transferName: "photos",
|
||||
contentHash: nil,
|
||||
fileCount: 1,
|
||||
totalSize: 10,
|
||||
ticket: nil,
|
||||
accessPolicy: .requireApproval,
|
||||
createdAt: 0,
|
||||
updatedAt: 0
|
||||
)
|
||||
}
|
||||
|
||||
private func state(with transfers: [Transfer]) -> CoreState {
|
||||
var core = CoreState()
|
||||
core.isInitialized = true
|
||||
core.transfers = transfers
|
||||
return core
|
||||
}
|
||||
|
||||
func testCompletedReceiveSuggestsItsSender() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
let (model, _) = makeModel(gateway, defaults: newDefaults())
|
||||
await model.refresh()
|
||||
|
||||
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||
await Task.yield()
|
||||
|
||||
XCTAssertEqual(model.state.currentSuggestion?.endpointId, "sender-endpoint")
|
||||
}
|
||||
|
||||
/// A transfer that never recorded a peer cannot be turned into a suggestion.
|
||||
func testReceiveWithoutAPeerIsNotSuggested() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
let (model, _) = makeModel(gateway, defaults: newDefaults())
|
||||
await model.refresh()
|
||||
|
||||
gateway.setState(state(with: [completedReceive(from: nil)]))
|
||||
await Task.yield()
|
||||
|
||||
XCTAssertNil(model.state.currentSuggestion)
|
||||
}
|
||||
|
||||
func testAlreadyRememberedDeviceIsNotSuggested() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.contactsResult = .success([
|
||||
DeviceContact(
|
||||
endpointId: "sender-endpoint",
|
||||
localLabel: nil,
|
||||
remoteDisplayName: nil,
|
||||
lastTransferAt: nil,
|
||||
createdAt: 0,
|
||||
canSend: true
|
||||
)
|
||||
])
|
||||
let (model, _) = makeModel(gateway, defaults: newDefaults())
|
||||
await model.refresh()
|
||||
|
||||
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||
await Task.yield()
|
||||
|
||||
XCTAssertNil(model.state.currentSuggestion)
|
||||
}
|
||||
|
||||
func testBlockedDeviceIsNotSuggested() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.blockedResult = .success(["sender-endpoint"])
|
||||
let (model, _) = makeModel(gateway, defaults: newDefaults())
|
||||
await model.refresh()
|
||||
|
||||
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||
await Task.yield()
|
||||
|
||||
XCTAssertNil(model.state.currentSuggestion)
|
||||
}
|
||||
|
||||
/// Declining has to stick, or every later transfer with the same device
|
||||
/// re-asks the question the user already answered.
|
||||
func testDecliningIsRememberedAcrossLaterTransfers() async {
|
||||
let defaults = newDefaults()
|
||||
let gateway = FakeCoreGateway()
|
||||
let (model, preferences) = makeModel(gateway, defaults: defaults)
|
||||
await model.refresh()
|
||||
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||
await Task.yield()
|
||||
let suggestion = try? XCTUnwrap(model.state.currentSuggestion)
|
||||
|
||||
model.declineSuggestion(suggestion!)
|
||||
|
||||
XCTAssertNil(model.state.currentSuggestion)
|
||||
XCTAssertTrue(preferences.preferences.declinedPairingSuggestions.contains("sender-endpoint"))
|
||||
|
||||
// A second transfer with the same device must stay silent.
|
||||
gateway.setState(CoreState())
|
||||
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||
await Task.yield()
|
||||
XCTAssertNil(model.state.currentSuggestion)
|
||||
}
|
||||
|
||||
func testAcceptingASuggestionIssuesAGrantUnderTheLocalUsername() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
let (model, _) = makeModel(gateway, defaults: newDefaults())
|
||||
await model.refresh()
|
||||
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||
await Task.yield()
|
||||
let suggestion = try? XCTUnwrap(model.state.currentSuggestion)
|
||||
|
||||
await model.acceptSuggestion(suggestion!)
|
||||
|
||||
XCTAssertEqual(gateway.allowedDevices.map(\.endpointId), ["sender-endpoint"])
|
||||
XCTAssertEqual(gateway.allowedDevices.first?.displayName, "tester")
|
||||
XCTAssertNil(model.state.currentSuggestion)
|
||||
}
|
||||
|
||||
/// Pairing deliberately after declining should work, so the decline is
|
||||
/// cleared rather than blocking the device forever.
|
||||
func testAcceptingClearsAnEarlierDecline() async {
|
||||
let defaults = newDefaults()
|
||||
let gateway = FakeCoreGateway()
|
||||
let (model, preferences) = makeModel(gateway, defaults: defaults)
|
||||
let suggestion = PairingSuggestion(
|
||||
endpointId: "sender-endpoint",
|
||||
displayName: nil,
|
||||
transferName: nil
|
||||
)
|
||||
model.declineSuggestion(suggestion)
|
||||
XCTAssertTrue(preferences.preferences.declinedPairingSuggestions.contains("sender-endpoint"))
|
||||
|
||||
await model.acceptSuggestion(suggestion)
|
||||
|
||||
XCTAssertFalse(preferences.preferences.declinedPairingSuggestions.contains("sender-endpoint"))
|
||||
}
|
||||
|
||||
func testTheSameDeviceIsOnlySuggestedOnce() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
let (model, _) = makeModel(gateway, defaults: newDefaults())
|
||||
await model.refresh()
|
||||
|
||||
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||
await Task.yield()
|
||||
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||
await Task.yield()
|
||||
|
||||
XCTAssertEqual(model.state.suggestions.count, 1)
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,98 @@ final class FakeCoreGateway: CoreGateway {
|
||||
return responseResult
|
||||
}
|
||||
func refresh() async -> Result<Void, Error> { .success(()) }
|
||||
|
||||
// MARK: Device history
|
||||
|
||||
var contactsResult: Result<[DeviceContact], Error> = .success([])
|
||||
var pairings: [PendingPairingModel] = []
|
||||
var offers: [IncomingOfferModel] = []
|
||||
var respondToPairingResult: Result<Bool, Error> = .success(true)
|
||||
/// Ticket handed back when an offer is accepted; nil models a declined one.
|
||||
var offerTicket: String? = "vnd1:offered"
|
||||
var sendToContactResult: Result<ContactSendOutcome, Error> = .failure(TestError.unimplemented)
|
||||
var heldOffersResult: Result<[HeldOfferModel], Error> = .success([])
|
||||
var pollResult: Result<UInt64, Error> = .success(0)
|
||||
private(set) var pollCount = 0
|
||||
var forgetContactResult: Result<Void, Error> = .success(())
|
||||
var blockedResult: Result<[String], Error> = .success([])
|
||||
|
||||
private(set) var allowedDevices: [(endpointId: String, displayName: String?)] = []
|
||||
private(set) var pairingResponses: [(endpointId: String, accepted: Bool)] = []
|
||||
private(set) var offerResponses: [(offerId: String, accepted: Bool)] = []
|
||||
private(set) var forgottenContacts: [String] = []
|
||||
private(set) var forgetAllCount = 0
|
||||
private(set) var blockedContactIds: [String] = []
|
||||
private(set) var unblockedContactIds: [String] = []
|
||||
private(set) var contactLabels: [(endpointId: String, label: String?)] = []
|
||||
private(set) var grantLifetimes: [GrantLifetimeOption] = []
|
||||
private(set) var sentToContacts: [String] = []
|
||||
|
||||
func contacts() async -> Result<[DeviceContact], Error> { contactsResult }
|
||||
func pendingPairings() async -> [PendingPairingModel] { pairings }
|
||||
func pendingOffers() async -> [IncomingOfferModel] { offers }
|
||||
func allowDeviceToReachMe(endpointId: String, displayName: String?) async -> Result<Void, Error> {
|
||||
allowedDevices.append((endpointId, displayName))
|
||||
return .success(())
|
||||
}
|
||||
func respondToPairing(endpointId: String, accepted: Bool) async -> Result<Bool, Error> {
|
||||
pairingResponses.append((endpointId, accepted))
|
||||
if case .success = respondToPairingResult {
|
||||
pairings.removeAll { $0.endpointId == endpointId }
|
||||
}
|
||||
return respondToPairingResult
|
||||
}
|
||||
func respondToOffer(offerId: String, accepted: Bool) async -> String? {
|
||||
offerResponses.append((offerId, accepted))
|
||||
offers.removeAll { $0.offerId == offerId }
|
||||
return accepted ? offerTicket : nil
|
||||
}
|
||||
func sendToContact(
|
||||
endpointId: String,
|
||||
sources: [ShareSource],
|
||||
transferName: String,
|
||||
senderName: String
|
||||
) async -> Result<ContactSendOutcome, Error> {
|
||||
sentToContacts.append(endpointId)
|
||||
return sendToContactResult
|
||||
}
|
||||
private(set) var offeredTransfers: [(transferId: UInt64, endpointId: String)] = []
|
||||
var offerTransferResult: Result<ContactSendOutcome, Error> = .failure(TestError.unimplemented)
|
||||
|
||||
func offerTransferToContact(
|
||||
transferId: UInt64,
|
||||
endpointId: String
|
||||
) async -> Result<ContactSendOutcome, Error> {
|
||||
offeredTransfers.append((transferId, endpointId))
|
||||
return offerTransferResult
|
||||
}
|
||||
func heldOffers() async -> Result<[HeldOfferModel], Error> { heldOffersResult }
|
||||
func pollContactsForOffers() async -> Result<UInt64, Error> {
|
||||
pollCount += 1
|
||||
return pollResult
|
||||
}
|
||||
func forgetContact(endpointId: String) async -> Result<Void, Error> {
|
||||
forgottenContacts.append(endpointId)
|
||||
return forgetContactResult
|
||||
}
|
||||
func forgetAllContacts() async -> Result<UInt64, Error> {
|
||||
forgetAllCount += 1
|
||||
return .success(0)
|
||||
}
|
||||
func blockContact(endpointId: String) async -> Result<Void, Error> {
|
||||
blockedContactIds.append(endpointId)
|
||||
return .success(())
|
||||
}
|
||||
func unblockContact(endpointId: String) async -> Result<Void, Error> {
|
||||
unblockedContactIds.append(endpointId)
|
||||
return .success(())
|
||||
}
|
||||
func blockedContacts() async -> Result<[String], Error> { blockedResult }
|
||||
func setContactLabel(endpointId: String, label: String?) async -> Result<Void, Error> {
|
||||
contactLabels.append((endpointId, label))
|
||||
return .success(())
|
||||
}
|
||||
func setGrantLifetime(_ lifetime: GrantLifetimeOption) async { grantLifetimes.append(lifetime) }
|
||||
}
|
||||
|
||||
/// Minimal `FileSystemService` fake — a writable path receive folder, no reveal.
|
||||
@@ -92,8 +184,21 @@ final class FakeFileSystemService: FileSystemService {
|
||||
func defaultReceiveFolder() -> ReceiveFolder { folder }
|
||||
func validateReceiveFolder(_ folder: ReceiveFolder) async -> FolderAccessStatus { .writable }
|
||||
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false }
|
||||
func sharePickedFiles(repository: CoreGateway, files: [PickedShareFile], transferName: String, senderName: String, accessPolicy: ShareAccessPolicy) async -> Result<Share, Error> {
|
||||
await repository.shareSources([], transferName: transferName, senderName: senderName, accessPolicy: accessPolicy)
|
||||
private(set) var shareDestinations: [ShareDestination] = []
|
||||
|
||||
func sharePickedFiles(repository: CoreGateway, files: [PickedShareFile], transferName: String, senderName: String, destination: ShareDestination) async -> Result<ContactSendOutcome, Error> {
|
||||
shareDestinations.append(destination)
|
||||
switch destination {
|
||||
case .invitation(let accessPolicy):
|
||||
return await repository.shareSources(
|
||||
[], transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
|
||||
)
|
||||
.map { ContactSendOutcome(share: $0, delivered: true) }
|
||||
case .contact(let endpointId):
|
||||
return await repository.sendToContact(
|
||||
endpointId: endpointId, sources: [], transferName: transferName, senderName: senderName
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,7 @@ final class SettingsModelTests: XCTestCase {
|
||||
preferences: preferences,
|
||||
notifications: LocalNotificationService(),
|
||||
messages: UiMessageController(),
|
||||
bugReports: NoopBugReportService(),
|
||||
diagnosticsIncluded: false
|
||||
bugReports: NoopBugReportService()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,13 +21,13 @@ final class UiMessageControllerTests: XCTestCase {
|
||||
|
||||
func testErrorSuppressesUserCancellation() {
|
||||
let c = UiMessageController()
|
||||
c.error(InvitationError.message("QR scanning was cancelled"))
|
||||
c.error(InvitationError.cancelled)
|
||||
XCTAssertNil(c.current) // cancellations are swallowed
|
||||
}
|
||||
|
||||
func testErrorShowsNonCancellation() {
|
||||
let c = UiMessageController()
|
||||
c.error(InvitationError.message("The transfer was refused"))
|
||||
c.error(InvitationError.raw("The transfer was refused"))
|
||||
XCTAssertEqual(c.current?.tone, .error)
|
||||
}
|
||||
}
|
||||
@@ -36,20 +36,23 @@ final class UiMessageControllerTests: XCTestCase {
|
||||
final class UserFacingErrorTests: XCTestCase {
|
||||
|
||||
func testIsUserCancellation() {
|
||||
XCTAssertTrue(InvitationError.message("NFC reading was cancelled").isUserCancellation)
|
||||
XCTAssertTrue(InvitationError.message("User canceled the picker").isUserCancellation)
|
||||
XCTAssertFalse(InvitationError.message("A database error occurred").isUserCancellation)
|
||||
XCTAssertTrue(InvitationError.cancelled.isUserCancellation)
|
||||
XCTAssertTrue(InvitationError.raw("User canceled the picker").isUserCancellation)
|
||||
XCTAssertFalse(InvitationError.raw("A database error occurred").isUserCancellation)
|
||||
}
|
||||
|
||||
func testToUiTextMapsKnownReasons() {
|
||||
XCTAssertEqual(InvitationError.message("The transfer was refused").toUiText(), .resource(L10n.Error.permission))
|
||||
XCTAssertEqual(InvitationError.message("invalid ticket").toUiText(), .resource(L10n.Error.invalidTicket))
|
||||
XCTAssertEqual(InvitationError.message("Select at least one file to share").toUiText(), .resource(L10n.Error.shareEmpty))
|
||||
XCTAssertEqual(InvitationError.message("Camera access is required").toUiText(), .resource(L10n.Error.camera))
|
||||
// Typed cases map directly at the UI boundary.
|
||||
XCTAssertEqual(InvitationError.shareEmpty.toUiText(), .resource(L10n.Error.shareEmpty))
|
||||
XCTAssertEqual(InvitationError.cameraUnavailable.toUiText(), .resource(L10n.Error.camera))
|
||||
XCTAssertEqual(InvitationError.nfcFailed.toUiText(), .resource(L10n.Error.nfc))
|
||||
// Dynamic `.raw` payloads still fall through the substring hints.
|
||||
XCTAssertEqual(InvitationError.raw("The transfer was refused").toUiText(), .resource(L10n.Error.permission))
|
||||
XCTAssertEqual(InvitationError.raw("invalid ticket").toUiText(), .resource(L10n.Error.invalidTicket))
|
||||
}
|
||||
|
||||
func testToUiTextFallsBackToGeneric() {
|
||||
XCTAssertEqual(InvitationError.message("something entirely unexpected").toUiText(), .resource(L10n.Error.generic))
|
||||
XCTAssertEqual(InvitationError.raw("something entirely unexpected").toUiText(), .resource(L10n.Error.generic))
|
||||
}
|
||||
|
||||
func testToUiTextMapsTypedTransferFailures() {
|
||||
|
||||
@@ -12,7 +12,9 @@ final class AppGraph: ObservableObject {
|
||||
let preferencesRepository: AppPreferencesRepository
|
||||
let filePreviewRepository: FilePreviewRepository
|
||||
let approvalCoordinator: ApprovalCoordinator
|
||||
let contactsModel: ContactsModel
|
||||
let transferNotificationCoordinator: TransferNotificationCoordinator
|
||||
let backgroundActivity: BackgroundActivityController
|
||||
|
||||
init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil) {
|
||||
self.dependencies = dependencies
|
||||
@@ -23,10 +25,15 @@ final class AppGraph: ObservableObject {
|
||||
fallback: AppPreferencesDefaults(
|
||||
username: dependencies.environment.defaultUsername,
|
||||
receiveFolder: dependencies.fileSystemService.defaultReceiveFolder(),
|
||||
themeMode: .system,
|
||||
diagnosticsEnabled: false
|
||||
themeMode: .system
|
||||
)
|
||||
)
|
||||
self.contactsModel = ContactsModel(
|
||||
repository: coreRepository,
|
||||
messages: messages,
|
||||
preferences: preferencesRepository,
|
||||
fileSystemService: dependencies.fileSystemService
|
||||
)
|
||||
self.approvalCoordinator = ApprovalCoordinator(
|
||||
repository: coreRepository,
|
||||
notifications: dependencies.notificationService,
|
||||
@@ -39,6 +46,7 @@ final class AppGraph: ObservableObject {
|
||||
visibility: visibility,
|
||||
messages: messages
|
||||
)
|
||||
self.backgroundActivity = BackgroundActivityController(repository: coreRepository)
|
||||
AppLogger.info("lifecycle", "graph created", ["platform": dependencies.environment.name])
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,6 @@ struct RootView: View {
|
||||
@StateObject private var sendModel: SendModel
|
||||
@StateObject private var receiveModel: ReceiveModel
|
||||
@StateObject private var settingsModel: SettingsModel
|
||||
@ObservedObject private var messages: UiMessageController
|
||||
@ObservedObject private var approvals: ApprovalCoordinator
|
||||
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
@@ -46,8 +44,6 @@ struct RootView: View {
|
||||
messages: graph.messages,
|
||||
bugReports: NoopBugReportService()
|
||||
))
|
||||
messages = graph.messages
|
||||
approvals = graph.approvalCoordinator
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -56,12 +52,22 @@ struct RootView: View {
|
||||
let isDark = resolveDarkTheme(appModel.themeMode, systemDark: systemDark)
|
||||
ZStack {
|
||||
navigation(windowClass: windowClass)
|
||||
SnackbarHost(controller: messages)
|
||||
ApprovalModalHost(
|
||||
state: approvals.state,
|
||||
onAccept: approvals.accept,
|
||||
onRefuse: approvals.refuse
|
||||
// Observe the coordinator/messages from the *persisted* `graph`
|
||||
// StateObject. Deriving them in `init` bound the view to a throwaway
|
||||
// AppGraph rebuilt on every re-init, whose coordinator never receives
|
||||
// core events — so the approval modal never appeared.
|
||||
ApprovalLayer(
|
||||
approvals: graph.approvalCoordinator,
|
||||
sendModel: sendModel
|
||||
)
|
||||
ContactPromptLayer(
|
||||
contacts: graph.contactsModel,
|
||||
receiveModel: receiveModel,
|
||||
approvals: graph.approvalCoordinator
|
||||
)
|
||||
// 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
|
||||
@@ -81,23 +87,26 @@ struct RootView: View {
|
||||
switch phase {
|
||||
case .active:
|
||||
graph.visibility.setForeground(true)
|
||||
graph.backgroundActivity.didBecomeForeground()
|
||||
settingsModel.refreshNotificationPermission()
|
||||
// Reconcile against the durable snapshot: while the window was
|
||||
// unfocused/occluded (common on macOS) live events may not have
|
||||
// rendered, leaving progress/status stale.
|
||||
Task { _ = await graph.coreRepository.refresh() }
|
||||
case .background, .inactive:
|
||||
// Opt-in and foreground-only: collecting transfers held for this
|
||||
// device also tells every contact that the app was opened.
|
||||
Task { await graph.contactsModel.checkForOffersOnForeground() }
|
||||
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)
|
||||
@unknown default:
|
||||
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)
|
||||
// macOS keeps `scenePhase == .active` even when the app loses focus, so
|
||||
// drive foreground/background off NSApplication's active state instead —
|
||||
@@ -164,9 +173,10 @@ struct RootView: View {
|
||||
@ViewBuilder
|
||||
private func screen(for destination: AppDestination, windowClass: WindowClass) -> some View {
|
||||
switch destination {
|
||||
case .send: SendScreen(model: sendModel, windowClass: windowClass)
|
||||
case .send: SendScreen(model: sendModel, contacts: graph.contactsModel, windowClass: windowClass)
|
||||
case .receive: ReceiveScreen(model: receiveModel, windowClass: windowClass)
|
||||
case .settings: SettingsScreen(model: settingsModel, windowClass: windowClass)
|
||||
case .settings:
|
||||
SettingsScreen(model: settingsModel, contacts: graph.contactsModel, windowClass: windowClass)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +201,66 @@ 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 {
|
||||
@@ -222,3 +292,57 @@ import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
/// Hosts the device-history consent prompts, alongside `ApprovalLayer`.
|
||||
///
|
||||
/// Separate from the approval layer because the two never compete: an approval
|
||||
/// belongs to a transfer this device is sending, and these belong to a device
|
||||
/// asking to reach it. Both are suppressed while the other is up so the user is
|
||||
/// never answering two modals at once.
|
||||
private struct ContactPromptLayer: View {
|
||||
@ObservedObject var contacts: ContactsModel
|
||||
let receiveModel: ReceiveModel
|
||||
@ObservedObject var approvals: ApprovalCoordinator
|
||||
|
||||
@State private var showPrompt = false
|
||||
|
||||
var body: some View {
|
||||
ContactPromptHost(
|
||||
isPresented: $showPrompt,
|
||||
state: contacts.state,
|
||||
onPairingResponse: { endpointId, accepted in
|
||||
Task { await contacts.respondToPairing(endpointId: endpointId, accepted: accepted) }
|
||||
},
|
||||
onOfferResponse: { offerId, accepted in
|
||||
Task {
|
||||
// The ticket is released only on acceptance; the receive then
|
||||
// runs through the ordinary path so the platform picks the
|
||||
// destination.
|
||||
if let ticket = await contacts.respondToOffer(offerId: offerId, accepted: accepted) {
|
||||
receiveModel.receiveOffered(ticket: ticket)
|
||||
}
|
||||
}
|
||||
},
|
||||
onSuggestionResponse: { suggestion, accepted in
|
||||
if accepted {
|
||||
Task { await contacts.acceptSuggestion(suggestion) }
|
||||
} else {
|
||||
contacts.declineSuggestion(suggestion)
|
||||
}
|
||||
}
|
||||
)
|
||||
.onChange(of: promptKey) { _, key in
|
||||
showPrompt = key != nil
|
||||
}
|
||||
}
|
||||
|
||||
/// One identity for "is there something to answer", so an offer replacing a
|
||||
/// pairing prompt re-presents rather than silently swapping content.
|
||||
private var promptKey: String? {
|
||||
guard approvals.state.current == nil else { return nil }
|
||||
if let offer = contacts.state.currentOffer { return "offer-\(offer.offerId)" }
|
||||
if let pairing = contacts.state.currentPairing { return "pairing-\(pairing.endpointId)" }
|
||||
if let suggestion = contacts.state.currentSuggestion { return "suggest-\(suggestion.endpointId)" }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ private let mainWindowId = "main"
|
||||
@main
|
||||
struct VniDropApp: App {
|
||||
@StateObject private var externalInvitations = ExternalInvitationController()
|
||||
#if DIRECT_DISTRIBUTION && os(macOS)
|
||||
// Sparkle auto-updater, present only in the direct-download (.dmg) build.
|
||||
@StateObject private var updater = SparkleUpdaterController()
|
||||
#endif
|
||||
|
||||
var body: some Scene {
|
||||
#if os(macOS)
|
||||
@@ -18,6 +22,11 @@ struct VniDropApp: App {
|
||||
.ignoresSafeArea()
|
||||
.onOpenURL(perform: openInvitation)
|
||||
}
|
||||
#if DIRECT_DISTRIBUTION
|
||||
.commands {
|
||||
UpdatesCommands(controller: updater)
|
||||
}
|
||||
#endif
|
||||
#else
|
||||
WindowGroup(id: mainWindowId) {
|
||||
RootView(dependencies: makeAppDependencies(externalInvitations: externalInvitations))
|
||||
|
||||
@@ -120,16 +120,22 @@ struct AppPreferences: Equatable {
|
||||
var username: String
|
||||
var receiveFolder: ReceiveFolder
|
||||
var themeMode: ThemeMode
|
||||
var diagnosticsEnabled: Bool
|
||||
var diagnosticsInstallId: String
|
||||
var relayConfiguration: RelayConfiguration
|
||||
/// Idle lifetime applied to grants this device issues from now on.
|
||||
var grantLifetime: GrantLifetimeOption
|
||||
/// Devices the user declined to remember. Persisted so a repeat transfer
|
||||
/// with the same device does not re-ask forever.
|
||||
var declinedPairingSuggestions: Set<String>
|
||||
/// Whether opening the app asks remembered devices for waiting transfers.
|
||||
/// Off by default: it reveals app-open times to every contact.
|
||||
var checkForOffersOnOpen: Bool
|
||||
}
|
||||
|
||||
struct AppPreferencesDefaults {
|
||||
let username: String
|
||||
let receiveFolder: ReceiveFolder
|
||||
let themeMode: ThemeMode
|
||||
var diagnosticsEnabled: Bool = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -145,9 +151,11 @@ final class AppPreferencesRepository: ObservableObject {
|
||||
static let receiveFolderValue = "receive_folder_value"
|
||||
static let receiveFolderDisplayName = "receive_folder_display_name"
|
||||
static let themeMode = "theme_mode"
|
||||
static let diagnosticsEnabled = "diagnostics_enabled"
|
||||
static let diagnosticsInstallId = "diagnostics_install_id"
|
||||
static let relayConfiguration = "relay_configuration"
|
||||
static let grantLifetime = "grant_lifetime"
|
||||
static let declinedPairingSuggestions = "declined_pairing_suggestions"
|
||||
static let checkForOffersOnOpen = "check_for_offers_on_open"
|
||||
}
|
||||
|
||||
init(defaults: UserDefaults = .standard, fallback: AppPreferencesDefaults) {
|
||||
@@ -160,15 +168,19 @@ final class AppPreferencesRepository: ObservableObject {
|
||||
let username = (defaults.string(forKey: Key.username)).flatMap { $0.isEmpty ? nil : $0 } ?? fallback.username
|
||||
let folder = resolveReceiveFolder(defaults, fallback: fallback.receiveFolder)
|
||||
let themeMode = defaults.string(forKey: Key.themeMode).flatMap(ThemeMode.init(rawValue:)) ?? fallback.themeMode
|
||||
let diagnostics = defaults.object(forKey: Key.diagnosticsEnabled) as? Bool ?? fallback.diagnosticsEnabled
|
||||
let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? ""
|
||||
let grantLifetime = defaults.string(forKey: Key.grantLifetime)
|
||||
.flatMap(GrantLifetimeOption.init(rawValue:)) ?? .days90
|
||||
let declined = Set(defaults.stringArray(forKey: Key.declinedPairingSuggestions) ?? [])
|
||||
return AppPreferences(
|
||||
username: username,
|
||||
receiveFolder: folder,
|
||||
themeMode: themeMode,
|
||||
diagnosticsEnabled: diagnostics,
|
||||
diagnosticsInstallId: installId,
|
||||
relayConfiguration: resolveRelayConfiguration(defaults)
|
||||
relayConfiguration: resolveRelayConfiguration(defaults),
|
||||
grantLifetime: grantLifetime,
|
||||
declinedPairingSuggestions: declined,
|
||||
checkForOffersOnOpen: defaults.bool(forKey: Key.checkForOffersOnOpen)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -214,13 +226,34 @@ final class AppPreferencesRepository: ObservableObject {
|
||||
setReceiveFolder(fallback.receiveFolder)
|
||||
}
|
||||
|
||||
func setThemeMode(_ mode: ThemeMode) {
|
||||
defaults.set(mode.rawValue, forKey: Key.themeMode)
|
||||
func declinePairingSuggestion(_ endpointId: String) {
|
||||
var declined = preferences.declinedPairingSuggestions
|
||||
declined.insert(endpointId)
|
||||
defaults.set(Array(declined), forKey: Key.declinedPairingSuggestions)
|
||||
reload()
|
||||
}
|
||||
|
||||
func setDiagnosticsEnabled(_ enabled: Bool) {
|
||||
defaults.set(enabled, forKey: Key.diagnosticsEnabled)
|
||||
/// Clears the decline so the device can be suggested again, used when the
|
||||
/// user pairs with it deliberately.
|
||||
func clearDeclinedPairingSuggestion(_ endpointId: String) {
|
||||
var declined = preferences.declinedPairingSuggestions
|
||||
guard declined.remove(endpointId) != nil else { return }
|
||||
defaults.set(Array(declined), forKey: Key.declinedPairingSuggestions)
|
||||
reload()
|
||||
}
|
||||
|
||||
func setCheckForOffersOnOpen(_ enabled: Bool) {
|
||||
defaults.set(enabled, forKey: Key.checkForOffersOnOpen)
|
||||
reload()
|
||||
}
|
||||
|
||||
func setGrantLifetime(_ lifetime: GrantLifetimeOption) {
|
||||
defaults.set(lifetime.rawValue, forKey: Key.grantLifetime)
|
||||
reload()
|
||||
}
|
||||
|
||||
func setThemeMode(_ mode: ThemeMode) {
|
||||
defaults.set(mode.rawValue, forKey: Key.themeMode)
|
||||
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
|
||||
}
|
||||
@@ -47,4 +47,42 @@ protocol CoreGateway: AnyObject {
|
||||
func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error>
|
||||
func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result<Void, Error>
|
||||
func refresh() async -> Result<Void, Error>
|
||||
|
||||
// MARK: Device history
|
||||
|
||||
func contacts() async -> Result<[DeviceContact], Error>
|
||||
func pendingPairings() async -> [PendingPairingModel]
|
||||
func pendingOffers() async -> [IncomingOfferModel]
|
||||
/// Hand a device a revocable capability to reach this one.
|
||||
func allowDeviceToReachMe(endpointId: String, displayName: String?) async -> Result<Void, Error>
|
||||
/// Accept or decline a device's offer to be remembered.
|
||||
func respondToPairing(endpointId: String, accepted: Bool) async -> Result<Bool, Error>
|
||||
/// Answer an incoming offer. Returns the ticket on acceptance, which the
|
||||
/// caller passes to `receive` with a platform-appropriate destination.
|
||||
func respondToOffer(offerId: String, accepted: Bool) async -> String?
|
||||
func sendToContact(
|
||||
endpointId: String,
|
||||
sources: [ShareSource],
|
||||
transferName: String,
|
||||
senderName: String
|
||||
) async -> Result<ContactSendOutcome, Error>
|
||||
/// Offer an existing share to a remembered device, alongside its QR code.
|
||||
func offerTransferToContact(
|
||||
transferId: UInt64,
|
||||
endpointId: String
|
||||
) async -> Result<ContactSendOutcome, Error>
|
||||
/// Transfers this device is holding for contacts that were not running.
|
||||
func heldOffers() async -> Result<[HeldOfferModel], Error>
|
||||
/// Ask remembered devices whether they hold anything for this one.
|
||||
///
|
||||
/// Only ever called from a foreground transition or an explicit user action:
|
||||
/// it reveals to every contact that this device is awake.
|
||||
func pollContactsForOffers() async -> Result<UInt64, Error>
|
||||
func forgetContact(endpointId: String) async -> Result<Void, Error>
|
||||
func forgetAllContacts() async -> Result<UInt64, Error>
|
||||
func blockContact(endpointId: String) async -> Result<Void, Error>
|
||||
func unblockContact(endpointId: String) async -> Result<Void, Error>
|
||||
func blockedContacts() async -> Result<[String], Error>
|
||||
func setContactLabel(endpointId: String, label: String?) async -> Result<Void, Error>
|
||||
func setGrantLifetime(_ lifetime: GrantLifetimeOption) async
|
||||
}
|
||||
|
||||
@@ -72,6 +72,16 @@ enum ShareAccessPolicy: Equatable, Sendable {
|
||||
case anyoneWithTransfer
|
||||
}
|
||||
|
||||
/// Where a picked selection is going.
|
||||
///
|
||||
/// A contact destination deliberately carries no access policy: the core forces
|
||||
/// approval-required for offers, so exposing the choice here would imply a
|
||||
/// setting that does not exist.
|
||||
enum ShareDestination: Equatable, Sendable {
|
||||
case invitation(accessPolicy: ShareAccessPolicy)
|
||||
case contact(endpointId: String)
|
||||
}
|
||||
|
||||
enum TransferDirection: Equatable, Sendable {
|
||||
case send
|
||||
case receive
|
||||
@@ -168,6 +178,10 @@ enum CoreSignal: Equatable, Sendable {
|
||||
case receiverHistoryChanged(transferId: UInt64)
|
||||
/// Transfer status/history changed enough to re-read the durable snapshot.
|
||||
case transfersChanged(transferId: UInt64)
|
||||
/// Device history changed: a contact was added, forgotten, or blocked.
|
||||
case contactsChanged
|
||||
/// An incoming offer arrived or was answered.
|
||||
case offersChanged
|
||||
}
|
||||
|
||||
// MARK: - Transfer helpers (ported from AppUiModels.kt)
|
||||
@@ -186,3 +200,112 @@ extension TransferStatus {
|
||||
self == .done || self == .failed || self == .cancelled
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Device history
|
||||
|
||||
/// A device the user has chosen to remember.
|
||||
///
|
||||
/// `localLabel` is the user's own name for the device and is authoritative for
|
||||
/// display; `remoteDisplayName` is whatever the device last called itself and is
|
||||
/// untrusted. The endpoint id is the only real identity.
|
||||
struct DeviceContact: Equatable, Identifiable, Sendable {
|
||||
let endpointId: String
|
||||
let localLabel: String?
|
||||
let remoteDisplayName: String?
|
||||
let lastTransferAt: Int64?
|
||||
let createdAt: Int64
|
||||
/// Whether a live grant is held. False once the peer revoked, the grant
|
||||
/// lapsed, or the peer reinstalled and lost its identity.
|
||||
let canSend: Bool
|
||||
|
||||
var id: String { endpointId }
|
||||
|
||||
/// Name to show, preferring the local label the peer cannot influence.
|
||||
var displayName: String {
|
||||
if let localLabel, !localLabel.isEmpty { return localLabel }
|
||||
if let remoteDisplayName, !remoteDisplayName.isEmpty { return remoteDisplayName }
|
||||
return String(localized: L10n.Approval.nearbyDevice)
|
||||
}
|
||||
|
||||
/// Short prefix of the endpoint id, for telling apart devices claiming the
|
||||
/// same name.
|
||||
var shortFingerprint: String { String(endpointId.prefix(8)) }
|
||||
}
|
||||
|
||||
/// A device offering to be remembered, awaiting this user's decision.
|
||||
struct PendingPairingModel: Equatable, Identifiable, Sendable {
|
||||
let endpointId: String
|
||||
let displayName: String?
|
||||
let receivedAt: Int64
|
||||
|
||||
var id: String { endpointId }
|
||||
|
||||
var resolvedName: String {
|
||||
guard let displayName, !displayName.isEmpty else {
|
||||
return String(localized: L10n.Approval.nearbyDevice)
|
||||
}
|
||||
return displayName
|
||||
}
|
||||
}
|
||||
|
||||
/// A transfer a remembered device is offering. Carries no ticket: that is a
|
||||
/// capability and the core releases it only once the user accepts.
|
||||
struct IncomingOfferModel: Equatable, Identifiable, Sendable {
|
||||
let offerId: String
|
||||
let fromEndpointId: String
|
||||
let senderDisplayName: String?
|
||||
let transferName: String
|
||||
let fileCount: UInt64
|
||||
let totalBytes: UInt64
|
||||
let receivedAt: Int64
|
||||
|
||||
var id: String { offerId }
|
||||
|
||||
var resolvedSenderName: String {
|
||||
guard let senderDisplayName, !senderDisplayName.isEmpty else {
|
||||
return String(localized: L10n.Approval.nearbyDevice)
|
||||
}
|
||||
return senderDisplayName
|
||||
}
|
||||
}
|
||||
|
||||
/// A transfer waiting for its target device to come back online.
|
||||
struct HeldOfferModel: Equatable, Identifiable, Sendable {
|
||||
let offerId: String
|
||||
let endpointId: String
|
||||
let transferId: UInt64
|
||||
let transferName: String
|
||||
let fileCount: UInt64
|
||||
let totalBytes: UInt64
|
||||
let createdAt: Int64
|
||||
|
||||
var id: String { offerId }
|
||||
}
|
||||
|
||||
/// Outcome of sending straight to a remembered device.
|
||||
struct ContactSendOutcome: Equatable, Sendable {
|
||||
let share: Share
|
||||
/// False when the device was not running: the transfer is held locally and
|
||||
/// collected the next time that device opens the app.
|
||||
let delivered: Bool
|
||||
}
|
||||
|
||||
/// How long a remembered device stays reachable while unused. The countdown
|
||||
/// restarts on every transfer.
|
||||
enum GrantLifetimeOption: String, CaseIterable, Identifiable, Sendable {
|
||||
case days30
|
||||
case days90
|
||||
case days365
|
||||
case never
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var days: Int? {
|
||||
switch self {
|
||||
case .days30: return 30
|
||||
case .days90: return 90
|
||||
case .days365: return 365
|
||||
case .never: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
||||
return .failure(CoreNetworkLifecycleError.transitionInProgress)
|
||||
}
|
||||
guard !sources.isEmpty else {
|
||||
return .failure(InvitationError.message("Select at least one file to share"))
|
||||
return .failure(InvitationError.shareEmpty)
|
||||
}
|
||||
return await runCore {
|
||||
let result = try self.requireCore().shareFiles(
|
||||
@@ -293,6 +293,122 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Device history
|
||||
|
||||
func contacts() async -> Result<[DeviceContact], Error> {
|
||||
await runCore {
|
||||
try self.requireCore().listContacts().map { $0.toModel() }
|
||||
}
|
||||
}
|
||||
|
||||
func pendingPairings() async -> [PendingPairingModel] {
|
||||
let result = await runCore { try self.requireCore().listPendingPairings().map { $0.toModel() } }
|
||||
return (try? result.get()) ?? []
|
||||
}
|
||||
|
||||
func pendingOffers() async -> [IncomingOfferModel] {
|
||||
let result = await runCore { try self.requireCore().listPendingOffers().map { $0.toModel() } }
|
||||
return (try? result.get()) ?? []
|
||||
}
|
||||
|
||||
func allowDeviceToReachMe(endpointId: String, displayName: String?) async -> Result<Void, Error> {
|
||||
await runCore {
|
||||
try self.requireCore().allowDeviceToReachMe(endpointId: endpointId, displayName: displayName)
|
||||
}
|
||||
}
|
||||
|
||||
func respondToPairing(endpointId: String, accepted: Bool) async -> Result<Bool, Error> {
|
||||
await runCore {
|
||||
try self.requireCore().respondToPairing(endpointId: endpointId, accepted: accepted)
|
||||
}
|
||||
}
|
||||
|
||||
func respondToOffer(offerId: String, accepted: Bool) async -> String? {
|
||||
let result = await runCore {
|
||||
try self.requireCore().respondToOffer(offerId: offerId, accepted: accepted)
|
||||
}
|
||||
return (try? result.get()) ?? nil
|
||||
}
|
||||
|
||||
func sendToContact(
|
||||
endpointId: String,
|
||||
sources: [ShareSource],
|
||||
transferName: String,
|
||||
senderName: String
|
||||
) async -> Result<ContactSendOutcome, Error> {
|
||||
guard !isNetworkTransitionInProgress else {
|
||||
return .failure(CoreNetworkLifecycleError.transitionInProgress)
|
||||
}
|
||||
guard !sources.isEmpty else {
|
||||
return .failure(InvitationError.shareEmpty)
|
||||
}
|
||||
return await runCore {
|
||||
// The access mode is forced to approval-required by the core for
|
||||
// offers; passing it here only keeps the metadata well-formed.
|
||||
let result = try self.requireCore().sendToContact(
|
||||
endpointId: endpointId,
|
||||
sources: sources,
|
||||
metadata: ShareMetadataInput(
|
||||
transferId: Self.nextTransferId(),
|
||||
transferName: transferName.isEmpty ? nil : transferName,
|
||||
senderName: senderName.isEmpty ? nil : senderName,
|
||||
accessMode: .approvalRequired
|
||||
)
|
||||
)
|
||||
return ContactSendOutcome(share: result.share.toModel(), delivered: result.delivered)
|
||||
}
|
||||
}
|
||||
|
||||
func offerTransferToContact(
|
||||
transferId: UInt64,
|
||||
endpointId: String
|
||||
) async -> Result<ContactSendOutcome, Error> {
|
||||
await runCore {
|
||||
let result = try self.requireCore().offerTransferToContact(
|
||||
transferId: transferId, endpointId: endpointId
|
||||
)
|
||||
return ContactSendOutcome(share: result.share.toModel(), delivered: result.delivered)
|
||||
}
|
||||
}
|
||||
|
||||
func heldOffers() async -> Result<[HeldOfferModel], Error> {
|
||||
await runCore { try self.requireCore().listHeldOffers().map { $0.toModel() } }
|
||||
}
|
||||
|
||||
func pollContactsForOffers() async -> Result<UInt64, Error> {
|
||||
await runCore { try self.requireCore().pollContactsForOffers() }
|
||||
}
|
||||
|
||||
func forgetContact(endpointId: String) async -> Result<Void, Error> {
|
||||
await runCore { try self.requireCore().forgetContact(endpointId: endpointId) }
|
||||
}
|
||||
|
||||
func forgetAllContacts() async -> Result<UInt64, Error> {
|
||||
await runCore { try self.requireCore().forgetAllContacts() }
|
||||
}
|
||||
|
||||
func blockContact(endpointId: String) async -> Result<Void, Error> {
|
||||
await runCore { try self.requireCore().blockContact(endpointId: endpointId) }
|
||||
}
|
||||
|
||||
func unblockContact(endpointId: String) async -> Result<Void, Error> {
|
||||
await runCore { try self.requireCore().unblockContact(endpointId: endpointId) }
|
||||
}
|
||||
|
||||
func blockedContacts() async -> Result<[String], Error> {
|
||||
await runCore { try self.requireCore().listBlockedContacts() }
|
||||
}
|
||||
|
||||
func setContactLabel(endpointId: String, label: String?) async -> Result<Void, Error> {
|
||||
await runCore {
|
||||
try self.requireCore().setContactLabel(endpointId: endpointId, label: label)
|
||||
}
|
||||
}
|
||||
|
||||
func setGrantLifetime(_ lifetime: GrantLifetimeOption) async {
|
||||
_ = await runCore { try self.requireCore().setGrantLifetime(lifetime: lifetime.toNative()) }
|
||||
}
|
||||
|
||||
// MARK: - Event sink handling (ported from CoreRepository.sink)
|
||||
|
||||
private func handle(event: CoreEvent) {
|
||||
@@ -302,6 +418,14 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
||||
if events.count > Self.maxEvents { events = Array(events.prefix(Self.maxEvents)) }
|
||||
state.events = events
|
||||
|
||||
// Contacts and offers are endpoint-scoped: they carry no transfer id, so
|
||||
// they are dispatched before the transfer-scoped handling below.
|
||||
switch model.phase {
|
||||
case "contacts": signalsSubject.send(.contactsChanged)
|
||||
case "offer": signalsSubject.send(.offersChanged)
|
||||
default: break
|
||||
}
|
||||
|
||||
guard let transferId = model.transferId else { return }
|
||||
switch model.phase {
|
||||
case "approval", "access": signalsSubject.send(.approvalChanged(transferId: transferId))
|
||||
@@ -353,7 +477,7 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
||||
|
||||
private nonisolated func requireCore() throws -> VnidropCore {
|
||||
guard let core = self.core else {
|
||||
throw InvitationError.message("Initialize the core first.")
|
||||
throw InvitationError.coreNotInitialized
|
||||
}
|
||||
return core
|
||||
}
|
||||
@@ -519,3 +643,61 @@ private extension ReceiverRequest {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ContactSummary {
|
||||
func toModel() -> DeviceContact {
|
||||
DeviceContact(
|
||||
endpointId: endpointId,
|
||||
localLabel: localLabel,
|
||||
remoteDisplayName: remoteDisplayName,
|
||||
lastTransferAt: lastTransferAt,
|
||||
createdAt: createdAt,
|
||||
canSend: canSend
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension PendingPairing {
|
||||
func toModel() -> PendingPairingModel {
|
||||
PendingPairingModel(endpointId: endpointId, displayName: displayName, receivedAt: receivedAt)
|
||||
}
|
||||
}
|
||||
|
||||
extension IncomingOffer {
|
||||
func toModel() -> IncomingOfferModel {
|
||||
IncomingOfferModel(
|
||||
offerId: offerId,
|
||||
fromEndpointId: fromEndpointId,
|
||||
senderDisplayName: senderDisplayName,
|
||||
transferName: transferName,
|
||||
fileCount: fileCount,
|
||||
totalBytes: totalBytes,
|
||||
receivedAt: receivedAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension HeldOfferSummary {
|
||||
func toModel() -> HeldOfferModel {
|
||||
HeldOfferModel(
|
||||
offerId: offerId,
|
||||
endpointId: endpointId,
|
||||
transferId: transferId,
|
||||
transferName: transferName,
|
||||
fileCount: fileCount,
|
||||
totalBytes: totalBytes,
|
||||
createdAt: createdAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension GrantLifetimeOption {
|
||||
func toNative() -> GrantLifetimeSetting {
|
||||
switch self {
|
||||
case .days30: return .days30
|
||||
case .days90: return .days90
|
||||
case .days365: return .days365
|
||||
case .never: return .never
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,23 +23,42 @@ final class ExternalInvitationController: ObservableObject {
|
||||
}
|
||||
|
||||
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 {
|
||||
case empty
|
||||
case tooLarge
|
||||
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? {
|
||||
switch self {
|
||||
case .empty: return "The invitation is empty"
|
||||
case .tooLarge: return "The invitation is too large"
|
||||
case .invalidEncoding: return "The invitation is not valid text"
|
||||
case .message(let m): return m
|
||||
}
|
||||
if case .raw(let reason) = self { return reason }
|
||||
return String(describing: self)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@ struct PickedShareFile: Equatable, Identifiable, Sendable {
|
||||
var isTemporaryCopy: Bool = false
|
||||
/// When true, `value` is a directory (path or security-scoped folder URL).
|
||||
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 }
|
||||
}
|
||||
@@ -29,13 +33,16 @@ protocol FileSystemService {
|
||||
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error>
|
||||
/// Releases only app-owned picker copies; never deletes original user sources.
|
||||
func discardPickedFiles(_ files: [PickedShareFile]) async
|
||||
/// Imports a picked selection, either as an invitation or straight to a
|
||||
/// remembered device. One entry point so the platform's security-scoped
|
||||
/// access handling covers both.
|
||||
func sharePickedFiles(
|
||||
repository: CoreGateway,
|
||||
files: [PickedShareFile],
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
accessPolicy: ShareAccessPolicy
|
||||
) async -> Result<Share, Error>
|
||||
destination: ShareDestination
|
||||
) async -> Result<ContactSendOutcome, Error>
|
||||
}
|
||||
|
||||
extension FileSystemService {
|
||||
@@ -48,7 +55,7 @@ extension FileSystemService {
|
||||
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false }
|
||||
|
||||
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 {}
|
||||
|
||||
@@ -19,7 +19,19 @@ struct LocalNotification {
|
||||
/// 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.
|
||||
private final class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate {
|
||||
///
|
||||
/// `@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
|
||||
@@ -36,14 +48,12 @@ private final class NotificationPresenter: NSObject, UNUserNotificationCenterDel
|
||||
didReceive response: UNNotificationResponse
|
||||
) async {
|
||||
#if os(macOS)
|
||||
await MainActor.run {
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -5,13 +5,17 @@ import SFSafeSymbols
|
||||
/// be swiped away. The endpoint id is the trusted identity; display names are
|
||||
/// peer-provided.
|
||||
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 onAccept: (String) -> Void
|
||||
let onRefuse: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
Color.clear
|
||||
.sheet(isPresented: .constant(state.current != nil)) {
|
||||
.sheet(isPresented: $isPresented) {
|
||||
if let request = state.current {
|
||||
ApprovalSheet(state: state, request: request, onAccept: onAccept, onRefuse: onRefuse)
|
||||
.interactiveDismissDisabled(true)
|
||||
|
||||
185
apple/VniDrop/Features/Contacts/ContactPrompts.swift
Normal file
185
apple/VniDrop/Features/Contacts/ContactPrompts.swift
Normal file
@@ -0,0 +1,185 @@
|
||||
import SFSafeSymbols
|
||||
import SwiftUI
|
||||
|
||||
/// Consent prompts for device history, presented as sheets like the receiver
|
||||
/// approval modal.
|
||||
///
|
||||
/// Both are dismissable by answering only. An incoming offer in particular must
|
||||
/// not be acceptable by accident, and a swipe-away would leave the sender
|
||||
/// waiting on a decision that never comes.
|
||||
struct ContactPromptHost: View {
|
||||
/// Driven by the host so a prompt is never presented while another sheet is
|
||||
/// still animating out — macOS silently drops the second one.
|
||||
@Binding var isPresented: Bool
|
||||
let state: ContactsState
|
||||
let onPairingResponse: (String, Bool) -> Void
|
||||
let onOfferResponse: (String, Bool) -> Void
|
||||
let onSuggestionResponse: (PairingSuggestion, Bool) -> Void
|
||||
|
||||
var body: some View {
|
||||
Color.clear
|
||||
.sheet(isPresented: $isPresented) {
|
||||
// Ordered by who is waiting: a sender is blocked on an offer, a
|
||||
// pairing request keeps until its consent window lapses, and a
|
||||
// post-transfer suggestion has nobody waiting at all.
|
||||
if let offer = state.currentOffer {
|
||||
OfferSheet(
|
||||
offer: offer,
|
||||
busy: state.busyOfferIds.contains(offer.offerId),
|
||||
onRespond: onOfferResponse
|
||||
)
|
||||
.interactiveDismissDisabled(true)
|
||||
.modifier(ContactPromptDetents())
|
||||
} else if let pairing = state.currentPairing {
|
||||
PairingSheet(
|
||||
pairing: pairing,
|
||||
busy: state.busyEndpoints.contains(pairing.endpointId),
|
||||
onRespond: onPairingResponse
|
||||
)
|
||||
.interactiveDismissDisabled(true)
|
||||
.modifier(ContactPromptDetents())
|
||||
} else if let suggestion = state.currentSuggestion {
|
||||
// Lowest priority: nobody is waiting on this answer, it just
|
||||
// follows a transfer that already finished.
|
||||
SuggestionSheet(
|
||||
suggestion: suggestion,
|
||||
busy: state.busyEndpoints.contains(suggestion.endpointId),
|
||||
onRespond: onSuggestionResponse
|
||||
)
|
||||
.interactiveDismissDisabled(true)
|
||||
.modifier(ContactPromptDetents())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ContactPromptDetents: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
#if os(iOS)
|
||||
content.presentationDetents([.medium])
|
||||
#else
|
||||
content.frame(minWidth: 420, minHeight: 300)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// "A remembered device wants to send you files."
|
||||
private struct OfferSheet: View {
|
||||
let offer: IncomingOfferModel
|
||||
let busy: Bool
|
||||
let onRespond: (String, Bool) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemSymbol: .trayAndArrowDownFill)
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(.tint)
|
||||
.padding(.top, 12)
|
||||
Text(String(localized: L10n.Offer.title))
|
||||
.font(.title2).fontWeight(.semibold)
|
||||
Text(L10n.Offer.body(device: offer.resolvedSenderName, transferName: offer.transferName))
|
||||
.multilineTextAlignment(.center)
|
||||
Text(L10n.Transfer.fileCount(count: Int(offer.fileCount)))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 0)
|
||||
HStack(spacing: 12) {
|
||||
Button(role: .cancel) {
|
||||
onRespond(offer.offerId, false)
|
||||
} label: {
|
||||
Text(String(localized: L10n.Offer.decline)).frame(maxWidth: .infinity)
|
||||
}
|
||||
Button {
|
||||
onRespond(offer.offerId, true)
|
||||
} label: {
|
||||
Text(String(localized: L10n.Offer.accept)).frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.disabled(busy)
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
}
|
||||
|
||||
/// "This device offered to let you reach it. Remember it?"
|
||||
private struct PairingSheet: View {
|
||||
let pairing: PendingPairingModel
|
||||
let busy: Bool
|
||||
let onRespond: (String, Bool) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemSymbol: .macbookAndIphone)
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(.tint)
|
||||
.padding(.top, 12)
|
||||
Text(String(localized: L10n.Pairing.requestTitle))
|
||||
.font(.title2).fontWeight(.semibold)
|
||||
Text(L10n.Pairing.requestBody(device: pairing.resolvedName))
|
||||
.multilineTextAlignment(.center)
|
||||
// Names are peer-supplied; the endpoint id is what actually identifies
|
||||
// the device.
|
||||
Text(L10n.Approval.endpointId(deviceId: pairing.endpointId))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Spacer(minLength: 0)
|
||||
HStack(spacing: 12) {
|
||||
Button(role: .cancel) {
|
||||
onRespond(pairing.endpointId, false)
|
||||
} label: {
|
||||
Text(String(localized: L10n.Pairing.decline)).frame(maxWidth: .infinity)
|
||||
}
|
||||
Button {
|
||||
onRespond(pairing.endpointId, true)
|
||||
} label: {
|
||||
Text(String(localized: L10n.Pairing.accept)).frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.disabled(busy)
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
}
|
||||
|
||||
/// "You just transferred with this device. Let it reach you next time?"
|
||||
private struct SuggestionSheet: View {
|
||||
let suggestion: PairingSuggestion
|
||||
let busy: Bool
|
||||
let onRespond: (PairingSuggestion, Bool) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemSymbol: .clockArrowCirclepath)
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(.tint)
|
||||
.padding(.top, 12)
|
||||
Text(String(localized: L10n.Pairing.allowTitle))
|
||||
.font(.title2).fontWeight(.semibold)
|
||||
Text(L10n.Pairing.requestBody(device: suggestion.resolvedName))
|
||||
.multilineTextAlignment(.center)
|
||||
Text(String(localized: L10n.Pairing.allowBody))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Spacer(minLength: 0)
|
||||
HStack(spacing: 12) {
|
||||
Button(role: .cancel) {
|
||||
onRespond(suggestion, false)
|
||||
} label: {
|
||||
Text(String(localized: L10n.Pairing.decline)).frame(maxWidth: .infinity)
|
||||
}
|
||||
Button {
|
||||
onRespond(suggestion, true)
|
||||
} label: {
|
||||
Text(String(localized: L10n.Pairing.allowConfirm)).frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.disabled(busy)
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
}
|
||||
451
apple/VniDrop/Features/Contacts/ContactsModel.swift
Normal file
451
apple/VniDrop/Features/Contacts/ContactsModel.swift
Normal file
@@ -0,0 +1,451 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
/// A device worth remembering after a completed transfer.
|
||||
///
|
||||
/// Only a suggestion: nothing is issued until the user agrees, because being
|
||||
/// reachable is a standing permission and a transfer is a one-off.
|
||||
struct PairingSuggestion: Equatable, Identifiable {
|
||||
let endpointId: String
|
||||
let displayName: String?
|
||||
let transferName: String?
|
||||
|
||||
var id: String { endpointId }
|
||||
|
||||
var resolvedName: String {
|
||||
guard let displayName, !displayName.isEmpty else {
|
||||
return String(localized: L10n.Approval.nearbyDevice)
|
||||
}
|
||||
return displayName
|
||||
}
|
||||
}
|
||||
|
||||
struct ContactsState: Equatable {
|
||||
var contacts: [DeviceContact] = []
|
||||
var blocked: [String] = []
|
||||
var pendingPairings: [PendingPairingModel] = []
|
||||
var pendingOffers: [IncomingOfferModel] = []
|
||||
var grantLifetime: GrantLifetimeOption = .days90
|
||||
var isLoading = false
|
||||
/// Endpoints with an in-flight decision, so a row can disable itself without
|
||||
/// blocking the rest of the list.
|
||||
var busyEndpoints: Set<String> = []
|
||||
var busyOfferIds: Set<String> = []
|
||||
var suggestions: [PairingSuggestion] = []
|
||||
/// Transfers this device is holding for contacts that were not running.
|
||||
var heldOffers: [HeldOfferModel] = []
|
||||
var checkForOffersOnOpen = false
|
||||
var isCheckingForOffers = false
|
||||
var selectedEndpointId: String?
|
||||
|
||||
var selected: DeviceContact? {
|
||||
guard let selectedEndpointId else { return nil }
|
||||
return contacts.first { $0.endpointId == selectedEndpointId }
|
||||
}
|
||||
|
||||
/// One prompt at a time: pairing consent is a modal decision and stacking
|
||||
/// sheets on top of each other reads as a loop of dialogs.
|
||||
var currentPairing: PendingPairingModel? { pendingPairings.first }
|
||||
var currentOffer: IncomingOfferModel? { pendingOffers.first }
|
||||
var currentSuggestion: PairingSuggestion? { suggestions.first }
|
||||
}
|
||||
|
||||
/// Drives the device-history surfaces: the list, its detail, and the two
|
||||
/// consent prompts. Ported in the MVVM shape used by the other feature models.
|
||||
@MainActor
|
||||
final class ContactsModel: ObservableObject {
|
||||
@Published private(set) var state = ContactsState()
|
||||
|
||||
/// Set when the detail screen asks for a file picker; the platform picker
|
||||
/// modifier observes it, mirroring `SendModel`.
|
||||
@Published var pendingFilePick = false
|
||||
/// Device the picked files are destined for.
|
||||
@Published private(set) var sendTarget: String?
|
||||
|
||||
private let repository: CoreGateway
|
||||
private let messages: UiMessageController
|
||||
private let preferences: AppPreferencesRepository
|
||||
private let fileSystemService: FileSystemService
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(
|
||||
repository: CoreGateway,
|
||||
messages: UiMessageController,
|
||||
preferences: AppPreferencesRepository,
|
||||
fileSystemService: FileSystemService
|
||||
) {
|
||||
self.repository = repository
|
||||
self.messages = messages
|
||||
self.preferences = preferences
|
||||
self.fileSystemService = fileSystemService
|
||||
state.grantLifetime = preferences.preferences.grantLifetime
|
||||
state.checkForOffersOnOpen = preferences.preferences.checkForOffersOnOpen
|
||||
|
||||
repository.signals
|
||||
.sink { [weak self] signal in
|
||||
guard let self else { return }
|
||||
switch signal {
|
||||
case .contactsChanged:
|
||||
Task { await self.refresh() }
|
||||
case .offersChanged:
|
||||
Task { await self.refreshOffers() }
|
||||
case .receiverHistoryChanged(let transferId), .transfersChanged(let transferId):
|
||||
// A completed delivery names the device that received from us.
|
||||
Task { await self.considerSendPeers(transferId: transferId) }
|
||||
case .approvalChanged:
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
repository.statePublisher
|
||||
.sink { [weak self] core in
|
||||
guard let self, core.isInitialized else { return }
|
||||
self.considerReceivePeers(core.transfers)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
repository.statePublisher
|
||||
.map(\.isInitialized)
|
||||
.removeDuplicates()
|
||||
.sink { [weak self] isInitialized in
|
||||
guard let self, isInitialized else { return }
|
||||
// The core owns the lifetime; push the stored preference on start
|
||||
// so a restart does not silently fall back to the default.
|
||||
Task {
|
||||
await self.repository.setGrantLifetime(self.state.grantLifetime)
|
||||
await self.refresh()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// MARK: - Loading
|
||||
|
||||
func refresh() async {
|
||||
state.isLoading = true
|
||||
defer { state.isLoading = false }
|
||||
|
||||
switch await repository.contacts() {
|
||||
case .success(let contacts):
|
||||
state.contacts = contacts
|
||||
case .failure(let error):
|
||||
messages.error(error)
|
||||
}
|
||||
if case .success(let blocked) = await repository.blockedContacts() {
|
||||
state.blocked = blocked
|
||||
}
|
||||
if case .success(let held) = await repository.heldOffers() {
|
||||
state.heldOffers = held
|
||||
}
|
||||
state.pendingPairings = await repository.pendingPairings()
|
||||
await refreshOffers()
|
||||
}
|
||||
|
||||
func refreshOffers() async {
|
||||
state.pendingOffers = await repository.pendingOffers()
|
||||
}
|
||||
|
||||
// MARK: - Post-transfer suggestions
|
||||
|
||||
/// A completed receive names its sender, so that device becomes a candidate.
|
||||
private func considerReceivePeers(_ transfers: [Transfer]) {
|
||||
let candidates = transfers
|
||||
.filter { $0.direction == .receive && $0.status == .done }
|
||||
.compactMap { transfer -> PairingSuggestion? in
|
||||
guard let peerId = transfer.peerId else { return nil }
|
||||
return PairingSuggestion(
|
||||
endpointId: peerId,
|
||||
displayName: nil,
|
||||
transferName: transfer.transferName
|
||||
)
|
||||
}
|
||||
add(suggestions: candidates)
|
||||
}
|
||||
|
||||
/// A completed delivery names the device we sent to.
|
||||
private func considerSendPeers(transferId: UInt64) async {
|
||||
guard case .success(let requests) = await repository.receiverRequests(transferId: transferId) else {
|
||||
return
|
||||
}
|
||||
let candidates = requests
|
||||
.filter { $0.status == .completed }
|
||||
.map { request in
|
||||
PairingSuggestion(
|
||||
endpointId: request.remoteEndpointId,
|
||||
displayName: request.receiverName ?? request.receiverDeviceName,
|
||||
transferName: request.transferName
|
||||
)
|
||||
}
|
||||
add(suggestions: candidates)
|
||||
}
|
||||
|
||||
/// Filters candidates down to devices actually worth asking about.
|
||||
private func add(suggestions candidates: [PairingSuggestion]) {
|
||||
let known = Set(state.contacts.map(\.endpointId))
|
||||
let blocked = Set(state.blocked)
|
||||
let declined = preferences.preferences.declinedPairingSuggestions
|
||||
let pending = Set(state.suggestions.map(\.endpointId))
|
||||
|
||||
let fresh = candidates.filter { candidate in
|
||||
!known.contains(candidate.endpointId)
|
||||
&& !blocked.contains(candidate.endpointId)
|
||||
&& !declined.contains(candidate.endpointId)
|
||||
&& !pending.contains(candidate.endpointId)
|
||||
}
|
||||
guard !fresh.isEmpty else { return }
|
||||
state.suggestions.append(contentsOf: fresh)
|
||||
}
|
||||
|
||||
/// Agree to be reachable by a suggested device.
|
||||
func acceptSuggestion(_ suggestion: PairingSuggestion) async {
|
||||
state.suggestions.removeAll { $0.endpointId == suggestion.endpointId }
|
||||
preferences.clearDeclinedPairingSuggestion(suggestion.endpointId)
|
||||
await allowDeviceToReachMe(
|
||||
endpointId: suggestion.endpointId,
|
||||
displayName: preferences.preferences.username
|
||||
)
|
||||
}
|
||||
|
||||
/// Decline, and remember the decline so the next transfer does not re-ask.
|
||||
func declineSuggestion(_ suggestion: PairingSuggestion) {
|
||||
state.suggestions.removeAll { $0.endpointId == suggestion.endpointId }
|
||||
preferences.declinePairingSuggestion(suggestion.endpointId)
|
||||
}
|
||||
|
||||
// MARK: - Collecting waiting transfers
|
||||
|
||||
func setCheckForOffersOnOpen(_ enabled: Bool) {
|
||||
state.checkForOffersOnOpen = enabled
|
||||
preferences.setCheckForOffersOnOpen(enabled)
|
||||
}
|
||||
|
||||
/// Called when the app comes to the foreground.
|
||||
///
|
||||
/// Opt-in, because asking every contact whether they have something waiting
|
||||
/// also tells them the app was opened. Never runs in the background.
|
||||
func checkForOffersOnForeground() async {
|
||||
guard state.checkForOffersOnOpen else { return }
|
||||
_ = await collectWaitingOffers()
|
||||
}
|
||||
|
||||
/// Explicit "check now". Returns how many transfers were collected so the
|
||||
/// caller can report an empty result, which a silent refresh cannot.
|
||||
@discardableResult
|
||||
func collectWaitingOffers() async -> UInt64 {
|
||||
guard !state.isCheckingForOffers else { return 0 }
|
||||
state.isCheckingForOffers = true
|
||||
defer { state.isCheckingForOffers = false }
|
||||
|
||||
switch await repository.pollContactsForOffers() {
|
||||
case .success(let collected):
|
||||
await refreshOffers()
|
||||
return collected
|
||||
case .failure(let error):
|
||||
messages.error(error)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selection
|
||||
|
||||
func select(_ endpointId: String?) { state.selectedEndpointId = endpointId }
|
||||
|
||||
// MARK: - Pairing consent
|
||||
|
||||
/// Agree to be reachable by a device, typically right after a transfer.
|
||||
func allowDeviceToReachMe(endpointId: String, displayName: String?) async {
|
||||
state.busyEndpoints.insert(endpointId)
|
||||
defer { state.busyEndpoints.remove(endpointId) }
|
||||
|
||||
if case .failure(let error) = await repository.allowDeviceToReachMe(
|
||||
endpointId: endpointId,
|
||||
displayName: displayName
|
||||
) {
|
||||
messages.error(error)
|
||||
return
|
||||
}
|
||||
await refresh()
|
||||
}
|
||||
|
||||
/// Answer a device's offer to be remembered.
|
||||
func respondToPairing(endpointId: String, accepted: Bool) async {
|
||||
state.busyEndpoints.insert(endpointId)
|
||||
defer { state.busyEndpoints.remove(endpointId) }
|
||||
|
||||
switch await repository.respondToPairing(endpointId: endpointId, accepted: accepted) {
|
||||
case .success:
|
||||
// Drop the prompt immediately: the core has already consumed it, and
|
||||
// leaving it on screen invites a second answer that does nothing.
|
||||
state.pendingPairings.removeAll { $0.endpointId == endpointId }
|
||||
if accepted { await refresh() }
|
||||
case .failure(let error):
|
||||
messages.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Incoming offers
|
||||
|
||||
/// Answer an incoming offer. Returns the ticket when accepted so the caller
|
||||
/// can run the receive with a platform-appropriate destination; the core
|
||||
/// releases it only on acceptance.
|
||||
func respondToOffer(offerId: String, accepted: Bool) async -> String? {
|
||||
state.busyOfferIds.insert(offerId)
|
||||
defer { state.busyOfferIds.remove(offerId) }
|
||||
|
||||
let ticket = await repository.respondToOffer(offerId: offerId, accepted: accepted)
|
||||
state.pendingOffers.removeAll { $0.offerId == offerId }
|
||||
return ticket
|
||||
}
|
||||
|
||||
// MARK: - Sending to a device
|
||||
|
||||
/// Start choosing files to send to a remembered device.
|
||||
func chooseFilesToSend(to endpointId: String) {
|
||||
sendTarget = endpointId
|
||||
pendingFilePick = true
|
||||
}
|
||||
|
||||
func onFilePickFailed(_ reason: String) {
|
||||
sendTarget = nil
|
||||
messages.error(InvitationError.raw(reason))
|
||||
}
|
||||
|
||||
/// Send the picked selection straight to the chosen device.
|
||||
///
|
||||
/// Only the receiving user is prompted; this call returns once they have
|
||||
/// answered, so the button stays busy until then.
|
||||
func onFilesPicked(_ files: [PickedShareFile]) async {
|
||||
guard let endpointId = sendTarget else { return }
|
||||
sendTarget = nil
|
||||
guard !files.isEmpty else { return }
|
||||
|
||||
state.busyEndpoints.insert(endpointId)
|
||||
defer { state.busyEndpoints.remove(endpointId) }
|
||||
|
||||
let result = await fileSystemService.sharePickedFiles(
|
||||
repository: repository,
|
||||
files: files,
|
||||
transferName: files.count == 1 ? files[0].displayName : "",
|
||||
senderName: preferences.preferences.username,
|
||||
destination: .contact(endpointId: endpointId)
|
||||
)
|
||||
await fileSystemService.discardPickedFiles(files)
|
||||
switch result {
|
||||
case .success(let outcome):
|
||||
// A closed app is a delay, not a failure: say so rather than
|
||||
// reporting success for something nobody has received.
|
||||
let text: UiText = outcome.delivered
|
||||
? .resource(L10n.Send.transferCreated)
|
||||
: .resource(L10n.Contacts.offerHeld)
|
||||
messages.tryShow(UiMessage(text: text, tone: outcome.delivered ? .success : .info))
|
||||
await refresh()
|
||||
case .failure(let error):
|
||||
messages.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire-and-report variant of ``offerTransfer(transferId:to:)``.
|
||||
///
|
||||
/// Owned by the model rather than a view so the request survives the picker
|
||||
/// being dismissed: the answer depends on a person at the other device.
|
||||
func offerTransferInBackground(transferId: UInt64, to contact: DeviceContact) {
|
||||
Task { await offerTransfer(transferId: transferId, to: contact) }
|
||||
}
|
||||
|
||||
/// Push an existing transfer to a remembered device.
|
||||
///
|
||||
/// Returns whether it landed, so the caller can distinguish "accepted" from
|
||||
/// "waiting for that device to open the app".
|
||||
@discardableResult
|
||||
func offerTransfer(transferId: UInt64, to contact: DeviceContact) async -> Bool {
|
||||
state.busyEndpoints.insert(contact.endpointId)
|
||||
defer { state.busyEndpoints.remove(contact.endpointId) }
|
||||
|
||||
switch await repository.offerTransferToContact(
|
||||
transferId: transferId,
|
||||
endpointId: contact.endpointId
|
||||
) {
|
||||
case .success(let outcome):
|
||||
let text: UiText = outcome.delivered
|
||||
? .dynamic(L10n.Contacts.sentToDevice(device: contact.displayName))
|
||||
: .resource(L10n.Contacts.offerHeld)
|
||||
messages.tryShow(UiMessage(text: text, tone: outcome.delivered ? .success : .info))
|
||||
await refresh()
|
||||
return outcome.delivered
|
||||
case .failure(let error) where error.offerRefusal != nil:
|
||||
// The offer was delivered and a person said no, or nobody answered.
|
||||
// Neither is a failure of this device, so neither is shown as one.
|
||||
let text = error.offerRefusal == .declined
|
||||
? L10n.Contacts.declinedByDevice(device: contact.displayName)
|
||||
: L10n.Contacts.noAnswer(device: contact.displayName)
|
||||
messages.tryShow(UiMessage(text: .dynamic(text), tone: .info))
|
||||
await refresh()
|
||||
return false
|
||||
case .failure(let error):
|
||||
messages.error(error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Management
|
||||
|
||||
func setLabel(endpointId: String, label: String) async {
|
||||
let trimmed = label.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if case .failure(let error) = await repository.setContactLabel(
|
||||
endpointId: endpointId,
|
||||
label: trimmed.isEmpty ? nil : trimmed
|
||||
) {
|
||||
messages.error(error)
|
||||
return
|
||||
}
|
||||
await refresh()
|
||||
}
|
||||
|
||||
func forget(endpointId: String) async {
|
||||
state.busyEndpoints.insert(endpointId)
|
||||
defer { state.busyEndpoints.remove(endpointId) }
|
||||
|
||||
if case .failure(let error) = await repository.forgetContact(endpointId: endpointId) {
|
||||
messages.error(error)
|
||||
return
|
||||
}
|
||||
if state.selectedEndpointId == endpointId { state.selectedEndpointId = nil }
|
||||
await refresh()
|
||||
}
|
||||
|
||||
func forgetAll() async {
|
||||
if case .failure(let error) = await repository.forgetAllContacts() {
|
||||
messages.error(error)
|
||||
return
|
||||
}
|
||||
state.selectedEndpointId = nil
|
||||
await refresh()
|
||||
}
|
||||
|
||||
func block(endpointId: String) async {
|
||||
state.busyEndpoints.insert(endpointId)
|
||||
defer { state.busyEndpoints.remove(endpointId) }
|
||||
|
||||
if case .failure(let error) = await repository.blockContact(endpointId: endpointId) {
|
||||
messages.error(error)
|
||||
return
|
||||
}
|
||||
if state.selectedEndpointId == endpointId { state.selectedEndpointId = nil }
|
||||
await refresh()
|
||||
}
|
||||
|
||||
func unblock(endpointId: String) async {
|
||||
if case .failure(let error) = await repository.unblockContact(endpointId: endpointId) {
|
||||
messages.error(error)
|
||||
return
|
||||
}
|
||||
await refresh()
|
||||
}
|
||||
|
||||
func setGrantLifetime(_ lifetime: GrantLifetimeOption) {
|
||||
state.grantLifetime = lifetime
|
||||
preferences.setGrantLifetime(lifetime)
|
||||
Task { await repository.setGrantLifetime(lifetime) }
|
||||
}
|
||||
}
|
||||
344
apple/VniDrop/Features/Contacts/ContactsScreen.swift
Normal file
344
apple/VniDrop/Features/Contacts/ContactsScreen.swift
Normal file
@@ -0,0 +1,344 @@
|
||||
import SFSafeSymbols
|
||||
import SwiftUI
|
||||
|
||||
/// Device history: the remembered devices, their detail, and the block list.
|
||||
///
|
||||
/// Pushed from Settings rather than owning a tab — it is a management surface,
|
||||
/// not part of the send/receive flow.
|
||||
struct ContactsScreen: View {
|
||||
@ObservedObject var model: ContactsModel
|
||||
/// Reports an empty result, which a silent refresh cannot convey.
|
||||
let onNothingWaiting: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Text(String(localized: L10n.Contacts.subtitle))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if model.state.contacts.isEmpty {
|
||||
Section {
|
||||
ContactsEmptyState()
|
||||
}
|
||||
} else {
|
||||
Section(String(localized: L10n.Contacts.title)) {
|
||||
ForEach(model.state.contacts) { contact in
|
||||
NavigationLink(value: SettingsSection.contactDetail(endpointId: contact.endpointId)) {
|
||||
ContactRow(contact: contact)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !model.state.heldOffers.isEmpty {
|
||||
Section(String(localized: L10n.Contacts.waitingTitle)) {
|
||||
ForEach(model.state.heldOffers) { offer in
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(offer.transferName)
|
||||
Text(String(offer.endpointId.prefix(16)))
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
Text(String(localized: L10n.Contacts.waitingHint))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if !model.state.blocked.isEmpty {
|
||||
Section(String(localized: L10n.Contacts.blockedTitle)) {
|
||||
ForEach(model.state.blocked, id: \.self) { endpointId in
|
||||
BlockedRow(endpointId: endpointId) {
|
||||
Task { await model.unblock(endpointId: endpointId) }
|
||||
}
|
||||
}
|
||||
Text(String(localized: L10n.Contacts.unblockHint))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
CollectOffersSection(model: model, onNothingWaiting: onNothingWaiting)
|
||||
|
||||
GrantLifetimeSection(model: model)
|
||||
|
||||
if !model.state.contacts.isEmpty {
|
||||
Section {
|
||||
ForgetAllButton { Task { await model.forgetAll() } }
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle(Text(String(localized: L10n.Contacts.title)))
|
||||
.task { await model.refresh() }
|
||||
}
|
||||
}
|
||||
|
||||
private struct ContactsEmptyState: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemSymbol: .macbookAndIphone)
|
||||
.font(.system(size: 32))
|
||||
.foregroundStyle(.tint)
|
||||
Text(String(localized: L10n.Contacts.emptyTitle))
|
||||
.font(.headline)
|
||||
Text(String(localized: L10n.Contacts.emptyBody))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ContactRow: View {
|
||||
let contact: DeviceContact
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(contact.displayName)
|
||||
if contact.canSend {
|
||||
if let lastTransferAt = contact.lastTransferAt {
|
||||
Text(L10n.Contacts.lastTransfer(date: Self.format(lastTransferAt)))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
// Reachability is derived from holding a live grant, so this is
|
||||
// the honest signal that sending will not work.
|
||||
Label(
|
||||
String(localized: L10n.Contacts.unreachable),
|
||||
systemSymbol: .exclamationmarkTriangleFill
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func format(_ millis: Int64) -> String {
|
||||
let date = Date(timeIntervalSince1970: TimeInterval(millis) / 1_000)
|
||||
return date.formatted(.relative(presentation: .named))
|
||||
}
|
||||
}
|
||||
|
||||
private struct BlockedRow: View {
|
||||
let endpointId: String
|
||||
let onUnblock: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(String(endpointId.prefix(16)))
|
||||
.font(.callout.monospaced())
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Spacer()
|
||||
Button(String(localized: L10n.Contacts.unblock), action: onUnblock)
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct CollectOffersSection: View {
|
||||
@ObservedObject var model: ContactsModel
|
||||
let onNothingWaiting: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
Toggle(
|
||||
String(localized: L10n.Contacts.checkOnOpen),
|
||||
isOn: Binding(
|
||||
get: { model.state.checkForOffersOnOpen },
|
||||
set: { model.setCheckForOffersOnOpen($0) }
|
||||
)
|
||||
)
|
||||
Button {
|
||||
Task {
|
||||
let collected = await model.collectWaitingOffers()
|
||||
if collected == 0 { onNothingWaiting() }
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text(String(localized: L10n.Contacts.checkNow))
|
||||
if model.state.isCheckingForOffers {
|
||||
Spacer()
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(model.state.isCheckingForOffers)
|
||||
} footer: {
|
||||
// The privacy cost is the point of the setting, so it is stated
|
||||
// where the switch is, not buried elsewhere.
|
||||
Text(String(localized: L10n.Contacts.checkOnOpenHint))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct GrantLifetimeSection: View {
|
||||
@ObservedObject var model: ContactsModel
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
Picker(
|
||||
String(localized: L10n.Contacts.grantLifetimeTitle),
|
||||
selection: Binding(
|
||||
get: { model.state.grantLifetime },
|
||||
set: { model.setGrantLifetime($0) }
|
||||
)
|
||||
) {
|
||||
ForEach(GrantLifetimeOption.allCases) { option in
|
||||
Text(Self.label(option)).tag(option)
|
||||
}
|
||||
}
|
||||
Text(String(localized: L10n.Contacts.grantLifetimeHint))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private static func label(_ option: GrantLifetimeOption) -> String {
|
||||
guard let days = option.days else {
|
||||
return String(localized: L10n.Contacts.grantLifetimeNever)
|
||||
}
|
||||
return L10n.Contacts.grantLifetimeDays(count: days)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ForgetAllButton: View {
|
||||
let onConfirm: () -> Void
|
||||
@State private var isConfirming = false
|
||||
|
||||
var body: some View {
|
||||
Button(role: .destructive) {
|
||||
isConfirming = true
|
||||
} label: {
|
||||
Text(String(localized: L10n.Contacts.forgetAll))
|
||||
}
|
||||
.confirmationDialog(
|
||||
String(localized: L10n.Contacts.forgetAll),
|
||||
isPresented: $isConfirming,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(String(localized: L10n.Contacts.forgetAll), role: .destructive, action: onConfirm)
|
||||
} message: {
|
||||
Text(String(localized: L10n.Contacts.forgetBody))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Detail for one remembered device: rename, send, forget, block.
|
||||
struct ContactDetailScreen: View {
|
||||
@ObservedObject var model: ContactsModel
|
||||
let endpointId: String
|
||||
|
||||
@State private var label = ""
|
||||
@State private var isConfirmingForget = false
|
||||
@State private var isConfirmingBlock = false
|
||||
|
||||
private var contact: DeviceContact? {
|
||||
model.state.contacts.first { $0.endpointId == endpointId }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
if let contact {
|
||||
Section {
|
||||
TextField(
|
||||
String(localized: L10n.Contacts.nameField),
|
||||
text: $label,
|
||||
prompt: Text(contact.displayName)
|
||||
)
|
||||
.onSubmit { commitLabel() }
|
||||
Text(String(localized: L10n.Contacts.nameHint))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
// The endpoint id is the only real identity: two devices can
|
||||
// claim the same name, but not the same key. Shown in full
|
||||
// and selectable so it can actually be compared.
|
||||
Text(L10n.Approval.endpointId(deviceId: contact.endpointId))
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
if contact.canSend {
|
||||
Section {
|
||||
Button {
|
||||
model.chooseFilesToSend(to: endpointId)
|
||||
} label: {
|
||||
Label(
|
||||
String(localized: L10n.Contacts.sendTo),
|
||||
systemSymbol: .paperplane
|
||||
)
|
||||
}
|
||||
.disabled(model.state.busyEndpoints.contains(endpointId))
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
Label(
|
||||
String(localized: L10n.Contacts.unreachableBody),
|
||||
systemSymbol: .exclamationmarkTriangleFill
|
||||
)
|
||||
.font(.footnote)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
isConfirmingForget = true
|
||||
} label: {
|
||||
Text(String(localized: L10n.Contacts.forget))
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
isConfirmingBlock = true
|
||||
} label: {
|
||||
Text(String(localized: L10n.Contacts.block))
|
||||
}
|
||||
}
|
||||
.disabled(model.state.busyEndpoints.contains(endpointId))
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle(Text(contact?.displayName ?? ""))
|
||||
.contactSendPickers(model: model)
|
||||
.onAppear { label = contact?.localLabel ?? "" }
|
||||
.onDisappear { commitLabel() }
|
||||
.confirmationDialog(
|
||||
String(localized: L10n.Contacts.forget),
|
||||
isPresented: $isConfirmingForget,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(String(localized: L10n.Contacts.forget), role: .destructive) {
|
||||
Task { await model.forget(endpointId: endpointId) }
|
||||
}
|
||||
} message: {
|
||||
Text(String(localized: L10n.Contacts.forgetBody))
|
||||
}
|
||||
.confirmationDialog(
|
||||
String(localized: L10n.Contacts.block),
|
||||
isPresented: $isConfirmingBlock,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(String(localized: L10n.Contacts.block), role: .destructive) {
|
||||
Task { await model.block(endpointId: endpointId) }
|
||||
}
|
||||
} message: {
|
||||
Text(String(localized: L10n.Contacts.unblockHint))
|
||||
}
|
||||
}
|
||||
|
||||
private func commitLabel() {
|
||||
guard label != (contact?.localLabel ?? "") else { return }
|
||||
Task { await model.setLabel(endpointId: endpointId, label: label) }
|
||||
}
|
||||
}
|
||||
73
apple/VniDrop/Features/Contacts/DevicePickerSheet.swift
Normal file
73
apple/VniDrop/Features/Contacts/DevicePickerSheet.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
import SFSafeSymbols
|
||||
import SwiftUI
|
||||
|
||||
/// Picks a remembered device to send an existing transfer to.
|
||||
///
|
||||
/// Offered next to the QR code as another way to deliver the same invitation,
|
||||
/// not as a second share of the same files.
|
||||
struct DevicePickerSheet: View {
|
||||
@ObservedObject var model: ContactsModel
|
||||
let transferId: UInt64
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
/// Only devices holding a live grant: the rest cannot be reached until they
|
||||
/// are paired again, so offering them here would fail on tap.
|
||||
private var reachable: [DeviceContact] {
|
||||
model.state.contacts.filter(\.canSend)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if reachable.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label(
|
||||
String(localized: L10n.Contacts.pickDeviceTitle),
|
||||
systemSymbol: .macbookAndIphone
|
||||
)
|
||||
} description: {
|
||||
Text(String(localized: L10n.Contacts.pickDeviceEmpty))
|
||||
}
|
||||
} else {
|
||||
List(reachable) { contact in
|
||||
Button {
|
||||
// Close first. The other device's user has to accept,
|
||||
// which can take as long as they take, and holding a
|
||||
// modal open on someone else's decision reads as a
|
||||
// hang. The outcome arrives as a message instead.
|
||||
dismiss()
|
||||
model.offerTransferInBackground(transferId: transferId, to: contact)
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(contact.displayName)
|
||||
Text(contact.shortFingerprint)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if model.state.busyEndpoints.contains(contact.endpointId) {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(model.state.busyEndpoints.contains(contact.endpointId))
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(Text(String(localized: L10n.Contacts.pickDeviceTitle)))
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(String(localized: L10n.Button.cancel)) { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await model.refresh() }
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 380, minHeight: 320)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -111,7 +111,7 @@ final class TransferNotificationCoordinator: ObservableObject {
|
||||
switch signal {
|
||||
case .receiverHistoryChanged(let transferId), .transfersChanged(let transferId):
|
||||
Task { await self.syncReceivers(transferId: transferId) }
|
||||
case .approvalChanged:
|
||||
case .approvalChanged, .contactsChanged, .offersChanged:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ enum ReceiveMethod {
|
||||
case invitationFile
|
||||
case qrCode
|
||||
case nfc
|
||||
/// Pushed by a remembered device and already accepted by the user, so no
|
||||
/// invitation was acquired by hand.
|
||||
case offer
|
||||
}
|
||||
|
||||
enum ReceiveHistoryDeleteTarget: Equatable {
|
||||
@@ -154,6 +157,40 @@ final class ReceiveModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Receive a transfer the user has already accepted in the offer prompt.
|
||||
///
|
||||
/// The consent happened in that prompt, so this does not ask again: it
|
||||
/// inspects the ticket and starts, falling back to the ordinary review sheet
|
||||
/// only when the destination is not usable and the user has to fix it.
|
||||
func receiveOffered(ticket: String) {
|
||||
let trimmed = ticket.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return messages.error(.resource(L10n.Error.invitationEmpty)) }
|
||||
state.ticket = trimmed
|
||||
state.method = .offer
|
||||
state.inspection = nil
|
||||
state.isInspecting = true
|
||||
Task {
|
||||
switch await repository.inspectTicket(trimmed) {
|
||||
case .success(let inspection):
|
||||
state.inspection = inspection
|
||||
state.isInspecting = false
|
||||
if state.canReceive(coreInitialized: coreState.isInitialized) {
|
||||
receive()
|
||||
} else {
|
||||
// Usually a missing or unwritable destination: show the review
|
||||
// sheet so the user can point it somewhere valid.
|
||||
state.isAcquisitionOpen = true
|
||||
}
|
||||
case .failure(let error):
|
||||
state.ticket = ""
|
||||
state.method = nil
|
||||
state.inspection = nil
|
||||
state.isInspecting = false
|
||||
messages.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func receive() {
|
||||
let current = state
|
||||
guard let folder = current.receiveFolder else { return }
|
||||
|
||||
@@ -25,6 +25,11 @@ struct SendState: Equatable {
|
||||
var selectedTransferId: UInt64?
|
||||
var transferThumbnails: [UInt64: Data] = [:]
|
||||
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 isLoadingReceivers = false
|
||||
var isDeleteConfirmationOpen = false
|
||||
@@ -56,6 +61,18 @@ final class SendModel: ObservableObject {
|
||||
private let messages: UiMessageController
|
||||
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(
|
||||
repository: CoreGateway,
|
||||
fileSystemService: FileSystemService,
|
||||
@@ -79,6 +96,8 @@ final class SendModel: ObservableObject {
|
||||
case .receiverHistoryChanged(let id), .approvalChanged(let id):
|
||||
if id == self.state.selectedTransferId { self.refreshReceivers(id) }
|
||||
self.refreshReceiverStatuses(for: id)
|
||||
case .contactsChanged, .offersChanged:
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
@@ -154,7 +173,7 @@ final class SendModel: ObservableObject {
|
||||
}
|
||||
|
||||
func onFilePickFailed(_ reason: String) {
|
||||
messages.error(InvitationError.message(reason.isEmpty ? "selection failed" : reason))
|
||||
messages.error(reason.isEmpty ? InvitationError.selectionFailed : InvitationError.raw(reason))
|
||||
}
|
||||
|
||||
func clearSelectedSource() {
|
||||
@@ -201,6 +220,17 @@ final class SendModel: ObservableObject {
|
||||
}
|
||||
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 dismissDeleteTransfer() { if !state.isDeleting { state.isDeleteConfirmationOpen = false } }
|
||||
|
||||
@@ -257,8 +287,19 @@ final class SendModel: ObservableObject {
|
||||
/// Uses the core's `respondReceiverRequest` (no backend change); applies to
|
||||
/// receivers that are still pending or accepted.
|
||||
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 {
|
||||
let result = await repository.respondReceiverRequest(requestId: requestId, accepted: false, reason: nil)
|
||||
let result = await repository.respondReceiverRequest(requestId: requestId, accepted: accepted, reason: nil)
|
||||
switch result {
|
||||
case .success:
|
||||
if let transferId = state.selectedTransferId { refreshReceivers(transferId) }
|
||||
@@ -312,9 +353,9 @@ final class SendModel: ObservableObject {
|
||||
files: current.selectedFiles,
|
||||
transferName: current.transferName.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
senderName: current.senderName.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
accessPolicy: current.accessPolicy
|
||||
destination: .invitation(accessPolicy: current.accessPolicy)
|
||||
)
|
||||
switch result {
|
||||
switch result.map(\.share) {
|
||||
case .success(let share):
|
||||
await fileSystemService.discardPickedFiles(current.selectedFiles)
|
||||
if let thumb = current.selectedFiles.compactMap(\.thumbnailData).first {
|
||||
|
||||
@@ -5,16 +5,21 @@ import SFSafeSymbols
|
||||
/// with the composer and detail panels as native sheets and delete as an alert.
|
||||
struct SendScreen: View {
|
||||
@ObservedObject var model: SendModel
|
||||
@ObservedObject var contacts: ContactsModel
|
||||
let windowClass: WindowClass
|
||||
|
||||
/// Transfer whose share panel is presented inline from the list context menu.
|
||||
@State private var shareTarget: Transfer?
|
||||
/// Transfer pending an inline (list-level) delete confirmation.
|
||||
@State private var deleteTarget: Transfer?
|
||||
|
||||
private var outgoing: [Transfer] {
|
||||
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? {
|
||||
guard let id = model.state.selectedTransferId else { return nil }
|
||||
return outgoing.first { $0.transferId == id }
|
||||
@@ -50,12 +55,13 @@ struct SendScreen: View {
|
||||
// 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 { shareTarget = nil } }),
|
||||
isPresented: Binding(get: { shareTarget != nil }, set: { if !$0 { model.closeShareTarget() } }),
|
||||
windowClass: windowClass,
|
||||
onDismiss: { shareTarget = nil }
|
||||
onDismiss: model.closeShareTarget,
|
||||
onDismissed: model.shareSheetDidDismiss
|
||||
) {
|
||||
if let shareTarget {
|
||||
TransferSharePanel(model: model, transfer: shareTarget)
|
||||
TransferSharePanel(model: model, contacts: contacts, transfer: shareTarget)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,14 +94,15 @@ struct SendScreen: View {
|
||||
/// 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).
|
||||
private func detailView(for transfer: Transfer) -> some View {
|
||||
TransferDetailsView(model: model, transfer: transfer, events: model.coreState.events)
|
||||
TransferDetailsView(model: model, contacts: contacts, transfer: transfer, events: model.coreState.events)
|
||||
.adaptiveDrawer(
|
||||
isPresented: Binding(get: { model.state.detailPanel != nil }, set: { _ in }),
|
||||
windowClass: windowClass,
|
||||
onDismiss: model.closeDetailPanel
|
||||
onDismiss: model.closeDetailPanel,
|
||||
onDismissed: model.shareSheetDidDismiss
|
||||
) {
|
||||
if let panel = model.state.detailPanel {
|
||||
DetailPanelContent(model: model, transfer: transfer, panel: panel)
|
||||
DetailPanelContent(model: model, contacts: contacts, transfer: transfer, panel: panel)
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
@@ -127,7 +134,7 @@ struct SendScreen: View {
|
||||
.contextMenu {
|
||||
if transfer.ticket != nil {
|
||||
Button {
|
||||
shareTarget = transfer
|
||||
model.openShareTarget(transfer.transferId)
|
||||
} label: {
|
||||
Label(String(localized: L10n.Transfer.shareTitle), systemSymbol: .squareAndArrowUp)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import CoreImage.CIFilterBuiltins
|
||||
|
||||
struct TransferDetailsView: View {
|
||||
@ObservedObject var model: SendModel
|
||||
@ObservedObject var contacts: ContactsModel
|
||||
let transfer: Transfer
|
||||
let events: [CoreEventModel]
|
||||
@State private var showStopConfirmation = false
|
||||
@@ -129,6 +130,7 @@ private struct DetailDestination: View {
|
||||
|
||||
struct DetailPanelContent: View {
|
||||
@ObservedObject var model: SendModel
|
||||
@ObservedObject var contacts: ContactsModel
|
||||
let transfer: Transfer
|
||||
let panel: TransferDetailPanel
|
||||
|
||||
@@ -142,10 +144,11 @@ struct DetailPanelContent: View {
|
||||
loading: model.state.isLoadingReceivers,
|
||||
events: model.coreState.events,
|
||||
transferTotalSize: transfer.totalSize,
|
||||
onCancel: model.cancelReceiver
|
||||
onCancel: model.cancelReceiver,
|
||||
onAccept: model.acceptReceiver
|
||||
)
|
||||
case .share:
|
||||
TransferSharePanel(model: model, transfer: transfer)
|
||||
TransferSharePanel(model: model, contacts: contacts, transfer: transfer)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,6 +196,7 @@ struct ReceiverHistoryPanel: View {
|
||||
let events: [CoreEventModel]
|
||||
let transferTotalSize: UInt64
|
||||
let onCancel: (String) -> Void
|
||||
let onAccept: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
PanelContainer(title: String(localized: L10n.Transfer.receiversTitle)) {
|
||||
@@ -203,7 +207,12 @@ struct ReceiverHistoryPanel: View {
|
||||
} else {
|
||||
ForEach(Array(receivers.enumerated()), id: \.element.id) { index, receiver in
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,6 +233,7 @@ private struct ReceiverRow: View {
|
||||
let receiver: ReceiverRequestModel
|
||||
let sendProgress: TransferProgress?
|
||||
let onCancel: (String) -> Void
|
||||
let onAccept: (String) -> Void
|
||||
|
||||
/// Only pending requests can be cancelled per-receiver: the core rejects a
|
||||
/// negative response to an already-accepted request ("...not approved, or it
|
||||
@@ -257,14 +267,27 @@ private struct ReceiverRow: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
if isCancelable {
|
||||
Button(role: .destructive) {
|
||||
onCancel(receiver.id)
|
||||
} label: {
|
||||
Text(String(localized: L10n.Button.refuse))
|
||||
.font(VniType.bodySmall)
|
||||
VStack(alignment: .trailing, spacing: 8) {
|
||||
Button(role: .destructive) {
|
||||
onCancel(receiver.id)
|
||||
} label: {
|
||||
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)
|
||||
@@ -275,6 +298,7 @@ private struct ReceiverRow: View {
|
||||
struct TransferSharePanel: View {
|
||||
@Environment(\.vniColors) private var colors
|
||||
@ObservedObject var model: SendModel
|
||||
@ObservedObject var contacts: ContactsModel
|
||||
let transfer: Transfer
|
||||
|
||||
var body: some View {
|
||||
@@ -288,7 +312,7 @@ struct TransferSharePanel: View {
|
||||
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
ShareActionsView(model: model, transfer: transfer, ticket: ticket)
|
||||
ShareActionsView(model: model, contacts: contacts, transfer: transfer, ticket: ticket)
|
||||
case .preparing:
|
||||
Text(String(localized: L10n.Transfer.eventPreparing)).foregroundStyle(colors.foregroundLighter)
|
||||
case .unavailable:
|
||||
|
||||
@@ -20,14 +20,25 @@ protocol TransferShareActions: AnyObject {
|
||||
struct ShareActionsView: View {
|
||||
@Environment(\.vniColors) private var colors
|
||||
@ObservedObject var model: SendModel
|
||||
@ObservedObject var contacts: ContactsModel
|
||||
let transfer: Transfer
|
||||
let ticket: String
|
||||
|
||||
@State private var actions: TransferShareActions = makePlatformShareActions()
|
||||
@State private var writingNfc = false
|
||||
@State private var choosingDevice = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
// Sending straight to a remembered device is another way to deliver
|
||||
// this same invitation, so it belongs with the other delivery
|
||||
// methods rather than in a separate flow.
|
||||
if contacts.state.contacts.contains(where: \.canSend) {
|
||||
SecondaryButton(
|
||||
title: String(localized: L10n.Contacts.sendToDevice),
|
||||
action: { choosingDevice = true }
|
||||
)
|
||||
}
|
||||
if actions.nfcAvailability != .hidden {
|
||||
SecondaryButton(
|
||||
title: writingNfc ? String(localized: L10n.Transfer.nfcWaiting) : String(localized: L10n.Button.writeNfc),
|
||||
@@ -57,5 +68,8 @@ struct ShareActionsView: View {
|
||||
}, enabled: actions.canUseNativeShare)
|
||||
}
|
||||
.onDisappear { actions.cancelNfcWrite() }
|
||||
.sheet(isPresented: $choosingDevice) {
|
||||
DevicePickerSheet(model: contacts, transferId: transfer.transferId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,12 +20,7 @@ protocol BugReportService {
|
||||
/// Offline-safe no-op used until the diagnostics transport is configured.
|
||||
struct NoopBugReportService: BugReportService {
|
||||
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 }
|
||||
}
|
||||
|
||||
/// Whether the diagnostics stack is compiled in (mirrors DiagnosticsBuildConfig).
|
||||
enum DiagnosticsBuildConfig {
|
||||
static let included = false
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ enum SettingsSection: Hashable {
|
||||
case appearance
|
||||
case notifications
|
||||
case network
|
||||
case contacts
|
||||
/// One device's detail. Part of this enum because the Settings stack has a
|
||||
/// typed path: a link carrying any other value type cannot push onto it.
|
||||
case contactDetail(endpointId: String)
|
||||
case storage
|
||||
case about
|
||||
case bugReport
|
||||
@@ -19,6 +23,7 @@ enum SettingsSection: Hashable {
|
||||
case .appearance: return L10n.Appearance.title
|
||||
case .notifications: return L10n.Notifications.title
|
||||
case .network: return L10n.Settings.networkTitle
|
||||
case .contacts, .contactDetail: return L10n.Contacts.title
|
||||
case .storage: return L10n.Storage.title
|
||||
case .about: return L10n.About.title
|
||||
case .bugReport: return L10n.About.bugReport
|
||||
@@ -44,7 +49,6 @@ struct SettingsState: Equatable {
|
||||
var supportsCustomReceiveFolders = true
|
||||
var themeMode: ThemeMode = .system
|
||||
var notificationPermission: NotificationPermission = .notDetermined
|
||||
var diagnosticsEnabled = false
|
||||
var relayMode: RelayPreferenceMode = .automatic
|
||||
var relayURLs: [String] = []
|
||||
var relayValidationError: RelayConfigurationValidationError?
|
||||
@@ -76,7 +80,7 @@ struct SettingsState: Equatable {
|
||||
&& lhs.supportsCustomReceiveFolders == rhs.supportsCustomReceiveFolders
|
||||
&& lhs.themeMode == rhs.themeMode
|
||||
&& 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
|
||||
@@ -111,7 +115,6 @@ final class SettingsModel: ObservableObject {
|
||||
private let notifications: LocalNotificationService
|
||||
private let messages: UiMessageController
|
||||
private let bugReports: BugReportService
|
||||
private let diagnosticsIncluded: Bool
|
||||
|
||||
private var usernamePersistTask: Task<Void, Never>?
|
||||
private var hasLocalUsernameDraft = false
|
||||
@@ -126,8 +129,7 @@ final class SettingsModel: ObservableObject {
|
||||
preferences: AppPreferencesRepository,
|
||||
notifications: LocalNotificationService,
|
||||
messages: UiMessageController,
|
||||
bugReports: BugReportService,
|
||||
diagnosticsIncluded: Bool = DiagnosticsBuildConfig.included
|
||||
bugReports: BugReportService
|
||||
) {
|
||||
self.environment = environment
|
||||
self.deviceInfoProvider = deviceInfoProvider
|
||||
@@ -137,7 +139,6 @@ final class SettingsModel: ObservableObject {
|
||||
self.notifications = notifications
|
||||
self.messages = messages
|
||||
self.bugReports = bugReports
|
||||
self.diagnosticsIncluded = diagnosticsIncluded
|
||||
self.state = SettingsState(
|
||||
supportsCustomReceiveFolders: fileSystemService.supportsCustomReceiveFolders,
|
||||
appVersion: environment.appVersion
|
||||
@@ -151,7 +152,6 @@ final class SettingsModel: ObservableObject {
|
||||
self.state.username = self.hasLocalUsernameDraft ? self.state.username : prefs.username
|
||||
self.state.receiveFolder = folder
|
||||
self.state.themeMode = prefs.themeMode
|
||||
self.state.diagnosticsEnabled = prefs.diagnosticsEnabled
|
||||
if !self.hasRelayConfigurationDraft {
|
||||
self.state.relayMode = prefs.relayConfiguration.mode
|
||||
self.state.relayURLs = prefs.relayConfiguration.relayURLs
|
||||
@@ -179,6 +179,12 @@ final class SettingsModel: ObservableObject {
|
||||
loadDeviceInfo()
|
||||
}
|
||||
|
||||
/// Surfaces "nothing waiting" from the contacts screen, which has no
|
||||
/// message controller of its own.
|
||||
func reportNothingWaiting() {
|
||||
messages.tryShow(UiMessage(text: .resource(L10n.Contacts.checkNone), tone: .info))
|
||||
}
|
||||
|
||||
func selectSection(_ section: SettingsSection) {
|
||||
state.selectedSection = section
|
||||
if section == .about || section == .bugReport {
|
||||
@@ -206,7 +212,7 @@ final class SettingsModel: ObservableObject {
|
||||
}
|
||||
|
||||
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() }
|
||||
|
||||
/// Whether the current receive folder is the platform default (so the reset
|
||||
@@ -230,17 +236,6 @@ final class SettingsModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func setDiagnosticsEnabled(_ enabled: Bool) {
|
||||
if !diagnosticsIncluded { return }
|
||||
Task {
|
||||
preferences.setDiagnosticsEnabled(enabled)
|
||||
messages.show(UiMessage(
|
||||
text: .resource(enabled ? L10n.Diagnostics.enabledMessage : L10n.Diagnostics.disabledMessage),
|
||||
tone: .success
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Network
|
||||
|
||||
func setRelayMode(_ mode: RelayPreferenceMode) {
|
||||
@@ -475,7 +470,7 @@ final class SettingsModel: ObservableObject {
|
||||
loadStorageUsage()
|
||||
messages.show(UiMessage(text: .resource(L10n.Storage.transfersDeleted), tone: .success))
|
||||
} else {
|
||||
messages.error(InvitationError.message("Could not delete \(failures) transfer records"))
|
||||
messages.error(InvitationError.deleteRecordsFailed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import SFSafeSymbols
|
||||
/// navigation. The model stays the source of truth via a derived path binding.
|
||||
struct SettingsScreen: View {
|
||||
@ObservedObject var model: SettingsModel
|
||||
@ObservedObject var contacts: ContactsModel
|
||||
let windowClass: WindowClass
|
||||
@State private var showBugReport = false
|
||||
|
||||
@@ -14,6 +15,8 @@ struct SettingsScreen: View {
|
||||
switch model.state.selectedSection {
|
||||
case .overview: return []
|
||||
case .bugReport: return [.about, .bugReport]
|
||||
case .contactDetail(let endpointId):
|
||||
return [.contacts, .contactDetail(endpointId: endpointId)]
|
||||
case let section: return [section]
|
||||
}
|
||||
},
|
||||
@@ -24,6 +27,21 @@ struct SettingsScreen: View {
|
||||
var body: some View {
|
||||
NavigationStack(path: path) {
|
||||
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 {
|
||||
NavigationLink(value: SettingsSection.preferences) {
|
||||
SettingsRow(icon: .personCropCircle, title: String(localized: L10n.Preferences.title), value: model.state.username)
|
||||
@@ -39,6 +57,15 @@ struct SettingsScreen: View {
|
||||
NavigationLink(value: SettingsSection.storage) {
|
||||
SettingsRow(icon: .internaldrive, title: String(localized: L10n.Storage.title), value: nil)
|
||||
}
|
||||
NavigationLink(value: SettingsSection.contacts) {
|
||||
SettingsRow(
|
||||
icon: .macbookAndIphone,
|
||||
title: String(localized: L10n.Contacts.title),
|
||||
value: contacts.state.contacts.isEmpty
|
||||
? nil
|
||||
: String(contacts.state.contacts.count)
|
||||
)
|
||||
}
|
||||
}
|
||||
Section(String(localized: L10n.Settings.advancedTitle)) {
|
||||
NavigationLink(value: SettingsSection.network) {
|
||||
@@ -65,6 +92,21 @@ struct SettingsScreen: View {
|
||||
|
||||
@ViewBuilder
|
||||
private func sectionForm(_ section: SettingsSection) -> some View {
|
||||
// Contacts brings its own Form and push destination, so it is not wrapped
|
||||
// in the shared section chrome.
|
||||
if case .contactDetail(let endpointId) = section {
|
||||
ContactDetailScreen(model: contacts, endpointId: endpointId)
|
||||
} else if section == .contacts {
|
||||
ContactsScreen(model: contacts) {
|
||||
model.reportNothingWaiting()
|
||||
}
|
||||
} else {
|
||||
settingsSectionForm(section)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func settingsSectionForm(_ section: SettingsSection) -> some View {
|
||||
let content = Form {
|
||||
SettingsSectionContent(model: model, section: section)
|
||||
}
|
||||
@@ -115,6 +157,9 @@ private struct SettingsSectionContent: View {
|
||||
NetworkSettings(model: model)
|
||||
case .storage:
|
||||
StorageSettings(model: model)
|
||||
case .contacts, .contactDetail:
|
||||
// Rendered by SettingsScreen itself, which owns the contacts model.
|
||||
EmptyView()
|
||||
case .about:
|
||||
AboutSettings(model: model)
|
||||
case .bugReport:
|
||||
|
||||
@@ -375,7 +375,7 @@ struct StorageSettings: View {
|
||||
struct AboutSettings: View {
|
||||
@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 {
|
||||
Section {
|
||||
@@ -415,17 +415,6 @@ struct AboutSettings: View {
|
||||
Label(String(localized: L10n.About.privacyPolicyLabel), systemSymbol: .handRaised)
|
||||
}
|
||||
}
|
||||
|
||||
if DiagnosticsBuildConfig.included {
|
||||
Section {
|
||||
Toggle(isOn: Binding(
|
||||
get: { model.state.diagnosticsEnabled },
|
||||
set: { model.setDiagnosticsEnabled($0) }
|
||||
)) {
|
||||
Text(String(localized: L10n.Diagnostics.title))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import UIKit
|
||||
@MainActor
|
||||
func makeAppDependencies(externalInvitations: ExternalInvitationController) -> AppDependencies {
|
||||
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(
|
||||
name: "\(device.systemName) \(device.systemVersion)",
|
||||
appVersion: version,
|
||||
|
||||
@@ -5,7 +5,7 @@ import AppKit
|
||||
/// Builds the macOS dependency graph, mirroring `rememberIosAppDependencies`.
|
||||
@MainActor
|
||||
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 env = PlatformEnvironment(
|
||||
name: "macOS " + ProcessInfo.processInfo.operatingSystemVersionString,
|
||||
|
||||
@@ -32,10 +32,10 @@ struct IosFileSystemService: FileSystemService {
|
||||
|
||||
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error> {
|
||||
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 {
|
||||
return .failure(InvitationError.message("The Files location URL is unavailable"))
|
||||
return .failure(InvitationError.filesystemUnavailable)
|
||||
}
|
||||
let opened = await withCheckedContinuation { continuation in
|
||||
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 {
|
||||
@@ -59,15 +59,24 @@ struct IosFileSystemService: FileSystemService {
|
||||
files: [PickedShareFile],
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
accessPolicy: ShareAccessPolicy
|
||||
) async -> Result<Share, Error> {
|
||||
destination: ShareDestination
|
||||
) async -> Result<ContactSendOutcome, Error> {
|
||||
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() }
|
||||
return await repository.shareSources(
|
||||
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
|
||||
)
|
||||
switch destination {
|
||||
case .invitation(let accessPolicy):
|
||||
return await repository.shareSources(
|
||||
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
|
||||
)
|
||||
.map { ContactSendOutcome(share: $0, delivered: true) }
|
||||
case .contact(let endpointId):
|
||||
return await repository.sendToContact(
|
||||
endpointId: endpointId, sources: sources,
|
||||
transferName: transferName, senderName: senderName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func validateSecurityScopedUrl(_ value: String) -> FolderAccessStatus {
|
||||
|
||||
@@ -39,17 +39,42 @@ struct MacFileSystemService: FileSystemService {
|
||||
files: [PickedShareFile],
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
accessPolicy: ShareAccessPolicy
|
||||
) async -> Result<Share, Error> {
|
||||
destination: ShareDestination
|
||||
) async -> Result<ContactSendOutcome, Error> {
|
||||
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 {
|
||||
ShareSource(kind: .path, value: $0.value, displayName: $0.displayName, isDirectory: $0.isDirectory)
|
||||
}
|
||||
return await repository.shareSources(
|
||||
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
|
||||
)
|
||||
switch destination {
|
||||
case .invitation(let accessPolicy):
|
||||
return await repository.shareSources(
|
||||
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
|
||||
)
|
||||
.map { ContactSendOutcome(share: $0, delivered: true) }
|
||||
case .contact(let endpointId):
|
||||
return await repository.sendToContact(
|
||||
endpointId: endpointId, sources: sources,
|
||||
transferName: transferName, senderName: senderName
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -70,6 +70,33 @@ struct SendPickers: ViewModifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// File picker for "send to this device", reusing the share picker's selection
|
||||
/// handling so security-scoped bookmarks are captured the same way.
|
||||
struct ContactSendPickers: ViewModifier {
|
||||
@ObservedObject var model: ContactsModel
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.fileImporter(
|
||||
isPresented: $model.pendingFilePick,
|
||||
allowedContentTypes: [.item],
|
||||
allowsMultipleSelection: true
|
||||
) { result in
|
||||
switch result {
|
||||
case .success(let urls):
|
||||
let files = urls.compactMap { PickerSupport.pickedFile(from: $0, isDirectory: false) }
|
||||
if files.isEmpty {
|
||||
model.onFilePickFailed("The selected document could not be opened")
|
||||
} else {
|
||||
Task { await model.onFilesPicked(files) }
|
||||
}
|
||||
case .failure(let error):
|
||||
if !error.isUserCancellation { model.onFilePickFailed(error.technicalDetail) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PickerSupport {
|
||||
static func receiveFolder(from url: URL) -> ReceiveFolder {
|
||||
#if os(iOS)
|
||||
@@ -107,9 +134,15 @@ enum PickerSupport {
|
||||
)
|
||||
#else
|
||||
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(
|
||||
value: url.path, displayName: url.lastPathComponent, sizeBytes: size,
|
||||
isTemporaryCopy: false, isDirectory: isDirectory
|
||||
isTemporaryCopy: false, isDirectory: isDirectory, securityScopeBookmark: bookmark
|
||||
)
|
||||
#endif
|
||||
}
|
||||
@@ -123,4 +156,8 @@ extension View {
|
||||
func sendPickers(model: SendModel) -> some View {
|
||||
modifier(SendPickers(model: model))
|
||||
}
|
||||
|
||||
func contactSendPickers(model: ContactsModel) -> some View {
|
||||
modifier(ContactSendPickers(model: model))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID
|
||||
picker.delegate = self
|
||||
picker.modalPresentationStyle = .formSheet
|
||||
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)
|
||||
}
|
||||
@@ -37,12 +37,12 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID
|
||||
func scanQrCode(onResult: @escaping (Result<String, Error>) -> Void) {
|
||||
cancel()
|
||||
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
|
||||
guard let self else { return }
|
||||
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
|
||||
self.qrController = nil
|
||||
@@ -57,7 +57,7 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID
|
||||
func readNfcInvitation(onResult: @escaping (Result<String, Error>) -> Void) {
|
||||
cancel()
|
||||
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
|
||||
self?.nfcReader = nil
|
||||
@@ -80,7 +80,7 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID
|
||||
let result = documentResult
|
||||
documentResult = nil
|
||||
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()
|
||||
defer { if started { url.stopAccessingSecurityScopedResource() } }
|
||||
let data = try Data(contentsOf: url)
|
||||
@@ -157,19 +157,19 @@ final class QrScannerViewController: UIViewController, AVCaptureMetadataOutputOb
|
||||
}
|
||||
|
||||
func cancelScan() {
|
||||
finish(.failure(InvitationError.message("QR scanning was cancelled")))
|
||||
finish(.failure(InvitationError.cancelled))
|
||||
}
|
||||
|
||||
private func configureSession() {
|
||||
guard let device = AVCaptureDevice.default(for: .video),
|
||||
let input = try? AVCaptureDeviceInput(device: device),
|
||||
session.canAddInput(input) else {
|
||||
return finish(.failure(InvitationError.message("No camera is available")))
|
||||
return finish(.failure(InvitationError.cameraUnavailable))
|
||||
}
|
||||
session.addInput(input)
|
||||
let output = AVCaptureMetadataOutput()
|
||||
guard session.canAddOutput(output) else {
|
||||
return finish(.failure(InvitationError.message("Could not configure the QR scanner")))
|
||||
return finish(.failure(InvitationError.cameraUnavailable))
|
||||
}
|
||||
session.addOutput(output)
|
||||
output.setMetadataObjectsDelegate(self, queue: .main)
|
||||
@@ -220,7 +220,7 @@ final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate, @unchec
|
||||
|
||||
func start() {
|
||||
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
|
||||
reader.begin()
|
||||
}
|
||||
@@ -233,7 +233,7 @@ final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate, @unchec
|
||||
func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
|
||||
if finished { return }
|
||||
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]) {
|
||||
@@ -242,7 +242,7 @@ final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate, @unchec
|
||||
.flatMap { $0.records }
|
||||
.compactMap { payloadAsInvitation($0) }
|
||||
.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
|
||||
}
|
||||
session.invalidate()
|
||||
|
||||
@@ -22,7 +22,7 @@ final class MacReceiveInvitationActions: ReceiveInvitationActions {
|
||||
}
|
||||
panel.begin { response in
|
||||
guard response == .OK, let url = panel.url else {
|
||||
onResult(.failure(InvitationError.message("cancelled")))
|
||||
onResult(.failure(InvitationError.cancelled))
|
||||
return
|
||||
}
|
||||
onResult(Result {
|
||||
@@ -33,11 +33,11 @@ final class MacReceiveInvitationActions: ReceiveInvitationActions {
|
||||
}
|
||||
|
||||
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) {
|
||||
onResult(.failure(InvitationError.message("NFC is unavailable on macOS")))
|
||||
onResult(.failure(InvitationError.nfcUnavailable))
|
||||
}
|
||||
|
||||
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) {
|
||||
cancelNfcWrite()
|
||||
guard NFCNDEFReaderSession.readingAvailable else {
|
||||
onResult(.failure(InvitationError.message("NFC is unavailable on this device")))
|
||||
onResult(.failure(InvitationError.nfcUnavailable))
|
||||
return
|
||||
}
|
||||
let writer = InvitationNfcWriter(ticket: ticket) { [weak self] result in
|
||||
@@ -55,7 +55,7 @@ final class IosTransferShareActions: NSObject, TransferShareActions {
|
||||
@MainActor
|
||||
private func present(_ controller: UIViewController) throws {
|
||||
guard let presenter = topPresenter() else {
|
||||
throw InvitationError.message("Could not find an iOS view controller")
|
||||
throw InvitationError.viewControllerUnavailable
|
||||
}
|
||||
presenter.present(controller, animated: true)
|
||||
}
|
||||
@@ -76,7 +76,7 @@ final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate, @unchec
|
||||
|
||||
func start() {
|
||||
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
|
||||
reader.begin()
|
||||
}
|
||||
@@ -89,14 +89,14 @@ final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate, @unchec
|
||||
func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
|
||||
if finished { return }
|
||||
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, didDetect tags: [NFCNDEFTag]) {
|
||||
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
|
||||
// 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)) }
|
||||
switch status {
|
||||
case .notSupported:
|
||||
self.finish(.failure(InvitationError.message("This NFC tag does not support NDEF")))
|
||||
self.finish(.failure(InvitationError.nfcFailed))
|
||||
case .readOnly:
|
||||
self.finish(.failure(InvitationError.message("This NFC tag is read-only")))
|
||||
self.finish(.failure(InvitationError.nfcFailed))
|
||||
default:
|
||||
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
|
||||
if let writeError {
|
||||
self.finish(.failure(writeError))
|
||||
} else {
|
||||
session.alertMessage = "Invitation written"
|
||||
session.alertMessage = String(localized: L10n.Transfer.nfcWritten)
|
||||
session.invalidate()
|
||||
self.finish(.success(()))
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ final class MacTransferShareActions: TransferShareActions {
|
||||
panel.allowedContentTypes = []
|
||||
panel.begin { response in
|
||||
guard response == .OK, let url = panel.url else {
|
||||
onResult(.failure(InvitationError.message("cancelled")))
|
||||
onResult(.failure(InvitationError.cancelled))
|
||||
return
|
||||
}
|
||||
onResult(Result { try ticket.write(to: url, atomically: true, encoding: .utf8) })
|
||||
@@ -28,7 +28,7 @@ final class MacTransferShareActions: TransferShareActions {
|
||||
do {
|
||||
let url = try writeTemporaryInvitation(ticket: ticket, transferName: transferName)
|
||||
guard let view = NSApp.keyWindow?.contentView else {
|
||||
onResult(.failure(InvitationError.message("No window available")))
|
||||
onResult(.failure(InvitationError.noWindowAvailable))
|
||||
return
|
||||
}
|
||||
let picker = NSSharingServicePicker(items: [url])
|
||||
@@ -40,7 +40,7 @@ final class MacTransferShareActions: TransferShareActions {
|
||||
}
|
||||
|
||||
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() {}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "idiom" : "universal", "platform" : "ios", "size" : "1024x1024", "filename" : "app-icon.png" },
|
||||
{ "idiom" : "mac", "scale" : "2x", "size" : "512x512", "filename" : "app-icon.png" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 49 KiB |
@@ -20,6 +20,8 @@
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<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>
|
||||
@@ -55,6 +57,20 @@
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<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>
|
||||
<true/>
|
||||
<key>NFCReaderUsageDescription</key>
|
||||
@@ -73,8 +89,6 @@
|
||||
<false/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<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>
|
||||
<array>
|
||||
<string>NDEF</string>
|
||||
<string>TAG</string>
|
||||
</array>
|
||||
|
||||
<!-- 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
|
||||
let windowClass: WindowClass
|
||||
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
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.sheet(
|
||||
isPresented: Binding(get: { isPresented }, set: { if !$0 { onDismiss() } })
|
||||
isPresented: Binding(get: { isPresented }, set: { if !$0 { onDismiss() } }),
|
||||
onDismiss: onDismissed
|
||||
) {
|
||||
SheetChrome(onClose: onDismiss) { drawerContent() }
|
||||
.modifier(PhoneDetents(enabled: windowClass == .phone))
|
||||
@@ -56,11 +61,12 @@ extension View {
|
||||
isPresented: Binding<Bool>,
|
||||
windowClass: WindowClass,
|
||||
onDismiss: @escaping () -> Void,
|
||||
onDismissed: (() -> Void)? = nil,
|
||||
@ViewBuilder content: @escaping () -> DrawerContent
|
||||
) -> some View {
|
||||
modifier(AdaptiveDrawer(
|
||||
isPresented: isPresented, windowClass: windowClass,
|
||||
onDismiss: onDismiss, drawerContent: content
|
||||
onDismiss: onDismiss, onDismissed: onDismissed, drawerContent: content
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,17 @@ import VnidropCore
|
||||
|
||||
/// Maps technical failures to stable, user-facing catalog keys. Ported from
|
||||
/// `ui/feedback/UserFacingError.kt`. Never exposes raw `reason=` blobs.
|
||||
/// How an offered transfer ended without being accepted.
|
||||
enum OfferRefusal {
|
||||
case declined
|
||||
case noAnswer
|
||||
}
|
||||
|
||||
extension Error {
|
||||
func toUiText() -> UiText {
|
||||
if let invitation = self as? InvitationError {
|
||||
return invitation.uiText
|
||||
}
|
||||
if let vni = self as? VnidropError {
|
||||
switch vni {
|
||||
case .Ticket:
|
||||
@@ -40,6 +49,7 @@ extension Error {
|
||||
|
||||
/// True when the user intentionally backed out of a flow.
|
||||
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()
|
||||
if haystack.isEmpty {
|
||||
@@ -53,6 +63,19 @@ extension Error {
|
||||
|| haystack.contains("user canceled")
|
||||
}
|
||||
|
||||
/// The other device answered, and the answer was no.
|
||||
///
|
||||
/// Not a failure of this device: the offer was delivered and a person
|
||||
/// declined it, so it is reported as information rather than an error.
|
||||
var offerRefusal: OfferRefusal? {
|
||||
let haystack = technicalDetail.lowercased()
|
||||
if haystack.contains("receiver-declined") || haystack.contains("declined-recently") {
|
||||
return .declined
|
||||
}
|
||||
if haystack.contains("no-response") { return .noAnswer }
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Prefers a `VnidropError` reason; else the localized description.
|
||||
var technicalDetail: String {
|
||||
if let vni = self as? VnidropError {
|
||||
@@ -76,6 +99,39 @@ extension Error {
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps each semantic `InvitationError` case to a localized user-facing message.
|
||||
/// This is the sole `InvitationError` → `L10n` boundary: no substring guessing,
|
||||
/// except for `.raw`, whose dynamic payload still falls through `reasonHints`.
|
||||
extension InvitationError {
|
||||
var uiText: UiText {
|
||||
switch self {
|
||||
case .empty:
|
||||
return .resource(L10n.Error.invitationEmpty)
|
||||
case .tooLarge, .unsupportedOperation, .noWindowAvailable,
|
||||
.viewControllerUnavailable, .qrUnavailable, .bugReportingUnavailable, .cancelled:
|
||||
return .resource(L10n.Error.generic)
|
||||
case .invalidEncoding, .invalidInvitationURL:
|
||||
return .resource(L10n.Error.invalidTicket)
|
||||
case .shareEmpty:
|
||||
return .resource(L10n.Error.shareEmpty)
|
||||
case .coreNotInitialized:
|
||||
return .resource(L10n.Error.startingUp)
|
||||
case .filesystemUnavailable:
|
||||
return .resource(L10n.Error.filesystem)
|
||||
case .nfcUnavailable, .nfcFailed:
|
||||
return .resource(L10n.Error.nfc)
|
||||
case .cameraUnavailable:
|
||||
return .resource(L10n.Error.camera)
|
||||
case .selectionFailed:
|
||||
return .resource(L10n.Error.selectionFailed)
|
||||
case .deleteRecordsFailed:
|
||||
return .resource(L10n.Error.repository)
|
||||
case .raw(let reason):
|
||||
return reasonHints(reason) ?? .resource(L10n.Error.generic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a receiver delivery/refusal reason code to a user-facing message, never
|
||||
/// surfacing the raw core code (e.g. `destination_exists`). Unknown codes fall back
|
||||
/// to the substring hints, then a generic message.
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
# XcodeGen spec for the native SwiftUI VniDrop app (iOS/iPadOS/macOS).
|
||||
# Regenerate the project with: xcodegen generate (run from apple/)
|
||||
# Requires two generated inputs first (both gitignored), before xcodegen:
|
||||
# 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
|
||||
options:
|
||||
bundleIdPrefix: com.vnidrop
|
||||
@@ -12,9 +14,22 @@ options:
|
||||
macOS: "15.0"
|
||||
createIntermediateGroups: true
|
||||
|
||||
# Build configurations. Declaring `configs` replaces XcodeGen's Debug/Release
|
||||
# defaults, so both are re-listed here. `Release-Direct` is a release-type config
|
||||
# used only by the VniDropDirect (notarized DMG + Sparkle) target; the App Store
|
||||
# `VniDrop` target ships under plain `Release`.
|
||||
configs:
|
||||
Debug: debug
|
||||
Release: release
|
||||
Release-Direct: release
|
||||
|
||||
# Project-wide build settings (applied to every target/config).
|
||||
settings:
|
||||
base:
|
||||
# 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).
|
||||
@@ -26,27 +41,36 @@ packages:
|
||||
SFSafeSymbols:
|
||||
url: https://github.com/SFSafeSymbols/SFSafeSymbols
|
||||
from: "5.3.0"
|
||||
# Sparkle powers in-app auto-updates for the direct-download (.dmg) build only.
|
||||
# It is linked exclusively by the VniDropDirect target — SwiftPM links products
|
||||
# per target, not per config, so keeping it off the App Store target is what
|
||||
# guarantees the store binary never bundles a self-updater (App Store forbids it).
|
||||
Sparkle:
|
||||
url: https://github.com/sparkle-project/Sparkle
|
||||
from: "2.9.4"
|
||||
|
||||
targets:
|
||||
VniDrop:
|
||||
# Shared definition for the two shipping app targets. `VniDrop` (App Store /
|
||||
# TestFlight) and `VniDropDirect` (notarized DMG + Sparkle) build the exact same
|
||||
# sources; only their destinations, extra dependencies, and the DIRECT_DISTRIBUTION
|
||||
# compile flag differ (set per target below).
|
||||
targetTemplates:
|
||||
AppBase:
|
||||
type: application
|
||||
supportedDestinations: [iOS, macOS]
|
||||
configFiles:
|
||||
Debug: Signing.xcconfig
|
||||
Release: Signing.xcconfig
|
||||
sources:
|
||||
- path: VniDrop
|
||||
excludes:
|
||||
- "Resources/Info.plist"
|
||||
- "Resources/VniDrop.entitlements"
|
||||
- "Resources/VniDropDirect.entitlements"
|
||||
- "Resources/**/.DS_Store"
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_NAME: VniDrop
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.vnidrop.app
|
||||
MARKETING_VERSION: "0.1.0"
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
MARKETING_VERSION: "$(PRODUCT_VERSION)"
|
||||
GENERATE_INFOPLIST_FILE: NO
|
||||
INFOPLIST_FILE: VniDrop/Resources/Info.plist
|
||||
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
|
||||
# Mirror the Info.plist identity so Xcode's Identity editor shows it too
|
||||
# (the editor reads these build settings, not the manual plist).
|
||||
INFOPLIST_KEY_CFBundleDisplayName: VniDrop
|
||||
@@ -59,10 +83,11 @@ targets:
|
||||
# AccentColor asset mirrors VniDropColors.brandPurple — keep them in sync.
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
|
||||
configs:
|
||||
debug:
|
||||
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
|
||||
release:
|
||||
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
|
||||
# Produce a dSYM in the archive so symbol upload succeeds.
|
||||
DEBUG_INFORMATION_FORMAT: dwarf-with-dsym
|
||||
release-direct:
|
||||
DEBUG_INFORMATION_FORMAT: dwarf-with-dsym
|
||||
dependencies:
|
||||
- package: VnidropCore
|
||||
- package: SFSafeSymbols
|
||||
@@ -85,12 +110,45 @@ targets:
|
||||
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:
|
||||
type: bundle.unit-test
|
||||
supportedDestinations: [iOS, macOS]
|
||||
configFiles:
|
||||
Debug: Signing.xcconfig
|
||||
Release: Signing.xcconfig
|
||||
Release-Direct: Signing.xcconfig
|
||||
sources:
|
||||
- path: Tests
|
||||
settings:
|
||||
@@ -112,3 +170,21 @@ schemes:
|
||||
config: Debug
|
||||
targets:
|
||||
- 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.
|
||||
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"
|
||||
SIM_ARM_TARGET="aarch64-apple-ios-sim"
|
||||
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
|
||||
72
apple/scripts/package-core.sh
Executable file
72
apple/scripts/package-core.sh
Executable file
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Packages the prebuilt Apple core into a single zip + checksum, for attaching to
|
||||
# the GitHub Release. Lets a consumer (e.g. Xcode Cloud) use the compiled core
|
||||
# instead of installing Rust and running build-core.sh. Run AFTER the core exists
|
||||
# (apple/scripts/build-core.sh, or `make apple-core` / `make build-apple-dmg`).
|
||||
#
|
||||
# The bundle carries both build outputs of build-core.sh:
|
||||
# - vnidrop.xcframework (static libs for device/sim/macOS + the FFI module)
|
||||
# - Vnidrop.swift (generated UniFFI bindings — a plain source file, not
|
||||
# part of the xcframework, so it must ship alongside)
|
||||
#
|
||||
# Produces (under apple/dist):
|
||||
# VnidropCore-<version>.zip
|
||||
# VnidropCore-<version>.zip.sha256 (sha256sum(1)/shasum-compatible format)
|
||||
#
|
||||
# Zip layout (root):
|
||||
# vnidrop.xcframework/
|
||||
# Vnidrop.swift
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
APPLE_DIR="$REPO_ROOT/apple"
|
||||
PKG_DIR="$APPLE_DIR/VnidropCore"
|
||||
XCFRAMEWORK="$PKG_DIR/vnidrop.xcframework"
|
||||
BINDINGS="$PKG_DIR/Sources/VnidropCore/Vnidrop.swift"
|
||||
DIST_DIR="$APPLE_DIR/dist"
|
||||
|
||||
VERSION="$("$REPO_ROOT/packaging/version/resolve-version.sh" product)"
|
||||
NAME="VnidropCore-$VERSION"
|
||||
ZIP="$DIST_DIR/$NAME.zip"
|
||||
CHECKSUM="$ZIP.sha256"
|
||||
|
||||
[ -d "$XCFRAMEWORK" ] || {
|
||||
echo "error: missing xcframework: $XCFRAMEWORK" >&2
|
||||
echo " build the core first (apple/scripts/build-core.sh)." >&2
|
||||
exit 1
|
||||
}
|
||||
[ -f "$BINDINGS" ] || {
|
||||
echo "error: missing generated bindings: $BINDINGS" >&2
|
||||
echo " build the core first (apple/scripts/build-core.sh)." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
mkdir -p "$DIST_DIR"
|
||||
rm -f "$ZIP" "$CHECKSUM"
|
||||
|
||||
# Stage a clean tree so the zip root holds exactly the two payloads (no absolute
|
||||
# paths or stray parent directories leak into the archive).
|
||||
STAGE="$(mktemp -d)"
|
||||
trap 'rm -rf "$STAGE"' EXIT
|
||||
cp -R "$XCFRAMEWORK" "$STAGE/vnidrop.xcframework"
|
||||
cp "$BINDINGS" "$STAGE/Vnidrop.swift"
|
||||
|
||||
# -X drops extra file attributes for a stabler archive across machines.
|
||||
( cd "$STAGE" && zip -q -r -X "$ZIP" vnidrop.xcframework Vnidrop.swift )
|
||||
|
||||
# sha256sum on Linux; shasum -a 256 on macOS. Both emit "<hash> <name>", which
|
||||
# `sha256sum --check` (used by assemble-release.sh) accepts.
|
||||
(
|
||||
cd "$DIST_DIR"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$NAME.zip" > "$NAME.zip.sha256"
|
||||
else
|
||||
shasum -a 256 "$NAME.zip" > "$NAME.zip.sha256"
|
||||
fi
|
||||
)
|
||||
|
||||
echo "==> Packaged prebuilt core"
|
||||
echo " zip: $ZIP"
|
||||
echo " checksum: $CHECKSUM"
|
||||
42
apple/scripts/sign-exported-app.sh
Executable file
42
apple/scripts/sign-exported-app.sh
Executable file
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 3 ]]; then
|
||||
printf 'Usage: %s <app-bundle> <signing-identity> <entitlements>\n' "$0" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
app=$1
|
||||
signing_identity=$2
|
||||
entitlements=$3
|
||||
|
||||
[[ -d $app ]] || {
|
||||
printf 'error: exported app bundle does not exist: %s\n' "$app" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ -n $signing_identity ]] || {
|
||||
printf 'error: signing identity is empty\n' >&2
|
||||
exit 1
|
||||
}
|
||||
[[ -f $entitlements ]] || {
|
||||
printf 'error: entitlements file does not exist: %s\n' "$entitlements" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
codesign \
|
||||
--force \
|
||||
--sign "$signing_identity" \
|
||||
--options runtime \
|
||||
--timestamp \
|
||||
--entitlements "$entitlements" \
|
||||
"$app"
|
||||
codesign --verify --deep --strict --verbose=2 "$app"
|
||||
|
||||
signature_details="$(codesign --display --verbose=4 "$app" 2>&1)"
|
||||
printf '%s\n' "$signature_details"
|
||||
printf '%s\n' "$signature_details" |
|
||||
grep -Eq 'flags=.*\(runtime([^)]*)?\)' || {
|
||||
printf 'error: exported app signature does not enable the hardened runtime\n' >&2
|
||||
exit 1
|
||||
}
|
||||
59
apple/scripts/tests/test-generate-appconfig.sh
Executable file
59
apple/scripts/tests/test-generate-appconfig.sh
Executable file
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Tests apple/scripts/generate-appconfig.sh: the shared app.properties is read
|
||||
# correctly, values are emitted as valid escaped Swift, and a missing key fails.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
generator="$script_dir/../generate-appconfig.sh"
|
||||
repo_root="$(cd "$script_dir/../../.." && pwd)"
|
||||
scratch="$(mktemp -d)"
|
||||
trap 'rm -rf "$scratch"' EXIT
|
||||
|
||||
# Run the generator against a fixture app.properties, emitting into a temp dir.
|
||||
generate() {
|
||||
VNIDROP_APP_PROPERTIES="$scratch/app.properties" \
|
||||
VNIDROP_APPLE_GENERATED_DIR="$scratch/out" \
|
||||
"$generator"
|
||||
}
|
||||
|
||||
expect_failure() {
|
||||
if "$@" >/dev/null 2>&1; then
|
||||
printf 'Expected command to fail: %s\n' "$*" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_contains() {
|
||||
local file=$1 needle=$2
|
||||
grep -qF "$needle" "$file" ||
|
||||
{ printf 'Expected %s to contain: %s\n' "$file" "$needle" >&2; exit 1; }
|
||||
}
|
||||
|
||||
out="$scratch/out/AppConfig.swift"
|
||||
|
||||
# 1. Nominal value is emitted verbatim as a Swift URL literal.
|
||||
printf 'PRIVACY_POLICY_URL=%s\n' 'https://example.test/privacy/' > "$scratch/app.properties"
|
||||
generate
|
||||
assert_contains "$out" 'URL(string: "https://example.test/privacy/")!'
|
||||
assert_contains "$out" 'enum AppConfig'
|
||||
|
||||
# 2. Characters special to a Swift string literal are escaped.
|
||||
printf 'PRIVACY_POLICY_URL=%s\n' 'https://a.test/"q"\z' > "$scratch/app.properties"
|
||||
generate
|
||||
assert_contains "$out" 'URL(string: "https://a.test/\"q\"\\z")!'
|
||||
|
||||
# 3. A missing key fails instead of emitting an empty value.
|
||||
printf 'OTHER_KEY=value\n' > "$scratch/app.properties"
|
||||
expect_failure generate
|
||||
|
||||
# 4. A duplicated key fails.
|
||||
printf 'PRIVACY_POLICY_URL=a\nPRIVACY_POLICY_URL=b\n' > "$scratch/app.properties"
|
||||
expect_failure generate
|
||||
|
||||
# 5. The real committed app.properties produces an https URL.
|
||||
VNIDROP_APPLE_GENERATED_DIR="$scratch/real" "$generator"
|
||||
assert_contains "$scratch/real/AppConfig.swift" 'URL(string: "https://'
|
||||
|
||||
printf 'generate-appconfig tests passed.\n'
|
||||
87
apple/scripts/tests/test-notarize.sh
Executable file
87
apple/scripts/tests/test-notarize.sh
Executable file
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
notarize="$script_dir/../notarize.sh"
|
||||
scratch="$(mktemp -d "${TMPDIR:-/tmp}/vnidrop-notarize-test.XXXXXX")"
|
||||
trap 'rm -rf "$scratch"' EXIT
|
||||
|
||||
mkdir -p "$scratch/bin"
|
||||
artifact="$scratch/VniDrop.dmg"
|
||||
calls="$scratch/calls.txt"
|
||||
log_output="$scratch/notary/notary-log.json"
|
||||
printf 'dmg\n' > "$artifact"
|
||||
|
||||
cat > "$scratch/bin/xcrun" <<'SCRIPT'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
printf '%s\n' "$*" >> "$FAKE_NOTARY_CALLS"
|
||||
if [[ $1 == notarytool && $2 == submit ]]; then
|
||||
case "${FAKE_NOTARY_MODE:-accepted}" in
|
||||
accepted)
|
||||
printf '%s\n' \
|
||||
'{"id":"11111111-1111-1111-1111-111111111111","status":"Accepted"}'
|
||||
;;
|
||||
invalid)
|
||||
printf '%s\n' \
|
||||
'{"id":"22222222-2222-2222-2222-222222222222","status":"Invalid"}'
|
||||
;;
|
||||
transport-error)
|
||||
printf '%s\n' 'notary service unavailable' >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
elif [[ $1 == notarytool && $2 == log ]]; then
|
||||
mkdir -p "$(dirname "$4")"
|
||||
printf '%s\n' \
|
||||
'{"status":"Invalid","issues":[{"message":"The signature is invalid."}]}' \
|
||||
> "$4"
|
||||
else
|
||||
printf 'unexpected xcrun invocation: %s\n' "$*" >&2
|
||||
exit 1
|
||||
fi
|
||||
SCRIPT
|
||||
chmod +x "$scratch/bin/xcrun"
|
||||
|
||||
PATH="$scratch/bin:$PATH" \
|
||||
FAKE_NOTARY_CALLS="$calls" \
|
||||
FAKE_NOTARY_MODE=accepted \
|
||||
"$notarize" "$artifact" test-profile "$log_output" >/dev/null
|
||||
[[ ! -e $log_output ]]
|
||||
[[ $(grep -c '^notarytool submit ' "$calls") -eq 1 ]]
|
||||
if grep -q '^notarytool log ' "$calls"; then
|
||||
printf 'Accepted submissions must not request a rejection log\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
: > "$calls"
|
||||
if PATH="$scratch/bin:$PATH" \
|
||||
FAKE_NOTARY_CALLS="$calls" \
|
||||
FAKE_NOTARY_MODE=invalid \
|
||||
"$notarize" "$artifact" test-profile "$log_output" >/dev/null 2>&1; then
|
||||
printf 'Invalid notarization must fail\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -F '"The signature is invalid."' "$log_output" >/dev/null
|
||||
grep -F \
|
||||
'notarytool log 22222222-2222-2222-2222-222222222222' \
|
||||
"$calls" >/dev/null
|
||||
|
||||
: > "$calls"
|
||||
rm -f "$log_output"
|
||||
if PATH="$scratch/bin:$PATH" \
|
||||
FAKE_NOTARY_CALLS="$calls" \
|
||||
FAKE_NOTARY_MODE=transport-error \
|
||||
"$notarize" "$artifact" test-profile "$log_output" >/dev/null 2>&1; then
|
||||
printf 'Notary transport errors must fail\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ ! -e $log_output ]]
|
||||
if grep -q '^notarytool log ' "$calls"; then
|
||||
printf 'A submission without an ID cannot request a rejection log\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'Notarization helper tests passed.\n'
|
||||
78
apple/scripts/tests/test-sign-exported-app.sh
Executable file
78
apple/scripts/tests/test-sign-exported-app.sh
Executable file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
sign_exported_app="$script_dir/../sign-exported-app.sh"
|
||||
scratch="$(mktemp -d "${TMPDIR:-/tmp}/vnidrop-codesign-test.XXXXXX")"
|
||||
trap 'rm -rf "$scratch"' EXIT
|
||||
|
||||
mkdir -p "$scratch/bin" "$scratch/VniDrop.app/Contents/MacOS"
|
||||
app="$scratch/VniDrop.app"
|
||||
entitlements="$scratch/VniDropDirect.entitlements"
|
||||
calls="$scratch/calls.txt"
|
||||
printf '<plist><dict/></plist>\n' > "$entitlements"
|
||||
printf 'binary\n' > "$app/Contents/MacOS/VniDrop"
|
||||
|
||||
cat > "$scratch/bin/codesign" <<'SCRIPT'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
printf '%s\n' "$*" >> "$FAKE_CODESIGN_CALLS"
|
||||
case " $* " in
|
||||
*" --display "*)
|
||||
if [[ ${FAKE_CODESIGN_MODE:-runtime} == missing-runtime ]]; then
|
||||
printf '%s\n' \
|
||||
'CodeDirectory v=20500 size=123 flags=0x0(none) hashes=1+0 location=embedded' \
|
||||
>&2
|
||||
else
|
||||
printf '%s\n' \
|
||||
'CodeDirectory v=20500 size=123 flags=0x10000(runtime) hashes=1+0 location=embedded' \
|
||||
>&2
|
||||
fi
|
||||
;;
|
||||
*" --verify "*)
|
||||
if [[ ${FAKE_CODESIGN_MODE:-runtime} == verify-error ]]; then
|
||||
printf '%s\n' 'invalid signature' >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
SCRIPT
|
||||
chmod +x "$scratch/bin/codesign"
|
||||
|
||||
PATH="$scratch/bin:$PATH" \
|
||||
FAKE_CODESIGN_CALLS="$calls" \
|
||||
"$sign_exported_app" \
|
||||
"$app" \
|
||||
'Developer ID Application: Example (ABCDEFGHIJ)' \
|
||||
"$entitlements" >/dev/null
|
||||
grep -F -- \
|
||||
'--force --sign Developer ID Application: Example (ABCDEFGHIJ) --options runtime --timestamp --entitlements' \
|
||||
"$calls" >/dev/null
|
||||
grep -F -- '--verify --deep --strict --verbose=2' "$calls" >/dev/null
|
||||
grep -F -- '--display --verbose=4' "$calls" >/dev/null
|
||||
|
||||
if PATH="$scratch/bin:$PATH" \
|
||||
FAKE_CODESIGN_CALLS="$calls" \
|
||||
FAKE_CODESIGN_MODE=missing-runtime \
|
||||
"$sign_exported_app" \
|
||||
"$app" \
|
||||
'Developer ID Application: Example (ABCDEFGHIJ)' \
|
||||
"$entitlements" >/dev/null 2>&1; then
|
||||
printf 'A signature without the hardened runtime must fail\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if PATH="$scratch/bin:$PATH" \
|
||||
FAKE_CODESIGN_CALLS="$calls" \
|
||||
FAKE_CODESIGN_MODE=verify-error \
|
||||
"$sign_exported_app" \
|
||||
"$app" \
|
||||
'Developer ID Application: Example (ABCDEFGHIJ)' \
|
||||
"$entitlements" >/dev/null 2>&1; then
|
||||
printf 'Signature verification errors must fail\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'Exported app signing tests passed.\n'
|
||||
@@ -1,3 +1,5 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
// this is necessary to avoid the plugins to be loaded multiple times
|
||||
// in each subproject's classloader
|
||||
@@ -13,3 +15,67 @@ plugins {
|
||||
alias(libs.plugins.kotlinJvm) apply false
|
||||
alias(libs.plugins.kotlinMultiplatform) apply false
|
||||
}
|
||||
|
||||
val versionFile = layout.projectDirectory.file("version.properties")
|
||||
val versionProperties = Properties().apply {
|
||||
versionFile.asFile.inputStream().use(::load)
|
||||
}
|
||||
|
||||
fun requiredVersionProperty(name: String): String =
|
||||
versionProperties.getProperty(name)?.takeIf { it.isNotBlank() }
|
||||
?: error("Missing $name in ${versionFile.asFile}")
|
||||
|
||||
fun canonicalInteger(name: String, value: String, range: LongRange): Long {
|
||||
require(value.matches(Regex("0|[1-9][0-9]*"))) {
|
||||
"$name must be a canonical non-negative integer"
|
||||
}
|
||||
val number = value.toLongOrNull()
|
||||
require(number != null && number in range) {
|
||||
"$name must be between ${range.first} and ${range.last}"
|
||||
}
|
||||
return number
|
||||
}
|
||||
|
||||
val productVersion = requiredVersionProperty("PRODUCT_VERSION")
|
||||
val productVersionMatch = Regex("(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)")
|
||||
.matchEntire(productVersion)
|
||||
?: error("PRODUCT_VERSION must use canonical MAJOR.MINOR.PATCH integers")
|
||||
val productVersionParts = productVersionMatch.groupValues.drop(1).map(String::toLong)
|
||||
require(productVersionParts[0] <= 2099 && productVersionParts.drop(1).all { it <= 999 }) {
|
||||
"PRODUCT_VERSION must use a major no greater than 2099 and minor/patch no greater than 999"
|
||||
}
|
||||
|
||||
val releaseChannel = requiredVersionProperty("RELEASE_CHANNEL")
|
||||
require(releaseChannel.matches(Regex("[a-z][a-z0-9-]*"))) {
|
||||
"RELEASE_CHANNEL contains unsupported characters"
|
||||
}
|
||||
val androidVersionCode =
|
||||
(productVersionParts[0] * 1_000_000L + productVersionParts[1] * 1_000L + productVersionParts[2])
|
||||
.also { require(it in 1L..2_100_000_000L) { "Derived Android version code is out of range" } }
|
||||
.toInt()
|
||||
val windowsVersionEpoch = canonicalInteger(
|
||||
"WINDOWS_VERSION_EPOCH",
|
||||
requiredVersionProperty("WINDOWS_VERSION_EPOCH"),
|
||||
1L..65535L,
|
||||
)
|
||||
val windowsMajor = productVersionParts[0] + windowsVersionEpoch
|
||||
require(windowsMajor <= 65535) {
|
||||
"Derived Windows package major exceeds 65535"
|
||||
}
|
||||
val windowsPackageVersion =
|
||||
"$windowsMajor.${productVersionParts[1]}.${productVersionParts[2]}.0"
|
||||
|
||||
extra["vnidrop.productVersion"] = productVersion
|
||||
extra["vnidrop.releaseChannel"] = releaseChannel
|
||||
extra["vnidrop.androidVersionCode"] = androidVersionCode
|
||||
extra["vnidrop.windowsPackageVersion"] = windowsPackageVersion
|
||||
|
||||
tasks.register("verifyVersion") {
|
||||
group = "verification"
|
||||
description = "Validates the canonical cross-platform application version."
|
||||
inputs.file(versionFile)
|
||||
inputs.property("productVersion", productVersion)
|
||||
inputs.property("releaseChannel", releaseChannel)
|
||||
inputs.property("androidVersionCode", androidVersionCode)
|
||||
inputs.property("windowsPackageVersion", windowsPackageVersion)
|
||||
}
|
||||
|
||||
40
ci_scripts/README.md
Normal file
40
ci_scripts/README.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Xcode Cloud CI scripts
|
||||
|
||||
Xcode Cloud runs the scripts in this directory around each build. Only
|
||||
`ci_post_clone.sh` is used today; add `ci_pre_xcodebuild.sh` /
|
||||
`ci_post_xcodebuild.sh` here if later steps are needed.
|
||||
|
||||
## What `ci_post_clone.sh` does
|
||||
|
||||
The Xcode project (`apple/VniDrop.xcodeproj`) and its generated inputs are **not**
|
||||
committed — they are produced by XcodeGen, localization, and the Rust core build.
|
||||
Since Xcode Cloud only checks out the repository, the post-clone script:
|
||||
|
||||
1. installs `swiftlint`, `xcodegen`, and `bun`;
|
||||
2. **downloads the prebuilt core** (`vnidrop.xcframework` + `Vnidrop.swift`) from
|
||||
the matching GitHub Release asset `VnidropCore-<version>.zip` — Xcode Cloud
|
||||
never builds Rust;
|
||||
3. runs localization + version/app config codegen and `xcodegen generate`
|
||||
(equivalent to `make apple-project` without the `apple-core` step).
|
||||
|
||||
The core asset for version `X.Y.Z` must be published on the `vX.Y.Z` release
|
||||
before an Xcode Cloud build for that version runs (see
|
||||
`apple/scripts/package-core.sh` and `.github/workflows/apple-release.yml`).
|
||||
|
||||
### Overrides (env vars, optional)
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `VNIDROP_CORE_REPO` | `sudosylabs/vnidrop` | Release repository to download the core from |
|
||||
| `VNIDROP_CORE_TAG` | `v<product-version>` | Release tag holding the core asset |
|
||||
|
||||
## Workflow configuration (App Store Connect)
|
||||
|
||||
The workflow itself (product, scheme, triggers, actions) is configured in App
|
||||
Store Connect, not in the repository. Point it at:
|
||||
|
||||
- **Project:** `apple/VniDrop.xcodeproj` (generated by the post-clone script)
|
||||
- **Scheme:** `VniDrop` (App Store / TestFlight target; shared, see `apple/project.yml`)
|
||||
|
||||
Archive actions use the release Rust profile via the published core asset; build
|
||||
and test actions reuse the same prebuilt core.
|
||||
72
ci_scripts/ci_post_clone.sh
Executable file
72
ci_scripts/ci_post_clone.sh
Executable file
@@ -0,0 +1,72 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Xcode Cloud post-clone step.
|
||||
#
|
||||
# The Apple Xcode project is generated (XcodeGen) and gitignored, and it links a
|
||||
# prebuilt Rust XCFramework plus generated localization/config files. Xcode Cloud
|
||||
# only checks out the repository, so this script:
|
||||
# 1. installs the non-Rust build tooling (swiftlint, xcodegen, bun);
|
||||
# 2. downloads the prebuilt core (vnidrop.xcframework + Vnidrop.swift) from the
|
||||
# matching GitHub Release asset — we never build Rust here;
|
||||
# 3. reproduces `make apple-project` minus the Rust `apple-core` step.
|
||||
#
|
||||
# Xcode Cloud runs this from the `ci_scripts` directory; CI_PRIMARY_REPOSITORY_PATH
|
||||
# points at the checked-out repository root.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="${CI_PRIMARY_REPOSITORY_PATH:-$(cd "$(dirname "$0")/.." && pwd)}"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
echo "==> Installing build tooling (Homebrew)"
|
||||
# swiftlint: enforced by a build phase (fails the build if missing).
|
||||
# xcodegen: generates apple/VniDrop.xcodeproj from apple/project.yml.
|
||||
brew install swiftlint xcodegen
|
||||
|
||||
echo "==> Installing Bun (localization generator)"
|
||||
if ! command -v bun >/dev/null 2>&1; then
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
fi
|
||||
export BUN_INSTALL="${BUN_INSTALL:-$HOME/.bun}"
|
||||
export PATH="$BUN_INSTALL/bin:$PATH"
|
||||
|
||||
# --- Prebuilt core: download instead of building Rust -------------------------
|
||||
# The Apple core (xcframework + UniFFI bindings) is published as a release asset
|
||||
# by apple/scripts/package-core.sh. See docs at the top of that script.
|
||||
VERSION="$(packaging/version/resolve-version.sh product)"
|
||||
CORE_REPO="${VNIDROP_CORE_REPO:-sudosylabs/vnidrop}"
|
||||
CORE_TAG="${VNIDROP_CORE_TAG:-v$VERSION}"
|
||||
CORE_ZIP="VnidropCore-$VERSION.zip"
|
||||
CORE_BASE_URL="https://github.com/$CORE_REPO/releases/download/$CORE_TAG"
|
||||
|
||||
PKG_DIR="$REPO_ROOT/apple/VnidropCore"
|
||||
DOWNLOAD_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$DOWNLOAD_DIR"' EXIT
|
||||
|
||||
echo "==> Downloading prebuilt core $CORE_ZIP from $CORE_REPO@$CORE_TAG"
|
||||
curl -fsSL "$CORE_BASE_URL/$CORE_ZIP" -o "$DOWNLOAD_DIR/$CORE_ZIP"
|
||||
curl -fsSL "$CORE_BASE_URL/$CORE_ZIP.sha256" -o "$DOWNLOAD_DIR/$CORE_ZIP.sha256"
|
||||
|
||||
echo "==> Verifying checksum"
|
||||
(
|
||||
cd "$DOWNLOAD_DIR"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum --check "$CORE_ZIP.sha256"
|
||||
else
|
||||
shasum -a 256 --check "$CORE_ZIP.sha256"
|
||||
fi
|
||||
)
|
||||
|
||||
echo "==> Installing core into apple/VnidropCore"
|
||||
unzip -q -o "$DOWNLOAD_DIR/$CORE_ZIP" -d "$DOWNLOAD_DIR/extracted"
|
||||
# Zip root holds: vnidrop.xcframework/ and Vnidrop.swift (see package-core.sh).
|
||||
rm -rf "$PKG_DIR/vnidrop.xcframework"
|
||||
cp -R "$DOWNLOAD_DIR/extracted/vnidrop.xcframework" "$PKG_DIR/vnidrop.xcframework"
|
||||
mkdir -p "$PKG_DIR/Sources/VnidropCore"
|
||||
cp "$DOWNLOAD_DIR/extracted/Vnidrop.swift" "$PKG_DIR/Sources/VnidropCore/Vnidrop.swift"
|
||||
|
||||
# --- Generate the project (everything except the Rust core) -------------------
|
||||
echo "==> Generating localization, version/app config, and the Xcode project"
|
||||
make localization apple-version-config apple-app-config
|
||||
(cd "$REPO_ROOT/apple" && xcodegen generate)
|
||||
|
||||
echo "==> ci_post_clone complete"
|
||||
@@ -1,5 +1,5 @@
|
||||
# Default command configuration. Override locally in the ignored
|
||||
# config.override.mk or on the command line (for example: make package-deb VERSION=1.2.0).
|
||||
# Default command configuration. Override local tool paths in the ignored
|
||||
# config.override.mk or on the command line.
|
||||
|
||||
ifeq ($(OS),Windows_NT)
|
||||
HOST_OS := windows
|
||||
@@ -24,7 +24,7 @@ XCODEGEN ?= xcodegen
|
||||
OPEN ?= open
|
||||
POWERSHELL ?= pwsh
|
||||
|
||||
VERSION ?= $(shell sed -n 's/^vnidrop.version=//p' $(ROOT)/gradle.properties)
|
||||
override VERSION := $(shell $(ROOT)/packaging/version/resolve-version.sh product)
|
||||
APPLE_PROFILE ?= debug
|
||||
APPLE_CONFIGURATION ?= Debug
|
||||
APPLE_DESTINATION ?=
|
||||
|
||||
@@ -16,6 +16,7 @@ blake3 = "1.8.3"
|
||||
data-encoding = "2.11.0"
|
||||
futures = "0.3"
|
||||
futures-lite = "2.6.1"
|
||||
getrandom = "0.3.4"
|
||||
iroh = "1.0.3"
|
||||
iroh-blobs = "0.103.0"
|
||||
irpc = "0.17.0"
|
||||
|
||||
31
crates/vnidrop/build.rs
Normal file
31
crates/vnidrop/build.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use std::{env, fs, path::PathBuf};
|
||||
|
||||
fn main() {
|
||||
let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap());
|
||||
let version_file = manifest_dir.join("../../version.properties");
|
||||
println!("cargo:rerun-if-changed={}", version_file.display());
|
||||
|
||||
let contents = fs::read_to_string(&version_file)
|
||||
.unwrap_or_else(|error| panic!("failed to read {}: {error}", version_file.display()));
|
||||
let versions: Vec<_> = contents
|
||||
.lines()
|
||||
.filter_map(|line| line.strip_prefix("PRODUCT_VERSION="))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
versions.len(),
|
||||
1,
|
||||
"{} must contain exactly one PRODUCT_VERSION",
|
||||
version_file.display()
|
||||
);
|
||||
let version = versions[0];
|
||||
let components: Vec<_> = version.split('.').collect();
|
||||
assert!(
|
||||
components.len() == 3
|
||||
&& components.iter().all(|component| {
|
||||
component.parse::<u16>().is_ok()
|
||||
&& (component == &"0" || !component.starts_with('0'))
|
||||
}),
|
||||
"PRODUCT_VERSION must use canonical MAJOR.MINOR.PATCH integers"
|
||||
);
|
||||
println!("cargo:rustc-env=VNIDROP_PRODUCT_VERSION={version}");
|
||||
}
|
||||
@@ -29,13 +29,6 @@ impl AccessPolicy {
|
||||
self.modes.write().await.insert(transfer_id, mode);
|
||||
}
|
||||
|
||||
pub(crate) async fn allows_without_approval(&self, transfer_id: u64) -> bool {
|
||||
matches!(
|
||||
self.modes.read().await.get(&transfer_id),
|
||||
Some(TransferAccessMode::Public)
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn remove_transfer(&self, transfer_id: u64) {
|
||||
self.modes.write().await.remove(&transfer_id);
|
||||
self.approved_sessions
|
||||
|
||||
@@ -180,6 +180,8 @@ pub struct CoreLimits {
|
||||
pub max_metadata_bytes: u64,
|
||||
pub max_events: u64,
|
||||
pub max_pending_approvals: u64,
|
||||
/// Incoming pairing offers awaiting the local user's decision.
|
||||
pub max_pending_offers: u64,
|
||||
pub max_concurrent_transfers: u64,
|
||||
pub event_queue_capacity: u64,
|
||||
}
|
||||
@@ -198,6 +200,9 @@ impl Default for CoreLimits {
|
||||
max_events: 500,
|
||||
// Bound handshake spam / notification pressure on the sender.
|
||||
max_pending_approvals: 64,
|
||||
// A pairing prompt needs the user in front of the device, so this
|
||||
// is far smaller than the handshake queue.
|
||||
max_pending_offers: 16,
|
||||
max_concurrent_transfers: 8,
|
||||
event_queue_capacity: 1_024,
|
||||
}
|
||||
@@ -215,6 +220,7 @@ impl CoreLimits {
|
||||
("max_metadata_bytes", self.max_metadata_bytes),
|
||||
("max_events", self.max_events),
|
||||
("max_pending_approvals", self.max_pending_approvals),
|
||||
("max_pending_offers", self.max_pending_offers),
|
||||
("max_concurrent_transfers", self.max_concurrent_transfers),
|
||||
("event_queue_capacity", self.event_queue_capacity),
|
||||
];
|
||||
@@ -225,6 +231,7 @@ impl CoreLimits {
|
||||
}
|
||||
for (name, value) in [
|
||||
("max_pending_approvals", self.max_pending_approvals),
|
||||
("max_pending_offers", self.max_pending_offers),
|
||||
("max_concurrent_transfers", self.max_concurrent_transfers),
|
||||
("event_queue_capacity", self.event_queue_capacity),
|
||||
] {
|
||||
@@ -452,6 +459,80 @@ pub struct TicketInspection {
|
||||
pub metadata: TransferMetadata,
|
||||
}
|
||||
|
||||
/// A device the user has chosen to remember.
|
||||
///
|
||||
/// Deliberately carries no grant material: capabilities never cross the UniFFI
|
||||
/// boundary, only the fact that one exists (`can_send`).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
|
||||
pub struct ContactSummary {
|
||||
pub endpoint_id: String,
|
||||
/// Set locally by the user. Authoritative for display.
|
||||
pub local_label: Option<String>,
|
||||
/// Last name the device claimed. Untrusted; never promoted to the label.
|
||||
pub remote_display_name: Option<String>,
|
||||
pub last_transfer_at: Option<i64>,
|
||||
pub created_at: i64,
|
||||
/// Whether this device can currently be sent to, i.e. a live grant is held.
|
||||
/// False after the peer revoked, expired, or reinstalled.
|
||||
pub can_send: bool,
|
||||
}
|
||||
|
||||
/// Outcome of sending straight to a remembered device.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
|
||||
pub struct ContactSendResult {
|
||||
pub share: ShareResult,
|
||||
/// False when the device was not running: the transfer is held here and the
|
||||
/// device collects it the next time it opens VniDrop.
|
||||
pub delivered: bool,
|
||||
}
|
||||
|
||||
/// A transfer this device is holding until its target comes back online.
|
||||
///
|
||||
/// Cancelling the underlying transfer withdraws it.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
|
||||
pub struct HeldOfferSummary {
|
||||
pub offer_id: String,
|
||||
pub endpoint_id: String,
|
||||
pub transfer_id: u64,
|
||||
pub transfer_name: String,
|
||||
pub file_count: u64,
|
||||
pub total_bytes: u64,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
/// A transfer a paired device is offering.
|
||||
///
|
||||
/// The ticket is deliberately absent: it is a capability, and it is handed over
|
||||
/// only when the user accepts.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
|
||||
pub struct IncomingOffer {
|
||||
pub offer_id: String,
|
||||
pub from_endpoint_id: String,
|
||||
pub sender_display_name: Option<String>,
|
||||
pub transfer_name: String,
|
||||
pub file_count: u64,
|
||||
pub total_bytes: u64,
|
||||
pub received_at: i64,
|
||||
}
|
||||
|
||||
/// A device offering to be remembered, awaiting the local user's decision.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
|
||||
pub struct PendingPairing {
|
||||
pub endpoint_id: String,
|
||||
pub display_name: Option<String>,
|
||||
pub received_at: i64,
|
||||
}
|
||||
|
||||
/// How long a grant survives without use, renewed on every accepted proof.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
|
||||
pub enum GrantLifetimeSetting {
|
||||
Days30,
|
||||
#[default]
|
||||
Days90,
|
||||
Days365,
|
||||
Never,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
|
||||
pub struct ReceiverRequest {
|
||||
pub id: String,
|
||||
|
||||
@@ -6,7 +6,7 @@ use tokio::sync::{oneshot, Mutex};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
access_policy::{AccessPolicy, APPROVAL_SESSION_TTL_MS},
|
||||
access_policy::{AccessDecision, AccessPolicy, APPROVAL_SESSION_TTL_MS},
|
||||
event_hub::EventHub,
|
||||
handshake::{
|
||||
DeliveryFailureReceipt, DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse,
|
||||
@@ -198,10 +198,15 @@ impl ApprovalService {
|
||||
.await
|
||||
{
|
||||
Ok(true) => {
|
||||
// An existing access session means this endpoint was already
|
||||
// authorised: either the share is public, or the sender pushed
|
||||
// this transfer to them. Prompting again would ask the sender
|
||||
// to approve a transfer they themselves initiated.
|
||||
if self
|
||||
.access_policy
|
||||
.allows_without_approval(request.transfer_id)
|
||||
.decide(request.transfer_id, Some(&remote_endpoint_id))
|
||||
.await
|
||||
== AccessDecision::Allow
|
||||
{
|
||||
self.allow_without_sender_decision(remote_endpoint_id, request)
|
||||
.await
|
||||
|
||||
627
crates/vnidrop/src/contacts.rs
Normal file
627
crates/vnidrop/src/contacts.rs
Normal file
@@ -0,0 +1,627 @@
|
||||
//! Storage for device history: contacts, the grants that make them usable, and
|
||||
//! the block list.
|
||||
//!
|
||||
//! Split out of [`crate::repository`] to keep that file focused; the tables are
|
||||
//! created as part of the same schema migration and share its pool.
|
||||
//!
|
||||
//! Grant secrets live here. They are key material and follow the same rule as
|
||||
//! tickets: never logged, never emitted in an event, never returned across the
|
||||
//! UniFFI boundary.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
use crate::grant::{parse_secret, GrantId, HeldGrant, IssuedGrant};
|
||||
|
||||
/// How long a dead grant is kept before being swept.
|
||||
///
|
||||
/// A revoked grant stays as a tombstone so a returning peer is told `Revoked`
|
||||
/// rather than `Unknown`; after this long, a peer that has not come back is
|
||||
/// unlikely to, and the row is noise.
|
||||
pub(crate) const DEAD_GRANT_RETENTION_MS: i64 = 30 * 24 * 60 * 60 * 1_000;
|
||||
|
||||
/// A device the user has transferred with and chosen to remember.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct Contact {
|
||||
pub(crate) endpoint_id: String,
|
||||
/// Set by the local user. Never overwritten by a name the remote claims.
|
||||
pub(crate) local_label: Option<String>,
|
||||
/// Last name the remote sent. Untrusted display data.
|
||||
pub(crate) remote_display_name: Option<String>,
|
||||
/// Encoded `EndpointAddr` from the last successful connection, so the peer
|
||||
/// stays dialable in relay profiles without public address lookup.
|
||||
pub(crate) last_known_addr: Option<String>,
|
||||
pub(crate) created_at: i64,
|
||||
pub(crate) last_transfer_at: Option<i64>,
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_schema(pool: &SqlitePool) -> Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
endpoint_id TEXT PRIMARY KEY,
|
||||
local_label TEXT,
|
||||
remote_display_name TEXT,
|
||||
last_known_addr TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_transfer_at INTEGER
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// Authoritative side: only the issuer can validate or revoke these.
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS grants_issued (
|
||||
grant_id TEXT PRIMARY KEY,
|
||||
grant_secret TEXT NOT NULL,
|
||||
issued_to_endpoint_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER,
|
||||
revoked_at INTEGER
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_grants_issued_endpoint ON grants_issued(issued_to_endpoint_id);",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS grants_held (
|
||||
grant_id TEXT PRIMARY KEY,
|
||||
grant_secret TEXT NOT NULL,
|
||||
peer_endpoint_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_grants_held_endpoint ON grants_held(peer_endpoint_id);",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS held_offers (
|
||||
offer_id TEXT PRIMARY KEY,
|
||||
endpoint_id TEXT NOT NULL,
|
||||
transfer_id INTEGER NOT NULL,
|
||||
ticket TEXT NOT NULL,
|
||||
transfer_name TEXT NOT NULL,
|
||||
sender_display_name TEXT,
|
||||
file_count INTEGER NOT NULL,
|
||||
total_bytes INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query("CREATE INDEX IF NOT EXISTS idx_held_offers_endpoint ON held_offers(endpoint_id);")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS blocked_endpoints (
|
||||
endpoint_id TEXT PRIMARY KEY,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An offer that could not be delivered because the target was not running.
|
||||
///
|
||||
/// Held on this device, not a server: the share stays here and the receiver
|
||||
/// collects the ticket when its app next comes to the foreground.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct HeldOffer {
|
||||
pub(crate) offer_id: String,
|
||||
pub(crate) endpoint_id: String,
|
||||
pub(crate) transfer_id: u64,
|
||||
pub(crate) ticket: String,
|
||||
pub(crate) transfer_name: String,
|
||||
pub(crate) sender_display_name: Option<String>,
|
||||
pub(crate) file_count: u64,
|
||||
pub(crate) total_bytes: u64,
|
||||
pub(crate) created_at: i64,
|
||||
}
|
||||
|
||||
/// Contacts, grants, and blocks over the shared repository pool.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ContactStore {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl ContactStore {
|
||||
pub(crate) fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
// -- contacts ---------------------------------------------------------
|
||||
|
||||
/// Record a contact, or refresh the untrusted display name of an existing
|
||||
/// one. The local label is deliberately left untouched.
|
||||
pub(crate) async fn upsert_contact(
|
||||
&self,
|
||||
endpoint_id: &str,
|
||||
remote_display_name: Option<&str>,
|
||||
now_ms: i64,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO contacts (endpoint_id, remote_display_name, created_at)
|
||||
VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(endpoint_id) DO UPDATE SET
|
||||
remote_display_name = COALESCE(excluded.remote_display_name, contacts.remote_display_name)
|
||||
"#,
|
||||
)
|
||||
.bind(endpoint_id)
|
||||
.bind(remote_display_name)
|
||||
.bind(now_ms)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn set_contact_label(
|
||||
&self,
|
||||
endpoint_id: &str,
|
||||
label: Option<&str>,
|
||||
) -> Result<()> {
|
||||
sqlx::query("UPDATE contacts SET local_label = ?2 WHERE endpoint_id = ?1")
|
||||
.bind(endpoint_id)
|
||||
.bind(label)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn touch_transfer(&self, endpoint_id: &str, now_ms: i64) -> Result<()> {
|
||||
sqlx::query("UPDATE contacts SET last_transfer_at = ?2 WHERE endpoint_id = ?1")
|
||||
.bind(endpoint_id)
|
||||
.bind(now_ms)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn set_last_known_addr(&self, endpoint_id: &str, addr: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE contacts SET last_known_addr = ?2 WHERE endpoint_id = ?1")
|
||||
.bind(endpoint_id)
|
||||
.bind(addr)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn list_contacts(&self) -> Result<Vec<Contact>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT endpoint_id, local_label, remote_display_name, last_known_addr,
|
||||
created_at, last_transfer_at
|
||||
FROM contacts
|
||||
ORDER BY COALESCE(last_transfer_at, created_at) DESC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| Contact {
|
||||
endpoint_id: row.get(0),
|
||||
local_label: row.get(1),
|
||||
remote_display_name: row.get(2),
|
||||
last_known_addr: row.get(3),
|
||||
created_at: row.get(4),
|
||||
last_transfer_at: row.get(5),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(crate) async fn find_contact(&self, endpoint_id: &str) -> Result<Option<Contact>> {
|
||||
Ok(self
|
||||
.list_contacts()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|contact| contact.endpoint_id == endpoint_id))
|
||||
}
|
||||
|
||||
/// Remove a contact and every grant in both directions.
|
||||
///
|
||||
/// Returns the ids of the grants this device had issued, so the caller can
|
||||
/// send the best-effort revoke notification. Deletion succeeds regardless of
|
||||
/// whether that notification is ever delivered.
|
||||
pub(crate) async fn delete_contact(&self, endpoint_id: &str) -> Result<Vec<GrantId>> {
|
||||
let issued = self.issued_grant_ids_for(endpoint_id).await?;
|
||||
let mut tx = self.pool.begin().await?;
|
||||
sqlx::query("DELETE FROM grants_issued WHERE issued_to_endpoint_id = ?1")
|
||||
.bind(endpoint_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM grants_held WHERE peer_endpoint_id = ?1")
|
||||
.bind(endpoint_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM contacts WHERE endpoint_id = ?1")
|
||||
.bind(endpoint_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(issued)
|
||||
}
|
||||
|
||||
/// Wholesale delete, for the same surface that clears transfer history.
|
||||
pub(crate) async fn delete_all_contacts(&self) -> Result<Vec<GrantId>> {
|
||||
let issued = self.all_issued_grant_ids().await?;
|
||||
let mut tx = self.pool.begin().await?;
|
||||
sqlx::query("DELETE FROM grants_issued")
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM grants_held")
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM contacts")
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(issued)
|
||||
}
|
||||
|
||||
// -- issued grants ----------------------------------------------------
|
||||
|
||||
pub(crate) async fn insert_issued_grant(&self, grant: &IssuedGrant) -> Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO grants_issued
|
||||
(grant_id, grant_secret, issued_to_endpoint_id, created_at, expires_at, revoked_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, NULL)
|
||||
"#,
|
||||
)
|
||||
.bind(grant.grant_id.encode())
|
||||
.bind(grant.secret.encode())
|
||||
.bind(&grant.issued_to_endpoint_id)
|
||||
.bind(grant.created_at)
|
||||
.bind(grant.expires_at)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Look up a grant by the id a peer presented.
|
||||
///
|
||||
/// A row whose secret fails to parse is corrupt storage, not a usable
|
||||
/// grant: surface the error rather than silently refusing the peer, which
|
||||
/// would look like revocation.
|
||||
pub(crate) async fn find_issued_grant(&self, grant_id: GrantId) -> Result<Option<IssuedGrant>> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT grant_id, grant_secret, issued_to_endpoint_id, created_at, expires_at, revoked_at
|
||||
FROM grants_issued
|
||||
WHERE grant_id = ?1
|
||||
"#,
|
||||
)
|
||||
.bind(grant_id.encode())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(row_to_issued_grant).transpose()
|
||||
}
|
||||
|
||||
/// Push the idle deadline forward after an accepted proof.
|
||||
pub(crate) async fn renew_issued_grant(
|
||||
&self,
|
||||
grant_id: GrantId,
|
||||
expires_at: Option<i64>,
|
||||
) -> Result<()> {
|
||||
sqlx::query("UPDATE grants_issued SET expires_at = ?2 WHERE grant_id = ?1")
|
||||
.bind(grant_id.encode())
|
||||
.bind(expires_at)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// End the relationship from the issuing side. Tombstoned rather than
|
||||
/// deleted so a later attempt is answered `Revoked` instead of `Unknown`.
|
||||
pub(crate) async fn revoke_issued_grant(&self, grant_id: GrantId, now_ms: i64) -> Result<()> {
|
||||
sqlx::query(
|
||||
"UPDATE grants_issued SET revoked_at = ?2 WHERE grant_id = ?1 AND revoked_at IS NULL",
|
||||
)
|
||||
.bind(grant_id.encode())
|
||||
.bind(now_ms)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn revoke_issued_grants_for(
|
||||
&self,
|
||||
endpoint_id: &str,
|
||||
now_ms: i64,
|
||||
) -> Result<Vec<GrantId>> {
|
||||
let ids = self.issued_grant_ids_for(endpoint_id).await?;
|
||||
sqlx::query(
|
||||
"UPDATE grants_issued SET revoked_at = ?2 WHERE issued_to_endpoint_id = ?1 AND revoked_at IS NULL",
|
||||
)
|
||||
.bind(endpoint_id)
|
||||
.bind(now_ms)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
async fn issued_grant_ids_for(&self, endpoint_id: &str) -> Result<Vec<GrantId>> {
|
||||
let rows =
|
||||
sqlx::query("SELECT grant_id FROM grants_issued WHERE issued_to_endpoint_id = ?1")
|
||||
.bind(endpoint_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.into_iter()
|
||||
.map(|row| GrantId::decode(row.get::<String, _>(0).as_str()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn all_issued_grant_ids(&self) -> Result<Vec<GrantId>> {
|
||||
let rows = sqlx::query("SELECT grant_id FROM grants_issued")
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.into_iter()
|
||||
.map(|row| GrantId::decode(row.get::<String, _>(0).as_str()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// -- held grants ------------------------------------------------------
|
||||
|
||||
pub(crate) async fn insert_held_grant(&self, grant: &HeldGrant) -> Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO grants_held
|
||||
(grant_id, grant_secret, peer_endpoint_id, created_at, expires_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(grant_id) DO UPDATE SET
|
||||
grant_secret = excluded.grant_secret,
|
||||
expires_at = excluded.expires_at
|
||||
"#,
|
||||
)
|
||||
.bind(grant.grant_id.encode())
|
||||
.bind(grant.secret.encode())
|
||||
.bind(&grant.peer_endpoint_id)
|
||||
.bind(grant.created_at)
|
||||
.bind(grant.expires_at)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The capability to reach `peer_endpoint_id`, if this device holds one.
|
||||
///
|
||||
/// Newest wins: re-pairing issues a fresh grant, and the old one is dead on
|
||||
/// the issuer's side anyway.
|
||||
pub(crate) async fn held_grant_for(&self, peer_endpoint_id: &str) -> Result<Option<HeldGrant>> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT grant_id, grant_secret, peer_endpoint_id, created_at, expires_at
|
||||
FROM grants_held
|
||||
WHERE peer_endpoint_id = ?1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(peer_endpoint_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(row_to_held_grant).transpose()
|
||||
}
|
||||
|
||||
/// Drop a grant this device holds, after the issuer reported it dead.
|
||||
pub(crate) async fn delete_held_grant(&self, grant_id: GrantId) -> Result<()> {
|
||||
sqlx::query("DELETE FROM grants_held WHERE grant_id = ?1")
|
||||
.bind(grant_id.encode())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -- block list -------------------------------------------------------
|
||||
|
||||
/// Block an endpoint and revoke anything it still holds, so blocking is not
|
||||
/// merely cosmetic while a live grant remains.
|
||||
pub(crate) async fn block_endpoint(&self, endpoint_id: &str, now_ms: i64) -> Result<()> {
|
||||
self.revoke_issued_grants_for(endpoint_id, now_ms).await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO blocked_endpoints (endpoint_id, created_at) VALUES (?1, ?2)
|
||||
ON CONFLICT(endpoint_id) DO NOTHING",
|
||||
)
|
||||
.bind(endpoint_id)
|
||||
.bind(now_ms)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn unblock_endpoint(&self, endpoint_id: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM blocked_endpoints WHERE endpoint_id = ?1")
|
||||
.bind(endpoint_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn is_blocked(&self, endpoint_id: &str) -> Result<bool> {
|
||||
let row =
|
||||
sqlx::query("SELECT EXISTS(SELECT 1 FROM blocked_endpoints WHERE endpoint_id = ?1)")
|
||||
.bind(endpoint_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(row.get::<i64, _>(0) == 1)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_blocked(&self) -> Result<Vec<String>> {
|
||||
let rows =
|
||||
sqlx::query("SELECT endpoint_id FROM blocked_endpoints ORDER BY created_at DESC")
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|row| row.get(0)).collect())
|
||||
}
|
||||
|
||||
// -- held offers ------------------------------------------------------
|
||||
|
||||
pub(crate) async fn insert_held_offer(&self, offer: &HeldOffer) -> Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO held_offers
|
||||
(offer_id, endpoint_id, transfer_id, ticket, transfer_name,
|
||||
sender_display_name, file_count, total_bytes, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
||||
"#,
|
||||
)
|
||||
.bind(&offer.offer_id)
|
||||
.bind(&offer.endpoint_id)
|
||||
.bind(offer.transfer_id as i64)
|
||||
.bind(&offer.ticket)
|
||||
.bind(&offer.transfer_name)
|
||||
.bind(offer.sender_display_name.as_deref())
|
||||
.bind(offer.file_count as i64)
|
||||
.bind(offer.total_bytes as i64)
|
||||
.bind(offer.created_at)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Offers waiting for one device to come and collect them.
|
||||
pub(crate) async fn held_offers_for(&self, endpoint_id: &str) -> Result<Vec<HeldOffer>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT offer_id, endpoint_id, transfer_id, ticket, transfer_name,
|
||||
sender_display_name, file_count, total_bytes, created_at
|
||||
FROM held_offers
|
||||
WHERE endpoint_id = ?1
|
||||
ORDER BY created_at ASC
|
||||
"#,
|
||||
)
|
||||
.bind(endpoint_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(row_to_held_offer).collect())
|
||||
}
|
||||
|
||||
pub(crate) async fn list_held_offers(&self) -> Result<Vec<HeldOffer>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT offer_id, endpoint_id, transfer_id, ticket, transfer_name,
|
||||
sender_display_name, file_count, total_bytes, created_at
|
||||
FROM held_offers
|
||||
ORDER BY created_at ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(row_to_held_offer).collect())
|
||||
}
|
||||
|
||||
/// Consumed once handed over, so a device polling twice is not offered the
|
||||
/// same transfer again.
|
||||
pub(crate) async fn delete_held_offers(&self, offer_ids: &[String]) -> Result<()> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
for offer_id in offer_ids {
|
||||
sqlx::query("DELETE FROM held_offers WHERE offer_id = ?1")
|
||||
.bind(offer_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_held_offers_for_transfer(&self, transfer_id: u64) -> Result<()> {
|
||||
sqlx::query("DELETE FROM held_offers WHERE transfer_id = ?1")
|
||||
.bind(transfer_id as i64)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -- maintenance ------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn corrupt_secret_for_test(&self, grant_id: GrantId) -> Result<()> {
|
||||
sqlx::query("UPDATE grants_issued SET grant_secret = 'not-hex' WHERE grant_id = ?1")
|
||||
.bind(grant_id.encode())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop grants that lapsed or were revoked long enough ago that no peer
|
||||
/// still needs to be told. Keeps tombstones bounded.
|
||||
pub(crate) async fn purge_dead_grants(&self, before_ms: i64) -> Result<u64> {
|
||||
let issued = sqlx::query(
|
||||
"DELETE FROM grants_issued
|
||||
WHERE (expires_at IS NOT NULL AND expires_at < ?1)
|
||||
OR (revoked_at IS NOT NULL AND revoked_at < ?1)",
|
||||
)
|
||||
.bind(before_ms)
|
||||
.execute(&self.pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
Ok(issued)
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_held_offer(row: sqlx::sqlite::SqliteRow) -> HeldOffer {
|
||||
HeldOffer {
|
||||
offer_id: row.get(0),
|
||||
endpoint_id: row.get(1),
|
||||
transfer_id: row.get::<i64, _>(2) as u64,
|
||||
ticket: row.get(3),
|
||||
transfer_name: row.get(4),
|
||||
sender_display_name: row.get(5),
|
||||
file_count: row.get::<i64, _>(6) as u64,
|
||||
total_bytes: row.get::<i64, _>(7) as u64,
|
||||
created_at: row.get(8),
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_issued_grant(row: sqlx::sqlite::SqliteRow) -> Result<IssuedGrant> {
|
||||
let grant_id = GrantId::decode(row.get::<String, _>(0).as_str())?;
|
||||
let secret = parse_secret(row.get::<String, _>(1).as_str())
|
||||
.context("stored grant secret is unusable")?;
|
||||
Ok(IssuedGrant {
|
||||
grant_id,
|
||||
secret,
|
||||
issued_to_endpoint_id: row.get(2),
|
||||
created_at: row.get(3),
|
||||
expires_at: row.get(4),
|
||||
revoked_at: row.get(5),
|
||||
})
|
||||
}
|
||||
|
||||
fn row_to_held_grant(row: sqlx::sqlite::SqliteRow) -> Result<HeldGrant> {
|
||||
let grant_id = GrantId::decode(row.get::<String, _>(0).as_str())?;
|
||||
let secret = parse_secret(row.get::<String, _>(1).as_str())
|
||||
.context("stored grant secret is unusable")?;
|
||||
Ok(HeldGrant {
|
||||
grant_id,
|
||||
secret,
|
||||
peer_endpoint_id: row.get(2),
|
||||
created_at: row.get(3),
|
||||
expires_at: row.get(4),
|
||||
})
|
||||
}
|
||||
364
crates/vnidrop/src/grant.rs
Normal file
364
crates/vnidrop/src/grant.rs
Normal file
@@ -0,0 +1,364 @@
|
||||
//! Grants: the capability a device issues so a known peer may reach it.
|
||||
//!
|
||||
//! A history entry is not "I remember this endpoint id", it is "this device
|
||||
//! issued me a capability". The issuer is the only party that can validate a
|
||||
//! grant, which is what makes both consent and revocation enforceable: refusing
|
||||
//! to issue leaves the peer with nothing usable, and deleting the issued record
|
||||
//! ends the relationship without the peer's cooperation.
|
||||
//!
|
||||
//! This module is pure: no storage, no network, no clock of its own. Callers
|
||||
//! supply `now_ms` so expiry and renewal stay testable.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use data_encoding::HEXLOWER;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Domain separator for the possession proof. Changing this invalidates every
|
||||
/// outstanding grant, so it is versioned rather than edited.
|
||||
const PROOF_CONTEXT: &[u8] = b"vnidrop-grant-v1";
|
||||
|
||||
const GRANT_ID_LEN: usize = 16;
|
||||
const GRANT_SECRET_LEN: usize = 32;
|
||||
const CHALLENGE_LEN: usize = 32;
|
||||
const PROOF_LEN: usize = 32;
|
||||
|
||||
/// Opaque public identifier for a grant. Safe to send in the clear.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub(crate) struct GrantId([u8; GRANT_ID_LEN]);
|
||||
|
||||
impl GrantId {
|
||||
pub(crate) fn generate() -> Self {
|
||||
Self(random_bytes())
|
||||
}
|
||||
|
||||
pub(crate) fn encode(&self) -> String {
|
||||
HEXLOWER.encode(&self.0)
|
||||
}
|
||||
|
||||
pub(crate) fn decode(value: &str) -> Result<Self> {
|
||||
let bytes = HEXLOWER
|
||||
.decode(value.as_bytes())
|
||||
.context("invalid grant id encoding")?;
|
||||
let bytes: [u8; GRANT_ID_LEN] = bytes
|
||||
.try_into()
|
||||
.map_err(|_| anyhow::anyhow!("invalid grant id length"))?;
|
||||
Ok(Self(bytes))
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for GrantId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "GrantId({})", self.encode())
|
||||
}
|
||||
}
|
||||
|
||||
/// Key material. Never logged, never emitted in an event, never returned across
|
||||
/// the UniFFI boundary.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub(crate) struct GrantSecret([u8; GRANT_SECRET_LEN]);
|
||||
|
||||
impl GrantSecret {
|
||||
pub(crate) fn generate() -> Self {
|
||||
Self(random_bytes())
|
||||
}
|
||||
|
||||
pub(crate) fn encode(&self) -> String {
|
||||
HEXLOWER.encode(&self.0)
|
||||
}
|
||||
|
||||
pub(crate) fn decode(value: &str) -> Result<Self> {
|
||||
let bytes = HEXLOWER
|
||||
.decode(value.as_bytes())
|
||||
.context("invalid grant secret encoding")?;
|
||||
let bytes: [u8; GRANT_SECRET_LEN] = bytes
|
||||
.try_into()
|
||||
.map_err(|_| anyhow::anyhow!("invalid grant secret length"))?;
|
||||
Ok(Self(bytes))
|
||||
}
|
||||
}
|
||||
|
||||
// Redacted on purpose: a secret must not reach a log line through a derived
|
||||
// Debug on some enclosing struct.
|
||||
impl fmt::Debug for GrantSecret {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("GrantSecret(redacted)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Random challenge sent by the issuer to bind a proof to one connection.
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub(crate) struct Challenge([u8; CHALLENGE_LEN]);
|
||||
|
||||
impl Challenge {
|
||||
pub(crate) fn generate() -> Self {
|
||||
Self(random_bytes())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn from_bytes(bytes: [u8; CHALLENGE_LEN]) -> Self {
|
||||
Self(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Challenge {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("Challenge(..)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Proof that the sender holds the secret behind `grant_id`.
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub(crate) struct GrantProof {
|
||||
pub(crate) grant_id: GrantId,
|
||||
mac: [u8; PROOF_LEN],
|
||||
}
|
||||
|
||||
impl fmt::Debug for GrantProof {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("GrantProof")
|
||||
.field("grant_id", &self.grant_id)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a presented proof was not accepted.
|
||||
///
|
||||
/// `Revoked` is reported to the peer so its client can drop the dead entry.
|
||||
/// `Unknown` is deliberately also used for blocked endpoints, so blocking
|
||||
/// cannot be detected by probing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum GrantRejection {
|
||||
Unknown,
|
||||
Revoked,
|
||||
Expired,
|
||||
WrongEndpoint,
|
||||
BadProof,
|
||||
}
|
||||
|
||||
impl GrantRejection {
|
||||
pub(crate) fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Unknown => "unknown",
|
||||
Self::Revoked => "revoked",
|
||||
Self::Expired => "expired",
|
||||
Self::WrongEndpoint => "wrong-endpoint",
|
||||
Self::BadProof => "bad-proof",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A grant as held by the party that issued it. This is the authoritative
|
||||
/// record: `grants_held` on the peer is only a copy for display.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct IssuedGrant {
|
||||
pub(crate) grant_id: GrantId,
|
||||
pub(crate) secret: GrantSecret,
|
||||
/// The grant is usable only by this endpoint, so it cannot be lent onward.
|
||||
pub(crate) issued_to_endpoint_id: String,
|
||||
pub(crate) created_at: i64,
|
||||
/// Idle expiry, pushed forward on every accepted proof. `None` never expires.
|
||||
pub(crate) expires_at: Option<i64>,
|
||||
pub(crate) revoked_at: Option<i64>,
|
||||
}
|
||||
|
||||
impl IssuedGrant {
|
||||
pub(crate) fn mint(
|
||||
issued_to_endpoint_id: String,
|
||||
now_ms: i64,
|
||||
lifetime: GrantLifetime,
|
||||
) -> Self {
|
||||
Self {
|
||||
grant_id: GrantId::generate(),
|
||||
secret: GrantSecret::generate(),
|
||||
issued_to_endpoint_id,
|
||||
created_at: now_ms,
|
||||
expires_at: lifetime.deadline_from(now_ms),
|
||||
revoked_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a proof presented by `remote_endpoint_id` over this connection's
|
||||
/// challenge. Returns the renewed expiry the caller must persist.
|
||||
///
|
||||
/// Checks run in a fixed order so a caller cannot learn more from an early
|
||||
/// return than from a late one: revocation and expiry are properties of the
|
||||
/// issuer's own record, and the endpoint binding is checked before the MAC
|
||||
/// so a stolen grant cannot be probed for validity from another device.
|
||||
pub(crate) fn accept(
|
||||
&self,
|
||||
proof: &GrantProof,
|
||||
challenge: &Challenge,
|
||||
issuer_endpoint_id: &str,
|
||||
remote_endpoint_id: &str,
|
||||
now_ms: i64,
|
||||
lifetime: GrantLifetime,
|
||||
) -> Result<Option<i64>, GrantRejection> {
|
||||
if proof.grant_id != self.grant_id {
|
||||
return Err(GrantRejection::Unknown);
|
||||
}
|
||||
if self.revoked_at.is_some() {
|
||||
return Err(GrantRejection::Revoked);
|
||||
}
|
||||
if self.is_expired(now_ms) {
|
||||
return Err(GrantRejection::Expired);
|
||||
}
|
||||
if remote_endpoint_id != self.issued_to_endpoint_id {
|
||||
return Err(GrantRejection::WrongEndpoint);
|
||||
}
|
||||
|
||||
let expected = compute_proof(
|
||||
&self.secret,
|
||||
challenge,
|
||||
issuer_endpoint_id,
|
||||
remote_endpoint_id,
|
||||
);
|
||||
// Constant-time: blake3::Hash's PartialEq is constant-time by design.
|
||||
if !constant_time_eq(&expected, &proof.mac) {
|
||||
return Err(GrantRejection::BadProof);
|
||||
}
|
||||
|
||||
Ok(lifetime.deadline_from(now_ms))
|
||||
}
|
||||
|
||||
pub(crate) fn is_expired(&self, now_ms: i64) -> bool {
|
||||
self.expires_at
|
||||
.is_some_and(|expires_at| expires_at < now_ms)
|
||||
}
|
||||
}
|
||||
|
||||
/// A grant as held by the party it was issued to: the capability used to reach
|
||||
/// the peer that minted it.
|
||||
///
|
||||
/// `expires_at` here is advisory only — a copy of what the issuer said at issue
|
||||
/// time, useful for showing "expires soon" in the UI. The issuer's record is
|
||||
/// authoritative and may have been renewed or revoked since.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct HeldGrant {
|
||||
pub(crate) grant_id: GrantId,
|
||||
pub(crate) secret: GrantSecret,
|
||||
/// The peer that issued this grant, and therefore the only one it works on.
|
||||
pub(crate) peer_endpoint_id: String,
|
||||
pub(crate) created_at: i64,
|
||||
pub(crate) expires_at: Option<i64>,
|
||||
}
|
||||
|
||||
impl HeldGrant {
|
||||
/// Build the proof to present to the issuing peer.
|
||||
pub(crate) fn prove(&self, challenge: &Challenge, self_endpoint_id: &str) -> GrantProof {
|
||||
prove(
|
||||
self.grant_id,
|
||||
&self.secret,
|
||||
challenge,
|
||||
&self.peer_endpoint_id,
|
||||
self_endpoint_id,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a grant survives without use. Grants expire on idleness rather than
|
||||
/// age, so a relationship in regular use never lapses while a forgotten one
|
||||
/// cleans itself up.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum GrantLifetime {
|
||||
Days(u32),
|
||||
Never,
|
||||
}
|
||||
|
||||
impl GrantLifetime {
|
||||
pub(crate) const DEFAULT_DAYS: u32 = 90;
|
||||
|
||||
pub(crate) fn deadline_from(self, now_ms: i64) -> Option<i64> {
|
||||
match self {
|
||||
Self::Never => None,
|
||||
Self::Days(days) => Some(now_ms + i64::from(days) * 24 * 60 * 60 * 1_000),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GrantLifetime {
|
||||
fn default() -> Self {
|
||||
Self::Days(Self::DEFAULT_DAYS)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::api::GrantLifetimeSetting> for GrantLifetime {
|
||||
fn from(setting: crate::api::GrantLifetimeSetting) -> Self {
|
||||
match setting {
|
||||
crate::api::GrantLifetimeSetting::Days30 => Self::Days(30),
|
||||
crate::api::GrantLifetimeSetting::Days90 => Self::Days(90),
|
||||
crate::api::GrantLifetimeSetting::Days365 => Self::Days(365),
|
||||
crate::api::GrantLifetimeSetting::Never => Self::Never,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the proof for a grant this device holds.
|
||||
pub(crate) fn prove(
|
||||
grant_id: GrantId,
|
||||
secret: &GrantSecret,
|
||||
challenge: &Challenge,
|
||||
issuer_endpoint_id: &str,
|
||||
holder_endpoint_id: &str,
|
||||
) -> GrantProof {
|
||||
GrantProof {
|
||||
grant_id,
|
||||
mac: compute_proof(secret, challenge, issuer_endpoint_id, holder_endpoint_id),
|
||||
}
|
||||
}
|
||||
|
||||
/// Keyed MAC over the challenge and both endpoint identities.
|
||||
///
|
||||
/// Binding the challenge stops a captured proof being replayed; binding both
|
||||
/// endpoint ids stops it being replayed against a different peer. Lengths are
|
||||
/// prefixed so two different id pairs cannot produce the same input.
|
||||
fn compute_proof(
|
||||
secret: &GrantSecret,
|
||||
challenge: &Challenge,
|
||||
issuer_endpoint_id: &str,
|
||||
holder_endpoint_id: &str,
|
||||
) -> [u8; PROOF_LEN] {
|
||||
let mut input = Vec::with_capacity(
|
||||
PROOF_CONTEXT.len()
|
||||
+ CHALLENGE_LEN
|
||||
+ issuer_endpoint_id.len()
|
||||
+ holder_endpoint_id.len()
|
||||
+ 16,
|
||||
);
|
||||
input.extend_from_slice(PROOF_CONTEXT);
|
||||
input.extend_from_slice(&challenge.0);
|
||||
push_length_prefixed(&mut input, issuer_endpoint_id.as_bytes());
|
||||
push_length_prefixed(&mut input, holder_endpoint_id.as_bytes());
|
||||
*blake3::keyed_hash(&secret.0, &input).as_bytes()
|
||||
}
|
||||
|
||||
fn push_length_prefixed(buffer: &mut Vec<u8>, bytes: &[u8]) {
|
||||
buffer.extend_from_slice(&(bytes.len() as u64).to_le_bytes());
|
||||
buffer.extend_from_slice(bytes);
|
||||
}
|
||||
|
||||
fn constant_time_eq(left: &[u8; PROOF_LEN], right: &[u8; PROOF_LEN]) -> bool {
|
||||
// blake3::Hash compares in constant time; reuse it rather than hand-rolling.
|
||||
blake3::Hash::from_bytes(*left) == blake3::Hash::from_bytes(*right)
|
||||
}
|
||||
|
||||
/// Cryptographically secure random bytes.
|
||||
///
|
||||
/// Panics if the OS entropy source fails. That is unrecoverable and must never
|
||||
/// degrade into a weak grant, so it is not surfaced as a fallible API.
|
||||
fn random_bytes<const N: usize>() -> [u8; N] {
|
||||
let mut bytes = [0u8; N];
|
||||
getrandom::fill(&mut bytes).expect("OS entropy source unavailable");
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Parse a stored grant secret, rejecting anything malformed rather than
|
||||
/// silently producing a grant that can never validate.
|
||||
pub(crate) fn parse_secret(value: &str) -> Result<GrantSecret> {
|
||||
let secret = GrantSecret::decode(value)?;
|
||||
if secret.0.iter().all(|byte| *byte == 0) {
|
||||
bail!("refusing an all-zero grant secret");
|
||||
}
|
||||
Ok(secret)
|
||||
}
|
||||
@@ -106,7 +106,7 @@ impl HandshakeClient {
|
||||
transfer_name: metadata.transfer_name.clone(),
|
||||
receiver_name: receiver_name.map(ToOwned::to_owned),
|
||||
receiver_device_name: None,
|
||||
app_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
app_version: env!("VNIDROP_PRODUCT_VERSION").to_string(),
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user