Compare commits
33 Commits
v1.0.0
...
4feff29c74
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4feff29c74 | ||
| 873ec6fd94 | |||
| a8a873c83d | |||
| 9e8564e013 | |||
|
|
d8eaf78997 | ||
| 8c1ce16c0e | |||
|
|
419c35d6b5 | ||
| 0011dfa175 | |||
| 276ee9f974 | |||
| 8178296d92 | |||
| 0efde72414 | |||
| 48a523371c | |||
| 674e59f8cc | |||
| 0309cfd649 | |||
| ea07f5661d | |||
| 141be2e55a | |||
| c71e1b97d9 | |||
| 91573f381a | |||
| 4448822bfa | |||
| 3c0f29adb6 | |||
| cd6a2d66aa | |||
|
|
9a3cfb55d0 | ||
| 17099d32f2 | |||
| e35c8c840a | |||
| b1b8fa202c | |||
| bfa489def1 | |||
| ceccfcda71 | |||
| ea376a382b | |||
| 601cbc486e | |||
| c14a2397ae | |||
|
|
6973cc7351 | ||
| ec03f210dc | |||
| d0844068bb |
67
.github/workflows/apple.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
name: Apple
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "apple/**"
|
||||||
|
- "crates/vnidrop/**"
|
||||||
|
- "crates/uniffi-bindgen/**"
|
||||||
|
- "Cargo.toml"
|
||||||
|
- "Cargo.lock"
|
||||||
|
- "Makefile"
|
||||||
|
- "config.mk"
|
||||||
|
- "make/**"
|
||||||
|
- ".github/workflows/apple.yml"
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
paths:
|
||||||
|
- "apple/**"
|
||||||
|
- "crates/vnidrop/**"
|
||||||
|
- "crates/uniffi-bindgen/**"
|
||||||
|
- "Cargo.toml"
|
||||||
|
- "Cargo.lock"
|
||||||
|
- "Makefile"
|
||||||
|
- "config.mk"
|
||||||
|
- "make/**"
|
||||||
|
- ".github/workflows/apple.yml"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: apple-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-test:
|
||||||
|
runs-on: macos-latest
|
||||||
|
timeout-minutes: 75
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Select Xcode
|
||||||
|
# Pin so the simulator device name below stays predictable.
|
||||||
|
run: sudo xcode-select -s /Applications/Xcode.app
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios,aarch64-apple-darwin
|
||||||
|
|
||||||
|
- name: Cache Cargo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: apple-cargo-${{ hashFiles('Cargo.lock') }}
|
||||||
|
restore-keys: apple-cargo-
|
||||||
|
|
||||||
|
- name: Install XcodeGen
|
||||||
|
run: brew install xcodegen
|
||||||
|
|
||||||
|
- name: Build and test Apple app
|
||||||
|
run: make check-apple
|
||||||
25
.github/workflows/diagnostics-api.yml
vendored
@@ -4,12 +4,18 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- "services/diagnostics-api/**"
|
- "services/diagnostics-api/**"
|
||||||
|
- "Makefile"
|
||||||
|
- "config.mk"
|
||||||
|
- "make/**"
|
||||||
- ".github/workflows/diagnostics-api.yml"
|
- ".github/workflows/diagnostics-api.yml"
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
paths:
|
paths:
|
||||||
- "services/diagnostics-api/**"
|
- "services/diagnostics-api/**"
|
||||||
|
- "Makefile"
|
||||||
|
- "config.mk"
|
||||||
|
- "make/**"
|
||||||
- ".github/workflows/diagnostics-api.yml"
|
- ".github/workflows/diagnostics-api.yml"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -23,9 +29,6 @@ jobs:
|
|||||||
quality:
|
quality:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: services/diagnostics-api
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -37,17 +40,5 @@ jobs:
|
|||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: services/diagnostics-api/package-lock.json
|
cache-dependency-path: services/diagnostics-api/package-lock.json
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Check diagnostics API
|
||||||
run: npm ci
|
run: make check-diagnostics
|
||||||
|
|
||||||
- name: Verify generated Worker types
|
|
||||||
run: npm run types:check
|
|
||||||
|
|
||||||
- name: Type-check
|
|
||||||
run: npm run typecheck
|
|
||||||
|
|
||||||
- name: Test in the Workers runtime
|
|
||||||
run: npm test
|
|
||||||
|
|
||||||
- name: Validate the deployment bundle
|
|
||||||
run: npm run deploy:dry-run
|
|
||||||
|
|||||||
19
.github/workflows/docs.yml
vendored
@@ -4,12 +4,18 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- "docs/**"
|
- "docs/**"
|
||||||
|
- "Makefile"
|
||||||
|
- "config.mk"
|
||||||
|
- "make/**"
|
||||||
- ".github/workflows/docs.yml"
|
- ".github/workflows/docs.yml"
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
paths:
|
paths:
|
||||||
- "docs/**"
|
- "docs/**"
|
||||||
|
- "Makefile"
|
||||||
|
- "config.mk"
|
||||||
|
- "make/**"
|
||||||
- ".github/workflows/docs.yml"
|
- ".github/workflows/docs.yml"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -23,9 +29,6 @@ jobs:
|
|||||||
quality:
|
quality:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: docs
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -37,11 +40,5 @@ jobs:
|
|||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: docs/package-lock.json
|
cache-dependency-path: docs/package-lock.json
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Check documentation website
|
||||||
run: npm ci
|
run: make check-docs
|
||||||
|
|
||||||
- name: Type-check
|
|
||||||
run: npm run typecheck
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: npm run build
|
|
||||||
|
|||||||
289
.github/workflows/linux-packages.yml
vendored
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
name: Linux packages
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- ".github/workflows/linux-packages.yml"
|
||||||
|
- "packaging/linux/**"
|
||||||
|
- "assets/linux/**"
|
||||||
|
- "desktopApp/**"
|
||||||
|
- "shared/**"
|
||||||
|
- "crates/vnidrop/**"
|
||||||
|
- "Cargo.toml"
|
||||||
|
- "Cargo.lock"
|
||||||
|
- "LICENSE"
|
||||||
|
- "build.gradle.kts"
|
||||||
|
- "settings.gradle.kts"
|
||||||
|
- "gradle.properties"
|
||||||
|
- "gradle/**"
|
||||||
|
- "gradlew"
|
||||||
|
- "Makefile"
|
||||||
|
- "config.mk"
|
||||||
|
- "make/**"
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*.*.*"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: Release version in MAJOR.MINOR.PATCH form
|
||||||
|
required: true
|
||||||
|
default: "1.0.0"
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: linux-packages-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-deb:
|
||||||
|
name: Build Debian package (x64)
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
timeout-minutes: 90
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Install packaging tools
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install --yes fakeroot unzip
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- name: Set up Rust 1.91
|
||||||
|
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # v1
|
||||||
|
with:
|
||||||
|
toolchain: "1.91.0"
|
||||||
|
|
||||||
|
- name: Cache Cargo
|
||||||
|
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: linux-deb-x64-cargo-1.91.0-${{ hashFiles('Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
linux-deb-x64-cargo-1.91.0-
|
||||||
|
|
||||||
|
- name: Resolve version
|
||||||
|
id: version
|
||||||
|
env:
|
||||||
|
REQUESTED_VERSION: ${{ inputs.version || '1.0.0' }}
|
||||||
|
run: |
|
||||||
|
version=$(packaging/linux/resolve-version.sh "$REQUESTED_VERSION")
|
||||||
|
echo "app=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Test and build Debian package
|
||||||
|
run: make package-deb VERSION=${{ steps.version.outputs.app }}
|
||||||
|
|
||||||
|
- name: Upload Debian artifact
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: vnidrop-${{ steps.version.outputs.app }}-linux-deb-x64
|
||||||
|
path: build/release/linux/deb/
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
|
compression-level: 0
|
||||||
|
|
||||||
|
- name: Summarize Debian package
|
||||||
|
run: |
|
||||||
|
echo "### Debian package" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "- Version: ${{ steps.version.outputs.app }}-1" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "- Architecture: amd64" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "- Build baseline: Ubuntu 22.04" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
build-rpm:
|
||||||
|
name: Build RPM package (x64)
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
container:
|
||||||
|
image: registry.fedoraproject.org/fedora:43
|
||||||
|
volumes:
|
||||||
|
- /usr/local/lib/android/sdk:/usr/local/lib/android/sdk
|
||||||
|
timeout-minutes: 90
|
||||||
|
env:
|
||||||
|
ANDROID_HOME: /usr/local/lib/android/sdk
|
||||||
|
ANDROID_SDK_ROOT: /usr/local/lib/android/sdk
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Install build and packaging tools
|
||||||
|
run: |
|
||||||
|
dnf install --assumeyes \
|
||||||
|
alsa-lib \
|
||||||
|
cpio \
|
||||||
|
curl \
|
||||||
|
cups-libs \
|
||||||
|
desktop-file-utils \
|
||||||
|
findutils \
|
||||||
|
fontconfig \
|
||||||
|
freetype \
|
||||||
|
gcc \
|
||||||
|
gcc-c++ \
|
||||||
|
git \
|
||||||
|
gzip \
|
||||||
|
gtk3 \
|
||||||
|
libX11 \
|
||||||
|
libXext \
|
||||||
|
libXi \
|
||||||
|
libXrandr \
|
||||||
|
libXrender \
|
||||||
|
libXtst \
|
||||||
|
make \
|
||||||
|
mesa-libGL \
|
||||||
|
rpm-build \
|
||||||
|
tar \
|
||||||
|
unzip \
|
||||||
|
which \
|
||||||
|
xz \
|
||||||
|
zstd
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- name: Set up Rust 1.91
|
||||||
|
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # v1
|
||||||
|
with:
|
||||||
|
toolchain: "1.91.0"
|
||||||
|
|
||||||
|
- name: Cache Cargo
|
||||||
|
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: linux-rpm-x64-cargo-1.91.0-${{ hashFiles('Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
linux-rpm-x64-cargo-1.91.0-
|
||||||
|
|
||||||
|
- name: Resolve version
|
||||||
|
id: version
|
||||||
|
env:
|
||||||
|
REQUESTED_VERSION: ${{ inputs.version || '1.0.0' }}
|
||||||
|
run: |
|
||||||
|
version=$(packaging/linux/resolve-version.sh "$REQUESTED_VERSION")
|
||||||
|
echo "app=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Build RPM package
|
||||||
|
run: make package-rpm VERSION=${{ steps.version.outputs.app }}
|
||||||
|
|
||||||
|
- name: Upload RPM artifact
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: vnidrop-${{ steps.version.outputs.app }}-linux-rpm-x64
|
||||||
|
path: build/release/linux/rpm/
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
|
compression-level: 0
|
||||||
|
|
||||||
|
- name: Summarize RPM package
|
||||||
|
run: |
|
||||||
|
echo "### RPM package" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
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
|
||||||
20
.github/workflows/rust-core.yml
vendored
@@ -6,6 +6,9 @@ on:
|
|||||||
- "Cargo.toml"
|
- "Cargo.toml"
|
||||||
- "Cargo.lock"
|
- "Cargo.lock"
|
||||||
- "crates/vnidrop/**"
|
- "crates/vnidrop/**"
|
||||||
|
- "Makefile"
|
||||||
|
- "config.mk"
|
||||||
|
- "make/**"
|
||||||
- ".github/workflows/rust-core.yml"
|
- ".github/workflows/rust-core.yml"
|
||||||
push:
|
push:
|
||||||
# Only after merge (or direct master pushes). Feature-branch work is covered
|
# Only after merge (or direct master pushes). Feature-branch work is covered
|
||||||
@@ -16,6 +19,9 @@ on:
|
|||||||
- "Cargo.toml"
|
- "Cargo.toml"
|
||||||
- "Cargo.lock"
|
- "Cargo.lock"
|
||||||
- "crates/vnidrop/**"
|
- "crates/vnidrop/**"
|
||||||
|
- "Makefile"
|
||||||
|
- "config.mk"
|
||||||
|
- "make/**"
|
||||||
- ".github/workflows/rust-core.yml"
|
- ".github/workflows/rust-core.yml"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -33,18 +39,10 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Install Rust quality components
|
- name: Install Rust quality components
|
||||||
run: rustup component add clippy rustfmt
|
run: rustup component add clippy rustfmt
|
||||||
- name: Check formatting
|
- name: Check Rust core
|
||||||
run: cargo fmt --all -- --check
|
run: make check-rust
|
||||||
- name: Run strict Clippy
|
|
||||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
|
||||||
- name: Run unit and integration tests
|
|
||||||
run: cargo test --workspace --all-targets
|
|
||||||
- name: Check documentation
|
|
||||||
env:
|
|
||||||
RUSTDOCFLAGS: -D warnings
|
|
||||||
run: cargo doc --workspace --no-deps
|
|
||||||
- name: Install cargo-audit
|
- name: Install cargo-audit
|
||||||
run: cargo install cargo-audit --locked
|
run: cargo install cargo-audit --locked
|
||||||
- name: Audit Rust dependencies
|
- name: Audit Rust dependencies
|
||||||
# Ignores are listed in .cargo/audit.toml for known transitive issues.
|
# Ignores are listed in .cargo/audit.toml for known transitive issues.
|
||||||
run: cargo audit
|
run: make audit-rust
|
||||||
|
|||||||
15
.github/workflows/shared-kmp.yml
vendored
@@ -15,6 +15,9 @@ on:
|
|||||||
- "gradle.properties"
|
- "gradle.properties"
|
||||||
- "androidApp/**"
|
- "androidApp/**"
|
||||||
- "desktopApp/**"
|
- "desktopApp/**"
|
||||||
|
- "Makefile"
|
||||||
|
- "config.mk"
|
||||||
|
- "make/**"
|
||||||
- ".github/workflows/shared-kmp.yml"
|
- ".github/workflows/shared-kmp.yml"
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
@@ -32,6 +35,9 @@ on:
|
|||||||
- "gradle.properties"
|
- "gradle.properties"
|
||||||
- "androidApp/**"
|
- "androidApp/**"
|
||||||
- "desktopApp/**"
|
- "desktopApp/**"
|
||||||
|
- "Makefile"
|
||||||
|
- "config.mk"
|
||||||
|
- "make/**"
|
||||||
- ".github/workflows/shared-kmp.yml"
|
- ".github/workflows/shared-kmp.yml"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -44,8 +50,7 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
jvm-test:
|
jvm-test:
|
||||||
# Host Rust embedding is enabled only for the current Gobley host target.
|
# Host Rust embedding is enabled only for the current Gobley host target.
|
||||||
# This job stays on macOS to cover the Apple targets as well as JVM tests.
|
runs-on: ubuntu-latest
|
||||||
runs-on: macos-latest
|
|
||||||
timeout-minutes: 75
|
timeout-minutes: 75
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -63,7 +68,7 @@ jobs:
|
|||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
targets: aarch64-apple-darwin,aarch64-linux-android,x86_64-linux-android
|
targets: aarch64-linux-android,x86_64-linux-android
|
||||||
|
|
||||||
- name: Cache Cargo
|
- name: Cache Cargo
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -95,7 +100,7 @@ jobs:
|
|||||||
echo "ANDROID_NDK_ROOT=${{ steps.setup-ndk.outputs.ndk-path }}" >> "$GITHUB_ENV"
|
echo "ANDROID_NDK_ROOT=${{ steps.setup-ndk.outputs.ndk-path }}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Run shared JVM tests
|
- name: Run shared JVM tests
|
||||||
run: ./gradlew :shared:jvmTest --no-daemon --stacktrace
|
run: make check-shared
|
||||||
|
|
||||||
- name: Verify Android native libraries
|
- name: Verify Android native libraries
|
||||||
run: ./gradlew :androidApp:verifyDebugVnidropLibraries --no-daemon --stacktrace
|
run: make verify-android-libs
|
||||||
|
|||||||
1
.gitignore
vendored
@@ -19,6 +19,7 @@ captures
|
|||||||
node_modules/
|
node_modules/
|
||||||
target/
|
target/
|
||||||
.junie
|
.junie
|
||||||
|
config.override.mk
|
||||||
|
|
||||||
# Local design export scratch
|
# Local design export scratch
|
||||||
output/
|
output/
|
||||||
|
|||||||
60
AGENTS.md
@@ -13,13 +13,14 @@ Nested guides take precedence when editing under those trees:
|
|||||||
|
|
||||||
## Project overview
|
## Project overview
|
||||||
|
|
||||||
VniDrop is a cross-platform **local P2P file transfer** app (Android, iOS, Desktop).
|
VniDrop is a cross-platform **local P2P file transfer** app.
|
||||||
|
|
||||||
| Layer | Path | Responsibility |
|
| Layer | Path | Responsibility |
|
||||||
|-------|------|----------------|
|
|-------|------|----------------|
|
||||||
| Rust core | `crates/vnidrop/` | Iroh endpoint, blobs, SQLite, tickets, approval, streaming |
|
| Rust core | `crates/vnidrop/` | Iroh endpoint, blobs, SQLite, tickets, approval, streaming |
|
||||||
| Shared KMP | `shared/` | Compose UI, ViewModels, expect/actual platform bridges |
|
| Shared KMP | `shared/` | Compose UI and platform bridges for Android, Windows, and Linux |
|
||||||
| Hosts | `androidApp/`, `iosApp/`, `desktopApp/` | Thin app shells |
|
| Compose hosts | `androidApp/`, `desktopApp/` | Thin Android and Windows/Linux app shells |
|
||||||
|
| Apple app | `apple/` | Native SwiftUI UI using generated Rust/UniFFI Swift bindings |
|
||||||
|
|
||||||
**Invariant:** UI/platform opens files and handles pickers; **Rust streams bytes**.
|
**Invariant:** UI/platform opens files and handles pickers; **Rust streams bytes**.
|
||||||
Do not design features that move transfer payloads through Kotlin heap by default.
|
Do not design features that move transfer payloads through Kotlin heap by default.
|
||||||
@@ -52,67 +53,62 @@ Domain docs (reference, do not paste into PRs):
|
|||||||
|
|
||||||
## Build and test
|
## Build and test
|
||||||
|
|
||||||
Install prerequisites when missing: Rust stable + rustfmt + clippy, JDK 17,
|
Install prerequisites when missing: GNU Make + Bash, Rust stable + rustfmt + clippy, JDK 17,
|
||||||
Android NDK/SDK only if building Android, Xcode only for iOS.
|
Android NDK/SDK only if building Android, Xcode only for the native Apple app.
|
||||||
|
|
||||||
### Rust core (`crates/vnidrop` or workspace root)
|
### Rust core (`crates/vnidrop` or workspace root)
|
||||||
|
|
||||||
Run from the **repo root** (Cargo workspace):
|
Run from the **repo root** (Cargo workspace):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo fmt --all -- --check
|
make check-rust
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
|
||||||
cargo test --workspace --all-targets
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Focused:
|
Focused:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo test -p vnidrop
|
make test-rust
|
||||||
cargo test -p vnidrop --test output_sink
|
make test-rust-output-sink
|
||||||
cargo test -p vnidrop --test transfer
|
make test-rust-transfer
|
||||||
cargo test -p vnidrop --test approval
|
make test-rust-approval
|
||||||
cargo test -p vnidrop --test lifecycle
|
make test-rust-lifecycle
|
||||||
```
|
```
|
||||||
|
|
||||||
After finishing Rust edits, format:
|
After finishing Rust edits, format:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo fmt --all
|
make format
|
||||||
```
|
```
|
||||||
|
|
||||||
CI also runs `cargo doc --workspace --no-deps` with `RUSTDOCFLAGS=-D warnings`
|
`make check-rust` includes documentation with warnings denied, matching
|
||||||
(see `.github/workflows/rust-core.yml`). Run it before large Rust public-API changes.
|
`.github/workflows/rust-core.yml`.
|
||||||
|
|
||||||
### Shared KMP / Compose (`shared/`)
|
### Shared KMP / Compose (`shared/`)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gradlew :shared:jvmTest
|
make check-shared
|
||||||
./gradlew :shared:compileKotlinJvm
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Other targets (slower / machine-dependent):
|
Other targets (slower / machine-dependent):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gradlew :shared:testAndroidHostTest
|
make test-android-host
|
||||||
./gradlew :shared:iosSimulatorArm64Test # macOS + Xcode
|
make check-android
|
||||||
./gradlew :androidApp:assembleDebug
|
make run-desktop
|
||||||
./gradlew :desktopApp:run
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** `jvmTest` CI currently runs on **macOS**. Gobley host cargo is enabled
|
**Note:** `jvmTest` CI runs on **Linux**. Gobley host cargo is enabled for the
|
||||||
for the current host and architecture, so local Linux and Windows builds embed
|
current host and architecture, so local desktop builds embed their matching
|
||||||
their matching desktop Rust library. Prefer macOS only when exact CI parity is
|
Rust library.
|
||||||
required.
|
|
||||||
|
|
||||||
### What to run before finishing
|
### What to run before finishing
|
||||||
|
|
||||||
| You changed… | Minimum verification |
|
| You changed… | Minimum verification |
|
||||||
|--------------|----------------------|
|
|--------------|----------------------|
|
||||||
| `crates/vnidrop/**` only | `cargo fmt`, `cargo clippy … -D warnings`, `cargo test -p vnidrop` |
|
| `crates/vnidrop/**` only | `make check-rust` |
|
||||||
| Cancel / export / sinks | Above + `cargo test -p vnidrop --test output_sink` |
|
| Cancel / export / sinks | Above + `make test-rust-output-sink` |
|
||||||
| `shared/**` only | `./gradlew :shared:jvmTest` |
|
| `shared/**` only | `make test-shared` |
|
||||||
| Both | Rust suite + `:shared:jvmTest` |
|
| Both | `make test-rust test-shared` |
|
||||||
| Docs only | No suite required; verify links/paths |
|
| Docs only | No suite required; verify links/paths |
|
||||||
|
|
||||||
Do not kill long `cargo` / Gradle runs mid-flight unless they hang past several
|
Do not kill long `cargo` / Gradle runs mid-flight unless they hang past several
|
||||||
@@ -144,12 +140,12 @@ shared/src/commonMain/kotlin/com/vnidrop/app/
|
|||||||
core/ # CoreGateway, models, pickers interfaces
|
core/ # CoreGateway, models, pickers interfaces
|
||||||
feature/send|receive|approvals|settings|app/
|
feature/send|receive|approvals|settings|app/
|
||||||
ui/theme|components|navigation|feedback|state/
|
ui/theme|components|navigation|feedback|state/
|
||||||
androidMain|iosMain|jvmMain/ # expect/actual implementations
|
androidMain|jvmMain/ # expect/actual implementations
|
||||||
```
|
```
|
||||||
|
|
||||||
### Platform file rules (do not violate)
|
### Platform file rules (do not violate)
|
||||||
|
|
||||||
- Desktop / path-based iOS: paths; directory walk in Rust when `is_directory`.
|
- Windows/Linux desktop: paths; directory walk in Rust when `is_directory`.
|
||||||
- Android **share**: ParcelFileDescriptor **file** FDs only — never a directory FD.
|
- Android **share**: ParcelFileDescriptor **file** FDs only — never a directory FD.
|
||||||
Folder share expands SAF trees in Kotlin to per-file FDs + relative names.
|
Folder share expands SAF trees in Kotlin to per-file FDs + relative names.
|
||||||
- Android **receive** default: MediaStore Downloads sink; custom trees via SAF write.
|
- Android **receive** default: MediaStore Downloads sink; custom trees via SAF write.
|
||||||
|
|||||||
@@ -35,24 +35,44 @@ Use a branch name that describes the outcome, such as
|
|||||||
|
|
||||||
Install the tools needed for the area you plan to change:
|
Install the tools needed for the area you plan to change:
|
||||||
|
|
||||||
|
- GNU Make and Bash for the root command interface
|
||||||
- JDK 17 or newer for Gradle and application builds
|
- JDK 17 or newer for Gradle and application builds
|
||||||
- Rust stable with `rustfmt` and Clippy for the transfer core
|
- Rust stable with `rustfmt` and Clippy for the transfer core
|
||||||
- Android SDK and NDK for Android builds
|
- Android SDK and NDK for Android builds
|
||||||
- Xcode on macOS for iOS builds and simulator tests
|
- Xcode and XcodeGen on macOS for native Apple builds and simulator tests
|
||||||
- Node.js 22.12 or newer for the optional diagnostics service
|
- Node.js 22.12 or newer for the optional diagnostics service
|
||||||
|
|
||||||
The first Rust and Gradle builds may take several minutes while dependencies are
|
The first Rust and Gradle builds may take several minutes while dependencies are
|
||||||
downloaded and native components are compiled.
|
downloaded and native components are compiled.
|
||||||
|
|
||||||
|
## Command Interface
|
||||||
|
|
||||||
|
Run development commands through the root `Makefile`. It keeps local and CI
|
||||||
|
commands aligned while continuing to delegate builds to Cargo, Gradle, Xcode,
|
||||||
|
Bun, and npm:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make help # list commands
|
||||||
|
make doctor # report missing host tools
|
||||||
|
make setup # install repository-local JavaScript dependencies
|
||||||
|
make check # portable Rust, shared, localization, docs, and service checks
|
||||||
|
```
|
||||||
|
|
||||||
|
Configuration can be passed on the command line, for example
|
||||||
|
`make package-deb VERSION=1.2.0`, or placed in an ignored
|
||||||
|
`config.override.mk`. Windows use requires GNU Make in a Bash environment; the
|
||||||
|
underlying Gradle and PowerShell entry points remain available when Make is not
|
||||||
|
installed.
|
||||||
|
|
||||||
## Repository Structure
|
## Repository Structure
|
||||||
|
|
||||||
| Path | Purpose |
|
| Path | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `crates/vnidrop/` | Rust transfer core, persistence, approval, and streaming |
|
| `crates/vnidrop/` | Rust transfer core, persistence, approval, and streaming |
|
||||||
| `shared/` | Shared Kotlin Multiplatform UI and platform bridges |
|
| `shared/` | Compose Multiplatform UI and bridges for Android, Windows, and Linux |
|
||||||
| `androidApp/` | Android application shell |
|
| `androidApp/` | Android application shell |
|
||||||
| `iosApp/` | iOS application shell |
|
| `desktopApp/` | Windows/Linux JVM application shell |
|
||||||
| `desktopApp/` | Desktop JVM application shell |
|
| `apple/` | Native SwiftUI application and Rust/UniFFI integration for Apple platforms |
|
||||||
| `services/diagnostics-api/` | Optional Cloudflare diagnostics service |
|
| `services/diagnostics-api/` | Optional Cloudflare diagnostics service |
|
||||||
|
|
||||||
Read the nearest contributor guidance before editing:
|
Read the nearest contributor guidance before editing:
|
||||||
@@ -87,43 +107,48 @@ Run checks from the repository root. Choose the suite for the files you changed.
|
|||||||
### Rust Core
|
### Rust Core
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo fmt --all
|
make format
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
make test-rust
|
||||||
cargo test -p vnidrop
|
|
||||||
```
|
```
|
||||||
|
|
||||||
For cancel, export, or output-sink changes, also run:
|
For cancel, export, or output-sink changes, also run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo test -p vnidrop --test output_sink
|
make test-rust-output-sink
|
||||||
```
|
```
|
||||||
|
|
||||||
For broader core changes, run the complete workspace suite:
|
For broader core changes, run the complete workspace suite:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo test --workspace --all-targets
|
make check-rust
|
||||||
```
|
```
|
||||||
|
|
||||||
### Shared Kotlin and Compose
|
### Shared Kotlin and Compose
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gradlew :shared:jvmTest
|
make test-shared
|
||||||
```
|
```
|
||||||
|
|
||||||
Platform-specific checks may also be appropriate:
|
Platform-specific checks may also be appropriate:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gradlew :shared:testAndroidHostTest
|
make test-android-host
|
||||||
./gradlew :shared:iosSimulatorArm64Test
|
make check-android
|
||||||
./gradlew :androidApp:assembleDebug
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Native Apple App
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make check-apple
|
||||||
|
```
|
||||||
|
|
||||||
|
Override the selected simulator when needed with
|
||||||
|
`make check-apple APPLE_DESTINATION='platform=iOS Simulator,name=iPhone 16'`.
|
||||||
|
|
||||||
### Diagnostics Service
|
### Diagnostics Service
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd services/diagnostics-api
|
make check-diagnostics
|
||||||
npm ci
|
|
||||||
npm run check
|
|
||||||
```
|
```
|
||||||
|
|
||||||
If a required check cannot run on your machine, explain why in the pull request
|
If a required check cannot run on your machine, explain why in the pull request
|
||||||
|
|||||||
54
Cargo.lock
generated
@@ -61,6 +61,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle"
|
||||||
|
version = "1.0.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.103"
|
version = "1.0.103"
|
||||||
@@ -496,6 +502,45 @@ dependencies = [
|
|||||||
"inout",
|
"inout",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap"
|
||||||
|
version = "4.6.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011"
|
||||||
|
dependencies = [
|
||||||
|
"clap_builder",
|
||||||
|
"clap_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_builder"
|
||||||
|
version = "4.6.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"clap_lex",
|
||||||
|
"strsim",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_derive"
|
||||||
|
version = "4.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.118",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_lex"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cmov"
|
name = "cmov"
|
||||||
version = "0.5.4"
|
version = "0.5.4"
|
||||||
@@ -4835,13 +4880,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "c6d968cb62160c11f2573e6be724ef8b1b18a277aededd17033f8a912d73e2b4"
|
checksum = "c6d968cb62160c11f2573e6be724ef8b1b18a277aededd17033f8a912d73e2b4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"camino",
|
||||||
"cargo_metadata",
|
"cargo_metadata",
|
||||||
|
"clap",
|
||||||
"uniffi_bindgen",
|
"uniffi_bindgen",
|
||||||
"uniffi_core",
|
"uniffi_core",
|
||||||
"uniffi_macros",
|
"uniffi_macros",
|
||||||
"uniffi_pipeline",
|
"uniffi_pipeline",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uniffi-bindgen"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"uniffi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uniffi_bindgen"
|
name = "uniffi_bindgen"
|
||||||
version = "0.29.4"
|
version = "0.29.4"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = ["crates/vnidrop"]
|
members = ["crates/vnidrop", "crates/uniffi-bindgen"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
|
|||||||
181
Makefile
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
ROOT := $(patsubst %/,%,$(dir $(abspath $(lastword $(MAKEFILE_LIST)))))
|
||||||
|
|
||||||
|
SHELL := bash
|
||||||
|
.SHELLFLAGS := -eu -o pipefail -c
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
include $(ROOT)/config.mk
|
||||||
|
-include $(ROOT)/config.override.mk
|
||||||
|
include $(ROOT)/make/release.mk
|
||||||
|
|
||||||
|
.PHONY: help doctor setup setup-localization setup-docs setup-diagnostics
|
||||||
|
.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: 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' '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'
|
||||||
|
@printf ' %-28s %s\n' 'APPLE_CODE_SIGNING=NO|YES' 'Enable Apple code signing (default: $(APPLE_CODE_SIGNING))'
|
||||||
|
|
||||||
|
doctor: ## Check that tools required by the current host are available.
|
||||||
|
@missing=0; \
|
||||||
|
for tool in "$(firstword $(CARGO))" java "$(firstword $(NPM))" "$(firstword $(BUN))"; do \
|
||||||
|
if command -v "$$tool" >/dev/null 2>&1; then \
|
||||||
|
printf 'ok %s\n' "$$tool"; \
|
||||||
|
else \
|
||||||
|
printf 'missing %s\n' "$$tool"; \
|
||||||
|
missing=1; \
|
||||||
|
fi; \
|
||||||
|
done; \
|
||||||
|
if [[ ! -f "$(GRADLE)" ]]; then printf 'missing %s\n' "$(GRADLE)"; missing=1; else printf 'ok %s\n' "$(GRADLE)"; fi; \
|
||||||
|
if [[ "$(HOST_OS)" == macos ]]; then \
|
||||||
|
for tool in "$(firstword $(XCODEBUILD))" "$(firstword $(XCODEGEN))"; do \
|
||||||
|
if command -v "$$tool" >/dev/null 2>&1; then printf 'ok %s\n' "$$tool"; else printf 'missing %s\n' "$$tool"; missing=1; fi; \
|
||||||
|
done; \
|
||||||
|
fi; \
|
||||||
|
exit $$missing
|
||||||
|
|
||||||
|
setup: setup-localization setup-docs setup-diagnostics ## Install repository-local JavaScript dependencies.
|
||||||
|
|
||||||
|
setup-localization: ## Install localization CLI dependencies with Bun.
|
||||||
|
cd $(ROOT)/localization && $(BUN) install --frozen-lockfile
|
||||||
|
|
||||||
|
setup-docs: ## Install documentation website dependencies.
|
||||||
|
cd $(ROOT)/docs && $(NPM) ci
|
||||||
|
|
||||||
|
setup-diagnostics: ## Install diagnostics Worker dependencies.
|
||||||
|
cd $(ROOT)/services/diagnostics-api && $(NPM) ci
|
||||||
|
|
||||||
|
format: ## Format Rust sources.
|
||||||
|
cd $(ROOT) && $(CARGO) fmt --all
|
||||||
|
|
||||||
|
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-rust: ## Run Rust formatting, lint, tests, and documentation checks.
|
||||||
|
cd $(ROOT) && $(CARGO) fmt --all -- --check
|
||||||
|
cd $(ROOT) && $(CARGO) clippy --workspace --all-targets -- -D warnings
|
||||||
|
cd $(ROOT) && $(CARGO) test --workspace --all-targets
|
||||||
|
cd $(ROOT) && RUSTDOCFLAGS='-D warnings' $(CARGO) doc --workspace --no-deps
|
||||||
|
|
||||||
|
audit-rust: ## Audit Rust dependencies (requires cargo-audit).
|
||||||
|
cd $(ROOT) && $(CARGO) audit
|
||||||
|
|
||||||
|
test-rust: ## Run the focused Rust core suite.
|
||||||
|
cd $(ROOT) && $(CARGO) test -p vnidrop
|
||||||
|
|
||||||
|
test-rust-all: ## Run every Rust workspace test target.
|
||||||
|
cd $(ROOT) && $(CARGO) test --workspace --all-targets
|
||||||
|
|
||||||
|
test-rust-transfer: ## Run Rust transfer integration tests.
|
||||||
|
cd $(ROOT) && $(CARGO) test -p vnidrop --test transfer
|
||||||
|
|
||||||
|
test-rust-approval: ## Run Rust approval integration tests.
|
||||||
|
cd $(ROOT) && $(CARGO) test -p vnidrop --test approval
|
||||||
|
|
||||||
|
test-rust-lifecycle: ## Run Rust lifecycle integration tests.
|
||||||
|
cd $(ROOT) && $(CARGO) test -p vnidrop --test lifecycle
|
||||||
|
|
||||||
|
test-rust-output-sink: ## Run Rust output-sink integration tests.
|
||||||
|
cd $(ROOT) && $(CARGO) test -p vnidrop --test output_sink
|
||||||
|
|
||||||
|
check-shared: ## Test and compile the shared Android/JVM module.
|
||||||
|
cd $(ROOT) && $(GRADLE) :shared:jvmTest :shared:compileKotlinJvm $(GRADLE_FLAGS)
|
||||||
|
|
||||||
|
test-shared: ## Run shared JVM tests.
|
||||||
|
cd $(ROOT) && $(GRADLE) :shared:jvmTest $(GRADLE_FLAGS)
|
||||||
|
|
||||||
|
test-android-host: ## Run Android host-side shared tests.
|
||||||
|
cd $(ROOT) && $(GRADLE) :shared:testAndroidHostTest $(GRADLE_FLAGS)
|
||||||
|
|
||||||
|
check-android: ## Build Android debug and verify packaged Rust libraries.
|
||||||
|
cd $(ROOT) && $(GRADLE) :androidApp:assembleDebug :androidApp:verifyDebugVnidropLibraries $(GRADLE_FLAGS)
|
||||||
|
|
||||||
|
verify-android-libs: ## Verify the Rust libraries packaged in the Android debug app.
|
||||||
|
cd $(ROOT) && $(GRADLE) :androidApp:verifyDebugVnidropLibraries $(GRADLE_FLAGS)
|
||||||
|
|
||||||
|
build-android: ## Build the Android debug APK.
|
||||||
|
cd $(ROOT) && $(GRADLE) :androidApp:assembleDebug $(GRADLE_FLAGS)
|
||||||
|
|
||||||
|
run-desktop: ## Run the Windows/Linux Compose desktop app.
|
||||||
|
cd $(ROOT) && $(GRADLE) :desktopApp:run $(GRADLE_FLAGS)
|
||||||
|
|
||||||
|
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 ## Generate the native Apple Xcode project.
|
||||||
|
cd $(ROOT)/apple && $(XCODEGEN) generate
|
||||||
|
|
||||||
|
open-apple-project: apple-project ## Generate and open the native Apple Xcode project.
|
||||||
|
cd $(ROOT)/apple && $(OPEN) VniDrop.xcodeproj
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
build-apple-ios: apple-project ## Build the native iOS simulator app (unsigned by default).
|
||||||
|
@destination="$(APPLE_DESTINATION)"; \
|
||||||
|
if [[ -z "$$destination" ]]; then \
|
||||||
|
device_id="$$(xcrun simctl list devices available | sed -nE '/iPhone/ s/.*\(([0-9A-F-]{36})\) \((Booted|Shutdown)\).*/\1/p' | head -1 || true)"; \
|
||||||
|
[[ -n "$$device_id" ]] || { printf 'No available iPhone simulator found. Set APPLE_DESTINATION explicitly.\n' >&2; exit 1; }; \
|
||||||
|
destination="platform=iOS Simulator,id=$$device_id"; \
|
||||||
|
fi; \
|
||||||
|
cd $(ROOT)/apple && $(XCODEBUILD) -project VniDrop.xcodeproj -scheme VniDrop -configuration $(APPLE_CONFIGURATION) -derivedDataPath "$(APPLE_DERIVED_DATA)" -destination "$$destination" CODE_SIGNING_ALLOWED=$(APPLE_CODE_SIGNING) CODE_SIGNING_REQUIRED=$(APPLE_CODE_SIGNING) build
|
||||||
|
|
||||||
|
check-apple: apple-project ## Build the Apple core and run iOS simulator tests.
|
||||||
|
@destination="$(APPLE_DESTINATION)"; \
|
||||||
|
if [[ -z "$$destination" ]]; then \
|
||||||
|
device_id="$$(xcrun simctl list devices available | sed -nE '/iPhone/ s/.*\(([0-9A-F-]{36})\) \((Booted|Shutdown)\).*/\1/p' | head -1 || true)"; \
|
||||||
|
[[ -n "$$device_id" ]] || { printf 'No available iPhone simulator found. Set APPLE_DESTINATION explicitly.\n' >&2; exit 1; }; \
|
||||||
|
destination="platform=iOS Simulator,id=$$device_id"; \
|
||||||
|
fi; \
|
||||||
|
printf 'Testing on: %s\n' "$$destination"; \
|
||||||
|
cd $(ROOT)/apple && $(XCODEBUILD) test -project VniDrop.xcodeproj -scheme VniDrop -configuration $(APPLE_CONFIGURATION) -derivedDataPath "$(APPLE_DERIVED_DATA)" -destination "$$destination" CODE_SIGNING_ALLOWED=$(APPLE_CODE_SIGNING) CODE_SIGNING_REQUIRED=$(APPLE_CODE_SIGNING)
|
||||||
|
|
||||||
|
check-localization: setup-localization ## Validate the localization source catalog.
|
||||||
|
cd $(ROOT)/localization && $(BUN) run validate
|
||||||
|
|
||||||
|
localization: setup-localization ## Regenerate Apple and KMP localization resources.
|
||||||
|
cd $(ROOT)/localization && $(BUN) run generate
|
||||||
|
|
||||||
|
localization-migrate: setup-localization ## Rebuild strings.json from platform resources.
|
||||||
|
cd $(ROOT)/localization && $(BUN) run migrate
|
||||||
|
|
||||||
|
check-docs: setup-docs ## Lint, type-check, and build the documentation website.
|
||||||
|
cd $(ROOT)/docs && $(NPM) run lint
|
||||||
|
cd $(ROOT)/docs && $(NPM) run typecheck
|
||||||
|
cd $(ROOT)/docs && $(NPM) run build
|
||||||
|
|
||||||
|
run-docs: setup-docs ## Run the documentation development server.
|
||||||
|
cd $(ROOT)/docs && $(NPM) run dev
|
||||||
|
|
||||||
|
check-diagnostics: setup-diagnostics ## Run diagnostics types, tests, and deployment dry-run.
|
||||||
|
cd $(ROOT)/services/diagnostics-api && $(NPM) run check
|
||||||
|
|
||||||
|
run-diagnostics: setup-diagnostics ## Run the diagnostics Worker locally.
|
||||||
|
cd $(ROOT)/services/diagnostics-api && $(NPM) run dev
|
||||||
|
|
||||||
|
diagnostics-db-local: setup-diagnostics ## Apply diagnostics database migrations locally.
|
||||||
|
cd $(ROOT)/services/diagnostics-api && $(NPM) run db:migrate:local
|
||||||
|
|
||||||
|
diagnostics-db-remote: setup-diagnostics ## Apply diagnostics database migrations to the configured remote D1 database.
|
||||||
|
cd $(ROOT)/services/diagnostics-api && $(NPM) run db:migrate:remote
|
||||||
|
|
||||||
|
diagnostics-typegen: setup-diagnostics ## Regenerate diagnostics Worker binding types.
|
||||||
|
cd $(ROOT)/services/diagnostics-api && $(NPM) run typegen
|
||||||
|
|
||||||
|
deploy-diagnostics: setup-diagnostics ## Check and deploy the diagnostics Worker to Cloudflare.
|
||||||
|
cd $(ROOT)/services/diagnostics-api && $(NPM) run deploy
|
||||||
20
README.md
@@ -88,7 +88,8 @@ people, especially when using **Anyone with this transfer**.
|
|||||||
- Per-receiver requests, approvals, progress, and delivery status
|
- Per-receiver requests, approvals, progress, and delivery status
|
||||||
- Cancel, stop sharing, and local transfer history
|
- Cancel, stop sharing, and local transfer history
|
||||||
- Safe receive destinations that do not silently overwrite existing files
|
- Safe receive destinations that do not silently overwrite existing files
|
||||||
- Android, iOS, and desktop apps built from a shared Compose Multiplatform UI
|
- Native SwiftUI apps on iOS, iPadOS, and macOS; Compose apps on Android,
|
||||||
|
Windows, and Linux
|
||||||
- Opt-in diagnostics with transfer contents, invitations, and file paths
|
- Opt-in diagnostics with transfer contents, invitations, and file paths
|
||||||
excluded
|
excluded
|
||||||
|
|
||||||
@@ -117,14 +118,21 @@ if you want to try the current version.
|
|||||||
git clone https://github.com/vnidrop/vnidrop.git
|
git clone https://github.com/vnidrop/vnidrop.git
|
||||||
cd vnidrop
|
cd vnidrop
|
||||||
|
|
||||||
# Desktop
|
# List the supported development commands and check prerequisites
|
||||||
./gradlew :desktopApp:run
|
make help
|
||||||
|
make doctor
|
||||||
|
|
||||||
|
# Windows/Linux desktop
|
||||||
|
make run-desktop
|
||||||
|
|
||||||
# Android debug build
|
# Android debug build
|
||||||
./gradlew :androidApp:assembleDebug
|
make build-android
|
||||||
|
|
||||||
# iOS
|
# Build and launch the macOS app
|
||||||
open iosApp/iosApp.xcodeproj
|
make open-apple
|
||||||
|
|
||||||
|
# Open the native project for iOS, iPadOS, or Xcode development
|
||||||
|
make open-apple-project
|
||||||
```
|
```
|
||||||
|
|
||||||
See [`CONTRIBUTING.md`](CONTRIBUTING.md) for prerequisites, development setup,
|
See [`CONTRIBUTING.md`](CONTRIBUTING.md) for prerequisites, development setup,
|
||||||
|
|||||||
16
apple/.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Generated by apple/scripts/build-core.sh
|
||||||
|
.build-core/
|
||||||
|
VnidropCore/vnidrop.xcframework/
|
||||||
|
VnidropCore/Sources/VnidropCore/Vnidrop.swift
|
||||||
|
|
||||||
|
# Generated by XcodeGen from project.yml
|
||||||
|
VniDrop.xcodeproj/
|
||||||
|
|
||||||
|
# Per-developer signing (team id); Signing.xcconfig optionally includes it
|
||||||
|
Local.xcconfig
|
||||||
|
|
||||||
|
# SwiftPM / Xcode
|
||||||
|
.build/
|
||||||
|
.swiftpm/
|
||||||
|
DerivedData/
|
||||||
|
*.xcuserstate
|
||||||
43
apple/Package.swift
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// 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"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
96
apple/README.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# VniDrop — native Apple app (iOS / iPadOS / macOS)
|
||||||
|
|
||||||
|
A native SwiftUI app for Apple platforms, sharing the existing Rust transfer core
|
||||||
|
(`crates/vnidrop`) through UniFFI-generated Swift bindings. The Rust crate is not
|
||||||
|
modified; the Kotlin/Compose app layer is ported to Swift and mirrors the Compose
|
||||||
|
UI screen-for-screen. Android, Windows, and Linux continue to use `shared/` + Compose.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
apple/
|
||||||
|
scripts/build-core.sh # builds the Rust core + generates Swift bindings + xcframework
|
||||||
|
VnidropCore/ # local SwiftPM package: xcframework + generated Vnidrop.swift
|
||||||
|
VniDrop/ # SwiftUI app sources
|
||||||
|
App/ # entry point, object graph, root view, environment
|
||||||
|
Core/ # repository, models, preferences, notifications, progress
|
||||||
|
Features/Send|Receive|Approvals|Settings/
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build & run
|
||||||
|
|
||||||
|
Prerequisites: Xcode, Rust with the Apple targets
|
||||||
|
(`aarch64-apple-ios`, `aarch64-apple-ios-sim`, `x86_64-apple-ios`,
|
||||||
|
`aarch64-apple-darwin`), and `xcodegen` (`brew install xcodegen`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From the repository root:
|
||||||
|
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 open-apple # build and launch the macOS app
|
||||||
|
make build-apple-ios # unsigned iOS simulator build
|
||||||
|
make check-apple # iOS simulator tests
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
not require an Apple Development team or provisioning profile. Make builds can
|
||||||
|
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
|
||||||
|
|
||||||
|
`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:
|
||||||
|
|
||||||
|
```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)"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Generated / ignored artifacts
|
||||||
|
|
||||||
|
`build-core.sh` produces build outputs that are gitignored (see `apple/.gitignore`):
|
||||||
|
`VnidropCore/vnidrop.xcframework/`, `VnidropCore/Sources/VnidropCore/Vnidrop.swift`,
|
||||||
|
and `.build-core/`. A clean checkout must run `build-core.sh` before generating or
|
||||||
|
opening the Xcode project. `VniDrop.xcodeproj` itself is generated by XcodeGen from
|
||||||
|
`project.yml` and does not need to be committed.
|
||||||
|
|
||||||
|
## Build profile note
|
||||||
|
|
||||||
|
The default is `debug`. The workspace `[profile.release]` uses thin LTO, which the
|
||||||
|
current macOS toolchain miscompiles into corrupt host proc-macro dylibs
|
||||||
|
("mis-aligned LINKEDIT string pool"). `build-core.sh` sets
|
||||||
|
`CARGO_PROFILE_DEV_STRIP=none` (matching the existing Gobley Xcode run-script) so
|
||||||
|
debug builds succeed. For a release core, disable LTO for proc-macros/build
|
||||||
|
scripts (e.g. add a `[profile.release.build-override] lto = false` locally) — the
|
||||||
|
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).
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
```
|
||||||
6
apple/Signing.xcconfig
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// VniDrop development and CI builds are intentionally unsigned.
|
||||||
|
CODE_SIGNING_ALLOWED = NO
|
||||||
|
CODE_SIGNING_REQUIRED = NO
|
||||||
|
|
||||||
|
// Signed local builds can opt in through this ignored file.
|
||||||
|
#include? "Local.xcconfig"
|
||||||
41
apple/Tests/AppModelTests.swift
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import VniDrop
|
||||||
|
|
||||||
|
/// Ports app-level assertions: core initialization on launch, destination
|
||||||
|
/// selection guard, and theme following preferences.
|
||||||
|
@MainActor
|
||||||
|
final class AppModelTests: XCTestCase {
|
||||||
|
|
||||||
|
private func makeModel(_ core: FakeCoreGateway, preferences: AppPreferencesRepository) -> AppModel {
|
||||||
|
AppModel(
|
||||||
|
environment: PlatformEnvironment(name: "Test", appVersion: "0.1.0", defaultCoreDataDir: NSTemporaryDirectory()),
|
||||||
|
repository: core,
|
||||||
|
preferences: preferences,
|
||||||
|
messages: UiMessageController()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInitializesCoreOnLaunch() async {
|
||||||
|
let core = FakeCoreGateway()
|
||||||
|
_ = makeModel(core, preferences: Fixtures.preferences())
|
||||||
|
await waitUntil { core.state.isInitialized }
|
||||||
|
XCTAssertTrue(core.state.isInitialized)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSelectDestination() {
|
||||||
|
let model = makeModel(FakeCoreGateway(), preferences: Fixtures.preferences())
|
||||||
|
XCTAssertEqual(model.destination, .send)
|
||||||
|
model.selectDestination(.settings)
|
||||||
|
XCTAssertEqual(model.destination, .settings)
|
||||||
|
model.selectDestination(.settings) // no-op guard
|
||||||
|
XCTAssertEqual(model.destination, .settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testThemeModeFollowsPreferences() async {
|
||||||
|
let prefs = Fixtures.preferences()
|
||||||
|
let model = makeModel(FakeCoreGateway(), preferences: prefs)
|
||||||
|
prefs.setThemeMode(.dark)
|
||||||
|
await waitUntil { model.themeMode == .dark }
|
||||||
|
XCTAssertEqual(model.themeMode, .dark)
|
||||||
|
}
|
||||||
|
}
|
||||||
51
apple/Tests/AppPreferencesRepositoryTests.swift
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import VniDrop
|
||||||
|
|
||||||
|
/// Ports `preferences/AppPreferencesRepositoryTest.kt` — values persist to the
|
||||||
|
/// backing store and reload identically.
|
||||||
|
@MainActor
|
||||||
|
final class AppPreferencesRepositoryTests: XCTestCase {
|
||||||
|
|
||||||
|
private func defaults() -> UserDefaults { UserDefaults(suiteName: "vnidrop.prefs.\(UUID().uuidString)")! }
|
||||||
|
private func fallback() -> AppPreferencesDefaults {
|
||||||
|
AppPreferencesDefaults(
|
||||||
|
username: "Default",
|
||||||
|
receiveFolder: ReceiveFolder(kind: .fileSystemPath, value: "/tmp", displayName: "Downloads"),
|
||||||
|
themeMode: .system
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFallbacksWhenEmpty() {
|
||||||
|
let repo = AppPreferencesRepository(defaults: defaults(), fallback: fallback())
|
||||||
|
XCTAssertEqual(repo.preferences.username, "Default")
|
||||||
|
XCTAssertEqual(repo.preferences.themeMode, .system)
|
||||||
|
XCTAssertFalse(repo.preferences.notificationsEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testValuesPersistAndReload() {
|
||||||
|
let store = defaults()
|
||||||
|
let fb = fallback()
|
||||||
|
let repo = AppPreferencesRepository(defaults: store, fallback: fb)
|
||||||
|
repo.setUsername("Bob")
|
||||||
|
repo.setThemeMode(.dark)
|
||||||
|
repo.setNotificationsEnabled(true)
|
||||||
|
repo.setReceiveFolder(ReceiveFolder(kind: .iosSecurityScopedUrl, value: "file:///x", displayName: "Custom"))
|
||||||
|
|
||||||
|
// A fresh repository over the same store reflects the persisted values.
|
||||||
|
let reloaded = AppPreferencesRepository(defaults: store, fallback: fb)
|
||||||
|
XCTAssertEqual(reloaded.preferences.username, "Bob")
|
||||||
|
XCTAssertEqual(reloaded.preferences.themeMode, .dark)
|
||||||
|
XCTAssertTrue(reloaded.preferences.notificationsEnabled)
|
||||||
|
XCTAssertEqual(reloaded.preferences.receiveFolder.displayName, "Custom")
|
||||||
|
XCTAssertEqual(reloaded.preferences.receiveFolder.kind, .iosSecurityScopedUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testResetReceiveFolderRestoresFallback() {
|
||||||
|
let store = defaults()
|
||||||
|
let fb = fallback()
|
||||||
|
let repo = AppPreferencesRepository(defaults: store, fallback: fb)
|
||||||
|
repo.setReceiveFolder(ReceiveFolder(kind: .fileSystemPath, value: "/custom", displayName: "Custom"))
|
||||||
|
repo.resetReceiveFolder()
|
||||||
|
XCTAssertEqual(repo.preferences.receiveFolder.value, "/tmp")
|
||||||
|
}
|
||||||
|
}
|
||||||
69
apple/Tests/ApprovalCoordinatorTests.swift
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import XCTest
|
||||||
|
import Combine
|
||||||
|
@testable import VniDrop
|
||||||
|
|
||||||
|
/// Ports `feature/approvals/ApprovalCoordinatorTest.kt` (the gateway-observable
|
||||||
|
/// parts; notification assertions require a notification-service seam we don't
|
||||||
|
/// have on Apple yet).
|
||||||
|
@MainActor
|
||||||
|
final class ApprovalCoordinatorTests: XCTestCase {
|
||||||
|
|
||||||
|
private func makeCoordinator(_ core: FakeCoreGateway) -> ApprovalCoordinator {
|
||||||
|
ApprovalCoordinator(
|
||||||
|
repository: core,
|
||||||
|
preferences: Fixtures.preferences(),
|
||||||
|
notifications: LocalNotificationService(),
|
||||||
|
visibility: AppVisibility(),
|
||||||
|
messages: UiMessageController()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOrdersPendingRequestsByRequestedAt() async {
|
||||||
|
let core = FakeCoreGateway()
|
||||||
|
core.requests[1] = [Fixtures.request(id: "new", requestedAt: 20),
|
||||||
|
Fixtures.request(id: "old", requestedAt: 10)]
|
||||||
|
let coordinator = makeCoordinator(core)
|
||||||
|
|
||||||
|
core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 1, direction: .send, status: .sharing)]))
|
||||||
|
core.emit(.approvalChanged(transferId: 1))
|
||||||
|
|
||||||
|
await waitUntil { !coordinator.state.pending.isEmpty }
|
||||||
|
XCTAssertEqual(coordinator.state.pending.map(\.id), ["old", "new"])
|
||||||
|
XCTAssertEqual(coordinator.state.current?.id, "old")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFailedResponseKeepsRequestVisibleAndClearsResponding() async {
|
||||||
|
let core = FakeCoreGateway()
|
||||||
|
core.requests[1] = [Fixtures.request(id: "request", requestedAt: 10)]
|
||||||
|
core.responseResult = .failure(TestError.unimplemented)
|
||||||
|
let coordinator = makeCoordinator(core)
|
||||||
|
|
||||||
|
core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 1, direction: .send, status: .sharing)]))
|
||||||
|
core.emit(.approvalChanged(transferId: 1))
|
||||||
|
await waitUntil { coordinator.state.pending.contains { $0.id == "request" } }
|
||||||
|
|
||||||
|
coordinator.accept("request")
|
||||||
|
await waitUntil { coordinator.state.respondingIds.isEmpty && core.responses.count == 1 }
|
||||||
|
|
||||||
|
XCTAssertTrue(coordinator.state.pending.contains { $0.id == "request" })
|
||||||
|
XCTAssertTrue(coordinator.state.respondingIds.isEmpty)
|
||||||
|
XCTAssertEqual(core.responses.first?.accepted, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAcceptRespondsPositivelyAndSingleFlights() async {
|
||||||
|
let core = FakeCoreGateway()
|
||||||
|
core.requests[1] = [Fixtures.request(id: "request", requestedAt: 10)]
|
||||||
|
let coordinator = makeCoordinator(core)
|
||||||
|
core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 1, direction: .send, status: .sharing)]))
|
||||||
|
core.emit(.approvalChanged(transferId: 1))
|
||||||
|
await waitUntil { coordinator.state.current != nil }
|
||||||
|
|
||||||
|
coordinator.accept("request")
|
||||||
|
coordinator.accept("request") // second call must be ignored (single-flight)
|
||||||
|
await waitUntil { core.responses.count >= 1 }
|
||||||
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||||
|
|
||||||
|
XCTAssertEqual(core.responses.count, 1)
|
||||||
|
XCTAssertEqual(core.responses.first?.id, "request")
|
||||||
|
}
|
||||||
|
}
|
||||||
137
apple/Tests/Fakes.swift
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import VnidropCore
|
||||||
|
@testable import VniDrop
|
||||||
|
|
||||||
|
enum TestError: Error { case unimplemented }
|
||||||
|
|
||||||
|
/// In-memory `CoreGateway`, mirroring `support/Fakes.kt`'s `FakeCoreGateway`.
|
||||||
|
/// Lets model tests drive core state/signals and stub results without the FFI.
|
||||||
|
@MainActor
|
||||||
|
final class FakeCoreGateway: CoreGateway {
|
||||||
|
private let stateSubject = CurrentValueSubject<CoreState, Never>(CoreState())
|
||||||
|
private let signalsSubject = PassthroughSubject<CoreSignal, Never>()
|
||||||
|
|
||||||
|
var state: CoreState { stateSubject.value }
|
||||||
|
var statePublisher: AnyPublisher<CoreState, Never> { stateSubject.eraseToAnyPublisher() }
|
||||||
|
var signals: AnyPublisher<CoreSignal, Never> { signalsSubject.eraseToAnyPublisher() }
|
||||||
|
|
||||||
|
// Stubbed results
|
||||||
|
var requests: [UInt64: [ReceiverRequestModel]] = [:]
|
||||||
|
var responseResult: Result<Void, Error> = .success(())
|
||||||
|
var shareResult: Result<Share, Error> = .failure(TestError.unimplemented)
|
||||||
|
var inspectionResult: Result<TicketInspectionModel, Error> = .failure(TestError.unimplemented)
|
||||||
|
var receiveResult: Result<Void, Error> = .success(())
|
||||||
|
var cancelResult: Result<Void, Error> = .success(())
|
||||||
|
var deleteResult: Result<Void, Error> = .success(())
|
||||||
|
var clearReceiveHistoryResult: Result<UInt64, Error> = .success(0)
|
||||||
|
|
||||||
|
// Recorded calls
|
||||||
|
private(set) var responses: [(id: String, accepted: Bool, reason: String?)] = []
|
||||||
|
private(set) var deletedTransfers: [UInt64] = []
|
||||||
|
private(set) var cancelledTransfers: [UInt64] = []
|
||||||
|
private(set) var clearReceiveHistoryCount = 0
|
||||||
|
private(set) var receiveCount = 0
|
||||||
|
private(set) var lastReceiveTicket: String?
|
||||||
|
private(set) var lastReceiveReceiverName: String?
|
||||||
|
private(set) var lastShareAccessPolicy: ShareAccessPolicy?
|
||||||
|
|
||||||
|
func setState(_ state: CoreState) { stateSubject.send(state) }
|
||||||
|
func emit(_ signal: CoreSignal) { signalsSubject.send(signal) }
|
||||||
|
|
||||||
|
func initialize(appDataDir: String) async -> Result<Void, Error> {
|
||||||
|
var s = stateSubject.value
|
||||||
|
s.isInitialized = true
|
||||||
|
stateSubject.send(s)
|
||||||
|
return .success(())
|
||||||
|
}
|
||||||
|
func shutdown() {}
|
||||||
|
func shareSources(_ sources: [ShareSource], transferName: String, senderName: String, accessPolicy: ShareAccessPolicy) async -> Result<Share, Error> {
|
||||||
|
lastShareAccessPolicy = accessPolicy
|
||||||
|
return shareResult
|
||||||
|
}
|
||||||
|
func inspectTicket(_ ticket: String) async -> Result<TicketInspectionModel, Error> { inspectionResult }
|
||||||
|
func receive(ticket: String, outputDir: String, receiverName: String) async -> Result<Void, Error> {
|
||||||
|
receiveCount += 1; lastReceiveTicket = ticket; lastReceiveReceiverName = receiverName
|
||||||
|
return receiveResult
|
||||||
|
}
|
||||||
|
func receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String) async -> Result<Void, Error> {
|
||||||
|
receiveCount += 1; lastReceiveTicket = ticket; lastReceiveReceiverName = receiverName
|
||||||
|
return receiveResult
|
||||||
|
}
|
||||||
|
func cancel(transferId: UInt64) async -> Result<Void, Error> { cancelledTransfers.append(transferId); return cancelResult }
|
||||||
|
func delete(transferId: UInt64) async -> Result<Void, Error> { deletedTransfers.append(transferId); return deleteResult }
|
||||||
|
func clearReceiveHistory() async -> Result<UInt64, Error> { clearReceiveHistoryCount += 1; return clearReceiveHistoryResult }
|
||||||
|
func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error> { .success(requests[transferId] ?? []) }
|
||||||
|
func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result<Void, Error> {
|
||||||
|
responses.append((requestId, accepted, reason))
|
||||||
|
return responseResult
|
||||||
|
}
|
||||||
|
func refresh() async -> Result<Void, Error> { .success(()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal `FileSystemService` fake — a writable path receive folder, no reveal.
|
||||||
|
@MainActor
|
||||||
|
final class FakeFileSystemService: FileSystemService {
|
||||||
|
var supportsCustomReceiveFolders = false
|
||||||
|
var folder = ReceiveFolder(kind: .fileSystemPath, value: "/tmp/vnidrop-tests", displayName: "Documents")
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class FakeDeviceInfoProvider: DeviceInfoProvider {
|
||||||
|
func load() async -> DeviceInfo {
|
||||||
|
DeviceInfo(deviceName: "Test Device", deviceModel: "TestModel",
|
||||||
|
operatingSystem: "TestOS 1.0", network: nil, batteryLevel: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Factories
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
enum Fixtures {
|
||||||
|
static func preferences(username: String = "Tester") -> AppPreferencesRepository {
|
||||||
|
let defaults = UserDefaults(suiteName: "vnidrop.tests.\(UUID().uuidString)")!
|
||||||
|
return AppPreferencesRepository(
|
||||||
|
defaults: defaults,
|
||||||
|
fallback: AppPreferencesDefaults(
|
||||||
|
username: username,
|
||||||
|
receiveFolder: ReceiveFolder(kind: .fileSystemPath, value: "/tmp/vnidrop-tests", displayName: "Documents"),
|
||||||
|
themeMode: .system
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func request(id: String, requestedAt: Int64, transferId: UInt64 = 1, status: ReceiverDeliveryStatus = .requested) -> ReceiverRequestModel {
|
||||||
|
ReceiverRequestModel(
|
||||||
|
id: id, transferId: transferId, remoteEndpointId: "endpoint-\(id)",
|
||||||
|
transferName: "Photos", receiverName: "Peer", receiverDeviceName: "Phone",
|
||||||
|
appVersion: "1.0", status: status, reason: nil,
|
||||||
|
requestedAt: requestedAt, respondedAt: nil, completedAt: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func transfer(id: UInt64, direction: TransferDirection, status: TransferStatus) -> Transfer {
|
||||||
|
Transfer(
|
||||||
|
localId: "local-\(id)", transferId: id, direction: direction, status: status,
|
||||||
|
peerId: nil, transferName: "Photos", contentHash: nil, fileCount: 1, totalSize: 1024,
|
||||||
|
ticket: "ticket", accessPolicy: .requireApproval, createdAt: 0, updatedAt: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polls `condition` on the main actor until true or `timeout` elapses. Used to
|
||||||
|
/// await the models' internal `Task`s, which XCTest can't join directly.
|
||||||
|
@MainActor
|
||||||
|
func waitUntil(timeout: TimeInterval = 2, _ condition: @escaping () -> Bool) async {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while !condition() && Date() < deadline {
|
||||||
|
try? await Task.sleep(nanoseconds: 5_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
43
apple/Tests/FilePreviewRepositoryTests.swift
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import VniDrop
|
||||||
|
|
||||||
|
/// Ports `feature/send/FilePreviewRepositoryTest.kt` — persisted thumbnails,
|
||||||
|
/// restore pruned to live transfer ids, and removal.
|
||||||
|
@MainActor
|
||||||
|
final class FilePreviewRepositoryTests: XCTestCase {
|
||||||
|
|
||||||
|
/// Minimal bytes that pass the PNG magic-byte check.
|
||||||
|
private let png = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
|
||||||
|
|
||||||
|
private func makeRepo() -> FilePreviewRepository {
|
||||||
|
FilePreviewRepository(appDataDir: NSTemporaryDirectory() + "previews-" + UUID().uuidString)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSaveStoresPreview() {
|
||||||
|
let repo = makeRepo()
|
||||||
|
repo.save(transferId: 1, bytes: png)
|
||||||
|
XCTAssertEqual(repo.previews[1], png)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSaveRejectsNonImageBytes() {
|
||||||
|
let repo = makeRepo()
|
||||||
|
repo.save(transferId: 1, bytes: Data("not an image".utf8))
|
||||||
|
XCTAssertNil(repo.previews[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRestorePrunesToActiveIds() {
|
||||||
|
let repo = makeRepo()
|
||||||
|
repo.save(transferId: 1, bytes: png)
|
||||||
|
repo.save(transferId: 2, bytes: png)
|
||||||
|
repo.restore(activeTransferIds: [1])
|
||||||
|
XCTAssertEqual(repo.previews[1], png)
|
||||||
|
XCTAssertNil(repo.previews[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRemoveDeletesPreview() {
|
||||||
|
let repo = makeRepo()
|
||||||
|
repo.save(transferId: 1, bytes: png)
|
||||||
|
repo.remove(transferId: 1)
|
||||||
|
XCTAssertNil(repo.previews[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
43
apple/Tests/InvitationTests.swift
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import VniDrop
|
||||||
|
|
||||||
|
/// Ports `feature/receive/ExternalInvitationControllerTest.kt` + the `.vnd`
|
||||||
|
/// decode/filename helpers.
|
||||||
|
@MainActor
|
||||||
|
final class InvitationTests: XCTestCase {
|
||||||
|
|
||||||
|
func testValidateInvitationAcceptsValid() {
|
||||||
|
guard case .success(let raw) = validateInvitation("some-ticket") else { return XCTFail("expected success") }
|
||||||
|
XCTAssertEqual(raw, "some-ticket")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testValidateRejectsEmpty() {
|
||||||
|
guard case .failure(let error) = validateInvitation(" \n ") else { return XCTFail("expected failure") }
|
||||||
|
XCTAssertTrue((error as? InvitationError) != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testValidateRejectsTooLarge() {
|
||||||
|
let big = String(repeating: "a", count: maxVniDropInvitationBytes + 1)
|
||||||
|
guard case .failure = validateInvitation(big) else { return XCTFail("expected failure") }
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDecodeInvitationBytesRoundTrip() throws {
|
||||||
|
let text = "vnidrop://ticket-abc"
|
||||||
|
let decoded = try decodeInvitationBytes(Data(text.utf8))
|
||||||
|
XCTAssertEqual(decoded, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDecodeRejectsEmptyData() {
|
||||||
|
XCTAssertThrowsError(try decodeInvitationBytes(Data()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDecodeRejectsInvalidUtf8() {
|
||||||
|
XCTAssertThrowsError(try decodeInvitationBytes(Data([0xFF, 0xFE, 0xFD])))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInvitationFileNameSanitizes() {
|
||||||
|
XCTAssertEqual(invitationFileName("My Photos"), "My-Photos.vnd")
|
||||||
|
XCTAssertEqual(invitationFileName(" "), "invitation.vnd")
|
||||||
|
XCTAssertTrue(invitationFileName("a/b:c*d").hasSuffix(".vnd"))
|
||||||
|
}
|
||||||
|
}
|
||||||
59
apple/Tests/ProgressDerivationTests.swift
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import VniDrop
|
||||||
|
|
||||||
|
/// Ports selected `shared/src/commonTest/.../ui/state` assertions to verify the
|
||||||
|
/// progress-derivation logic matches the Kotlin implementation.
|
||||||
|
final class ProgressDerivationTests: XCTestCase {
|
||||||
|
|
||||||
|
func testFormatBytes() {
|
||||||
|
XCTAssertEqual(formatBytes(0), "0 B")
|
||||||
|
XCTAssertEqual(formatBytes(1023), "1023 B")
|
||||||
|
XCTAssertEqual(formatBytes(1024), "1.0 KB")
|
||||||
|
XCTAssertEqual(formatBytes(1536), "1.5 KB")
|
||||||
|
XCTAssertEqual(formatBytes(1024 * 1024), "1.0 MB")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWindowClassThresholds() {
|
||||||
|
XCTAssertEqual(windowClassFor(width: 320), .phone)
|
||||||
|
XCTAssertEqual(windowClassFor(width: 599), .phone)
|
||||||
|
XCTAssertEqual(windowClassFor(width: 600), .tablet)
|
||||||
|
XCTAssertEqual(windowClassFor(width: 919), .tablet)
|
||||||
|
XCTAssertEqual(windowClassFor(width: 920), .desktop)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testParseProgressPrefersExported() {
|
||||||
|
XCTAssertEqual(parseProgress("{\"exported\":50,\"file_size\":100}"), 0.5)
|
||||||
|
XCTAssertEqual(parseProgress("{\"downloaded\":25,\"total_size\":100}"), 0.25)
|
||||||
|
XCTAssertNil(parseProgress("{\"foo\":1}"))
|
||||||
|
XCTAssertEqual(parseProgress("{\"offset\":200,\"size\":100}"), 1.0) // clamped
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFindStringSkipsNull() {
|
||||||
|
XCTAssertEqual(findString("{\"endpoint_id\":\"abc\"}", key: "endpoint_id"), "abc")
|
||||||
|
XCTAssertNil(findString("{\"endpoint_id\":null}", key: "endpoint_id"))
|
||||||
|
XCTAssertNil(findString("{\"endpoint_id\":123}", key: "endpoint_id"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testProgressForTransferUsesLatestNewestFirst() {
|
||||||
|
let events = [
|
||||||
|
event(phase: "import", kind: "copy-progress", json: "{\"exported\":30,\"file_size\":100}"),
|
||||||
|
event(phase: "import", kind: "started", json: "{}"),
|
||||||
|
]
|
||||||
|
let progress = progressForTransfer(events: events, transferId: 1)
|
||||||
|
XCTAssertEqual(progress?.labelKey, "progress_preparing")
|
||||||
|
XCTAssertEqual(progress?.progress, 0.3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStatusLabelKeys() {
|
||||||
|
XCTAssertEqual(statusLabelKey(.sharing), "status_available")
|
||||||
|
XCTAssertEqual(statusLabelKey(.receiving), "status_receiving")
|
||||||
|
XCTAssertEqual(statusLabelKey(.done), "status_completed")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func event(phase: String, kind: String, json: String) -> CoreEventModel {
|
||||||
|
CoreEventModel(
|
||||||
|
id: UUID().uuidString, timestamp: 0, scope: "transfer", transferId: 1,
|
||||||
|
direction: "send", phase: phase, kind: kind, dataJson: json
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
67
apple/Tests/ReceiveModelTests.swift
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import VniDrop
|
||||||
|
|
||||||
|
/// Ports the receive-side state-machine assertions from `feature/ViewModelsTest.kt`.
|
||||||
|
@MainActor
|
||||||
|
final class ReceiveModelTests: XCTestCase {
|
||||||
|
|
||||||
|
private func makeModel(_ core: FakeCoreGateway) -> ReceiveModel {
|
||||||
|
ReceiveModel(
|
||||||
|
repository: core,
|
||||||
|
fileSystemService: FakeFileSystemService(),
|
||||||
|
preferences: Fixtures.preferences(),
|
||||||
|
messages: UiMessageController()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeleteHistoryItemDeletesTerminalReceiveTransfer() async {
|
||||||
|
let core = FakeCoreGateway()
|
||||||
|
let model = makeModel(core)
|
||||||
|
core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 5, direction: .receive, status: .done)]))
|
||||||
|
await waitUntil { model.coreState.transfers.contains { $0.transferId == 5 } }
|
||||||
|
|
||||||
|
model.requestDeleteHistoryItem(5)
|
||||||
|
XCTAssertEqual(model.state.historyDeleteTarget, .transfer(transferId: 5))
|
||||||
|
|
||||||
|
model.confirmHistoryDelete()
|
||||||
|
await waitUntil { core.deletedTransfers.contains(5) }
|
||||||
|
XCTAssertEqual(core.deletedTransfers, [5])
|
||||||
|
XCTAssertNil(model.state.historyDeleteTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testClearHistoryCallsClearReceiveHistory() async {
|
||||||
|
let core = FakeCoreGateway()
|
||||||
|
let model = makeModel(core)
|
||||||
|
core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 5, direction: .receive, status: .done)]))
|
||||||
|
await waitUntil { !model.coreState.transfers.isEmpty }
|
||||||
|
|
||||||
|
model.requestClearHistory()
|
||||||
|
XCTAssertEqual(model.state.historyDeleteTarget, .all)
|
||||||
|
|
||||||
|
model.confirmHistoryDelete()
|
||||||
|
await waitUntil { core.clearReceiveHistoryCount == 1 }
|
||||||
|
XCTAssertEqual(core.clearReceiveHistoryCount, 1)
|
||||||
|
XCTAssertNil(model.state.historyDeleteTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeleteHistoryItemIgnoresNonTerminalTransfer() async {
|
||||||
|
let core = FakeCoreGateway()
|
||||||
|
let model = makeModel(core)
|
||||||
|
core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 9, direction: .receive, status: .receiving)]))
|
||||||
|
await waitUntil { !model.coreState.transfers.isEmpty }
|
||||||
|
|
||||||
|
model.requestDeleteHistoryItem(9)
|
||||||
|
XCTAssertNil(model.state.historyDeleteTarget) // in-flight receive can't be deleted from history
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCancelActiveReceiveCancelsTheReceivingTransfer() async {
|
||||||
|
let core = FakeCoreGateway()
|
||||||
|
let model = makeModel(core)
|
||||||
|
core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 7, direction: .receive, status: .receiving)]))
|
||||||
|
await waitUntil { !model.coreState.transfers.isEmpty }
|
||||||
|
|
||||||
|
model.cancelActiveReceive()
|
||||||
|
await waitUntil { core.cancelledTransfers.contains(7) }
|
||||||
|
XCTAssertEqual(core.cancelledTransfers, [7])
|
||||||
|
}
|
||||||
|
}
|
||||||
58
apple/Tests/SendModelTests.swift
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import VniDrop
|
||||||
|
|
||||||
|
/// Ports the send-side state-machine assertions from `feature/ViewModelsTest.kt`.
|
||||||
|
@MainActor
|
||||||
|
final class SendModelTests: XCTestCase {
|
||||||
|
|
||||||
|
private func makeModel(_ core: FakeCoreGateway) -> SendModel {
|
||||||
|
SendModel(
|
||||||
|
repository: core,
|
||||||
|
fileSystemService: FakeFileSystemService(),
|
||||||
|
preferences: Fixtures.preferences(),
|
||||||
|
filePreviewRepository: FilePreviewRepository(appDataDir: NSTemporaryDirectory() + UUID().uuidString),
|
||||||
|
messages: UiMessageController()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOpenAndCloseTransferDetails() {
|
||||||
|
let model = makeModel(FakeCoreGateway())
|
||||||
|
model.openTransfer(3)
|
||||||
|
XCTAssertEqual(model.state.selectedTransferId, 3)
|
||||||
|
model.closeTransferDetails()
|
||||||
|
XCTAssertNil(model.state.selectedTransferId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeleteTransferConfirmationFlow() async {
|
||||||
|
let core = FakeCoreGateway()
|
||||||
|
let model = makeModel(core)
|
||||||
|
model.openTransfer(3)
|
||||||
|
|
||||||
|
model.requestDeleteTransfer()
|
||||||
|
XCTAssertTrue(model.state.isDeleteConfirmationOpen)
|
||||||
|
|
||||||
|
model.confirmDeleteTransfer()
|
||||||
|
await waitUntil { core.deletedTransfers.contains(3) }
|
||||||
|
XCTAssertEqual(core.deletedTransfers, [3])
|
||||||
|
XCTAssertNil(model.state.selectedTransferId)
|
||||||
|
XCTAssertFalse(model.state.isDeleteConfirmationOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStopSharingCancelsTheTransfer() async {
|
||||||
|
let core = FakeCoreGateway()
|
||||||
|
let model = makeModel(core)
|
||||||
|
model.stopSharing(transferId: 4)
|
||||||
|
await waitUntil { core.cancelledTransfers.contains(4) }
|
||||||
|
XCTAssertEqual(core.cancelledTransfers, [4])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCancelReceiverRefusesTheRequest() async {
|
||||||
|
let core = FakeCoreGateway()
|
||||||
|
let model = makeModel(core)
|
||||||
|
model.openTransfer(1)
|
||||||
|
model.cancelReceiver(requestId: "req-1")
|
||||||
|
await waitUntil { core.responses.contains { $0.id == "req-1" } }
|
||||||
|
let response = core.responses.first { $0.id == "req-1" }
|
||||||
|
XCTAssertEqual(response?.accepted, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
45
apple/Tests/SettingsModelTests.swift
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import VniDrop
|
||||||
|
|
||||||
|
/// Ports settings assertions from `feature/ViewModelsTest.kt` — username debounce
|
||||||
|
/// persistence and the Storage "delete all transfers" flow.
|
||||||
|
@MainActor
|
||||||
|
final class SettingsModelTests: XCTestCase {
|
||||||
|
|
||||||
|
private func makeModel(_ core: FakeCoreGateway, preferences: AppPreferencesRepository) -> SettingsModel {
|
||||||
|
SettingsModel(
|
||||||
|
environment: PlatformEnvironment(name: "Test", appVersion: "0.1.0", defaultCoreDataDir: NSTemporaryDirectory()),
|
||||||
|
deviceInfoProvider: FakeDeviceInfoProvider(),
|
||||||
|
fileSystemService: FakeFileSystemService(),
|
||||||
|
repository: core,
|
||||||
|
preferences: preferences,
|
||||||
|
notifications: LocalNotificationService(),
|
||||||
|
messages: UiMessageController(),
|
||||||
|
bugReports: NoopBugReportService(),
|
||||||
|
diagnosticsIncluded: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUsernameChangeDebouncesAndPersists() async {
|
||||||
|
let prefs = Fixtures.preferences(username: "Original")
|
||||||
|
let model = makeModel(FakeCoreGateway(), preferences: prefs)
|
||||||
|
|
||||||
|
model.setUsername("Alice")
|
||||||
|
XCTAssertEqual(model.state.username, "Alice") // immediate local echo
|
||||||
|
await waitUntil { prefs.preferences.username == "Alice" } // persisted after debounce
|
||||||
|
XCTAssertEqual(prefs.preferences.username, "Alice")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeleteAllTransfersDeletesEveryTransfer() async {
|
||||||
|
let core = FakeCoreGateway()
|
||||||
|
let model = makeModel(core, preferences: Fixtures.preferences())
|
||||||
|
core.setState(CoreState(isInitialized: true, transfers: [
|
||||||
|
Fixtures.transfer(id: 2, direction: .send, status: .sharing),
|
||||||
|
Fixtures.transfer(id: 3, direction: .receive, status: .done),
|
||||||
|
]))
|
||||||
|
|
||||||
|
model.deleteAllTransfers()
|
||||||
|
await waitUntil { core.deletedTransfers.count == 2 }
|
||||||
|
XCTAssertEqual(Set(core.deletedTransfers), [2, 3])
|
||||||
|
}
|
||||||
|
}
|
||||||
53
apple/Tests/UiFeedbackTests.swift
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import VniDrop
|
||||||
|
|
||||||
|
/// Ports `ui/feedback/UiMessageControllerTest.kt` and `UserFacingErrorTest.kt`.
|
||||||
|
@MainActor
|
||||||
|
final class UiMessageControllerTests: XCTestCase {
|
||||||
|
|
||||||
|
func testQueuesAndAdvances() {
|
||||||
|
let c = UiMessageController()
|
||||||
|
c.show(UiMessage(text: .dynamic("first")))
|
||||||
|
c.show(UiMessage(text: .dynamic("second")))
|
||||||
|
XCTAssertEqual(c.current?.text, .dynamic("first"))
|
||||||
|
|
||||||
|
c.advance()
|
||||||
|
XCTAssertEqual(c.current?.text, .dynamic("second"))
|
||||||
|
|
||||||
|
c.advance()
|
||||||
|
XCTAssertNil(c.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testErrorSuppressesUserCancellation() {
|
||||||
|
let c = UiMessageController()
|
||||||
|
c.error(InvitationError.message("QR scanning was cancelled"))
|
||||||
|
XCTAssertNil(c.current) // cancellations are swallowed
|
||||||
|
}
|
||||||
|
|
||||||
|
func testErrorShowsNonCancellation() {
|
||||||
|
let c = UiMessageController()
|
||||||
|
c.error(InvitationError.message("The transfer was refused"))
|
||||||
|
XCTAssertEqual(c.current?.tone, .error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testToUiTextMapsKnownReasons() {
|
||||||
|
XCTAssertEqual(InvitationError.message("The transfer was refused").toUiText(), .resource("error_permission"))
|
||||||
|
XCTAssertEqual(InvitationError.message("invalid ticket").toUiText(), .resource("error_invalid_ticket"))
|
||||||
|
XCTAssertEqual(InvitationError.message("Select at least one file to share").toUiText(), .resource("error_share_empty"))
|
||||||
|
XCTAssertEqual(InvitationError.message("Camera access is required").toUiText(), .resource("error_camera"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testToUiTextFallsBackToGeneric() {
|
||||||
|
XCTAssertEqual(InvitationError.message("something entirely unexpected").toUiText(), .resource("error_generic"))
|
||||||
|
}
|
||||||
|
}
|
||||||
32
apple/VniDrop/App/AppEnvironment.swift
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Platform environment, ported from `Platform.kt` (`PlatformEnvironment`).
|
||||||
|
struct PlatformEnvironment {
|
||||||
|
let name: String
|
||||||
|
let appVersion: String
|
||||||
|
let defaultCoreDataDir: String
|
||||||
|
var defaultUsername: String = "Receiver"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Device info for diagnostics/about, ported from `DeviceInfo`.
|
||||||
|
struct DeviceInfo {
|
||||||
|
let deviceName: String?
|
||||||
|
let deviceModel: String?
|
||||||
|
let operatingSystem: String
|
||||||
|
let network: String?
|
||||||
|
let batteryLevel: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
protocol DeviceInfoProvider {
|
||||||
|
func load() async -> DeviceInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bundle of platform dependencies, ported from `AppDependencies`.
|
||||||
|
struct AppDependencies {
|
||||||
|
let environment: PlatformEnvironment
|
||||||
|
let deviceInfoProvider: DeviceInfoProvider
|
||||||
|
let fileSystemService: FileSystemService
|
||||||
|
let notificationService: LocalNotificationService
|
||||||
|
let externalInvitations: ExternalInvitationController
|
||||||
|
}
|
||||||
43
apple/VniDrop/App/AppGraph.swift
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// Object graph wiring the repositories and coordinators together, ported from
|
||||||
|
/// `AppGraph.kt`. Owned by the app root for the process lifetime.
|
||||||
|
@MainActor
|
||||||
|
final class AppGraph: ObservableObject {
|
||||||
|
let dependencies: AppDependencies
|
||||||
|
let coreRepository: CoreRepository
|
||||||
|
let visibility = AppVisibility()
|
||||||
|
let messages = UiMessageController()
|
||||||
|
let preferencesRepository: AppPreferencesRepository
|
||||||
|
let filePreviewRepository: FilePreviewRepository
|
||||||
|
let approvalCoordinator: ApprovalCoordinator
|
||||||
|
|
||||||
|
init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil) {
|
||||||
|
self.dependencies = dependencies
|
||||||
|
let coreRepository = coreRepository ?? CoreRepository()
|
||||||
|
self.coreRepository = coreRepository
|
||||||
|
self.filePreviewRepository = FilePreviewRepository(appDataDir: dependencies.environment.defaultCoreDataDir)
|
||||||
|
self.preferencesRepository = AppPreferencesRepository(
|
||||||
|
fallback: AppPreferencesDefaults(
|
||||||
|
username: dependencies.environment.defaultUsername,
|
||||||
|
receiveFolder: dependencies.fileSystemService.defaultReceiveFolder(),
|
||||||
|
themeMode: .system,
|
||||||
|
notificationsEnabled: false,
|
||||||
|
diagnosticsEnabled: false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.approvalCoordinator = ApprovalCoordinator(
|
||||||
|
repository: coreRepository,
|
||||||
|
preferences: preferencesRepository,
|
||||||
|
notifications: dependencies.notificationService,
|
||||||
|
visibility: visibility,
|
||||||
|
messages: messages
|
||||||
|
)
|
||||||
|
AppLogger.info("lifecycle", "graph created", ["platform": dependencies.environment.name])
|
||||||
|
}
|
||||||
|
|
||||||
|
func close() {
|
||||||
|
coreRepository.shutdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
189
apple/VniDrop/App/RootView.swift
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// App root, ported from `App.kt`. Owns the object graph and feature models, wires
|
||||||
|
/// the adaptive shell, floating actions, snackbar host, and approval modal.
|
||||||
|
struct RootView: View {
|
||||||
|
@StateObject private var graph: AppGraph
|
||||||
|
@StateObject private var appModel: AppModel
|
||||||
|
@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
|
||||||
|
|
||||||
|
init(dependencies: AppDependencies) {
|
||||||
|
let graph = AppGraph(dependencies: dependencies)
|
||||||
|
_graph = StateObject(wrappedValue: graph)
|
||||||
|
_appModel = StateObject(wrappedValue: AppModel(
|
||||||
|
environment: dependencies.environment,
|
||||||
|
repository: graph.coreRepository,
|
||||||
|
preferences: graph.preferencesRepository,
|
||||||
|
messages: graph.messages
|
||||||
|
))
|
||||||
|
_sendModel = StateObject(wrappedValue: SendModel(
|
||||||
|
repository: graph.coreRepository,
|
||||||
|
fileSystemService: dependencies.fileSystemService,
|
||||||
|
preferences: graph.preferencesRepository,
|
||||||
|
filePreviewRepository: graph.filePreviewRepository,
|
||||||
|
messages: graph.messages
|
||||||
|
))
|
||||||
|
_receiveModel = StateObject(wrappedValue: ReceiveModel(
|
||||||
|
repository: graph.coreRepository,
|
||||||
|
fileSystemService: dependencies.fileSystemService,
|
||||||
|
preferences: graph.preferencesRepository,
|
||||||
|
messages: graph.messages
|
||||||
|
))
|
||||||
|
_settingsModel = StateObject(wrappedValue: SettingsModel(
|
||||||
|
environment: dependencies.environment,
|
||||||
|
deviceInfoProvider: dependencies.deviceInfoProvider,
|
||||||
|
fileSystemService: dependencies.fileSystemService,
|
||||||
|
repository: graph.coreRepository,
|
||||||
|
preferences: graph.preferencesRepository,
|
||||||
|
notifications: dependencies.notificationService,
|
||||||
|
messages: graph.messages,
|
||||||
|
bugReports: NoopBugReportService()
|
||||||
|
))
|
||||||
|
messages = graph.messages
|
||||||
|
approvals = graph.approvalCoordinator
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { proxy in
|
||||||
|
let windowClass = windowClassFor(width: proxy.size.width)
|
||||||
|
let isDark = resolveDarkTheme(appModel.themeMode, systemDark: systemDark)
|
||||||
|
ZStack {
|
||||||
|
navigation(windowClass: windowClass)
|
||||||
|
SnackbarHost(controller: messages)
|
||||||
|
ApprovalModalHost(
|
||||||
|
state: approvals.state,
|
||||||
|
onAccept: approvals.accept,
|
||||||
|
onRefuse: approvals.refuse
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.vniDropTheme(isDark: isDark)
|
||||||
|
.preferredColorScheme(appModel.themeMode.preferredColorScheme)
|
||||||
|
.environment(\.vniColors, isDark ? .dark : .light)
|
||||||
|
}
|
||||||
|
.platformPickers(settingsModel: settingsModel)
|
||||||
|
.task { await consumeExternalInvitations() }
|
||||||
|
.onChange(of: scenePhase) { _, phase in
|
||||||
|
switch phase {
|
||||||
|
case .active:
|
||||||
|
graph.visibility.setForeground(true)
|
||||||
|
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:
|
||||||
|
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 —
|
||||||
|
// otherwise notifications (only posted when unfocused) never fire.
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didResignActiveNotification)) { _ in
|
||||||
|
graph.visibility.setForeground(false)
|
||||||
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
||||||
|
graph.visibility.setForeground(true)
|
||||||
|
settingsModel.refreshNotificationPermission()
|
||||||
|
Task { _ = await graph.coreRepository.refresh() }
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// iOS uses a bottom tab bar; macOS uses a native source-list sidebar so each
|
||||||
|
/// screen's toolbar lives in the detail column instead of the shared title bar.
|
||||||
|
@ViewBuilder
|
||||||
|
private func navigation(windowClass: WindowClass) -> some View {
|
||||||
|
#if os(macOS)
|
||||||
|
NavigationSplitView {
|
||||||
|
List(AppDestination.allCases, selection: sidebarBinding) { destination in
|
||||||
|
Label(LocalizedStringKey(destination.labelKey), systemImage: destination.systemImage)
|
||||||
|
.tag(destination)
|
||||||
|
}
|
||||||
|
.navigationSplitViewColumnWidth(min: 180, ideal: 200, max: 260)
|
||||||
|
} detail: {
|
||||||
|
screen(for: appModel.destination, windowClass: windowClass)
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
TabView(selection: destinationBinding) {
|
||||||
|
ForEach(AppDestination.allCases) { destination in
|
||||||
|
screen(for: destination, windowClass: windowClass)
|
||||||
|
.tabItem {
|
||||||
|
Label(LocalizedStringKey(destination.labelKey), systemImage: destination.systemImage)
|
||||||
|
}
|
||||||
|
.tag(destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sidebarBinding: Binding<AppDestination?> {
|
||||||
|
Binding(
|
||||||
|
get: { appModel.destination },
|
||||||
|
set: { newValue in
|
||||||
|
if let value = newValue {
|
||||||
|
Task { @MainActor in appModel.selectDestination(value) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var destinationBinding: Binding<AppDestination> {
|
||||||
|
// Defer the write out of the current view-update cycle: TabView reconciles
|
||||||
|
// its selection synchronously during body evaluation on macOS, and mutating
|
||||||
|
// the published `destination` there triggers a "publishing within view
|
||||||
|
// updates" warning.
|
||||||
|
Binding(get: { appModel.destination }, set: { newValue in
|
||||||
|
Task { @MainActor in appModel.selectDestination(newValue) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func screen(for destination: AppDestination, windowClass: WindowClass) -> some View {
|
||||||
|
switch destination {
|
||||||
|
case .send: SendScreen(model: sendModel, windowClass: windowClass)
|
||||||
|
case .receive: ReceiveScreen(model: receiveModel, windowClass: windowClass)
|
||||||
|
case .settings: SettingsScreen(model: settingsModel, windowClass: windowClass)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var systemDark: Bool {
|
||||||
|
#if os(iOS)
|
||||||
|
return UITraitCollection.current.userInterfaceStyle == .dark
|
||||||
|
#else
|
||||||
|
return NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func consumeExternalInvitations() async {
|
||||||
|
for await invitation in graph.dependencies.externalInvitations.invitations {
|
||||||
|
appModel.selectDestination(.receive)
|
||||||
|
switch invitation {
|
||||||
|
case .success(let raw):
|
||||||
|
receiveModel.onInvitationResult(.invitationFile, .success(raw))
|
||||||
|
case .failure(let error):
|
||||||
|
receiveModel.onInvitationResult(.invitationFile, .failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#else
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
40
apple/VniDrop/App/VniDropApp.swift
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Native app entry point for iOS, iPadOS, and macOS.
|
||||||
|
/// Opens `.vnd` invitations via `onOpenURL` and routes them to the receive flow.
|
||||||
|
@main
|
||||||
|
struct VniDropApp: App {
|
||||||
|
@StateObject private var externalInvitations = ExternalInvitationController()
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
RootView(dependencies: makeAppDependencies(externalInvitations: externalInvitations))
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.onOpenURL(perform: openInvitation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a `.vnd` invitation document under a security scope, enforcing the
|
||||||
|
/// 64 KiB / strict-UTF-8 rules from `ContentView.swift`.
|
||||||
|
private func openInvitation(_ url: URL) {
|
||||||
|
guard url.pathExtension.caseInsensitiveCompare(vniDropInvitationExtension) == .orderedSame else {
|
||||||
|
externalInvitations.reportOpenFailure(message: "This is not a VniDrop invitation")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let started = url.startAccessingSecurityScopedResource()
|
||||||
|
defer { if started { url.stopAccessingSecurityScopedResource() } }
|
||||||
|
do {
|
||||||
|
let values = try url.resourceValues(forKeys: [.fileSizeKey])
|
||||||
|
if let size = values.fileSize, size > maxVniDropInvitationBytes {
|
||||||
|
throw InvitationError.tooLarge
|
||||||
|
}
|
||||||
|
let data = try Data(contentsOf: url, options: .mappedIfSafe)
|
||||||
|
let raw = try decodeInvitationBytes(data)
|
||||||
|
externalInvitations.openInvitation(raw: raw)
|
||||||
|
} catch {
|
||||||
|
externalInvitations.reportOpenFailure(
|
||||||
|
message: (error as? LocalizedError)?.errorDescription ?? "The invitation could not be opened"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
apple/VniDrop/Core/AppLogger.swift
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// Minimal structured logger, ported from `logging/AppLogger.kt`. Never logs
|
||||||
|
/// tickets, endpoint ids, or file contents (callers pass only redacted fields).
|
||||||
|
enum AppLogger {
|
||||||
|
private static let logger = Logger(subsystem: "com.vnidrop.app", category: "app")
|
||||||
|
|
||||||
|
static func info(_ scope: String, _ message: String, _ fields: [String: String] = [:]) {
|
||||||
|
logger.info("[\(scope, privacy: .public)] \(message, privacy: .public) \(fieldString(fields), privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func error(_ scope: String, _ message: String, _ error: Error? = nil) {
|
||||||
|
if let error {
|
||||||
|
logger.error("[\(scope, privacy: .public)] \(message, privacy: .public): \(error.technicalDetail, privacy: .public)")
|
||||||
|
} else {
|
||||||
|
logger.error("[\(scope, privacy: .public)] \(message, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func fieldString(_ fields: [String: String]) -> String {
|
||||||
|
fields.isEmpty ? "" : fields.map { "\($0)=\($1)" }.joined(separator: " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
135
apple/VniDrop/Core/AppPreferences.swift
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// Receive-destination descriptor, ported from `core/FileSystemService.kt`.
|
||||||
|
enum ReceiveFolderKind: String, Codable, Sendable {
|
||||||
|
case fileSystemPath
|
||||||
|
case iosSecurityScopedUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ReceiveFolder: Equatable, Codable, Sendable {
|
||||||
|
let kind: ReceiveFolderKind
|
||||||
|
let value: String
|
||||||
|
let displayName: String
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FolderAccessStatus {
|
||||||
|
case writable
|
||||||
|
case permissionRequired
|
||||||
|
case unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persisted app preferences, ported from `preferences/AppPreferencesRepository.kt`.
|
||||||
|
/// Backed by `UserDefaults` instead of DataStore; keys and semantics match.
|
||||||
|
struct AppPreferences: Equatable {
|
||||||
|
var username: String
|
||||||
|
var receiveFolder: ReceiveFolder
|
||||||
|
var themeMode: ThemeMode
|
||||||
|
var notificationsEnabled: Bool
|
||||||
|
var diagnosticsEnabled: Bool
|
||||||
|
var diagnosticsInstallId: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AppPreferencesDefaults {
|
||||||
|
let username: String
|
||||||
|
let receiveFolder: ReceiveFolder
|
||||||
|
let themeMode: ThemeMode
|
||||||
|
var notificationsEnabled: Bool = false
|
||||||
|
var diagnosticsEnabled: Bool = false
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AppPreferencesRepository: ObservableObject {
|
||||||
|
@Published private(set) var preferences: AppPreferences
|
||||||
|
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
private let fallback: AppPreferencesDefaults
|
||||||
|
|
||||||
|
private enum Key {
|
||||||
|
static let username = "username"
|
||||||
|
static let receiveFolderKind = "receive_folder_kind"
|
||||||
|
static let receiveFolderValue = "receive_folder_value"
|
||||||
|
static let receiveFolderDisplayName = "receive_folder_display_name"
|
||||||
|
static let themeMode = "theme_mode"
|
||||||
|
static let notificationsEnabled = "notifications_enabled"
|
||||||
|
static let diagnosticsEnabled = "diagnostics_enabled"
|
||||||
|
static let diagnosticsInstallId = "diagnostics_install_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(defaults: UserDefaults = .standard, fallback: AppPreferencesDefaults) {
|
||||||
|
self.defaults = defaults
|
||||||
|
self.fallback = fallback
|
||||||
|
self.preferences = Self.read(from: defaults, fallback: fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func read(from defaults: UserDefaults, fallback: AppPreferencesDefaults) -> AppPreferences {
|
||||||
|
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 notifications = defaults.object(forKey: Key.notificationsEnabled) as? Bool ?? fallback.notificationsEnabled
|
||||||
|
let diagnostics = defaults.object(forKey: Key.diagnosticsEnabled) as? Bool ?? fallback.diagnosticsEnabled
|
||||||
|
let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? ""
|
||||||
|
return AppPreferences(
|
||||||
|
username: username,
|
||||||
|
receiveFolder: folder,
|
||||||
|
themeMode: themeMode,
|
||||||
|
notificationsEnabled: notifications,
|
||||||
|
diagnosticsEnabled: diagnostics,
|
||||||
|
diagnosticsInstallId: installId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolveReceiveFolder(_ defaults: UserDefaults, fallback: ReceiveFolder) -> ReceiveFolder {
|
||||||
|
let kind = defaults.string(forKey: Key.receiveFolderKind)
|
||||||
|
.flatMap(ReceiveFolderKind.init(rawValue:)) ?? fallback.kind
|
||||||
|
let value = defaults.string(forKey: Key.receiveFolderValue).flatMap { $0.isEmpty ? nil : $0 } ?? fallback.value
|
||||||
|
let displayName = defaults.string(forKey: Key.receiveFolderDisplayName)
|
||||||
|
.flatMap { $0.isEmpty ? nil : $0 } ?? fallback.displayName
|
||||||
|
return ReceiveFolder(kind: kind, value: value, displayName: displayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reload() {
|
||||||
|
preferences = Self.read(from: defaults, fallback: fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setUsername(_ username: String) {
|
||||||
|
defaults.set(username.trimmingCharacters(in: .whitespacesAndNewlines), forKey: Key.username)
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setReceiveFolder(_ folder: ReceiveFolder) {
|
||||||
|
defaults.set(folder.kind.rawValue, forKey: Key.receiveFolderKind)
|
||||||
|
defaults.set(folder.value, forKey: Key.receiveFolderValue)
|
||||||
|
defaults.set(folder.displayName, forKey: Key.receiveFolderDisplayName)
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetReceiveFolder() {
|
||||||
|
setReceiveFolder(fallback.receiveFolder)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setThemeMode(_ mode: ThemeMode) {
|
||||||
|
defaults.set(mode.rawValue, forKey: Key.themeMode)
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setNotificationsEnabled(_ enabled: Bool) {
|
||||||
|
defaults.set(enabled, forKey: Key.notificationsEnabled)
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setDiagnosticsEnabled(_ enabled: Bool) {
|
||||||
|
defaults.set(enabled, forKey: Key.diagnosticsEnabled)
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func ensureDiagnosticsInstallId() -> String {
|
||||||
|
let existing = preferences.diagnosticsInstallId
|
||||||
|
if !existing.isEmpty { return existing }
|
||||||
|
let created = UUID().uuidString
|
||||||
|
defaults.set(created, forKey: Key.diagnosticsInstallId)
|
||||||
|
reload()
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apple/VniDrop/Core/AppVisibility.swift
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// Tracks whether the app is in the foreground, ported from `platform/AppVisibility.kt`.
|
||||||
|
@MainActor
|
||||||
|
final class AppVisibility: ObservableObject {
|
||||||
|
@Published private(set) var isForeground: Bool = true
|
||||||
|
|
||||||
|
func setForeground(_ value: Bool) {
|
||||||
|
isForeground = value
|
||||||
|
}
|
||||||
|
}
|
||||||
38
apple/VniDrop/Core/CoreGateway.swift
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import VnidropCore
|
||||||
|
|
||||||
|
/// Seam between the feature models and the Rust core, mirroring `CoreGateway`
|
||||||
|
/// in the KMP `shared` module. `CoreRepository` is the production implementation;
|
||||||
|
/// tests substitute a fake so the models can be exercised without the FFI.
|
||||||
|
@MainActor
|
||||||
|
protocol CoreGateway: AnyObject {
|
||||||
|
/// Latest published core state.
|
||||||
|
var state: CoreState { get }
|
||||||
|
/// Publisher of core-state changes (the models subscribe to this).
|
||||||
|
var statePublisher: AnyPublisher<CoreState, Never> { get }
|
||||||
|
/// Coalesced change hints emitted by the event sink.
|
||||||
|
var signals: AnyPublisher<CoreSignal, Never> { get }
|
||||||
|
|
||||||
|
func initialize(appDataDir: String) async -> Result<Void, Error>
|
||||||
|
func shutdown()
|
||||||
|
func shareSources(
|
||||||
|
_ sources: [ShareSource],
|
||||||
|
transferName: String,
|
||||||
|
senderName: String,
|
||||||
|
accessPolicy: ShareAccessPolicy
|
||||||
|
) async -> Result<Share, Error>
|
||||||
|
func inspectTicket(_ ticket: String) async -> Result<TicketInspectionModel, Error>
|
||||||
|
func receive(ticket: String, outputDir: String, receiverName: String) async -> Result<Void, Error>
|
||||||
|
func receiveIntoSecurityScopedDirectory(
|
||||||
|
ticket: String,
|
||||||
|
outputDirectoryUrl: String,
|
||||||
|
receiverName: String
|
||||||
|
) async -> Result<Void, Error>
|
||||||
|
func cancel(transferId: UInt64) async -> Result<Void, Error>
|
||||||
|
func delete(transferId: UInt64) async -> Result<Void, Error>
|
||||||
|
func clearReceiveHistory() async -> Result<UInt64, Error>
|
||||||
|
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>
|
||||||
|
}
|
||||||
141
apple/VniDrop/Core/CoreModels.swift
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// App-facing domain models, ported from `core/CoreModels.kt`. The repository maps
|
||||||
|
/// the generated UniFFI records/enums into these so the UI never depends on the
|
||||||
|
/// binding surface directly.
|
||||||
|
|
||||||
|
struct CoreStatus: Equatable, Sendable {
|
||||||
|
let endpointId: String
|
||||||
|
let activeTransfers: UInt64
|
||||||
|
let activeShares: UInt64
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CoreEventModel: Equatable, Identifiable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let timestamp: Int64
|
||||||
|
let scope: String
|
||||||
|
let transferId: UInt64?
|
||||||
|
let direction: String?
|
||||||
|
let phase: String
|
||||||
|
let kind: String
|
||||||
|
let dataJson: String
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ShareAccessPolicy: Equatable, Sendable {
|
||||||
|
case requireApproval
|
||||||
|
case anyoneWithTransfer
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TransferDirection: Equatable, Sendable {
|
||||||
|
case send
|
||||||
|
case receive
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TransferStatus: Equatable, Sendable {
|
||||||
|
case importing
|
||||||
|
case sharing
|
||||||
|
case receiving
|
||||||
|
case done
|
||||||
|
case failed
|
||||||
|
case cancelled
|
||||||
|
case stopped
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Transfer: Equatable, Identifiable, Sendable {
|
||||||
|
let localId: String
|
||||||
|
let transferId: UInt64
|
||||||
|
let direction: TransferDirection
|
||||||
|
let status: TransferStatus
|
||||||
|
let peerId: String?
|
||||||
|
let transferName: String?
|
||||||
|
let contentHash: String?
|
||||||
|
let fileCount: UInt64
|
||||||
|
let totalSize: UInt64
|
||||||
|
let ticket: String?
|
||||||
|
let accessPolicy: ShareAccessPolicy
|
||||||
|
let createdAt: Int64
|
||||||
|
let updatedAt: Int64
|
||||||
|
|
||||||
|
var id: String { localId }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Share: Equatable, Sendable {
|
||||||
|
let transferId: UInt64
|
||||||
|
let ticket: String
|
||||||
|
let transferName: String
|
||||||
|
let contentHash: String
|
||||||
|
let fileCount: UInt64
|
||||||
|
let totalSize: UInt64
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TransferMetadataModel: Equatable, Sendable {
|
||||||
|
let transferId: UInt64
|
||||||
|
let transferName: String
|
||||||
|
let senderName: String?
|
||||||
|
let contentHash: String
|
||||||
|
let fileCount: UInt64
|
||||||
|
let totalSize: UInt64
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TicketInspectionModel: Equatable, Sendable {
|
||||||
|
let kind: String
|
||||||
|
let metadata: TransferMetadataModel
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ReceiverDeliveryStatus: Equatable, Sendable {
|
||||||
|
case requested
|
||||||
|
case accepted
|
||||||
|
case refused
|
||||||
|
case expired
|
||||||
|
case completed
|
||||||
|
case unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ReceiverRequestModel: Equatable, Identifiable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let transferId: UInt64
|
||||||
|
let remoteEndpointId: String
|
||||||
|
let transferName: String
|
||||||
|
let receiverName: String?
|
||||||
|
let receiverDeviceName: String?
|
||||||
|
let appVersion: String
|
||||||
|
let status: ReceiverDeliveryStatus
|
||||||
|
let reason: String?
|
||||||
|
let requestedAt: Int64
|
||||||
|
let respondedAt: Int64?
|
||||||
|
let completedAt: Int64?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CoreState: Equatable, Sendable {
|
||||||
|
var isInitialized: Bool = false
|
||||||
|
var status: CoreStatus?
|
||||||
|
var events: [CoreEventModel] = []
|
||||||
|
var transfers: [Transfer] = []
|
||||||
|
var lastShare: Share?
|
||||||
|
var lastInspection: TicketInspectionModel?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coalesced change hints emitted from the event sink, ported from `CoreSignal`.
|
||||||
|
enum CoreSignal: Equatable, Sendable {
|
||||||
|
case approvalChanged(transferId: UInt64)
|
||||||
|
case receiverHistoryChanged(transferId: UInt64)
|
||||||
|
/// Transfer status/history changed enough to re-read the durable snapshot.
|
||||||
|
case transfersChanged(transferId: UInt64)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Transfer helpers (ported from AppUiModels.kt)
|
||||||
|
|
||||||
|
extension TransferStatus {
|
||||||
|
var isActiveTransfer: Bool {
|
||||||
|
self == .importing || self == .sharing || self == .receiving
|
||||||
|
}
|
||||||
|
|
||||||
|
var canCancelTransfer: Bool {
|
||||||
|
self == .importing || self == .sharing || self == .receiving
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Terminal receive-history states eligible for deletion.
|
||||||
|
var isTerminalReceiveHistory: Bool {
|
||||||
|
self == .done || self == .failed || self == .cancelled
|
||||||
|
}
|
||||||
|
}
|
||||||
396
apple/VniDrop/Core/CoreRepository.swift
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
@preconcurrency import VnidropCore
|
||||||
|
|
||||||
|
/// Swift port of `core/CoreRepository.kt`. Owns the `VnidropCore` handle, maps the
|
||||||
|
/// generated UniFFI records into app domain models, publishes an observable
|
||||||
|
/// `CoreState`, and emits coalesced `CoreSignal`s from the event sink.
|
||||||
|
///
|
||||||
|
/// UniFFI calls block (the core drives its own runtime via `block_on`), so they
|
||||||
|
/// run on a background queue and results are hopped back to the main actor.
|
||||||
|
@MainActor
|
||||||
|
final class CoreRepository: ObservableObject, CoreGateway {
|
||||||
|
@Published private(set) var state = CoreState()
|
||||||
|
var statePublisher: AnyPublisher<CoreState, Never> { $state.eraseToAnyPublisher() }
|
||||||
|
|
||||||
|
private let signalsSubject = PassthroughSubject<CoreSignal, Never>()
|
||||||
|
/// Coalesced change hints; subscribe to react to approval/history/transfer changes.
|
||||||
|
var signals: AnyPublisher<CoreSignal, Never> { signalsSubject.eraseToAnyPublisher() }
|
||||||
|
|
||||||
|
// Set on the main actor (initialize/shutdown) but read from `queue` inside
|
||||||
|
// `runCore`; the underlying core is internally synchronized, so this crossing
|
||||||
|
// is safe. `nonisolated(unsafe)` documents that contract for Swift 6.
|
||||||
|
private nonisolated(unsafe) var core: VnidropCore?
|
||||||
|
private let queue = DispatchQueue(label: "com.vnidrop.core", qos: .userInitiated)
|
||||||
|
private lazy var sink = RepositoryEventSink { [weak self] event in
|
||||||
|
Task { @MainActor in self?.handle(event: event) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static let maxEvents = 200
|
||||||
|
|
||||||
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
|
func initialize(appDataDir: String) async -> Result<Void, Error> {
|
||||||
|
await runCore { [sink] in
|
||||||
|
self.core?.shutdown()
|
||||||
|
let created = try VnidropCore.initialize(appDataDir: appDataDir, eventSink: sink)
|
||||||
|
return created
|
||||||
|
}.map { created in
|
||||||
|
self.core = created
|
||||||
|
self.refreshSnapshot()
|
||||||
|
self.state.isInitialized = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shutdown() {
|
||||||
|
core?.shutdown()
|
||||||
|
core = nil
|
||||||
|
state = CoreState()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Share
|
||||||
|
|
||||||
|
func shareSources(
|
||||||
|
_ sources: [ShareSource],
|
||||||
|
transferName: String,
|
||||||
|
senderName: String,
|
||||||
|
accessPolicy: ShareAccessPolicy
|
||||||
|
) async -> Result<Share, Error> {
|
||||||
|
guard !sources.isEmpty else {
|
||||||
|
return .failure(InvitationError.message("Select at least one file to share"))
|
||||||
|
}
|
||||||
|
return await runCore {
|
||||||
|
let result = try self.requireCore().shareFiles(
|
||||||
|
sources: sources,
|
||||||
|
metadata: ShareMetadataInput(
|
||||||
|
transferId: Self.nextTransferId(),
|
||||||
|
transferName: transferName.isEmpty ? nil : transferName,
|
||||||
|
senderName: senderName.isEmpty ? nil : senderName,
|
||||||
|
accessMode: accessPolicy.toNative()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.toModel()
|
||||||
|
}.map { share in
|
||||||
|
self.refreshSnapshot()
|
||||||
|
self.state.lastShare = share
|
||||||
|
return share
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Inspect / Receive
|
||||||
|
|
||||||
|
func inspectTicket(_ ticket: String) async -> Result<TicketInspectionModel, Error> {
|
||||||
|
await runCore {
|
||||||
|
try self.requireCore().inspectTicket(ticket: ticket).toModel()
|
||||||
|
}.map { inspection in
|
||||||
|
self.state.lastInspection = inspection
|
||||||
|
return inspection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func receive(ticket: String, outputDir: String, receiverName: String) async -> Result<Void, Error> {
|
||||||
|
await runCore {
|
||||||
|
try self.requireCore().receive(
|
||||||
|
ticket: ticket,
|
||||||
|
outputDir: outputDir,
|
||||||
|
receiverName: receiverName.isEmpty ? nil : receiverName
|
||||||
|
)
|
||||||
|
}.map { self.refreshSnapshot() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receive into a security-scoped directory URL, holding access while the core
|
||||||
|
/// streams (mirrors `receiveIntoSecurityScopedDirectory`).
|
||||||
|
func receiveIntoSecurityScopedDirectory(
|
||||||
|
ticket: String,
|
||||||
|
outputDirectoryUrl: String,
|
||||||
|
receiverName: String
|
||||||
|
) async -> Result<Void, Error> {
|
||||||
|
await runCore {
|
||||||
|
try withSecurityScopedAccess(pathOrUrl: outputDirectoryUrl) {
|
||||||
|
try self.requireCore().receive(
|
||||||
|
ticket: ticket,
|
||||||
|
outputDir: outputDirectoryUrl,
|
||||||
|
receiverName: receiverName.isEmpty ? nil : receiverName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.map { self.refreshSnapshot() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lifecycle actions
|
||||||
|
|
||||||
|
func cancel(transferId: UInt64) async -> Result<Void, Error> {
|
||||||
|
await runCore {
|
||||||
|
try self.requireCore().cancelTransfer(transferId: transferId)
|
||||||
|
}.map { self.refreshSnapshot() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete(transferId: UInt64) async -> Result<Void, Error> {
|
||||||
|
await runCore {
|
||||||
|
try self.requireCore().deleteTransfer(transferId: transferId)
|
||||||
|
}.map {
|
||||||
|
self.refreshSnapshot()
|
||||||
|
self.signalsSubject.send(.approvalChanged(transferId: transferId))
|
||||||
|
self.signalsSubject.send(.receiverHistoryChanged(transferId: transferId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearReceiveHistory() async -> Result<UInt64, Error> {
|
||||||
|
await runCore {
|
||||||
|
try self.requireCore().deleteReceiveHistory()
|
||||||
|
}.map { deleted in
|
||||||
|
self.refreshSnapshot()
|
||||||
|
return deleted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error> {
|
||||||
|
await runCore {
|
||||||
|
try self.requireCore().listReceiverRequests(transferId: transferId).map { $0.toModel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func respondReceiverRequest(
|
||||||
|
requestId: String,
|
||||||
|
accepted: Bool,
|
||||||
|
reason: String? = nil
|
||||||
|
) async -> Result<Void, Error> {
|
||||||
|
await runCore {
|
||||||
|
try self.requireCore().respondReceiverRequest(requestId: requestId, accepted: accepted, reason: reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh() async -> Result<Void, Error> {
|
||||||
|
// Read from the core off the main actor, then apply the snapshot on the
|
||||||
|
// main actor so `@Published state` is never mutated from `queue`.
|
||||||
|
await runCore { self.readSnapshot() }.map { snapshot in
|
||||||
|
if let snapshot { self.applySnapshot(snapshot) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Event sink handling (ported from CoreRepository.sink)
|
||||||
|
|
||||||
|
private func handle(event: CoreEvent) {
|
||||||
|
let model = event.toModel()
|
||||||
|
var events = state.events
|
||||||
|
events.insert(model, at: 0)
|
||||||
|
if events.count > Self.maxEvents { events = Array(events.prefix(Self.maxEvents)) }
|
||||||
|
state.events = events
|
||||||
|
|
||||||
|
guard let transferId = model.transferId else { return }
|
||||||
|
switch model.phase {
|
||||||
|
case "approval": signalsSubject.send(.approvalChanged(transferId: transferId))
|
||||||
|
case "delivery": signalsSubject.send(.receiverHistoryChanged(transferId: transferId))
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
if model.shouldRefreshTransfers {
|
||||||
|
signalsSubject.send(.transfersChanged(transferId: transferId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internals
|
||||||
|
|
||||||
|
/// Snapshot of the values read from the core in one pass.
|
||||||
|
private struct CoreSnapshot: Sendable {
|
||||||
|
let status: CoreStatus
|
||||||
|
let transfers: [Transfer]
|
||||||
|
let events: [CoreEventModel]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the current core state. Safe to call off the main actor (pure core
|
||||||
|
/// FFI reads); does not touch `@Published` state.
|
||||||
|
private nonisolated func readSnapshot() -> CoreSnapshot? {
|
||||||
|
guard let core = self.core else { return nil }
|
||||||
|
let status = core.status()
|
||||||
|
let transfers = (try? core.listTransfers())?.map { $0.toModel() } ?? []
|
||||||
|
let events = (try? core.listEvents(transferId: nil))?.prefix(Self.maxEvents).map { $0.toModel() } ?? []
|
||||||
|
return CoreSnapshot(
|
||||||
|
status: CoreStatus(
|
||||||
|
endpointId: status.endpointId,
|
||||||
|
activeTransfers: status.activeTransfers,
|
||||||
|
activeShares: status.activeShares
|
||||||
|
),
|
||||||
|
transfers: transfers,
|
||||||
|
events: Array(events)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies a snapshot to `@Published state`. Must run on the main actor.
|
||||||
|
private func applySnapshot(_ snapshot: CoreSnapshot) {
|
||||||
|
state.status = snapshot.status
|
||||||
|
state.transfers = snapshot.transfers
|
||||||
|
state.events = snapshot.events
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshSnapshot() {
|
||||||
|
if let snapshot = readSnapshot() { applySnapshot(snapshot) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated func requireCore() throws -> VnidropCore {
|
||||||
|
guard let core = self.core else {
|
||||||
|
throw InvitationError.message("Initialize the core first.")
|
||||||
|
}
|
||||||
|
return core
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs a blocking core call off the main actor and hops the result back.
|
||||||
|
private nonisolated func runCore<T: Sendable>(_ block: @escaping @Sendable () throws -> T) async -> Result<T, Error> {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
queue.async {
|
||||||
|
let result: Result<T, Error>
|
||||||
|
do {
|
||||||
|
result = .success(try block())
|
||||||
|
} catch {
|
||||||
|
result = .failure(error)
|
||||||
|
}
|
||||||
|
continuation.resume(returning: result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func nextTransferId() -> UInt64 {
|
||||||
|
UInt64.random(in: 1...UInt64(Int64.max))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Event sink bridged to the repository. `onEvent` is invoked on core-owned
|
||||||
|
/// threads; the handler hops to the main actor.
|
||||||
|
private final class RepositoryEventSink: CoreEventSink, @unchecked Sendable {
|
||||||
|
private let handler: @Sendable (CoreEvent) -> Void
|
||||||
|
init(handler: @escaping @Sendable (CoreEvent) -> Void) { self.handler = handler }
|
||||||
|
func onEvent(event: CoreEvent) { handler(event) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs `body` while holding security-scoped access to a bookmarked URL/path.
|
||||||
|
private func withSecurityScopedAccess<T>(pathOrUrl: String, _ body: () throws -> T) throws -> T {
|
||||||
|
let url = URL(string: pathOrUrl) ?? URL(fileURLWithPath: pathOrUrl)
|
||||||
|
let started = url.startAccessingSecurityScopedResource()
|
||||||
|
defer { if started { url.stopAccessingSecurityScopedResource() } }
|
||||||
|
return try body()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Mapping (ported from CoreRepository.kt)
|
||||||
|
|
||||||
|
private extension CoreEvent {
|
||||||
|
func toModel() -> CoreEventModel {
|
||||||
|
CoreEventModel(
|
||||||
|
id: id, timestamp: timestamp, scope: scope, transferId: transferId,
|
||||||
|
direction: direction, phase: phase, kind: kind, dataJson: dataJson
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let refreshPhases: Set<String> = ["lifecycle", "error", "ticket", "import", "download", "export", "handshake"]
|
||||||
|
private let refreshKinds: Set<String> = [
|
||||||
|
"started", "done", "created", "failed", "cancelled", "share-stopped", "found-collection", "connected",
|
||||||
|
]
|
||||||
|
|
||||||
|
private extension CoreEventModel {
|
||||||
|
var shouldRefreshTransfers: Bool {
|
||||||
|
refreshPhases.contains(phase) && refreshKinds.contains(kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ShareAccessPolicy {
|
||||||
|
func toNative() -> TransferAccessMode {
|
||||||
|
switch self {
|
||||||
|
case .requireApproval: return .approvalRequired
|
||||||
|
case .anyoneWithTransfer: return .public
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension TransferAccessMode {
|
||||||
|
func toModel() -> ShareAccessPolicy {
|
||||||
|
switch self {
|
||||||
|
case .approvalRequired: return .requireApproval
|
||||||
|
case .public: return .anyoneWithTransfer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension StoredTransfer {
|
||||||
|
func toModel() -> Transfer {
|
||||||
|
Transfer(
|
||||||
|
localId: localId,
|
||||||
|
transferId: transferId,
|
||||||
|
direction: Self.direction(direction),
|
||||||
|
status: Self.status(status),
|
||||||
|
peerId: peerId,
|
||||||
|
transferName: transferName,
|
||||||
|
contentHash: contentHash,
|
||||||
|
fileCount: fileCount,
|
||||||
|
totalSize: totalSize,
|
||||||
|
ticket: ticket,
|
||||||
|
accessPolicy: accessMode.toModel(),
|
||||||
|
createdAt: createdAt,
|
||||||
|
updatedAt: updatedAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func direction(_ raw: String) -> TransferDirection {
|
||||||
|
switch raw {
|
||||||
|
case "send": return .send
|
||||||
|
case "receive": return .receive
|
||||||
|
default: return .send
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func status(_ raw: String) -> TransferStatus {
|
||||||
|
switch raw {
|
||||||
|
case "importing": return .importing
|
||||||
|
case "sharing": return .sharing
|
||||||
|
case "receiving": return .receiving
|
||||||
|
case "done": return .done
|
||||||
|
case "failed": return .failed
|
||||||
|
case "cancelled": return .cancelled
|
||||||
|
case "stopped": return .stopped
|
||||||
|
default: return .failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension ShareResult {
|
||||||
|
func toModel() -> Share {
|
||||||
|
Share(
|
||||||
|
transferId: transferId, ticket: ticket, transferName: transferName,
|
||||||
|
contentHash: hash, fileCount: fileCount, totalSize: totalSize
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension TicketInspection {
|
||||||
|
func toModel() -> TicketInspectionModel {
|
||||||
|
TicketInspectionModel(kind: kind, metadata: metadata.toModel())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension TransferMetadata {
|
||||||
|
func toModel() -> TransferMetadataModel {
|
||||||
|
TransferMetadataModel(
|
||||||
|
transferId: transferId, transferName: transferName, senderName: senderName,
|
||||||
|
contentHash: contentHash, fileCount: fileCount, totalSize: totalSize
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension ReceiverRequest {
|
||||||
|
func toModel() -> ReceiverRequestModel {
|
||||||
|
ReceiverRequestModel(
|
||||||
|
id: id, transferId: transferId, remoteEndpointId: remoteEndpointId,
|
||||||
|
transferName: transferName, receiverName: receiverName, receiverDeviceName: receiverDeviceName,
|
||||||
|
appVersion: appVersion, status: Self.status(status), reason: reason,
|
||||||
|
requestedAt: requestedAt, respondedAt: respondedAt, completedAt: completedAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func status(_ raw: String) -> ReceiverDeliveryStatus {
|
||||||
|
switch raw {
|
||||||
|
case "requested": return .requested
|
||||||
|
case "accepted": return .accepted
|
||||||
|
case "refused": return .refused
|
||||||
|
case "expired": return .expired
|
||||||
|
case "completed": return .completed
|
||||||
|
default: return .unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
apple/VniDrop/Core/ExternalInvitationController.swift
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
let vniDropInvitationMimeType = "application/vnd.vnidrop.transfer"
|
||||||
|
let vniDropInvitationExtension = "vnd"
|
||||||
|
let maxVniDropInvitationBytes = 64 * 1024
|
||||||
|
|
||||||
|
/// Buffered ingress for invitation documents opened by the OS, ported from
|
||||||
|
/// `ExternalInvitationController.kt`. Hosts can submit before the UI is attached
|
||||||
|
/// during a cold launch; each document is consumed exactly once.
|
||||||
|
@MainActor
|
||||||
|
final class ExternalInvitationController: ObservableObject {
|
||||||
|
/// Emits validated (or failed) invitations. The app-level receive workflow
|
||||||
|
/// consumes each exactly once.
|
||||||
|
private var continuation: AsyncStream<Result<String, Error>>.Continuation?
|
||||||
|
lazy var invitations: AsyncStream<Result<String, Error>> = {
|
||||||
|
AsyncStream { continuation in
|
||||||
|
self.continuation = continuation
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
func openInvitation(raw: String) {
|
||||||
|
continuation?.yield(validateInvitation(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
func reportOpenFailure(message: String) {
|
||||||
|
continuation?.yield(.failure(InvitationError.message(message)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum InvitationError: LocalizedError {
|
||||||
|
case empty
|
||||||
|
case tooLarge
|
||||||
|
case invalidEncoding
|
||||||
|
case message(String)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateInvitation(_ raw: String) -> Result<String, Error> {
|
||||||
|
if raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
return .failure(InvitationError.empty)
|
||||||
|
}
|
||||||
|
if raw.utf8.count > maxVniDropInvitationBytes {
|
||||||
|
return .failure(InvitationError.tooLarge)
|
||||||
|
}
|
||||||
|
return .success(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode invitation document bytes as strict UTF-8, ported from
|
||||||
|
/// `decodeInvitationBytes`. Rejects payloads that are not lossless UTF-8.
|
||||||
|
func decodeInvitationBytes(_ bytes: Data) throws -> String {
|
||||||
|
guard !bytes.isEmpty else { throw InvitationError.empty }
|
||||||
|
guard bytes.count <= maxVniDropInvitationBytes else { throw InvitationError.tooLarge }
|
||||||
|
guard let text = String(data: bytes, encoding: .utf8), Data(text.utf8) == bytes else {
|
||||||
|
throw InvitationError.invalidEncoding
|
||||||
|
}
|
||||||
|
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw InvitationError.empty }
|
||||||
|
return text
|
||||||
|
}
|
||||||
59
apple/VniDrop/Core/FileSystemService.swift
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import Foundation
|
||||||
|
import VnidropCore
|
||||||
|
|
||||||
|
/// A file/folder selected for sharing, ported from `PickedShareFile` in
|
||||||
|
/// `core/FilePicker.kt`.
|
||||||
|
struct PickedShareFile: Equatable, Identifiable, Sendable {
|
||||||
|
let value: String
|
||||||
|
let displayName: String
|
||||||
|
var sizeBytes: UInt64? = nil
|
||||||
|
var thumbnailData: Data? = nil
|
||||||
|
/// App-owned picker copy that may be deleted after import or abandonment.
|
||||||
|
var isTemporaryCopy: Bool = false
|
||||||
|
/// When true, `value` is a directory (path or security-scoped folder URL).
|
||||||
|
var isDirectory: Bool = false
|
||||||
|
|
||||||
|
var id: String { value }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receive-destination and share-source platform bridge, ported from
|
||||||
|
/// `core/FileSystemService.kt` and its iOS/desktop actuals.
|
||||||
|
@MainActor
|
||||||
|
protocol FileSystemService {
|
||||||
|
var supportsCustomReceiveFolders: Bool { get }
|
||||||
|
|
||||||
|
func defaultReceiveFolder() -> ReceiveFolder
|
||||||
|
func effectiveReceiveFolder(_ configured: ReceiveFolder) -> ReceiveFolder
|
||||||
|
func validateReceiveFolder(_ folder: ReceiveFolder) async -> FolderAccessStatus
|
||||||
|
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool
|
||||||
|
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error>
|
||||||
|
/// Releases only app-owned picker copies; never deletes original user sources.
|
||||||
|
func discardPickedFiles(_ files: [PickedShareFile]) async
|
||||||
|
func sharePickedFiles(
|
||||||
|
repository: CoreGateway,
|
||||||
|
files: [PickedShareFile],
|
||||||
|
transferName: String,
|
||||||
|
senderName: String,
|
||||||
|
accessPolicy: ShareAccessPolicy
|
||||||
|
) async -> Result<Share, Error>
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FileSystemService {
|
||||||
|
var supportsCustomReceiveFolders: Bool { true }
|
||||||
|
|
||||||
|
func effectiveReceiveFolder(_ configured: ReceiveFolder) -> ReceiveFolder {
|
||||||
|
supportsCustomReceiveFolders ? configured : defaultReceiveFolder()
|
||||||
|
}
|
||||||
|
|
||||||
|
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false }
|
||||||
|
|
||||||
|
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error> {
|
||||||
|
.failure(InvitationError.message("Revealing the receive folder is not supported"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func discardPickedFiles(_ files: [PickedShareFile]) async {}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ReceiveFolder {
|
||||||
|
var isFileSystemPath: Bool { kind == .fileSystemPath }
|
||||||
|
}
|
||||||
102
apple/VniDrop/Core/LocalNotificationService.swift
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
/// Notification permission state, ported from `NotificationPermission`.
|
||||||
|
enum NotificationPermission {
|
||||||
|
case notDetermined
|
||||||
|
case granted
|
||||||
|
case denied
|
||||||
|
case unsupported
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LocalNotification {
|
||||||
|
let id: String
|
||||||
|
let title: String
|
||||||
|
let body: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local notification service backed by `UNUserNotificationCenter`.
|
||||||
|
@MainActor
|
||||||
|
final class LocalNotificationService: ObservableObject {
|
||||||
|
@Published private(set) var permission: NotificationPermission = .notDetermined
|
||||||
|
|
||||||
|
private let center = UNUserNotificationCenter.current()
|
||||||
|
|
||||||
|
func refreshPermission() async -> NotificationPermission {
|
||||||
|
let settings = await center.notificationSettings()
|
||||||
|
let mapped = Self.map(settings.authorizationStatus)
|
||||||
|
permission = mapped
|
||||||
|
return mapped
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestPermission() async -> NotificationPermission {
|
||||||
|
do {
|
||||||
|
_ = try await center.requestAuthorization(options: [.alert, .sound, .badge])
|
||||||
|
} catch {
|
||||||
|
AppLogger.error("notifications", "authorization request failed", error)
|
||||||
|
}
|
||||||
|
return await refreshPermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
func openSettings() async -> Result<Void, Error> {
|
||||||
|
#if os(iOS)
|
||||||
|
guard let url = URL(string: UIApplication.openSettingsURLString) else {
|
||||||
|
return .failure(NotificationError.settingsUnavailable)
|
||||||
|
}
|
||||||
|
let opened = await UIApplication.shared.open(url)
|
||||||
|
return opened ? .success(()) : .failure(NotificationError.settingsUnavailable)
|
||||||
|
#else
|
||||||
|
guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") else {
|
||||||
|
return .failure(NotificationError.settingsUnavailable)
|
||||||
|
}
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
return .success(())
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func publish(_ notification: LocalNotification) async -> Result<Void, Error> {
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = notification.title
|
||||||
|
content.body = notification.body
|
||||||
|
content.sound = .default
|
||||||
|
let request = UNNotificationRequest(identifier: notification.id, content: content, trigger: nil)
|
||||||
|
do {
|
||||||
|
try await center.add(request)
|
||||||
|
return .success(())
|
||||||
|
} catch {
|
||||||
|
return .failure(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel(id: String) {
|
||||||
|
center.removePendingNotificationRequests(withIdentifiers: [id])
|
||||||
|
center.removeDeliveredNotifications(withIdentifiers: [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelAll() {
|
||||||
|
center.removeAllPendingNotificationRequests()
|
||||||
|
center.removeAllDeliveredNotifications()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func map(_ status: UNAuthorizationStatus) -> NotificationPermission {
|
||||||
|
switch status {
|
||||||
|
case .authorized, .provisional, .ephemeral: return .granted
|
||||||
|
case .denied: return .denied
|
||||||
|
case .notDetermined: return .notDetermined
|
||||||
|
@unknown default: return .notDetermined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum NotificationError: LocalizedError {
|
||||||
|
case settingsUnavailable
|
||||||
|
var errorDescription: String? { "Could not open notification settings" }
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#else
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
303
apple/VniDrop/Core/TransferProgress.swift
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Window size classes, ported from `AppUiModels.kt`. Thresholds are in points,
|
||||||
|
/// matching the Compose dp thresholds.
|
||||||
|
enum WindowClass {
|
||||||
|
case phone
|
||||||
|
case tablet
|
||||||
|
case desktop
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowClassFor(width: Double) -> WindowClass {
|
||||||
|
if width >= 920 { return .desktop }
|
||||||
|
if width >= 600 { return .tablet }
|
||||||
|
return .phone
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A progress snapshot derived from core events. `label` is a localization key
|
||||||
|
/// resolved at the view layer.
|
||||||
|
struct TransferProgress: Equatable {
|
||||||
|
let transferId: UInt64?
|
||||||
|
let phase: String
|
||||||
|
let kind: String
|
||||||
|
let labelKey: String
|
||||||
|
let progress: Double?
|
||||||
|
var detail: String? = nil
|
||||||
|
/// Pre-resolved label that overrides `labelKey` when set (e.g. "Sending to 2",
|
||||||
|
/// which needs a runtime count).
|
||||||
|
var label: String? = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusLabelKey(_ status: TransferStatus) -> String {
|
||||||
|
switch status {
|
||||||
|
case .importing: return "status_preparing"
|
||||||
|
case .sharing: return "status_available"
|
||||||
|
case .receiving: return "status_receiving"
|
||||||
|
case .done: return "status_completed"
|
||||||
|
case .cancelled: return "status_cancelled"
|
||||||
|
case .stopped: return "status_stopped"
|
||||||
|
case .failed: return "status_failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let progressPhases: Set<String> = [
|
||||||
|
"import", "ticket", "access", "transfer", "download", "export",
|
||||||
|
"lifecycle", "network", "handshake", "error",
|
||||||
|
]
|
||||||
|
|
||||||
|
private let progressKinds: Set<String> = [
|
||||||
|
"started", "copy-progress", "copy-done", "outboard-progress", "done",
|
||||||
|
"created", "progress", "completed", "aborted", "failed",
|
||||||
|
"connecting", "connected", "found-collection",
|
||||||
|
"cancelled", "share-stopped",
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Latest progress snapshot for a transfer. Events are newest-first.
|
||||||
|
func progressForTransfer(events: [CoreEventModel], transferId: UInt64) -> TransferProgress? {
|
||||||
|
let relevant = events.filter { event in
|
||||||
|
event.transferId == transferId
|
||||||
|
&& progressPhases.contains(event.phase)
|
||||||
|
&& progressKinds.contains(event.kind)
|
||||||
|
}
|
||||||
|
guard let latest = relevant.first else { return nil }
|
||||||
|
let sizeHint = findKnownSize(events: events, transferId: transferId)
|
||||||
|
return TransferProgress(
|
||||||
|
transferId: transferId,
|
||||||
|
phase: latest.phase,
|
||||||
|
kind: latest.kind,
|
||||||
|
labelKey: humanProgressLabel(latest),
|
||||||
|
progress: parseProgress(latest.dataJson, sizeHint: sizeHint),
|
||||||
|
detail: progressDetail(latest)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live byte progress for one receiver on an outgoing share.
|
||||||
|
func progressForReceiver(
|
||||||
|
events: [CoreEventModel],
|
||||||
|
transferId: UInt64,
|
||||||
|
remoteEndpointId: String,
|
||||||
|
totalSizeHint: UInt64? = nil
|
||||||
|
) -> TransferProgress? {
|
||||||
|
if remoteEndpointId.isEmpty { return nil }
|
||||||
|
let connectionIds = connectionIdsForEndpoint(events: events, remoteEndpointId: remoteEndpointId)
|
||||||
|
let transferEvents = events.filter { event in
|
||||||
|
event.transferId == transferId
|
||||||
|
&& event.direction == "send"
|
||||||
|
&& event.phase == "transfer"
|
||||||
|
&& ["started", "progress", "completed", "aborted"].contains(event.kind)
|
||||||
|
&& eventBelongsToReceiver(event, remoteEndpointId: remoteEndpointId, connectionIds: connectionIds)
|
||||||
|
}
|
||||||
|
if transferEvents.isEmpty { return nil }
|
||||||
|
|
||||||
|
let latest = transferEvents[0]
|
||||||
|
if latest.kind == "aborted" {
|
||||||
|
return TransferProgress(
|
||||||
|
transferId: transferId, phase: "transfer", kind: "aborted",
|
||||||
|
labelKey: "progress_interrupted", progress: nil, detail: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if latest.kind == "completed" && !transferEvents.contains(where: { $0.kind == "progress" || $0.kind == "started" }) {
|
||||||
|
return TransferProgress(
|
||||||
|
transferId: transferId, phase: "transfer", kind: "completed",
|
||||||
|
labelKey: "progress_completed", progress: 1, detail: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let progress = aggregateReceiverProgress(events: transferEvents, totalSizeHint: totalSizeHint)
|
||||||
|
return TransferProgress(
|
||||||
|
transferId: transferId, phase: "transfer", kind: latest.kind,
|
||||||
|
labelKey: "progress_sending", progress: progress, detail: progressDetail(latest)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatBytes(_ size: UInt64) -> String {
|
||||||
|
var scaled = Double(size)
|
||||||
|
let units = ["B", "KB", "MB", "GB", "TB"]
|
||||||
|
var unitIndex = 0
|
||||||
|
while scaled >= 1024 && unitIndex < units.count - 1 {
|
||||||
|
scaled /= 1024
|
||||||
|
unitIndex += 1
|
||||||
|
}
|
||||||
|
if unitIndex == 0 {
|
||||||
|
return "\(size) \(units[unitIndex])"
|
||||||
|
}
|
||||||
|
let rounded = (scaled * 10).rounded() / 10
|
||||||
|
return "\(rounded) \(units[unitIndex])"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internals (ported literally from AppUiModels.kt)
|
||||||
|
|
||||||
|
private func humanProgressLabel(_ event: CoreEventModel) -> String {
|
||||||
|
switch (event.phase, event.kind) {
|
||||||
|
case ("import", "copy-progress"), ("import", "outboard-progress"), ("import", "started"):
|
||||||
|
return "progress_preparing"
|
||||||
|
case ("import", "done"): return "progress_ready"
|
||||||
|
case ("ticket", "created"): return "progress_share_ready"
|
||||||
|
case ("network", "connecting"): return "progress_connecting"
|
||||||
|
case ("network", "connected"): return "progress_connected"
|
||||||
|
case ("download", "found-collection"): return "progress_getting_ready"
|
||||||
|
case ("download", "progress"): return "progress_downloading"
|
||||||
|
case ("export", "progress"): return "progress_saving"
|
||||||
|
case ("transfer", "progress"): return "progress_sending"
|
||||||
|
case ("transfer", "started"): return "progress_connected"
|
||||||
|
case ("transfer", "completed"): return "progress_completed"
|
||||||
|
case ("lifecycle", "done"): return "progress_completed"
|
||||||
|
case ("lifecycle", "cancelled"): return "progress_cancelled"
|
||||||
|
default:
|
||||||
|
if event.phase == "handshake" { return "progress_requesting_access" }
|
||||||
|
if event.kind == "failed" { return "progress_failed" }
|
||||||
|
return "progress_working"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func progressDetail(_ event: CoreEventModel) -> String? {
|
||||||
|
let fileName = findString(event.dataJson, key: "file_name")
|
||||||
|
let current = findNumber(event.dataJson, key: "current_file_index").map { Int64($0) }
|
||||||
|
let totalFiles = findNumber(event.dataJson, key: "total_files").map { Int64($0) }
|
||||||
|
if let fileName, let current, let totalFiles, totalFiles > 0 {
|
||||||
|
return "\(fileName) (\(current + 1)/\(totalFiles))"
|
||||||
|
}
|
||||||
|
return fileName
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseProgress(_ json: String, sizeHint: Double? = nil) -> Double? {
|
||||||
|
let transferred = findNumber(json, key: "exported")
|
||||||
|
?? findNumber(json, key: "downloaded")
|
||||||
|
?? findNumber(json, key: "offset")
|
||||||
|
?? findNumber(json, key: "end_offset")
|
||||||
|
?? findNumber(json, key: "transferred")
|
||||||
|
?? findNumber(json, key: "written")
|
||||||
|
let total = findNumber(json, key: "file_size")
|
||||||
|
?? findNumber(json, key: "total_size")
|
||||||
|
?? findNumber(json, key: "size")
|
||||||
|
?? findNumber(json, key: "total")
|
||||||
|
?? sizeHint
|
||||||
|
guard let transferred, let total, total > 0 else { return nil }
|
||||||
|
return min(1, max(0, transferred / total))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findKnownSize(events: [CoreEventModel], transferId: UInt64) -> Double? {
|
||||||
|
for event in events where event.transferId == transferId {
|
||||||
|
if let s = findNumber(event.dataJson, key: "size"), s > 0 { return s }
|
||||||
|
if let s = findNumber(event.dataJson, key: "total_size"), s > 0 { return s }
|
||||||
|
if let s = findNumber(event.dataJson, key: "file_size"), s > 0 { return s }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class BlobState {
|
||||||
|
var size: Double?
|
||||||
|
var offset: Double = 0
|
||||||
|
var completed = false
|
||||||
|
var aborted = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func aggregateReceiverProgress(events: [CoreEventModel], totalSizeHint: UInt64?) -> Double? {
|
||||||
|
let chronological = events.reversed()
|
||||||
|
var byRequest = [String: BlobState]()
|
||||||
|
var order = [String]()
|
||||||
|
var connectionScopedOffset: Double?
|
||||||
|
var connectionScopedSize: Double?
|
||||||
|
|
||||||
|
for event in chronological {
|
||||||
|
let requestKey = findNumber(event.dataJson, key: "request_id").map { String(Int64($0)) }
|
||||||
|
?? findString(event.dataJson, key: "request_id")
|
||||||
|
let size = findNumber(event.dataJson, key: "size")
|
||||||
|
let endOffset = findNumber(event.dataJson, key: "end_offset")
|
||||||
|
?? findNumber(event.dataJson, key: "offset")
|
||||||
|
?? findNumber(event.dataJson, key: "transferred")
|
||||||
|
|
||||||
|
if let requestKey {
|
||||||
|
let state: BlobState
|
||||||
|
if let existing = byRequest[requestKey] {
|
||||||
|
state = existing
|
||||||
|
} else {
|
||||||
|
state = BlobState()
|
||||||
|
byRequest[requestKey] = state
|
||||||
|
order.append(requestKey)
|
||||||
|
}
|
||||||
|
if let size, size > 0 { state.size = size }
|
||||||
|
switch event.kind {
|
||||||
|
case "progress", "started":
|
||||||
|
if let endOffset { state.offset = max(state.offset, endOffset) }
|
||||||
|
state.aborted = false
|
||||||
|
case "completed":
|
||||||
|
state.completed = true
|
||||||
|
if let s = state.size { state.offset = s }
|
||||||
|
case "aborted":
|
||||||
|
state.aborted = true
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if let size, size > 0 { connectionScopedSize = size }
|
||||||
|
if let endOffset { connectionScopedOffset = endOffset }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !byRequest.isEmpty {
|
||||||
|
let active = order.compactMap { byRequest[$0] }.filter { !$0.aborted }
|
||||||
|
if active.isEmpty { return nil }
|
||||||
|
let transferred = active.reduce(0.0) { acc, state in
|
||||||
|
acc + (state.completed ? (state.size ?? state.offset) : state.offset)
|
||||||
|
}
|
||||||
|
let observedSize = active.compactMap { $0.size }.reduce(0, +)
|
||||||
|
let total = totalSizeHint.map { Double($0) }.flatMap { $0 > 0 ? $0 : nil }
|
||||||
|
?? (observedSize > 0 ? observedSize : nil)
|
||||||
|
guard let total, total > 0 else { return nil }
|
||||||
|
return min(1, max(0, transferred / total))
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = totalSizeHint.map { Double($0) }.flatMap { $0 > 0 ? $0 : nil }
|
||||||
|
?? connectionScopedSize.flatMap { $0 > 0 ? $0 : nil }
|
||||||
|
guard let transferred = connectionScopedOffset, let total, total > 0 else { return nil }
|
||||||
|
return min(1, max(0, transferred / total))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func connectionIdsForEndpoint(events: [CoreEventModel], remoteEndpointId: String) -> Set<String> {
|
||||||
|
var ids = Set<String>()
|
||||||
|
for event in events {
|
||||||
|
guard let endpoint = findString(event.dataJson, key: "endpoint_id"), endpoint == remoteEndpointId else { continue }
|
||||||
|
if let n = findNumber(event.dataJson, key: "connection_id") { ids.insert(String(Int64(n))) }
|
||||||
|
if let s = findString(event.dataJson, key: "connection_id") { ids.insert(s) }
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
private func eventBelongsToReceiver(_ event: CoreEventModel, remoteEndpointId: String, connectionIds: Set<String>) -> Bool {
|
||||||
|
if let endpoint = findString(event.dataJson, key: "endpoint_id") {
|
||||||
|
return endpoint == remoteEndpointId
|
||||||
|
}
|
||||||
|
let connectionId = findNumber(event.dataJson, key: "connection_id").map { String(Int64($0)) }
|
||||||
|
?? findString(event.dataJson, key: "connection_id")
|
||||||
|
guard let connectionId else { return false }
|
||||||
|
return connectionIds.contains(connectionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lightweight JSON scraping, ported from AppUiModels.kt (matches core event shapes).
|
||||||
|
func findNumber(_ json: String, key: String) -> Double? {
|
||||||
|
let marker = "\"\(key)\":"
|
||||||
|
guard let range = json.range(of: marker) else { return nil }
|
||||||
|
let after = json[range.upperBound...].drop { $0 == " " }
|
||||||
|
if after.hasPrefix("null") { return nil }
|
||||||
|
let terminators: Set<Character> = [",", "}", "]"]
|
||||||
|
var value = ""
|
||||||
|
for ch in json[range.upperBound...] {
|
||||||
|
if terminators.contains(ch) { break }
|
||||||
|
value.append(ch)
|
||||||
|
}
|
||||||
|
let trimmed = value.trimmingCharacters(in: .whitespaces).trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
||||||
|
return Double(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findString(_ json: String, key: String) -> String? {
|
||||||
|
let marker = "\"\(key)\":"
|
||||||
|
guard let range = json.range(of: marker) else { return nil }
|
||||||
|
let after = json[range.upperBound...].drop { $0 == " " }
|
||||||
|
if after.hasPrefix("null") { return nil }
|
||||||
|
guard after.hasPrefix("\"") else { return nil }
|
||||||
|
let content = after.dropFirst()
|
||||||
|
guard let endIdx = content.firstIndex(of: "\"") else { return nil }
|
||||||
|
if content.startIndex == endIdx { return nil }
|
||||||
|
return String(content[content.startIndex..<endIdx])
|
||||||
|
}
|
||||||
44
apple/VniDrop/Features/App/AppModel.swift
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// Top-level app state, ported from `feature/app/AppViewModel.kt`. Initializes the
|
||||||
|
/// core on launch and tracks the selected destination + theme.
|
||||||
|
@MainActor
|
||||||
|
final class AppModel: ObservableObject {
|
||||||
|
@Published private(set) var destination: AppDestination = .send
|
||||||
|
@Published private(set) var themeMode: ThemeMode = .system
|
||||||
|
|
||||||
|
private let environment: PlatformEnvironment
|
||||||
|
private let repository: CoreGateway
|
||||||
|
private let messages: UiMessageController
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(
|
||||||
|
environment: PlatformEnvironment,
|
||||||
|
repository: CoreGateway,
|
||||||
|
preferences: AppPreferencesRepository,
|
||||||
|
messages: UiMessageController
|
||||||
|
) {
|
||||||
|
self.environment = environment
|
||||||
|
self.repository = repository
|
||||||
|
self.messages = messages
|
||||||
|
|
||||||
|
AppLogger.info("lifecycle", "app started", ["platform": environment.name])
|
||||||
|
|
||||||
|
Task {
|
||||||
|
let result = await repository.initialize(appDataDir: environment.defaultCoreDataDir)
|
||||||
|
if case .failure(let error) = result { messages.error(error) }
|
||||||
|
}
|
||||||
|
|
||||||
|
preferences.$preferences
|
||||||
|
.map(\.themeMode)
|
||||||
|
.removeDuplicates()
|
||||||
|
.sink { [weak self] mode in self?.themeMode = mode }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectDestination(_ destination: AppDestination) {
|
||||||
|
guard destination != self.destination else { return }
|
||||||
|
self.destination = destination
|
||||||
|
}
|
||||||
|
}
|
||||||
179
apple/VniDrop/Features/Approvals/ApprovalCoordinator.swift
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// A pending receiver approval, ported from `feature/approvals/ApprovalCoordinator.kt`.
|
||||||
|
struct PendingApproval: Equatable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let transferId: UInt64
|
||||||
|
let transferName: String
|
||||||
|
let receiverName: String?
|
||||||
|
let receiverDeviceName: String?
|
||||||
|
/// Cryptographic peer identity — not display-name spoofable.
|
||||||
|
let remoteEndpointId: String
|
||||||
|
let requestedAt: Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ApprovalState: Equatable {
|
||||||
|
var pending: [PendingApproval] = []
|
||||||
|
var respondingIds: Set<String> = []
|
||||||
|
|
||||||
|
var current: PendingApproval? { pending.first }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drives receiver-approval prompts and their notifications, ported from
|
||||||
|
/// `ApprovalCoordinator.kt`.
|
||||||
|
@MainActor
|
||||||
|
final class ApprovalCoordinator: ObservableObject {
|
||||||
|
@Published private(set) var state = ApprovalState()
|
||||||
|
|
||||||
|
private let repository: CoreGateway
|
||||||
|
private let preferences: AppPreferencesRepository
|
||||||
|
private let notifications: LocalNotificationService
|
||||||
|
private let visibility: AppVisibility
|
||||||
|
private let messages: UiMessageController
|
||||||
|
|
||||||
|
private var publishedNotificationIds = Set<String>()
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(
|
||||||
|
repository: CoreGateway,
|
||||||
|
preferences: AppPreferencesRepository,
|
||||||
|
notifications: LocalNotificationService,
|
||||||
|
visibility: AppVisibility,
|
||||||
|
messages: UiMessageController
|
||||||
|
) {
|
||||||
|
self.repository = repository
|
||||||
|
self.preferences = preferences
|
||||||
|
self.notifications = notifications
|
||||||
|
self.visibility = visibility
|
||||||
|
self.messages = messages
|
||||||
|
|
||||||
|
repository.signals
|
||||||
|
.sink { [weak self] signal in
|
||||||
|
guard let self else { return }
|
||||||
|
if case .approvalChanged(let transferId) = signal {
|
||||||
|
Task { await self.refresh(transferId: transferId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
repository.statePublisher
|
||||||
|
.sink { [weak self] core in
|
||||||
|
guard let self, core.isInitialized else { return }
|
||||||
|
let sharing = core.transfers.filter { $0.direction == .send && $0.status == .sharing }
|
||||||
|
for transfer in sharing {
|
||||||
|
Task { await self.refresh(transferId: transfer.transferId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
// Recompute notifications when any input changes.
|
||||||
|
Publishers.CombineLatest4(
|
||||||
|
preferences.$preferences,
|
||||||
|
visibility.$isForeground,
|
||||||
|
$state,
|
||||||
|
notifications.$permission
|
||||||
|
)
|
||||||
|
.sink { [weak self] preferences, foreground, approvalState, permission in
|
||||||
|
guard let self else { return }
|
||||||
|
Task {
|
||||||
|
await self.synchronizeNotifications(
|
||||||
|
enabled: preferences.notificationsEnabled,
|
||||||
|
foreground: foreground,
|
||||||
|
pending: approvalState.pending,
|
||||||
|
permission: permission
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
func accept(_ requestId: String) { respond(requestId, accepted: true) }
|
||||||
|
func refuse(_ requestId: String) { respond(requestId, accepted: false) }
|
||||||
|
|
||||||
|
private func respond(_ requestId: String, accepted: Bool) {
|
||||||
|
guard !state.respondingIds.contains(requestId) else { return }
|
||||||
|
state.respondingIds.insert(requestId)
|
||||||
|
Task {
|
||||||
|
let request = state.pending.first { $0.id == requestId }
|
||||||
|
let result = await repository.respondReceiverRequest(
|
||||||
|
requestId: requestId,
|
||||||
|
accepted: accepted,
|
||||||
|
reason: accepted ? nil : "sender-refused"
|
||||||
|
)
|
||||||
|
state.respondingIds.remove(requestId)
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
if let request { await refresh(transferId: request.transferId) }
|
||||||
|
case .failure(let error):
|
||||||
|
messages.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refresh(transferId: UInt64) async {
|
||||||
|
let result = await repository.receiverRequests(transferId: transferId)
|
||||||
|
switch result {
|
||||||
|
case .success(let requests):
|
||||||
|
let refreshed = requests
|
||||||
|
.filter { $0.status == .requested }
|
||||||
|
.map { $0.toPending() }
|
||||||
|
let refreshedIds = Set(refreshed.map { $0.id })
|
||||||
|
let removed = Set(state.pending.filter { $0.transferId == transferId }.map { $0.id })
|
||||||
|
.subtracting(refreshedIds)
|
||||||
|
for id in removed {
|
||||||
|
notifications.cancel(id: Self.notificationId(id))
|
||||||
|
publishedNotificationIds.remove(id)
|
||||||
|
}
|
||||||
|
var pending = state.pending.filter { $0.transferId != transferId } + refreshed
|
||||||
|
// distinctBy id, sorted by requestedAt
|
||||||
|
var seen = Set<String>()
|
||||||
|
pending = pending.filter { seen.insert($0.id).inserted }
|
||||||
|
.sorted { $0.requestedAt < $1.requestedAt }
|
||||||
|
state.pending = pending
|
||||||
|
case .failure(let error):
|
||||||
|
messages.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func synchronizeNotifications(
|
||||||
|
enabled: Bool,
|
||||||
|
foreground: Bool,
|
||||||
|
pending: [PendingApproval],
|
||||||
|
permission: NotificationPermission
|
||||||
|
) async {
|
||||||
|
if foreground || !enabled || permission != .granted {
|
||||||
|
notifications.cancelAll()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for request in pending where !publishedNotificationIds.contains(request.id) {
|
||||||
|
let receiver = request.receiverName
|
||||||
|
?? request.receiverDeviceName
|
||||||
|
?? String(localized: "approval_nearby_device")
|
||||||
|
let title = String(localized: "approval_connection_request")
|
||||||
|
let body = String(
|
||||||
|
format: String(localized: "approval_request_body"),
|
||||||
|
receiver, request.transferName
|
||||||
|
)
|
||||||
|
let result = await notifications.publish(
|
||||||
|
LocalNotification(id: Self.notificationId(request.id), title: title, body: body)
|
||||||
|
)
|
||||||
|
switch result {
|
||||||
|
case .success: publishedNotificationIds.insert(request.id)
|
||||||
|
case .failure(let error): messages.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func notificationId(_ requestId: String) -> String { "approval-\(requestId)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension ReceiverRequestModel {
|
||||||
|
func toPending() -> PendingApproval {
|
||||||
|
PendingApproval(
|
||||||
|
id: id, transferId: transferId, transferName: transferName,
|
||||||
|
receiverName: receiverName, receiverDeviceName: receiverDeviceName,
|
||||||
|
remoteEndpointId: remoteEndpointId, requestedAt: requestedAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
73
apple/VniDrop/Features/Approvals/ApprovalModal.swift
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Non-dismissable receiver-approval modal, presented as a native sheet that can't
|
||||||
|
/// be swiped away. The endpoint id is the trusted identity; display names are
|
||||||
|
/// peer-provided.
|
||||||
|
struct ApprovalModalHost: View {
|
||||||
|
let state: ApprovalState
|
||||||
|
let onAccept: (String) -> Void
|
||||||
|
let onRefuse: (String) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Color.clear
|
||||||
|
.sheet(isPresented: .constant(state.current != nil)) {
|
||||||
|
if let request = state.current {
|
||||||
|
ApprovalSheet(state: state, request: request, onAccept: onAccept, onRefuse: onRefuse)
|
||||||
|
.interactiveDismissDisabled(true)
|
||||||
|
.modifier(ApprovalDetents())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ApprovalDetents: ViewModifier {
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
#if os(iOS)
|
||||||
|
content.presentationDetents([.medium])
|
||||||
|
#else
|
||||||
|
content.frame(minWidth: 420, minHeight: 320)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ApprovalSheet: View {
|
||||||
|
let state: ApprovalState
|
||||||
|
let request: PendingApproval
|
||||||
|
let onAccept: (String) -> Void
|
||||||
|
let onRefuse: (String) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let busy = state.respondingIds.contains(request.id)
|
||||||
|
let receiver = request.receiverName ?? request.receiverDeviceName ?? String(localized: "approval_nearby_device")
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "checkmark.shield.fill")
|
||||||
|
.font(.system(size: 44))
|
||||||
|
.foregroundStyle(.tint)
|
||||||
|
.padding(.top, 12)
|
||||||
|
Text(LocalizedStringKey("approval_connection_request"))
|
||||||
|
.font(.title2).fontWeight(.semibold)
|
||||||
|
Text(String(format: String(localized: "approval_request_body"), receiver, request.transferName))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
Text(String(format: String(localized: "approval_endpoint_id"), request.remoteEndpointId))
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
if state.pending.count > 1 {
|
||||||
|
Text(String(format: String(localized: "approval_pending_count"), state.pending.count))
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
if busy { ProgressView() }
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Button(action: { onAccept(request.id) }) {
|
||||||
|
Text(LocalizedStringKey("button_approve")).frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent).controlSize(.large).disabled(busy)
|
||||||
|
Button(role: .destructive, action: { onRefuse(request.id) }) {
|
||||||
|
Text(LocalizedStringKey("button_refuse")).frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered).controlSize(.large).disabled(busy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
}
|
||||||
|
}
|
||||||
146
apple/VniDrop/Features/Receive/ReceiveInvitationActions.swift
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum ReceiveMethodAvailability { case available, unavailable, hidden }
|
||||||
|
|
||||||
|
/// Invitation acquisition actions shared by the native Apple feature models.
|
||||||
|
@MainActor
|
||||||
|
protocol ReceiveInvitationActions: AnyObject {
|
||||||
|
var fileAvailability: ReceiveMethodAvailability { get }
|
||||||
|
var qrAvailability: ReceiveMethodAvailability { get }
|
||||||
|
var nfcAvailability: ReceiveMethodAvailability { get }
|
||||||
|
|
||||||
|
func pickInvitation(onResult: @escaping (Result<String, Error>) -> Void)
|
||||||
|
func scanQrCode(onResult: @escaping (Result<String, Error>) -> Void)
|
||||||
|
func readNfcInvitation(onResult: @escaping (Result<String, Error>) -> Void)
|
||||||
|
func cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Method chooser panel, ported from `ReceiveMethodPanel` in `ReceiveScreen.kt`.
|
||||||
|
struct ReceiveMethodPanel: View {
|
||||||
|
@Environment(\.vniColors) private var colors
|
||||||
|
@ObservedObject var model: ReceiveModel
|
||||||
|
@State private var actions: ReceiveInvitationActions = makeReceiveInvitationActions()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text(LocalizedStringKey("receive_choose_method_title")).font(VniType.titleLarge)
|
||||||
|
Text(LocalizedStringKey("receive_choose_method_body")).foregroundStyle(colors.foregroundLighter)
|
||||||
|
|
||||||
|
MethodRow(
|
||||||
|
icon: "doc", titleKey: "receive_method_file", descKey: "receive_method_file_description",
|
||||||
|
availability: actions.fileAvailability
|
||||||
|
) { actions.pickInvitation { model.onInvitationResult(.invitationFile, $0) } }
|
||||||
|
|
||||||
|
if actions.qrAvailability != .hidden {
|
||||||
|
MethodRow(
|
||||||
|
icon: "qrcode.viewfinder", titleKey: "receive_method_scan", descKey: "receive_method_scan_description",
|
||||||
|
availability: actions.qrAvailability
|
||||||
|
) { actions.scanQrCode { model.onInvitationResult(.qrCode, $0) } }
|
||||||
|
}
|
||||||
|
if actions.nfcAvailability != .hidden {
|
||||||
|
MethodRow(
|
||||||
|
icon: "wave.3.right",
|
||||||
|
titleOverride: model.state.isWaitingForNfc ? String(localized: "receive_nfc_waiting") : nil,
|
||||||
|
titleKey: "receive_method_nfc", descKey: "receive_method_nfc_description",
|
||||||
|
availability: model.state.isWaitingForNfc ? .unavailable : actions.nfcAvailability
|
||||||
|
) {
|
||||||
|
model.setWaitingForNfc(true)
|
||||||
|
actions.readNfcInvitation { model.onInvitationResult(.nfc, $0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20).padding(.vertical, 14)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MethodRow: View {
|
||||||
|
@Environment(\.vniColors) private var colors
|
||||||
|
let icon: String
|
||||||
|
var titleOverride: String? = nil
|
||||||
|
let titleKey: String
|
||||||
|
let descKey: String
|
||||||
|
let availability: ReceiveMethodAvailability
|
||||||
|
let onTap: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let enabled = availability == .available
|
||||||
|
Button(action: onTap) {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Image(systemName: icon).font(.system(size: 22))
|
||||||
|
.foregroundStyle(enabled ? colors.brandLink : colors.foregroundLighter)
|
||||||
|
.frame(width: 24)
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
if let titleOverride {
|
||||||
|
Text(titleOverride).font(VniType.bodyLarge)
|
||||||
|
} else {
|
||||||
|
Text(LocalizedStringKey(titleKey)).font(VniType.bodyLarge)
|
||||||
|
}
|
||||||
|
Text(LocalizedStringKey(descKey)).font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if availability == .unavailable {
|
||||||
|
Text(LocalizedStringKey("value_unavailable")).font(VniType.labelSmall).foregroundStyle(colors.foregroundLighter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(colors.backgroundSurface200, in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invitation review panel, ported from `InvitationReviewPanel` in `ReceiveScreen.kt`.
|
||||||
|
struct InvitationReviewPanel: View {
|
||||||
|
@Environment(\.vniColors) private var colors
|
||||||
|
@ObservedObject var model: ReceiveModel
|
||||||
|
|
||||||
|
private var state: ReceiveState { model.state }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
Text(LocalizedStringKey("receive_review_title")).font(VniType.titleLarge)
|
||||||
|
if state.isInspecting {
|
||||||
|
ProgressView().frame(maxWidth: .infinity).padding(40)
|
||||||
|
}
|
||||||
|
if let inspection = state.inspection {
|
||||||
|
let metadata = inspection.metadata
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(metadata.transferName).font(VniType.bodyLarge).lineLimit(2)
|
||||||
|
Text("\(metadata.fileCount) \(String(localized: "metadata_files").lowercased()) · \(formatBytes(metadata.totalSize))")
|
||||||
|
.foregroundStyle(colors.foregroundLighter)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(colors.backgroundSurface200, in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
|
||||||
|
Field(label: String(localized: "field_receiver_name"),
|
||||||
|
value: Binding(get: { state.receiverName }, set: { model.setReceiverName($0) }))
|
||||||
|
Text(state.receiveFolder?.displayName ?? String(localized: "value_unavailable"))
|
||||||
|
.font(VniType.bodySmall)
|
||||||
|
.foregroundStyle(state.folderAccessStatus == .writable ? colors.foregroundLight : colors.destructiveDefault)
|
||||||
|
|
||||||
|
if state.isReceiving {
|
||||||
|
let progressId = state.activeReceiveTransferId
|
||||||
|
?? model.coreState.events.first { $0.direction == "receive" && $0.transferId != nil }?.transferId
|
||||||
|
let progress = progressId.flatMap { progressForTransfer(events: model.coreState.events, transferId: $0) }
|
||||||
|
ProgressRow(labelKey: progress?.labelKey ?? "progress_receiving", progress: progress?.progress, detail: progress?.detail)
|
||||||
|
SecondaryButton(title: String(localized: "button_cancel_receive"), action: model.cancelActiveReceive)
|
||||||
|
} else {
|
||||||
|
PrimaryButton(
|
||||||
|
title: String(localized: "button_receive"), action: model.receive,
|
||||||
|
enabled: state.canReceive(coreInitialized: model.coreState.isInitialized)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if let error = state.lastReceiveError {
|
||||||
|
Text(error.resolved()).font(VniType.bodySmall).foregroundStyle(colors.destructiveDefault)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20).padding(.vertical, 14)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
265
apple/VniDrop/Features/Receive/ReceiveModel.swift
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// Which acquisition method produced an invitation, ported from `ReceiveMethod`.
|
||||||
|
enum ReceiveMethod {
|
||||||
|
case invitationFile
|
||||||
|
case qrCode
|
||||||
|
case nfc
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ReceiveHistoryDeleteTarget: Equatable {
|
||||||
|
case transfer(transferId: UInt64)
|
||||||
|
case all
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receive feature state, ported from `feature/receive/ReceiveViewModel.kt`.
|
||||||
|
struct ReceiveState: Equatable {
|
||||||
|
var isAcquisitionOpen = false
|
||||||
|
var ticket = ""
|
||||||
|
var method: ReceiveMethod?
|
||||||
|
var inspection: TicketInspectionModel?
|
||||||
|
var receiverName = ""
|
||||||
|
var receiveFolder: ReceiveFolder?
|
||||||
|
var folderAccessStatus: FolderAccessStatus = .unavailable
|
||||||
|
var isInspecting = false
|
||||||
|
var isReceiving = false
|
||||||
|
var activeReceiveTransferId: UInt64?
|
||||||
|
var lastReceiveError: UiText?
|
||||||
|
var isWaitingForNfc = false
|
||||||
|
var historyDeleteTarget: ReceiveHistoryDeleteTarget?
|
||||||
|
var isDeletingHistory = false
|
||||||
|
|
||||||
|
func canReceive(coreInitialized: Bool) -> Bool {
|
||||||
|
coreInitialized && !ticket.isEmpty && inspection != nil
|
||||||
|
&& folderAccessStatus == .writable && !isReceiving && !isInspecting
|
||||||
|
}
|
||||||
|
|
||||||
|
static func == (lhs: ReceiveState, rhs: ReceiveState) -> Bool {
|
||||||
|
lhs.isAcquisitionOpen == rhs.isAcquisitionOpen && lhs.ticket == rhs.ticket
|
||||||
|
&& lhs.inspection == rhs.inspection && lhs.receiverName == rhs.receiverName
|
||||||
|
&& lhs.receiveFolder == rhs.receiveFolder && lhs.folderAccessStatus == rhs.folderAccessStatus
|
||||||
|
&& lhs.isInspecting == rhs.isInspecting && lhs.isReceiving == rhs.isReceiving
|
||||||
|
&& lhs.activeReceiveTransferId == rhs.activeReceiveTransferId
|
||||||
|
&& lhs.lastReceiveError == rhs.lastReceiveError && lhs.isWaitingForNfc == rhs.isWaitingForNfc
|
||||||
|
&& lhs.historyDeleteTarget == rhs.historyDeleteTarget && lhs.isDeletingHistory == rhs.isDeletingHistory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ReceiveModel: ObservableObject {
|
||||||
|
@Published private(set) var state = ReceiveState()
|
||||||
|
@Published private(set) var coreState = CoreState()
|
||||||
|
|
||||||
|
private let repository: CoreGateway
|
||||||
|
private let fileSystemService: FileSystemService
|
||||||
|
private let messages: UiMessageController
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(
|
||||||
|
repository: CoreGateway,
|
||||||
|
fileSystemService: FileSystemService,
|
||||||
|
preferences: AppPreferencesRepository,
|
||||||
|
messages: UiMessageController
|
||||||
|
) {
|
||||||
|
self.repository = repository
|
||||||
|
self.fileSystemService = fileSystemService
|
||||||
|
self.messages = messages
|
||||||
|
|
||||||
|
repository.statePublisher.sink { [weak self] in self?.coreState = $0 }.store(in: &cancellables)
|
||||||
|
|
||||||
|
preferences.$preferences
|
||||||
|
.sink { [weak self] prefs in
|
||||||
|
guard let self else { return }
|
||||||
|
Task {
|
||||||
|
let folder = self.fileSystemService.effectiveReceiveFolder(prefs.receiveFolder)
|
||||||
|
let status = await self.fileSystemService.validateReceiveFolder(folder)
|
||||||
|
if self.state.receiverName.isEmpty { self.state.receiverName = prefs.username }
|
||||||
|
self.state.receiveFolder = folder
|
||||||
|
self.state.folderAccessStatus = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
repository.signals
|
||||||
|
.sink { [weak self] signal in
|
||||||
|
guard let self else { return }
|
||||||
|
if case .transfersChanged(let id) = signal {
|
||||||
|
Task { _ = await self.repository.refresh() }
|
||||||
|
if self.state.isReceiving && id != 0 {
|
||||||
|
self.state.activeReceiveTransferId = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
func openAcquisition() { state.isAcquisitionOpen = true }
|
||||||
|
func dismissAcquisition() {
|
||||||
|
if !state.isReceiving && !state.isInspecting { resetAcquisition() }
|
||||||
|
}
|
||||||
|
func setReceiverName(_ value: String) { state.receiverName = value }
|
||||||
|
func setWaitingForNfc(_ waiting: Bool) { state.isWaitingForNfc = waiting }
|
||||||
|
|
||||||
|
func requestDeleteHistoryItem(_ transferId: UInt64) {
|
||||||
|
let canDelete = coreState.transfers.contains {
|
||||||
|
$0.transferId == transferId && $0.direction == .receive && $0.status.isTerminalReceiveHistory
|
||||||
|
}
|
||||||
|
if canDelete { state.historyDeleteTarget = .transfer(transferId: transferId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestClearHistory() {
|
||||||
|
if coreState.transfers.contains(where: { $0.direction == .receive && $0.status.isTerminalReceiveHistory }) {
|
||||||
|
state.historyDeleteTarget = .all
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dismissHistoryDelete() {
|
||||||
|
if !state.isDeletingHistory { state.historyDeleteTarget = nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
func confirmHistoryDelete() {
|
||||||
|
guard let target = state.historyDeleteTarget, !state.isDeletingHistory else { return }
|
||||||
|
state.isDeletingHistory = true
|
||||||
|
Task {
|
||||||
|
let result: Result<Void, Error>
|
||||||
|
switch target {
|
||||||
|
case .transfer(let id):
|
||||||
|
result = await repository.delete(transferId: id)
|
||||||
|
case .all:
|
||||||
|
result = (await repository.clearReceiveHistory()).map { _ in () }
|
||||||
|
}
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
state.historyDeleteTarget = nil
|
||||||
|
state.isDeletingHistory = false
|
||||||
|
let key = target == .all ? "receive_history_cleared" : "transfer_deleted"
|
||||||
|
messages.tryShow(UiMessage(text: .resource(key), tone: .success))
|
||||||
|
case .failure(let error):
|
||||||
|
state.isDeletingHistory = false
|
||||||
|
messages.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func onInvitationResult(_ method: ReceiveMethod, _ result: Result<String, Error>) {
|
||||||
|
state.isWaitingForNfc = false
|
||||||
|
switch result {
|
||||||
|
case .success(let raw): inspectInvitation(method, raw)
|
||||||
|
case .failure(let error): messages.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func receive() {
|
||||||
|
let current = state
|
||||||
|
guard let folder = current.receiveFolder else { return }
|
||||||
|
if !current.canReceive(coreInitialized: coreState.isInitialized) { return }
|
||||||
|
state.isReceiving = true
|
||||||
|
state.lastReceiveError = nil
|
||||||
|
state.activeReceiveTransferId = nil
|
||||||
|
Task {
|
||||||
|
let result: Result<Void, Error>
|
||||||
|
if folder.kind == .iosSecurityScopedUrl {
|
||||||
|
result = await repository.receiveIntoSecurityScopedDirectory(
|
||||||
|
ticket: current.ticket, outputDirectoryUrl: folder.value, receiverName: current.receiverName
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
result = await repository.receive(
|
||||||
|
ticket: current.ticket, outputDir: folder.value, receiverName: current.receiverName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
resetAcquisition()
|
||||||
|
let canReveal = fileSystemService.canRevealReceiveFolder(folder)
|
||||||
|
messages.tryShow(UiMessage(
|
||||||
|
text: .resource("receive_completed"),
|
||||||
|
tone: .success,
|
||||||
|
actionLabel: canReveal ? .resource("button_show_in_files") : nil,
|
||||||
|
onAction: canReveal ? { self.revealReceiveFolder(folder) } : nil
|
||||||
|
))
|
||||||
|
case .failure(let error):
|
||||||
|
if error.isUserCancellation {
|
||||||
|
state.isReceiving = false
|
||||||
|
state.activeReceiveTransferId = nil
|
||||||
|
state.lastReceiveError = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let uiText = error.toUiText()
|
||||||
|
state.isReceiving = false
|
||||||
|
state.activeReceiveTransferId = nil
|
||||||
|
state.lastReceiveError = uiText
|
||||||
|
messages.tryShow(UiMessage(
|
||||||
|
text: uiText,
|
||||||
|
tone: .error,
|
||||||
|
actionLabel: .resource("button_retry"),
|
||||||
|
onAction: { self.receive() }
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelActiveReceive() {
|
||||||
|
let transferId = state.activeReceiveTransferId
|
||||||
|
?? coreState.transfers.first { $0.direction == .receive && $0.status == .receiving }?.transferId
|
||||||
|
?? coreState.events.first { $0.direction == "receive" && $0.transferId != nil }?.transferId
|
||||||
|
guard let transferId else { return }
|
||||||
|
Task {
|
||||||
|
let result = await repository.cancel(transferId: transferId)
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
state.isReceiving = false
|
||||||
|
state.activeReceiveTransferId = nil
|
||||||
|
state.lastReceiveError = nil
|
||||||
|
_ = await repository.refresh()
|
||||||
|
case .failure(let error):
|
||||||
|
messages.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func revealReceiveFolder(_ folder: ReceiveFolder) {
|
||||||
|
Task {
|
||||||
|
let result = await fileSystemService.revealReceiveFolder(folder)
|
||||||
|
if case .failure = result {
|
||||||
|
messages.show(UiMessage(text: .resource("receive_open_files_failed"), tone: .error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func inspectInvitation(_ method: ReceiveMethod, _ raw: String) {
|
||||||
|
let ticket = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if ticket.isEmpty { return messages.error(.resource("error_invitation_empty")) }
|
||||||
|
state.isAcquisitionOpen = true
|
||||||
|
state.ticket = ticket
|
||||||
|
state.method = method
|
||||||
|
state.inspection = nil
|
||||||
|
state.isInspecting = true
|
||||||
|
Task {
|
||||||
|
let result = await repository.inspectTicket(ticket)
|
||||||
|
switch result {
|
||||||
|
case .success(let inspection):
|
||||||
|
state.inspection = inspection
|
||||||
|
state.isInspecting = false
|
||||||
|
case .failure(let error):
|
||||||
|
state.ticket = ""
|
||||||
|
state.method = nil
|
||||||
|
state.inspection = nil
|
||||||
|
state.isInspecting = false
|
||||||
|
messages.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resetAcquisition() {
|
||||||
|
state.isAcquisitionOpen = false
|
||||||
|
state.ticket = ""
|
||||||
|
state.method = nil
|
||||||
|
state.inspection = nil
|
||||||
|
state.isInspecting = false
|
||||||
|
state.isReceiving = false
|
||||||
|
state.activeReceiveTransferId = nil
|
||||||
|
state.lastReceiveError = nil
|
||||||
|
state.isWaitingForNfc = false
|
||||||
|
}
|
||||||
|
}
|
||||||
149
apple/VniDrop/Features/Receive/ReceiveScreen.swift
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Receive screen, rebuilt on native SwiftUI. A grouped `List` of received
|
||||||
|
/// transfers with swipe-to-delete, and the acquisition flow as a native sheet.
|
||||||
|
struct ReceiveScreen: View {
|
||||||
|
@ObservedObject var model: ReceiveModel
|
||||||
|
let windowClass: WindowClass
|
||||||
|
|
||||||
|
private var transfers: [Transfer] {
|
||||||
|
model.coreState.transfers.filter { $0.direction == .receive }
|
||||||
|
}
|
||||||
|
private var deletable: [Transfer] {
|
||||||
|
transfers.filter { $0.status.isTerminalReceiveHistory }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if transfers.isEmpty {
|
||||||
|
emptyState
|
||||||
|
} else {
|
||||||
|
history
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(Text(LocalizedStringKey("receive_title")))
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button(action: model.openAcquisition) {
|
||||||
|
Label(String(localized: "button_receive_files"), systemImage: "plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !deletable.isEmpty {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button(role: .destructive, action: model.requestClearHistory) {
|
||||||
|
Label(String(localized: "receive_clear_history"), systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.adaptiveDrawer(
|
||||||
|
isPresented: Binding(get: { model.state.isAcquisitionOpen }, set: { _ in }),
|
||||||
|
windowClass: windowClass,
|
||||||
|
onDismiss: model.dismissAcquisition
|
||||||
|
) {
|
||||||
|
if model.state.ticket.isEmpty {
|
||||||
|
ReceiveMethodPanel(model: model)
|
||||||
|
} else {
|
||||||
|
InvitationReviewPanel(model: model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert(
|
||||||
|
Text(LocalizedStringKey(clearAllPending ? "receive_clear_history_title" : "receive_delete_history_title")),
|
||||||
|
isPresented: Binding(get: { model.state.historyDeleteTarget != nil }, set: { if !$0 { Task { @MainActor in model.dismissHistoryDelete() } } })
|
||||||
|
) {
|
||||||
|
Button(String(localized: "button_cancel"), role: .cancel, action: model.dismissHistoryDelete)
|
||||||
|
Button(String(localized: clearAllPending ? "receive_clear_history" : "button_delete_transfer"),
|
||||||
|
role: .destructive, action: model.confirmHistoryDelete)
|
||||||
|
} message: {
|
||||||
|
historyDeleteMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var history: some View {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
ForEach(transfers) { transfer in
|
||||||
|
ReceiveTransferRow(
|
||||||
|
transfer: transfer,
|
||||||
|
progress: progressForTransfer(events: model.coreState.events, transferId: transfer.transferId)
|
||||||
|
)
|
||||||
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||||
|
if transfer.status.isTerminalReceiveHistory {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
model.requestDeleteHistoryItem(transfer.transferId)
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: "button_delete_transfer"), systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text(LocalizedStringKey("receive_history_title"))
|
||||||
|
} footer: {
|
||||||
|
Text(LocalizedStringKey("receive_new_subtitle"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyState: some View {
|
||||||
|
ContentUnavailableView {
|
||||||
|
Label(String(localized: "receive_empty_title"), systemImage: "tray.and.arrow.down")
|
||||||
|
} description: {
|
||||||
|
Text(LocalizedStringKey("receive_empty_body"))
|
||||||
|
} actions: {
|
||||||
|
Button(action: model.openAcquisition) {
|
||||||
|
Label(String(localized: "button_receive_files"), systemImage: "plus")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.controlSize(.large)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var clearAllPending: Bool { model.state.historyDeleteTarget == .all }
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var historyDeleteMessage: some View {
|
||||||
|
if let target = model.state.historyDeleteTarget {
|
||||||
|
if target == .all {
|
||||||
|
Text(LocalizedStringKey("receive_clear_history_description"))
|
||||||
|
} else {
|
||||||
|
Text(String(format: String(localized: "receive_delete_history_description"),
|
||||||
|
transferName(for: target) ?? String(localized: "receive_unknown_transfer")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func transferName(for target: ReceiveHistoryDeleteTarget) -> String? {
|
||||||
|
if case .transfer(let id) = target {
|
||||||
|
return transfers.first { $0.transferId == id }?.transferName
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ReceiveTransferRow: View {
|
||||||
|
let transfer: Transfer
|
||||||
|
let progress: TransferProgress?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "doc")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.background(.quaternary, in: RoundedRectangle(cornerRadius: 9))
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(transfer.transferName ?? String(localized: "receive_unknown_transfer"))
|
||||||
|
.font(.body).lineLimit(1)
|
||||||
|
Text("\(formatBytes(transfer.totalSize)) · \(statusLabel(transfer.status))")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
if transfer.status == .receiving, let progress {
|
||||||
|
ProgressRow(labelKey: progress.labelKey, progress: progress.progress, detail: progress.detail)
|
||||||
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
141
apple/VniDrop/Features/Send/FilePreviewRepository.swift
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// Native Apple preview cache and thumbnail loader.
|
||||||
|
/// Only small PNG/JPEG/WEBP previews are retained, under a total quota.
|
||||||
|
struct PreviewStoragePolicy {
|
||||||
|
var maxEntryBytes: Int = 512 * 1024
|
||||||
|
var maxTotalBytes: Int64 = 20 * 1024 * 1024
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PreviewFileInfo {
|
||||||
|
let transferId: UInt64
|
||||||
|
let byteSize: Int64
|
||||||
|
let modifiedAtMillis: Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class FilePreviewRepository: ObservableObject {
|
||||||
|
@Published private(set) var previews: [UInt64: Data] = [:]
|
||||||
|
|
||||||
|
private let directory: String
|
||||||
|
private let policy: PreviewStoragePolicy
|
||||||
|
private let fm = FileManager.default
|
||||||
|
|
||||||
|
init(appDataDir: String, policy: PreviewStoragePolicy = PreviewStoragePolicy()) {
|
||||||
|
self.directory = (appDataDir as NSString).appendingPathComponent("ui/previews")
|
||||||
|
self.policy = policy
|
||||||
|
}
|
||||||
|
|
||||||
|
func restore(activeTransferIds: Set<UInt64>) {
|
||||||
|
let files = listFiles()
|
||||||
|
for file in files where !(activeTransferIds.contains(file.transferId)
|
||||||
|
&& (1...Int64(policy.maxEntryBytes)).contains(file.byteSize)) {
|
||||||
|
deleteFile(file.transferId)
|
||||||
|
}
|
||||||
|
enforceQuota()
|
||||||
|
var result: [UInt64: Data] = [:]
|
||||||
|
for file in listFiles() where activeTransferIds.contains(file.transferId) {
|
||||||
|
if let data = readFile(file.transferId), data.isSupportedPreview, data.count <= policy.maxEntryBytes {
|
||||||
|
result[file.transferId] = data
|
||||||
|
} else {
|
||||||
|
deleteFile(file.transferId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previews = result
|
||||||
|
}
|
||||||
|
|
||||||
|
func save(transferId: UInt64, bytes: Data) {
|
||||||
|
guard (1...policy.maxEntryBytes).contains(bytes.count), bytes.isSupportedPreview else { return }
|
||||||
|
guard writeAtomically(transferId: transferId, bytes: bytes) else { return }
|
||||||
|
enforceQuota(protectedTransferId: transferId)
|
||||||
|
if readFile(transferId) != nil {
|
||||||
|
previews[transferId] = bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(transferId: UInt64) {
|
||||||
|
deleteFile(transferId)
|
||||||
|
previews.removeValue(forKey: transferId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Store (ported from IosPreviewStore)
|
||||||
|
|
||||||
|
private func enforceQuota(protectedTransferId: UInt64? = nil) {
|
||||||
|
let files = listFiles().sorted { $0.modifiedAtMillis < $1.modifiedAtMillis }
|
||||||
|
var total = files.reduce(Int64(0)) { $0 + $1.byteSize }
|
||||||
|
for file in files {
|
||||||
|
if total <= policy.maxTotalBytes { break }
|
||||||
|
if file.transferId == protectedTransferId { continue }
|
||||||
|
deleteFile(file.transferId)
|
||||||
|
total -= file.byteSize
|
||||||
|
previews.removeValue(forKey: file.transferId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureDirectory() {
|
||||||
|
try? fm.createDirectory(atPath: directory, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func path(_ transferId: UInt64) -> String {
|
||||||
|
(directory as NSString).appendingPathComponent("\(transferId).preview")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func listFiles() -> [PreviewFileInfo] {
|
||||||
|
ensureDirectory()
|
||||||
|
guard let names = try? fm.contentsOfDirectory(atPath: directory) else { return [] }
|
||||||
|
return names.compactMap { name in
|
||||||
|
guard name.hasSuffix(".preview"),
|
||||||
|
let id = UInt64(name.replacingOccurrences(of: ".preview", with: "")) else { return nil }
|
||||||
|
let full = (directory as NSString).appendingPathComponent(name)
|
||||||
|
guard let attrs = try? fm.attributesOfItem(atPath: full) else { return nil }
|
||||||
|
let size = (attrs[.size] as? NSNumber)?.int64Value ?? 0
|
||||||
|
let modified = ((attrs[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0) * 1000
|
||||||
|
return PreviewFileInfo(transferId: id, byteSize: size, modifiedAtMillis: Int64(modified))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func readFile(_ transferId: UInt64) -> Data? {
|
||||||
|
try? Data(contentsOf: URL(fileURLWithPath: path(transferId)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func writeAtomically(transferId: UInt64, bytes: Data) -> Bool {
|
||||||
|
ensureDirectory()
|
||||||
|
if fm.fileExists(atPath: path(transferId)) { return true }
|
||||||
|
let temporary = (directory as NSString).appendingPathComponent(".\(transferId).tmp")
|
||||||
|
do {
|
||||||
|
try bytes.write(to: URL(fileURLWithPath: temporary), options: .atomic)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
if fm.fileExists(atPath: path(transferId)) {
|
||||||
|
try? fm.removeItem(atPath: temporary)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
try fm.moveItem(atPath: temporary, toPath: path(transferId))
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
try? fm.removeItem(atPath: temporary)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteFile(_ transferId: UInt64) {
|
||||||
|
try? fm.removeItem(atPath: path(transferId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Data {
|
||||||
|
/// Matches `isSupportedPreview()`: PNG / JPEG / WEBP magic bytes.
|
||||||
|
var isSupportedPreview: Bool {
|
||||||
|
let bytes = [UInt8](self)
|
||||||
|
let png = bytes.count >= 8 && bytes[0] == 0x89
|
||||||
|
&& bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47 // "PNG"
|
||||||
|
let jpeg = bytes.count >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF
|
||||||
|
let webp = bytes.count >= 12
|
||||||
|
&& bytes[0] == 0x52 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x46 // "RIFF"
|
||||||
|
&& bytes[8] == 0x57 && bytes[9] == 0x45 && bytes[10] == 0x42 && bytes[11] == 0x50 // "WEBP"
|
||||||
|
return png || jpeg || webp
|
||||||
|
}
|
||||||
|
}
|
||||||
351
apple/VniDrop/Features/Send/SendModel.swift
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
enum TransferDetailPanel: Equatable {
|
||||||
|
case activity
|
||||||
|
case receivers
|
||||||
|
case share
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which invitation delivery action produced a result (for success copy).
|
||||||
|
enum InvitationAction {
|
||||||
|
case export
|
||||||
|
case share
|
||||||
|
case nfc
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send feature state, ported from `feature/send/SendViewModel.kt` (`SendState`).
|
||||||
|
struct SendState: Equatable {
|
||||||
|
var isComposerOpen = false
|
||||||
|
var selectedFiles: [PickedShareFile] = []
|
||||||
|
var transferName = ""
|
||||||
|
var senderName = ""
|
||||||
|
var accessPolicy: ShareAccessPolicy = .requireApproval
|
||||||
|
var isSharing = false
|
||||||
|
var selectedTransferId: UInt64?
|
||||||
|
var transferThumbnails: [UInt64: Data] = [:]
|
||||||
|
var detailPanel: TransferDetailPanel?
|
||||||
|
var receiverHistory: [ReceiverRequestModel] = []
|
||||||
|
var isLoadingReceivers = false
|
||||||
|
var isDeleteConfirmationOpen = false
|
||||||
|
var isDeleting = false
|
||||||
|
|
||||||
|
func canCreateShare(coreInitialized: Bool) -> Bool {
|
||||||
|
coreInitialized && !selectedFiles.isEmpty && !transferName.isEmpty && !isSharing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class SendModel: ObservableObject {
|
||||||
|
@Published private(set) var state = SendState()
|
||||||
|
@Published private(set) var coreState = CoreState()
|
||||||
|
|
||||||
|
/// Requests a file/folder pick or clipboard copy, consumed by the view layer.
|
||||||
|
@Published var pendingFilePick = false
|
||||||
|
@Published var pendingFolderPick = false
|
||||||
|
|
||||||
|
/// Receiver delivery records per active (sharing) transfer, used to decide
|
||||||
|
/// which transfers still have an in-flight receiver. Delivery status is the
|
||||||
|
/// authoritative signal; byte-transfer events alone don't reliably mark a
|
||||||
|
/// small transfer complete.
|
||||||
|
@Published private(set) var receiversByTransfer: [UInt64: [ReceiverRequestModel]] = [:]
|
||||||
|
|
||||||
|
private let repository: CoreGateway
|
||||||
|
private let fileSystemService: FileSystemService
|
||||||
|
private let filePreviewRepository: FilePreviewRepository
|
||||||
|
private let messages: UiMessageController
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(
|
||||||
|
repository: CoreGateway,
|
||||||
|
fileSystemService: FileSystemService,
|
||||||
|
preferences: AppPreferencesRepository,
|
||||||
|
filePreviewRepository: FilePreviewRepository,
|
||||||
|
messages: UiMessageController
|
||||||
|
) {
|
||||||
|
self.repository = repository
|
||||||
|
self.fileSystemService = fileSystemService
|
||||||
|
self.filePreviewRepository = filePreviewRepository
|
||||||
|
self.messages = messages
|
||||||
|
|
||||||
|
repository.statePublisher.sink { [weak self] in self?.coreState = $0 }.store(in: &cancellables)
|
||||||
|
|
||||||
|
repository.signals
|
||||||
|
.sink { [weak self] signal in
|
||||||
|
guard let self else { return }
|
||||||
|
switch signal {
|
||||||
|
case .transfersChanged:
|
||||||
|
Task { _ = await self.repository.refresh() }
|
||||||
|
case .receiverHistoryChanged(let id), .approvalChanged(let id):
|
||||||
|
if id == self.state.selectedTransferId { self.refreshReceivers(id) }
|
||||||
|
self.refreshReceiverStatuses(for: id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
// Keep receiver delivery records current for every sharing/importing
|
||||||
|
// outgoing transfer (new shares appear here; status transitions arrive via
|
||||||
|
// the receiverHistoryChanged signal above).
|
||||||
|
repository.statePublisher
|
||||||
|
.map { core -> Set<UInt64> in
|
||||||
|
Set(core.transfers
|
||||||
|
.filter { $0.direction == .send && ($0.status == .sharing || $0.status == .importing) }
|
||||||
|
.map(\.transferId))
|
||||||
|
}
|
||||||
|
.removeDuplicates()
|
||||||
|
.sink { [weak self] ids in self?.syncSharingReceivers(ids) }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
filePreviewRepository.$previews
|
||||||
|
.sink { [weak self] previews in self?.state.transferThumbnails = previews }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
repository.statePublisher
|
||||||
|
.map { core -> Set<UInt64>? in
|
||||||
|
core.isInitialized ? Set(core.transfers.map(\.transferId)) : nil
|
||||||
|
}
|
||||||
|
.removeDuplicates()
|
||||||
|
.sink { [weak self] ids in
|
||||||
|
if let ids { self?.filePreviewRepository.restore(activeTransferIds: ids) }
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
preferences.$preferences
|
||||||
|
.sink { [weak self] prefs in
|
||||||
|
guard let self else { return }
|
||||||
|
if self.state.senderName.isEmpty { self.state.senderName = prefs.username }
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Composer
|
||||||
|
|
||||||
|
func openComposer() {
|
||||||
|
if state.isSharing { return }
|
||||||
|
let discarded = state.selectedFiles
|
||||||
|
state.isComposerOpen = true
|
||||||
|
state.selectedFiles = []
|
||||||
|
state.transferName = ""
|
||||||
|
state.accessPolicy = .requireApproval
|
||||||
|
discardPickedFiles(discarded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dismissComposer() {
|
||||||
|
if state.isSharing { return }
|
||||||
|
let discarded = state.selectedFiles
|
||||||
|
state.isComposerOpen = false
|
||||||
|
state.selectedFiles = []
|
||||||
|
state.transferName = ""
|
||||||
|
state.accessPolicy = .requireApproval
|
||||||
|
discardPickedFiles(discarded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectFile() { pendingFilePick = true }
|
||||||
|
func selectFolder() { pendingFolderPick = true }
|
||||||
|
|
||||||
|
func onFilesPicked(_ files: [PickedShareFile]) {
|
||||||
|
if files.isEmpty { return }
|
||||||
|
let selectedValues = Set(files.map(\.value))
|
||||||
|
let discarded = state.selectedFiles.filter { !selectedValues.contains($0.value) }
|
||||||
|
state.isComposerOpen = true
|
||||||
|
state.selectedFiles = files
|
||||||
|
state.transferName = defaultTransferName(files)
|
||||||
|
discardPickedFiles(discarded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func onFilePickFailed(_ reason: String) {
|
||||||
|
messages.error(InvitationError.message(reason.isEmpty ? "selection failed" : reason))
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearSelectedSource() {
|
||||||
|
let discarded = state.selectedFiles
|
||||||
|
state.selectedFiles = []
|
||||||
|
state.transferName = ""
|
||||||
|
discardPickedFiles(discarded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeSelectedFile(_ value: String) {
|
||||||
|
let discarded = state.selectedFiles.filter { $0.value == value }
|
||||||
|
let remaining = state.selectedFiles.filter { $0.value != value }
|
||||||
|
let wasDefault = state.transferName == defaultTransferName(state.selectedFiles)
|
||||||
|
state.selectedFiles = remaining
|
||||||
|
state.transferName = remaining.isEmpty ? "" : (wasDefault ? defaultTransferName(remaining) : state.transferName)
|
||||||
|
discardPickedFiles(discarded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setTransferName(_ value: String) { state.transferName = value }
|
||||||
|
func setSenderName(_ value: String) { state.senderName = value }
|
||||||
|
func setAccessPolicy(_ value: ShareAccessPolicy) { state.accessPolicy = value }
|
||||||
|
|
||||||
|
// MARK: - Transfer detail
|
||||||
|
|
||||||
|
func openTransfer(_ transferId: UInt64) {
|
||||||
|
state.selectedTransferId = transferId
|
||||||
|
state.detailPanel = nil
|
||||||
|
refreshReceivers(transferId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeTransferDetails() {
|
||||||
|
state.selectedTransferId = nil
|
||||||
|
state.detailPanel = nil
|
||||||
|
state.receiverHistory = []
|
||||||
|
state.isDeleteConfirmationOpen = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func openActivity() { state.detailPanel = .activity }
|
||||||
|
func openShare() { state.detailPanel = .share }
|
||||||
|
func openReceivers() {
|
||||||
|
guard let id = state.selectedTransferId else { return }
|
||||||
|
state.detailPanel = .receivers
|
||||||
|
refreshReceivers(id)
|
||||||
|
}
|
||||||
|
func closeDetailPanel() { state.detailPanel = nil }
|
||||||
|
|
||||||
|
func requestDeleteTransfer() { state.isDeleteConfirmationOpen = true }
|
||||||
|
func dismissDeleteTransfer() { if !state.isDeleting { state.isDeleteConfirmationOpen = false } }
|
||||||
|
|
||||||
|
func confirmDeleteTransfer() {
|
||||||
|
guard let transferId = state.selectedTransferId, !state.isDeleting else { return }
|
||||||
|
state.isDeleting = true
|
||||||
|
Task {
|
||||||
|
let result = await repository.delete(transferId: transferId)
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
filePreviewRepository.remove(transferId: transferId)
|
||||||
|
state.selectedTransferId = nil
|
||||||
|
state.detailPanel = nil
|
||||||
|
state.receiverHistory = []
|
||||||
|
state.isDeleteConfirmationOpen = false
|
||||||
|
state.isDeleting = false
|
||||||
|
messages.tryShow(UiMessage(text: .resource("transfer_deleted"), tone: .success))
|
||||||
|
case .failure(let error):
|
||||||
|
state.isDeleting = false
|
||||||
|
messages.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancels/refuses a single receiver by responding to its request negatively.
|
||||||
|
/// Uses the core's `respondReceiverRequest` (no backend change); applies to
|
||||||
|
/// receivers that are still pending or accepted.
|
||||||
|
func cancelReceiver(requestId: String) {
|
||||||
|
Task {
|
||||||
|
let result = await repository.respondReceiverRequest(requestId: requestId, accepted: false, reason: nil)
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
if let transferId = state.selectedTransferId { refreshReceivers(transferId) }
|
||||||
|
_ = await repository.refresh()
|
||||||
|
case .failure(let error):
|
||||||
|
messages.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops an active outgoing share (interrupts any in-flight receivers). The
|
||||||
|
/// transfer stays in history as "Stopped". Uses the core's `cancelTransfer`.
|
||||||
|
func stopSharing(transferId: UInt64) {
|
||||||
|
Task {
|
||||||
|
let result = await repository.cancel(transferId: transferId)
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
_ = await repository.refresh()
|
||||||
|
messages.tryShow(UiMessage(text: .resource("transfer_event_stopped"), tone: .info))
|
||||||
|
case .failure(let error):
|
||||||
|
messages.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Invitation results / share
|
||||||
|
|
||||||
|
func onInvitationResult(_ action: InvitationAction, _ result: Result<Void, Error>) {
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
let key: String?
|
||||||
|
switch action {
|
||||||
|
case .export: key = "transfer_invitation_saved"
|
||||||
|
case .nfc: key = "transfer_nfc_written"
|
||||||
|
case .share: key = nil // system share sheet already confirms
|
||||||
|
}
|
||||||
|
if let key { messages.tryShow(UiMessage(text: .resource(key), tone: .success)) }
|
||||||
|
case .failure(let error):
|
||||||
|
messages.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createShare() {
|
||||||
|
let current = state
|
||||||
|
if current.selectedFiles.isEmpty { return }
|
||||||
|
if !current.canCreateShare(coreInitialized: coreState.isInitialized) { return }
|
||||||
|
state.isSharing = true
|
||||||
|
Task {
|
||||||
|
let result = await fileSystemService.sharePickedFiles(
|
||||||
|
repository: repository,
|
||||||
|
files: current.selectedFiles,
|
||||||
|
transferName: current.transferName.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
senderName: current.senderName.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
accessPolicy: current.accessPolicy
|
||||||
|
)
|
||||||
|
switch result {
|
||||||
|
case .success(let share):
|
||||||
|
await fileSystemService.discardPickedFiles(current.selectedFiles)
|
||||||
|
if let thumb = current.selectedFiles.compactMap(\.thumbnailData).first {
|
||||||
|
filePreviewRepository.save(transferId: share.transferId, bytes: thumb)
|
||||||
|
}
|
||||||
|
state.isComposerOpen = false
|
||||||
|
state.selectedFiles = []
|
||||||
|
state.transferName = ""
|
||||||
|
state.accessPolicy = .requireApproval
|
||||||
|
state.isSharing = false
|
||||||
|
messages.show(UiMessage(text: .resource("send_transfer_created"), tone: .success))
|
||||||
|
case .failure(let error):
|
||||||
|
state.isSharing = false
|
||||||
|
messages.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internals
|
||||||
|
|
||||||
|
private func defaultTransferName(_ files: [PickedShareFile]) -> String {
|
||||||
|
if files.isEmpty { return "" }
|
||||||
|
if files.count == 1 { return files[0].displayName }
|
||||||
|
if files.allSatisfy(\.isDirectory) { return "\(files.count) folders" }
|
||||||
|
return "\(files.count) files"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func discardPickedFiles(_ files: [PickedShareFile]) {
|
||||||
|
if files.isEmpty { return }
|
||||||
|
Task { await fileSystemService.discardPickedFiles(files) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh the receiver records for the sharing set, pruning transfers that are
|
||||||
|
/// no longer active.
|
||||||
|
private func syncSharingReceivers(_ ids: Set<UInt64>) {
|
||||||
|
receiversByTransfer = receiversByTransfer.filter { ids.contains($0.key) }
|
||||||
|
for id in ids { refreshReceiverStatuses(for: id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshReceiverStatuses(for transferId: UInt64) {
|
||||||
|
Task {
|
||||||
|
if case .success(let requests) = await repository.receiverRequests(transferId: transferId) {
|
||||||
|
receiversByTransfer[transferId] = requests
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshReceivers(_ transferId: UInt64) {
|
||||||
|
state.isLoadingReceivers = true
|
||||||
|
Task {
|
||||||
|
let result = await repository.receiverRequests(transferId: transferId)
|
||||||
|
switch result {
|
||||||
|
case .success(let requests):
|
||||||
|
state.receiverHistory = requests
|
||||||
|
state.isLoadingReceivers = false
|
||||||
|
case .failure(let error):
|
||||||
|
state.isLoadingReceivers = false
|
||||||
|
messages.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
211
apple/VniDrop/Features/Send/SendScreen.swift
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Send screen, rebuilt on native SwiftUI. A grouped `List` of outgoing transfers,
|
||||||
|
/// with the composer and detail panels as native sheets and delete as an alert.
|
||||||
|
struct SendScreen: View {
|
||||||
|
@ObservedObject var model: SendModel
|
||||||
|
let windowClass: WindowClass
|
||||||
|
|
||||||
|
private var outgoing: [Transfer] {
|
||||||
|
model.coreState.transfers.filter { $0.direction == .send }
|
||||||
|
}
|
||||||
|
private var selectedTransfer: Transfer? {
|
||||||
|
guard let id = model.state.selectedTransferId else { return nil }
|
||||||
|
return outgoing.first { $0.transferId == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var detailsBinding: Binding<Bool> {
|
||||||
|
Binding(get: { model.state.selectedTransferId != nil }, set: { if !$0 { model.closeTransferDetails() } })
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if outgoing.isEmpty {
|
||||||
|
emptyState
|
||||||
|
} else {
|
||||||
|
catalog
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(Text(LocalizedStringKey("send_title")))
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button(action: model.openComposer) {
|
||||||
|
Label(String(localized: "button_create_new_transfer"), systemImage: "plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationDestination(isPresented: detailsBinding) {
|
||||||
|
if let transfer = selectedTransfer {
|
||||||
|
detailView(for: transfer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.adaptiveDrawer(
|
||||||
|
isPresented: Binding(get: { model.state.isComposerOpen }, set: { _ in }),
|
||||||
|
windowClass: windowClass,
|
||||||
|
onDismiss: model.dismissComposer
|
||||||
|
) {
|
||||||
|
TransferComposer(model: model, windowClass: windowClass)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pushed transfer details view, with its detail-panel sheet and delete
|
||||||
|
/// alert attached here so they present from the detail's own context (presenting
|
||||||
|
/// 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)
|
||||||
|
.adaptiveDrawer(
|
||||||
|
isPresented: Binding(get: { model.state.detailPanel != nil }, set: { _ in }),
|
||||||
|
windowClass: windowClass,
|
||||||
|
onDismiss: model.closeDetailPanel
|
||||||
|
) {
|
||||||
|
if let panel = model.state.detailPanel {
|
||||||
|
DetailPanelContent(model: model, transfer: transfer, panel: panel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert(
|
||||||
|
Text(LocalizedStringKey("transfer_delete_title")),
|
||||||
|
isPresented: Binding(get: { model.state.isDeleteConfirmationOpen }, set: { if !$0 { Task { @MainActor in model.dismissDeleteTransfer() } } })
|
||||||
|
) {
|
||||||
|
Button(String(localized: "button_cancel"), role: .cancel, action: model.dismissDeleteTransfer)
|
||||||
|
Button(String(localized: "button_delete_transfer"), role: .destructive, action: model.confirmDeleteTransfer)
|
||||||
|
} message: {
|
||||||
|
Text(String(format: String(localized: "transfer_delete_description"),
|
||||||
|
transfer.transferName ?? String(localized: "send_new_transfer_title")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var catalog: some View {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
ForEach(outgoing) { transfer in
|
||||||
|
Button {
|
||||||
|
model.openTransfer(transfer.transferId)
|
||||||
|
} label: {
|
||||||
|
TransferListItem(
|
||||||
|
transfer: transfer,
|
||||||
|
thumbnail: model.state.transferThumbnails[transfer.transferId],
|
||||||
|
progress: progress(for: transfer)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text(LocalizedStringKey("send_transfers_title"))
|
||||||
|
} footer: {
|
||||||
|
Text(LocalizedStringKey("send_subtitle"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyState: some View {
|
||||||
|
ContentUnavailableView {
|
||||||
|
Label(String(localized: "send_empty_title"), systemImage: "paperplane")
|
||||||
|
} description: {
|
||||||
|
Text(LocalizedStringKey("send_empty_body"))
|
||||||
|
} actions: {
|
||||||
|
Button(action: model.openComposer) {
|
||||||
|
Label(String(localized: "button_create_new_transfer"), systemImage: "plus")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.controlSize(.large)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func progress(for transfer: Transfer) -> TransferProgress? {
|
||||||
|
switch transfer.status {
|
||||||
|
case .importing: return progressForTransfer(events: model.coreState.events, transferId: transfer.transferId)
|
||||||
|
case .sharing: return sharingProgress(for: transfer)
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Progress for an active share, driven by receivers whose delivery is still
|
||||||
|
/// in flight (`.accepted`). Returns nil when none are downloading, so the bar
|
||||||
|
/// clears once every receiver has completed even if byte events lag.
|
||||||
|
private func sharingProgress(for transfer: Transfer) -> TransferProgress? {
|
||||||
|
let active = (model.receiversByTransfer[transfer.transferId] ?? []).filter { $0.status == .accepted }
|
||||||
|
if active.isEmpty { return nil }
|
||||||
|
let fractions = active.compactMap {
|
||||||
|
progressForReceiver(events: model.coreState.events, transferId: transfer.transferId,
|
||||||
|
remoteEndpointId: $0.remoteEndpointId, totalSizeHint: transfer.totalSize)?.progress
|
||||||
|
}
|
||||||
|
let combined = fractions.isEmpty ? nil : fractions.reduce(0, +) / Double(fractions.count)
|
||||||
|
if active.count == 1 {
|
||||||
|
return TransferProgress(transferId: transfer.transferId, phase: "transfer", kind: "progress",
|
||||||
|
labelKey: "progress_sending", progress: combined)
|
||||||
|
}
|
||||||
|
return TransferProgress(transferId: transfer.transferId, phase: "transfer", kind: "progress",
|
||||||
|
labelKey: "progress_sending", progress: combined,
|
||||||
|
label: String(format: String(localized: "progress_sending_to_count"), active.count))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TransferListItem: View {
|
||||||
|
let transfer: Transfer
|
||||||
|
let thumbnail: Data?
|
||||||
|
let progress: TransferProgress?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
FileArtwork(thumbnail: thumbnail)
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.background(.quaternary, in: RoundedRectangle(cornerRadius: 9))
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
HStack {
|
||||||
|
Text(transfer.transferName ?? String(localized: "send_new_transfer_title"))
|
||||||
|
.font(.body).lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
StatusPill(label: statusLabel(transfer.status), tone: transfer.status.pillTone)
|
||||||
|
}
|
||||||
|
Text("\(formatBytes(transfer.totalSize)) · \(accessPolicyLabel(transfer.accessPolicy))")
|
||||||
|
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||||
|
if let progress, transfer.status == .importing || transfer.status == .sharing {
|
||||||
|
ProgressRow(labelKey: progress.labelKey, progress: progress.progress, detail: progress.detail, labelText: progress.label)
|
||||||
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Image(systemName: "chevron.forward")
|
||||||
|
.font(.footnote.weight(.semibold)).foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FileArtwork: View {
|
||||||
|
let thumbnail: Data?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let thumbnail, let image = PlatformImage.from(data: thumbnail) {
|
||||||
|
image.resizable().aspectRatio(contentMode: .fill)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
} else {
|
||||||
|
Image(systemName: "doc")
|
||||||
|
.font(.system(size: 18))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusLabel(_ status: TransferStatus) -> String {
|
||||||
|
String(localized: String.LocalizationValue(statusLabelKey(status)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func accessPolicyLabel(_ policy: ShareAccessPolicy) -> String {
|
||||||
|
switch policy {
|
||||||
|
case .requireApproval: return String(localized: "send_access_approval")
|
||||||
|
case .anyoneWithTransfer: return String(localized: "send_access_anyone")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TransferStatus {
|
||||||
|
var pillTone: PillTone {
|
||||||
|
switch self {
|
||||||
|
case .sharing, .done: return .brand
|
||||||
|
case .importing, .receiving: return .warning
|
||||||
|
case .failed, .cancelled: return .destructive
|
||||||
|
case .stopped: return .neutral
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
169
apple/VniDrop/Features/Send/TransferComposer.swift
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Transfer composer drawer, ported from `feature/send/TransferComposer.kt`.
|
||||||
|
/// Two steps: choose files/folder, then review + name + access policy + share.
|
||||||
|
struct TransferComposer: View {
|
||||||
|
@ObservedObject var model: SendModel
|
||||||
|
let windowClass: WindowClass
|
||||||
|
|
||||||
|
private var state: SendState { model.state }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
if state.selectedFiles.isEmpty {
|
||||||
|
chooseStep
|
||||||
|
} else {
|
||||||
|
reviewStep
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20).padding(.vertical, 12)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.sendPickers(model: model)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var chooseStep: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Text(LocalizedStringKey("send_choose_file_title")).font(.title2).fontWeight(.semibold)
|
||||||
|
Text(LocalizedStringKey("send_choose_file_body"))
|
||||||
|
.font(.subheadline).foregroundStyle(.secondary)
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
Image(systemName: "doc").font(.system(size: 30)).foregroundStyle(.tint)
|
||||||
|
PrimaryButton(title: String(localized: "button_choose_files"), action: model.selectFile).fixedSize()
|
||||||
|
QuietButton(title: String(localized: "button_choose_folder"), action: model.selectFolder)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(28)
|
||||||
|
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 16))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var reviewStep: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Text(LocalizedStringKey("send_review_title")).font(.title2).fontWeight(.semibold)
|
||||||
|
if state.selectedFiles.count > 1 {
|
||||||
|
Text(String(format: String(localized: "send_selected_files_count"), state.selectedFiles.count))
|
||||||
|
.font(.subheadline).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
ForEach(state.selectedFiles) { file in
|
||||||
|
SelectedFileCard(
|
||||||
|
file: file,
|
||||||
|
canRemove: state.selectedFiles.count > 1 && !state.isSharing,
|
||||||
|
onRemove: { model.removeSelectedFile(file.value) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Field(label: String(localized: "field_transfer_name"),
|
||||||
|
value: Binding(get: { state.transferName }, set: { model.setTransferName($0) }))
|
||||||
|
Field(label: String(localized: "field_sender_name"),
|
||||||
|
value: Binding(get: { state.senderName }, set: { model.setSenderName($0) }))
|
||||||
|
Text(LocalizedStringKey("send_access_title")).font(.headline)
|
||||||
|
PolicyOption(
|
||||||
|
icon: "checkmark.shield", titleKey: "send_access_approval", descKey: "send_access_approval_description",
|
||||||
|
selected: state.accessPolicy == .requireApproval,
|
||||||
|
onTap: { model.setAccessPolicy(.requireApproval) }
|
||||||
|
)
|
||||||
|
PolicyOption(
|
||||||
|
icon: "globe", titleKey: "send_access_anyone", descKey: "send_access_anyone_description",
|
||||||
|
selected: state.accessPolicy == .anyoneWithTransfer,
|
||||||
|
onTap: { model.setAccessPolicy(.anyoneWithTransfer) }
|
||||||
|
)
|
||||||
|
if state.accessPolicy == .anyoneWithTransfer {
|
||||||
|
Label(String(localized: "send_access_anyone_warning"), systemImage: "exclamationmark.triangle.fill")
|
||||||
|
.font(.caption).foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
actions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var actions: some View {
|
||||||
|
let shareTitle = state.isSharing
|
||||||
|
? String(localized: "button_sharing_file") : String(localized: "button_share_file")
|
||||||
|
let shareButton = PrimaryButton(
|
||||||
|
title: shareTitle, action: model.createShare,
|
||||||
|
enabled: state.canCreateShare(coreInitialized: model.coreState.isInitialized)
|
||||||
|
)
|
||||||
|
if windowClass == .phone {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
shareButton
|
||||||
|
QuietButton(title: String(localized: "button_change_files"), action: model.selectFile, enabled: !state.isSharing)
|
||||||
|
QuietButton(title: String(localized: "button_choose_folder"), action: model.selectFolder, enabled: !state.isSharing)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
shareButton.fixedSize()
|
||||||
|
QuietButton(title: String(localized: "button_change_files"), action: model.selectFile, enabled: !state.isSharing)
|
||||||
|
QuietButton(title: String(localized: "button_choose_folder"), action: model.selectFolder, enabled: !state.isSharing)
|
||||||
|
QuietButton(title: String(localized: "button_clear"), action: model.clearSelectedSource, enabled: !state.isSharing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SelectedFileCard: View {
|
||||||
|
let file: PickedShareFile
|
||||||
|
let canRemove: Bool
|
||||||
|
let onRemove: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
FileArtwork(thumbnail: file.thumbnailData)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.background(.quaternary, in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(file.displayName).lineLimit(1)
|
||||||
|
Text(subtitle).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if canRemove {
|
||||||
|
Button(role: .destructive, action: onRemove) {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.tint(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var subtitle: String {
|
||||||
|
if file.isDirectory { return String(localized: "send_folder_label") }
|
||||||
|
if let size = file.sizeBytes { return formatBytes(size) }
|
||||||
|
return String(localized: "send_file_size_unknown")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PolicyOption: View {
|
||||||
|
let icon: String
|
||||||
|
let titleKey: String
|
||||||
|
let descKey: String
|
||||||
|
let selected: Bool
|
||||||
|
let onTap: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: onTap) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 20))
|
||||||
|
.foregroundStyle(selected ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary))
|
||||||
|
.frame(width: 22)
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(LocalizedStringKey(titleKey))
|
||||||
|
Text(LocalizedStringKey(descKey)).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||||
|
.foregroundStyle(selected ? AnyShapeStyle(.tint) : AnyShapeStyle(.tertiary))
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(.quaternary.opacity(selected ? 0.8 : 0.4), in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.stroke(selected ? AnyShapeStyle(.tint) : AnyShapeStyle(.clear), lineWidth: 1.5)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
378
apple/VniDrop/Features/Send/TransferDetailsView.swift
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import CoreImage.CIFilterBuiltins
|
||||||
|
|
||||||
|
/// Transfer details + drawer panels, ported from `feature/send/TransferDetails.kt`.
|
||||||
|
|
||||||
|
struct TransferDetailsView: View {
|
||||||
|
@ObservedObject var model: SendModel
|
||||||
|
let transfer: Transfer
|
||||||
|
let events: [CoreEventModel]
|
||||||
|
@State private var showStopConfirmation = false
|
||||||
|
|
||||||
|
private var isActiveShare: Bool {
|
||||||
|
transfer.status == .sharing || transfer.status == .importing
|
||||||
|
}
|
||||||
|
|
||||||
|
private var pendingReceivers: Int {
|
||||||
|
model.state.receiverHistory.filter { $0.status == .requested || $0.status == .accepted }.count
|
||||||
|
}
|
||||||
|
private var completedReceivers: Int {
|
||||||
|
model.state.receiverHistory.filter { $0.status == .completed }.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
LabeledContent(String(localized: "metadata_status"), value: statusLabel(transfer.status))
|
||||||
|
LabeledContent(String(localized: "metadata_size"), value: formatBytes(transfer.totalSize))
|
||||||
|
LabeledContent(String(localized: "send_access_title"), value: accessPolicyLabel(transfer.accessPolicy))
|
||||||
|
} header: {
|
||||||
|
Text(transfer.transferName ?? String(localized: "send_new_transfer_title"))
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
DetailDestination(
|
||||||
|
title: String(localized: "transfer_activity_title"),
|
||||||
|
description: String(localized: "transfer_activity_description"),
|
||||||
|
count: events.filter { $0.transferId == transfer.transferId && $0.isMeaningfulActivity }.count,
|
||||||
|
onTap: model.openActivity
|
||||||
|
)
|
||||||
|
DetailDestination(
|
||||||
|
title: String(localized: "transfer_receivers_title"),
|
||||||
|
description: receiversDescription(pendingReceivers, completedReceivers),
|
||||||
|
count: pendingReceivers + completedReceivers,
|
||||||
|
onTap: model.openReceivers
|
||||||
|
)
|
||||||
|
DetailDestination(
|
||||||
|
title: String(localized: "transfer_share_title"),
|
||||||
|
description: String(localized: "transfer_share_description"),
|
||||||
|
count: 0,
|
||||||
|
onTap: model.openShare
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isActiveShare {
|
||||||
|
Section {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
showStopConfirmation = true
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: "send_stop_sharing"), systemImage: "stop.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
.navigationTitle(Text(LocalizedStringKey("send_transfer_details_title")))
|
||||||
|
#if os(iOS)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
#endif
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button(role: .destructive, action: model.requestDeleteTransfer) {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.confirmationDialog(
|
||||||
|
Text(LocalizedStringKey("send_stop_sharing")),
|
||||||
|
isPresented: $showStopConfirmation,
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button(String(localized: "send_stop_sharing"), role: .destructive) {
|
||||||
|
model.stopSharing(transferId: transfer.transferId)
|
||||||
|
}
|
||||||
|
Button(String(localized: "button_cancel"), role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text(LocalizedStringKey("send_stop_sharing_description"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func receiversDescription(_ pending: Int, _ completed: Int) -> String {
|
||||||
|
if pending > 0 && completed > 0 {
|
||||||
|
return "\(String(format: String(localized: "transfer_receivers_pending"), pending)) · \(String(format: String(localized: "transfer_receivers_completed_count"), completed))"
|
||||||
|
}
|
||||||
|
if pending > 0 { return String(format: String(localized: "transfer_receivers_pending"), pending) }
|
||||||
|
if completed > 0 { return String(format: String(localized: "transfer_receivers_completed_count"), completed) }
|
||||||
|
return String(localized: "transfer_receivers_description")
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DetailDestination: View {
|
||||||
|
let title: String
|
||||||
|
let description: String
|
||||||
|
let count: Int
|
||||||
|
let onTap: () -> Void
|
||||||
|
var body: some View {
|
||||||
|
Button(action: onTap) {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(title).foregroundStyle(.primary)
|
||||||
|
Text(description).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if count > 0 {
|
||||||
|
Text("\(count)")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Image(systemName: "chevron.forward")
|
||||||
|
.font(.footnote.weight(.semibold)).foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Detail panels
|
||||||
|
|
||||||
|
struct DetailPanelContent: View {
|
||||||
|
@ObservedObject var model: SendModel
|
||||||
|
let transfer: Transfer
|
||||||
|
let panel: TransferDetailPanel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch panel {
|
||||||
|
case .activity:
|
||||||
|
TransferActivityPanel(events: model.coreState.events, transferId: transfer.transferId)
|
||||||
|
case .receivers:
|
||||||
|
ReceiverHistoryPanel(
|
||||||
|
receivers: model.state.receiverHistory,
|
||||||
|
loading: model.state.isLoadingReceivers,
|
||||||
|
events: model.coreState.events,
|
||||||
|
transferTotalSize: transfer.totalSize,
|
||||||
|
onCancel: model.cancelReceiver
|
||||||
|
)
|
||||||
|
case .share:
|
||||||
|
TransferSharePanel(model: model, transfer: transfer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PanelContainer<Content: View>: View {
|
||||||
|
let title: String
|
||||||
|
@ViewBuilder let content: () -> Content
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
Text(title).font(VniType.titleLarge)
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20).padding(.vertical, 14)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TransferActivityPanel: View {
|
||||||
|
@Environment(\.vniColors) private var colors
|
||||||
|
let events: [CoreEventModel]
|
||||||
|
let transferId: UInt64
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let visible = events
|
||||||
|
.filter { $0.transferId == transferId && $0.isMeaningfulActivity }
|
||||||
|
.sorted { $0.timestamp > $1.timestamp }
|
||||||
|
PanelContainer(title: String(localized: "transfer_activity_title")) {
|
||||||
|
if visible.isEmpty {
|
||||||
|
Text(LocalizedStringKey("transfer_no_activity")).foregroundStyle(colors.foregroundLighter)
|
||||||
|
} else {
|
||||||
|
ForEach(Array(visible.enumerated()), id: \.offset) { index, event in
|
||||||
|
if index > 0 { Divider().overlay(colors.borderDefault) }
|
||||||
|
Text(LocalizedStringKey(event.activityTitleKey))
|
||||||
|
.fontWeight(.medium).padding(.vertical, 14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ReceiverHistoryPanel: View {
|
||||||
|
@Environment(\.vniColors) private var colors
|
||||||
|
let receivers: [ReceiverRequestModel]
|
||||||
|
let loading: Bool
|
||||||
|
let events: [CoreEventModel]
|
||||||
|
let transferTotalSize: UInt64
|
||||||
|
let onCancel: (String) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
PanelContainer(title: String(localized: "transfer_receivers_title")) {
|
||||||
|
if loading {
|
||||||
|
ProgressView().frame(maxWidth: .infinity).padding(40)
|
||||||
|
} else if receivers.isEmpty {
|
||||||
|
Text(LocalizedStringKey("transfer_no_receivers")).foregroundStyle(colors.foregroundLighter)
|
||||||
|
} 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendProgress(for receiver: ReceiverRequestModel) -> TransferProgress? {
|
||||||
|
switch receiver.status {
|
||||||
|
case .accepted, .requested:
|
||||||
|
return progressForReceiver(events: events, transferId: receiver.transferId,
|
||||||
|
remoteEndpointId: receiver.remoteEndpointId, totalSizeHint: transferTotalSize)
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ReceiverRow: View {
|
||||||
|
@Environment(\.vniColors) private var colors
|
||||||
|
let receiver: ReceiverRequestModel
|
||||||
|
let sendProgress: TransferProgress?
|
||||||
|
let onCancel: (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
|
||||||
|
/// was refused"). Interrupting an in-flight receiver needs Stop sharing.
|
||||||
|
private var isCancelable: Bool {
|
||||||
|
receiver.status == .requested
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let name = receiver.receiverName ?? receiver.receiverDeviceName ?? String(localized: "transfer_nearby_device")
|
||||||
|
let showLive = sendProgress != nil && receiver.status != .completed
|
||||||
|
&& receiver.status != .refused && receiver.status != .expired
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(name).font(VniType.bodyLarge).lineLimit(1)
|
||||||
|
if let deviceName = receiver.receiverDeviceName, deviceName != name {
|
||||||
|
Text(deviceName).font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
||||||
|
}
|
||||||
|
if showLive, let sendProgress {
|
||||||
|
ProgressRow(labelKey: sendProgress.labelKey, progress: sendProgress.progress, detail: sendProgress.detail, labelText: sendProgress.label)
|
||||||
|
} else {
|
||||||
|
Text(LocalizedStringKey(receiver.status.statusTextKey))
|
||||||
|
.font(VniType.bodySmall).fontWeight(.medium)
|
||||||
|
.foregroundStyle(receiver.status.statusColor(colors))
|
||||||
|
}
|
||||||
|
if let reason = receiver.reason, !reason.isEmpty {
|
||||||
|
Text(reason).font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
if isCancelable {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
onCancel(receiver.id)
|
||||||
|
} label: {
|
||||||
|
Text(LocalizedStringKey("button_refuse"))
|
||||||
|
.font(VniType.bodySmall)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.tint(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.vertical, 13)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TransferSharePanel: View {
|
||||||
|
@Environment(\.vniColors) private var colors
|
||||||
|
@ObservedObject var model: SendModel
|
||||||
|
let transfer: Transfer
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
PanelContainer(title: String(localized: "transfer_share_title")) {
|
||||||
|
if let ticket = transfer.ticket {
|
||||||
|
qrCard(ticket: ticket)
|
||||||
|
Text(LocalizedStringKey("transfer_scan_qr"))
|
||||||
|
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
ShareActionsView(model: model, transfer: transfer, ticket: ticket)
|
||||||
|
} else {
|
||||||
|
Text(LocalizedStringKey("transfer_event_preparing")).foregroundStyle(colors.foregroundLighter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func qrCard(ticket: String) -> some View {
|
||||||
|
ZStack {
|
||||||
|
if let qr = QRCode.generate(from: ticket) {
|
||||||
|
qr.interpolation(.none).resizable().scaledToFit().padding(14)
|
||||||
|
} else {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 268, height: 268)
|
||||||
|
.background(Color.white, in: RoundedRectangle(cornerRadius: 18))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - QR generation (CoreImage)
|
||||||
|
|
||||||
|
enum QRCode {
|
||||||
|
static func generate(from string: String) -> Image? {
|
||||||
|
let context = CIContext()
|
||||||
|
let filter = CIFilter.qrCodeGenerator()
|
||||||
|
filter.message = Data(string.utf8)
|
||||||
|
filter.correctionLevel = "M"
|
||||||
|
guard let output = filter.outputImage else { return nil }
|
||||||
|
let scaled = output.transformed(by: CGAffineTransform(scaleX: 10, y: 10))
|
||||||
|
guard let cgImage = context.createCGImage(scaled, from: scaled.extent) else { return nil }
|
||||||
|
#if os(iOS)
|
||||||
|
return Image(uiImage: UIImage(cgImage: cgImage))
|
||||||
|
#else
|
||||||
|
return Image(nsImage: NSImage(cgImage: cgImage, size: .zero))
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Event helpers (ported from TransferDetails.kt)
|
||||||
|
|
||||||
|
extension CoreEventModel {
|
||||||
|
var isMeaningfulActivity: Bool {
|
||||||
|
(phase == "import" && kind == "started")
|
||||||
|
|| (phase == "ticket" && kind == "created")
|
||||||
|
|| (phase == "network" && (kind == "connecting" || kind == "connected"))
|
||||||
|
|| (phase == "download" && kind == "found-collection")
|
||||||
|
|| (phase == "lifecycle" && ["done", "cancelled", "share-stopped"].contains(kind))
|
||||||
|
|| ["receiver-requested", "receiver-accepted", "receiver-auto-approved",
|
||||||
|
"receiver-refused", "receiver-completed", "share-stopped", "failed"].contains(kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
var activityTitleKey: String {
|
||||||
|
if phase == "import" && kind == "started" { return "transfer_event_preparing" }
|
||||||
|
if phase == "ticket" && kind == "created" { return "transfer_event_ready" }
|
||||||
|
if phase == "network" { return "transfer_event_connecting" }
|
||||||
|
if phase == "download" { return "transfer_event_downloading" }
|
||||||
|
if phase == "export" { return "transfer_event_saving" }
|
||||||
|
if kind == "receiver-requested" { return "transfer_event_requested" }
|
||||||
|
if kind == "receiver-accepted" || kind == "receiver-auto-approved" { return "transfer_event_approved" }
|
||||||
|
if kind == "receiver-refused" { return "transfer_event_refused" }
|
||||||
|
if kind == "receiver-completed" { return "transfer_event_completed" }
|
||||||
|
if kind == "share-stopped" || (phase == "lifecycle" && kind == "cancelled") { return "transfer_event_stopped" }
|
||||||
|
if kind == "failed" { return "transfer_event_failed" }
|
||||||
|
return "transfer_event_updated"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ReceiverDeliveryStatus {
|
||||||
|
var statusTextKey: String {
|
||||||
|
switch self {
|
||||||
|
case .requested: return "transfer_receiver_requested"
|
||||||
|
case .accepted: return "transfer_receiver_accepted"
|
||||||
|
case .refused: return "transfer_receiver_refused"
|
||||||
|
case .expired: return "transfer_receiver_expired"
|
||||||
|
case .completed: return "transfer_receiver_completed"
|
||||||
|
case .unknown: return "transfer_receiver_unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusColor(_ colors: VniDropColors) -> Color {
|
||||||
|
switch self {
|
||||||
|
case .completed: return colors.brandDefault
|
||||||
|
case .refused, .expired: return colors.destructiveDefault
|
||||||
|
default: return colors.foregroundLighter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#else
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
61
apple/VniDrop/Features/Send/TransferShareActions.swift
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum NfcShareAvailability { case available, unavailable, hidden }
|
||||||
|
|
||||||
|
/// Invitation delivery actions shared by the native Apple feature models.
|
||||||
|
/// Platform implementations perform export, native share, and NFC write.
|
||||||
|
@MainActor
|
||||||
|
protocol TransferShareActions: AnyObject {
|
||||||
|
var canUseNativeShare: Bool { get }
|
||||||
|
var nfcAvailability: NfcShareAvailability { get }
|
||||||
|
|
||||||
|
func exportInvitation(ticket: String, transferName: String, onResult: @escaping (Result<Void, Error>) -> Void)
|
||||||
|
func shareInvitation(ticket: String, transferName: String, onResult: @escaping (Result<Void, Error>) -> Void)
|
||||||
|
func writeInvitationToNfc(ticket: String, onResult: @escaping (Result<Void, Error>) -> Void)
|
||||||
|
func cancelNfcWrite()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The QR + delivery buttons for a transfer's share panel, ported from the button
|
||||||
|
/// stack in `TransferSharePanel` (`TransferDetails.kt`).
|
||||||
|
struct ShareActionsView: View {
|
||||||
|
@Environment(\.vniColors) private var colors
|
||||||
|
@ObservedObject var model: SendModel
|
||||||
|
let transfer: Transfer
|
||||||
|
let ticket: String
|
||||||
|
|
||||||
|
@State private var actions: TransferShareActions = makePlatformShareActions()
|
||||||
|
@State private var writingNfc = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
if actions.nfcAvailability != .hidden {
|
||||||
|
SecondaryButton(
|
||||||
|
title: writingNfc ? String(localized: "transfer_nfc_waiting") : String(localized: "button_write_nfc"),
|
||||||
|
action: {
|
||||||
|
writingNfc = true
|
||||||
|
actions.writeInvitationToNfc(ticket: ticket) { result in
|
||||||
|
writingNfc = false
|
||||||
|
model.onInvitationResult(.nfc, result)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: actions.nfcAvailability == .available && !writingNfc
|
||||||
|
)
|
||||||
|
if actions.nfcAvailability == .unavailable {
|
||||||
|
Text(LocalizedStringKey("transfer_nfc_unavailable"))
|
||||||
|
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SecondaryButton(title: String(localized: "button_download_invitation"), action: {
|
||||||
|
actions.exportInvitation(ticket: ticket, transferName: transfer.transferName ?? "") {
|
||||||
|
model.onInvitationResult(.export, $0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
PrimaryButton(title: String(localized: "button_native_share"), action: {
|
||||||
|
actions.shareInvitation(ticket: ticket, transferName: transfer.transferName ?? "") {
|
||||||
|
model.onInvitationResult(.share, $0)
|
||||||
|
}
|
||||||
|
}, enabled: actions.canUseNativeShare)
|
||||||
|
}
|
||||||
|
.onDisappear { actions.cancelNfcWrite() }
|
||||||
|
}
|
||||||
|
}
|
||||||
31
apple/VniDrop/Features/Settings/BugReportService.swift
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Bug-report draft, ported from `diagnostics/BugReportService.kt`.
|
||||||
|
struct BugReportDraft {
|
||||||
|
let whatHappened: String
|
||||||
|
let expected: String
|
||||||
|
let steps: String
|
||||||
|
let contact: String
|
||||||
|
let includeLogs: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bug-report submission. The full diagnostics transport (URLSession + build
|
||||||
|
/// config) lands in the diagnostics phase; this protocol is the stable seam.
|
||||||
|
@MainActor
|
||||||
|
protocol BugReportService {
|
||||||
|
func submit(_ draft: BugReportDraft, deviceInfo: DeviceInfo?) async -> Result<Void, Error>
|
||||||
|
func previewLogBytes() async -> Int
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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"))
|
||||||
|
}
|
||||||
|
func previewLogBytes() async -> Int { 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the diagnostics stack is compiled in (mirrors DiagnosticsBuildConfig).
|
||||||
|
enum DiagnosticsBuildConfig {
|
||||||
|
static let included = false
|
||||||
|
}
|
||||||
360
apple/VniDrop/Features/Settings/SettingsModel.swift
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// Settings sections, ported from `feature/settings/SettingsViewModel.kt`.
|
||||||
|
enum SettingsSection: Hashable {
|
||||||
|
case overview
|
||||||
|
case preferences
|
||||||
|
case appearance
|
||||||
|
case notifications
|
||||||
|
case storage
|
||||||
|
case about
|
||||||
|
case bugReport
|
||||||
|
|
||||||
|
var titleKey: String {
|
||||||
|
switch self {
|
||||||
|
case .overview: return "settings_title"
|
||||||
|
case .preferences: return "preferences_title"
|
||||||
|
case .appearance: return "appearance_title"
|
||||||
|
case .notifications: return "notifications_title"
|
||||||
|
case .storage: return "storage_title"
|
||||||
|
case .about: return "about_title"
|
||||||
|
case .bugReport: return "about_bug_report"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// On-disk usage breakdown for the Storage screen.
|
||||||
|
struct StorageBreakdown: Equatable {
|
||||||
|
var receivedFiles: UInt64 = 0
|
||||||
|
var transferData: UInt64 = 0
|
||||||
|
var temporary: UInt64 = 0
|
||||||
|
var total: UInt64 { receivedFiles + transferData + temporary }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SettingsState: Equatable {
|
||||||
|
var selectedSection: SettingsSection = .overview
|
||||||
|
var username = ""
|
||||||
|
var receiveFolder: ReceiveFolder?
|
||||||
|
var folderAccessStatus: FolderAccessStatus = .unavailable
|
||||||
|
var isValidatingFolder = false
|
||||||
|
var supportsCustomReceiveFolders = true
|
||||||
|
var themeMode: ThemeMode = .system
|
||||||
|
var notificationsEnabled = false
|
||||||
|
var notificationPermission: NotificationPermission = .notDetermined
|
||||||
|
var diagnosticsEnabled = false
|
||||||
|
var deviceInfo: DeviceInfo?
|
||||||
|
var appVersion = ""
|
||||||
|
var isLoadingDeviceInfo = false
|
||||||
|
var bugWhatHappened = ""
|
||||||
|
var bugExpected = ""
|
||||||
|
var bugSteps = ""
|
||||||
|
var bugContact = ""
|
||||||
|
var bugIncludeLogs = true
|
||||||
|
var isSubmittingBugReport = false
|
||||||
|
var bugLogPreviewBytes = 0
|
||||||
|
var storage: StorageBreakdown?
|
||||||
|
var isCalculatingStorage = false
|
||||||
|
var isDeletingTransfers = false
|
||||||
|
|
||||||
|
static func == (lhs: SettingsState, rhs: SettingsState) -> Bool {
|
||||||
|
lhs.selectedSection == rhs.selectedSection && lhs.username == rhs.username
|
||||||
|
&& lhs.receiveFolder == rhs.receiveFolder && lhs.folderAccessStatus == rhs.folderAccessStatus
|
||||||
|
&& lhs.isValidatingFolder == rhs.isValidatingFolder
|
||||||
|
&& lhs.supportsCustomReceiveFolders == rhs.supportsCustomReceiveFolders
|
||||||
|
&& lhs.themeMode == rhs.themeMode && lhs.notificationsEnabled == rhs.notificationsEnabled
|
||||||
|
&& lhs.notificationPermission == rhs.notificationPermission
|
||||||
|
&& lhs.diagnosticsEnabled == rhs.diagnosticsEnabled && lhs.appVersion == rhs.appVersion
|
||||||
|
&& lhs.isLoadingDeviceInfo == rhs.isLoadingDeviceInfo
|
||||||
|
&& lhs.bugWhatHappened == rhs.bugWhatHappened && lhs.bugExpected == rhs.bugExpected
|
||||||
|
&& lhs.bugSteps == rhs.bugSteps && lhs.bugContact == rhs.bugContact
|
||||||
|
&& lhs.bugIncludeLogs == rhs.bugIncludeLogs && lhs.isSubmittingBugReport == rhs.isSubmittingBugReport
|
||||||
|
&& lhs.bugLogPreviewBytes == rhs.bugLogPreviewBytes
|
||||||
|
&& lhs.storage == rhs.storage && lhs.isCalculatingStorage == rhs.isCalculatingStorage
|
||||||
|
&& lhs.isDeletingTransfers == rhs.isDeletingTransfers
|
||||||
|
&& lhs.deviceInfo?.operatingSystem == rhs.deviceInfo?.operatingSystem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class SettingsModel: ObservableObject {
|
||||||
|
@Published private(set) var state: SettingsState
|
||||||
|
/// Set by the view to request the receive-folder picker (macOS).
|
||||||
|
@Published var pendingReceiveFolderPick = false
|
||||||
|
|
||||||
|
private let environment: PlatformEnvironment
|
||||||
|
private let deviceInfoProvider: DeviceInfoProvider
|
||||||
|
private let fileSystemService: FileSystemService
|
||||||
|
private let repository: CoreGateway
|
||||||
|
private let preferences: AppPreferencesRepository
|
||||||
|
private let notifications: LocalNotificationService
|
||||||
|
private let messages: UiMessageController
|
||||||
|
private let bugReports: BugReportService
|
||||||
|
private let diagnosticsIncluded: Bool
|
||||||
|
|
||||||
|
private var enableNotificationsAfterSettings = false
|
||||||
|
private var usernamePersistTask: Task<Void, Never>?
|
||||||
|
private var hasLocalUsernameDraft = false
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(
|
||||||
|
environment: PlatformEnvironment,
|
||||||
|
deviceInfoProvider: DeviceInfoProvider,
|
||||||
|
fileSystemService: FileSystemService,
|
||||||
|
repository: CoreGateway,
|
||||||
|
preferences: AppPreferencesRepository,
|
||||||
|
notifications: LocalNotificationService,
|
||||||
|
messages: UiMessageController,
|
||||||
|
bugReports: BugReportService,
|
||||||
|
diagnosticsIncluded: Bool = DiagnosticsBuildConfig.included
|
||||||
|
) {
|
||||||
|
self.environment = environment
|
||||||
|
self.deviceInfoProvider = deviceInfoProvider
|
||||||
|
self.fileSystemService = fileSystemService
|
||||||
|
self.repository = repository
|
||||||
|
self.preferences = preferences
|
||||||
|
self.notifications = notifications
|
||||||
|
self.messages = messages
|
||||||
|
self.bugReports = bugReports
|
||||||
|
self.diagnosticsIncluded = diagnosticsIncluded
|
||||||
|
self.state = SettingsState(
|
||||||
|
supportsCustomReceiveFolders: fileSystemService.supportsCustomReceiveFolders,
|
||||||
|
appVersion: environment.appVersion
|
||||||
|
)
|
||||||
|
|
||||||
|
preferences.$preferences
|
||||||
|
.sink { [weak self] prefs in
|
||||||
|
guard let self else { return }
|
||||||
|
let previousFolder = self.state.receiveFolder
|
||||||
|
let folder = self.fileSystemService.effectiveReceiveFolder(prefs.receiveFolder)
|
||||||
|
self.state.username = self.hasLocalUsernameDraft ? self.state.username : prefs.username
|
||||||
|
self.state.receiveFolder = folder
|
||||||
|
self.state.themeMode = prefs.themeMode
|
||||||
|
self.state.notificationsEnabled = prefs.notificationsEnabled
|
||||||
|
self.state.diagnosticsEnabled = prefs.diagnosticsEnabled
|
||||||
|
if folder != previousFolder { Task { await self.validateFolder(folder) } }
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
refreshNotificationPermission()
|
||||||
|
loadDeviceInfo()
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectSection(_ section: SettingsSection) {
|
||||||
|
state.selectedSection = section
|
||||||
|
if section == .about || section == .bugReport {
|
||||||
|
loadDeviceInfo()
|
||||||
|
if section == .bugReport { refreshBugLogPreview() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setUsername(_ value: String) {
|
||||||
|
hasLocalUsernameDraft = true
|
||||||
|
state.username = value
|
||||||
|
usernamePersistTask?.cancel()
|
||||||
|
usernamePersistTask = Task {
|
||||||
|
try? await Task.sleep(nanoseconds: 350_000_000)
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
preferences.setUsername(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setThemeMode(_ mode: ThemeMode) { preferences.setThemeMode(mode) }
|
||||||
|
|
||||||
|
func chooseReceiveFolder() {
|
||||||
|
if !fileSystemService.supportsCustomReceiveFolders { return }
|
||||||
|
pendingReceiveFolderPick = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func onReceiveFolderPicked(_ folder: ReceiveFolder) { preferences.setReceiveFolder(folder) }
|
||||||
|
func onReceiveFolderPickFailed(_ reason: String) { messages.error(InvitationError.message(reason)) }
|
||||||
|
func resetReceiveFolder() { preferences.resetReceiveFolder() }
|
||||||
|
|
||||||
|
func setNotificationsEnabled(_ enabled: Bool) {
|
||||||
|
Task {
|
||||||
|
if !enabled {
|
||||||
|
preferences.setNotificationsEnabled(false)
|
||||||
|
notifications.cancelAll()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let permission = await notifications.requestPermission()
|
||||||
|
state.notificationPermission = permission
|
||||||
|
if permission == .granted {
|
||||||
|
await enableNotifications()
|
||||||
|
} else {
|
||||||
|
preferences.setNotificationsEnabled(false)
|
||||||
|
let key = permission == .unsupported ? "notifications_unsupported" : "notifications_permission_denied"
|
||||||
|
messages.show(UiMessage(
|
||||||
|
text: .resource(key),
|
||||||
|
tone: .warning,
|
||||||
|
actionLabel: permission == .denied ? .resource("button_open_settings") : nil,
|
||||||
|
onAction: permission == .denied ? { self.openNotificationSettings() } : nil
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setDiagnosticsEnabled(_ enabled: Bool) {
|
||||||
|
if !diagnosticsIncluded { return }
|
||||||
|
Task {
|
||||||
|
preferences.setDiagnosticsEnabled(enabled)
|
||||||
|
messages.show(UiMessage(
|
||||||
|
text: .resource(enabled ? "diagnostics_enabled_message" : "diagnostics_disabled_message"),
|
||||||
|
tone: .success
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setBugWhatHappened(_ value: String) { state.bugWhatHappened = value }
|
||||||
|
func setBugExpected(_ value: String) { state.bugExpected = value }
|
||||||
|
func setBugSteps(_ value: String) { state.bugSteps = value }
|
||||||
|
func setBugContact(_ value: String) { state.bugContact = value }
|
||||||
|
func setBugIncludeLogs(_ value: Bool) { state.bugIncludeLogs = value }
|
||||||
|
|
||||||
|
func submitBugReport(onSuccess: @escaping () -> Void = {}) {
|
||||||
|
if state.isSubmittingBugReport { return }
|
||||||
|
Task {
|
||||||
|
let snapshot = state
|
||||||
|
let what = snapshot.bugWhatHappened.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let expected = snapshot.bugExpected.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if what.isEmpty {
|
||||||
|
messages.show(UiMessage(text: .resource("bug_report_missing_what"), tone: .warning))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if expected.isEmpty {
|
||||||
|
messages.show(UiMessage(text: .resource("bug_report_missing_expected"), tone: .warning))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.isSubmittingBugReport = true
|
||||||
|
let result = await bugReports.submit(
|
||||||
|
BugReportDraft(
|
||||||
|
whatHappened: what, expected: expected, steps: snapshot.bugSteps,
|
||||||
|
contact: snapshot.bugContact, includeLogs: snapshot.bugIncludeLogs
|
||||||
|
),
|
||||||
|
deviceInfo: snapshot.deviceInfo
|
||||||
|
)
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
state.isSubmittingBugReport = false
|
||||||
|
state.bugWhatHappened = ""
|
||||||
|
state.bugExpected = ""
|
||||||
|
state.bugSteps = ""
|
||||||
|
state.bugContact = ""
|
||||||
|
state.bugIncludeLogs = true
|
||||||
|
messages.show(UiMessage(text: .resource("bug_report_submitted"), tone: .success))
|
||||||
|
onSuccess()
|
||||||
|
case .failure:
|
||||||
|
state.isSubmittingBugReport = false
|
||||||
|
messages.show(UiMessage(text: .resource("bug_report_submit_failed"), tone: .error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openNotificationSettings() {
|
||||||
|
Task {
|
||||||
|
enableNotificationsAfterSettings = true
|
||||||
|
let result = await notifications.openSettings()
|
||||||
|
if case .failure = result {
|
||||||
|
enableNotificationsAfterSettings = false
|
||||||
|
messages.show(UiMessage(text: .resource("notifications_settings_open_failed"), tone: .error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshNotificationPermission() {
|
||||||
|
Task {
|
||||||
|
let permission = await notifications.refreshPermission()
|
||||||
|
state.notificationPermission = permission
|
||||||
|
if enableNotificationsAfterSettings {
|
||||||
|
enableNotificationsAfterSettings = false
|
||||||
|
if permission == .granted { await enableNotifications() }
|
||||||
|
} else if permission != .granted && state.notificationsEnabled {
|
||||||
|
preferences.setNotificationsEnabled(false)
|
||||||
|
notifications.cancelAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func enableNotifications() async {
|
||||||
|
preferences.setNotificationsEnabled(true)
|
||||||
|
messages.show(UiMessage(text: .resource("notifications_enabled_message"), tone: .success))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Storage
|
||||||
|
|
||||||
|
/// Recomputes the on-disk usage breakdown off the main actor.
|
||||||
|
func loadStorageUsage() {
|
||||||
|
if state.isCalculatingStorage { return }
|
||||||
|
state.isCalculatingStorage = true
|
||||||
|
let coreDir = environment.defaultCoreDataDir
|
||||||
|
let receiveDir = state.receiveFolder?.isFileSystemPath == true ? state.receiveFolder?.value : nil
|
||||||
|
let tempDir = NSTemporaryDirectory()
|
||||||
|
Task.detached {
|
||||||
|
let breakdown = StorageBreakdown(
|
||||||
|
receivedFiles: receiveDir.map { SettingsModel.directorySize($0) } ?? 0,
|
||||||
|
transferData: SettingsModel.directorySize(coreDir),
|
||||||
|
temporary: SettingsModel.directorySize(tempDir)
|
||||||
|
)
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.state.storage = breakdown
|
||||||
|
self?.state.isCalculatingStorage = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes every send/receive transfer via the core, freeing the imported
|
||||||
|
/// shared-file content and clearing history. Node identity and received files
|
||||||
|
/// are left untouched.
|
||||||
|
func deleteAllTransfers() {
|
||||||
|
if state.isDeletingTransfers { return }
|
||||||
|
state.isDeletingTransfers = true
|
||||||
|
Task {
|
||||||
|
for id in repository.state.transfers.map(\.transferId) {
|
||||||
|
_ = await repository.delete(transferId: id)
|
||||||
|
}
|
||||||
|
_ = await repository.refresh()
|
||||||
|
state.isDeletingTransfers = false
|
||||||
|
loadStorageUsage()
|
||||||
|
messages.show(UiMessage(text: .resource("storage_transfers_deleted"), tone: .success))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursive size of every regular file under `path` (0 if missing).
|
||||||
|
nonisolated static func directorySize(_ path: String) -> UInt64 {
|
||||||
|
let url = URL(fileURLWithPath: path)
|
||||||
|
guard let enumerator = FileManager.default.enumerator(
|
||||||
|
at: url, includingPropertiesForKeys: [.isRegularFileKey, .totalFileAllocatedSizeKey, .fileSizeKey]
|
||||||
|
) else { return 0 }
|
||||||
|
var total: UInt64 = 0
|
||||||
|
for case let fileURL as URL in enumerator {
|
||||||
|
let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .totalFileAllocatedSizeKey, .fileSizeKey])
|
||||||
|
guard values?.isRegularFile == true else { continue }
|
||||||
|
total += UInt64(values?.totalFileAllocatedSize ?? values?.fileSize ?? 0)
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadDeviceInfo() {
|
||||||
|
if state.isLoadingDeviceInfo { return }
|
||||||
|
state.isLoadingDeviceInfo = true
|
||||||
|
Task {
|
||||||
|
let info = await deviceInfoProvider.load()
|
||||||
|
state.deviceInfo = info
|
||||||
|
state.isLoadingDeviceInfo = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshBugLogPreview() {
|
||||||
|
Task {
|
||||||
|
let bytes = await bugReports.previewLogBytes()
|
||||||
|
state.bugLogPreviewBytes = bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func validateFolder(_ folder: ReceiveFolder) async {
|
||||||
|
state.isValidatingFolder = true
|
||||||
|
let status = await fileSystemService.validateReceiveFolder(folder)
|
||||||
|
state.folderAccessStatus = status
|
||||||
|
state.isValidatingFolder = false
|
||||||
|
}
|
||||||
|
}
|
||||||
137
apple/VniDrop/Features/Settings/SettingsScreen.swift
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Settings screen, rebuilt on a native `Form` with `NavigationStack` push
|
||||||
|
/// navigation. The model stays the source of truth via a derived path binding.
|
||||||
|
struct SettingsScreen: View {
|
||||||
|
@ObservedObject var model: SettingsModel
|
||||||
|
let windowClass: WindowClass
|
||||||
|
@State private var showBugReport = false
|
||||||
|
|
||||||
|
private var path: Binding<[SettingsSection]> {
|
||||||
|
Binding(
|
||||||
|
get: {
|
||||||
|
switch model.state.selectedSection {
|
||||||
|
case .overview: return []
|
||||||
|
case .bugReport: return [.about, .bugReport]
|
||||||
|
case let section: return [section]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
set: { newPath in model.selectSection(newPath.last ?? .overview) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack(path: path) {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
NavigationLink(value: SettingsSection.preferences) {
|
||||||
|
SettingsRow(icon: "person.crop.circle", title: String(localized: "preferences_title"), value: model.state.username)
|
||||||
|
}
|
||||||
|
NavigationLink(value: SettingsSection.appearance) {
|
||||||
|
SettingsRow(icon: "sun.max", title: String(localized: "appearance_title"), value: themeModeLabel(model.state.themeMode))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
NavigationLink(value: SettingsSection.notifications) {
|
||||||
|
SettingsRow(icon: "bell", title: String(localized: "notifications_title"), value: nil)
|
||||||
|
}
|
||||||
|
NavigationLink(value: SettingsSection.storage) {
|
||||||
|
SettingsRow(icon: "internaldrive", title: String(localized: "storage_title"), value: nil)
|
||||||
|
}
|
||||||
|
NavigationLink(value: SettingsSection.about) {
|
||||||
|
SettingsRow(icon: "info.circle", title: String(localized: "about_title"), value: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
.navigationTitle(Text(LocalizedStringKey("settings_title")))
|
||||||
|
.navigationDestination(for: SettingsSection.self) { section in
|
||||||
|
sectionForm(section)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func sectionForm(_ section: SettingsSection) -> some View {
|
||||||
|
let content = Form {
|
||||||
|
SettingsSectionContent(model: model, section: section)
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
.navigationTitle(Text(LocalizedStringKey(section.titleKey)))
|
||||||
|
|
||||||
|
if section == .about {
|
||||||
|
content
|
||||||
|
#if os(iOS)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
#endif
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button {
|
||||||
|
showBugReport = true
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: "about_bug_report"), systemImage: "ladybug")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showBugReport) {
|
||||||
|
BugReportSheet(model: model)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
content
|
||||||
|
#if os(iOS)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SettingsSectionContent: View {
|
||||||
|
@ObservedObject var model: SettingsModel
|
||||||
|
let section: SettingsSection
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch section {
|
||||||
|
case .overview:
|
||||||
|
EmptyView()
|
||||||
|
case .preferences:
|
||||||
|
PreferencesSettings(model: model)
|
||||||
|
case .appearance:
|
||||||
|
AppearanceSettings(model: model)
|
||||||
|
case .notifications:
|
||||||
|
NotificationSettings(model: model)
|
||||||
|
case .storage:
|
||||||
|
StorageSettings(model: model)
|
||||||
|
case .about:
|
||||||
|
AboutSettings(model: model)
|
||||||
|
case .bugReport:
|
||||||
|
BugReportSettings(model: model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SettingsRow: View {
|
||||||
|
let icon: String
|
||||||
|
let title: String
|
||||||
|
let value: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.foregroundStyle(.tint)
|
||||||
|
.frame(width: 26)
|
||||||
|
Text(title).foregroundStyle(.primary)
|
||||||
|
Spacer()
|
||||||
|
if let value {
|
||||||
|
Text(value).foregroundStyle(.secondary).lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func themeModeLabel(_ mode: ThemeMode) -> String {
|
||||||
|
switch mode {
|
||||||
|
case .system: return String(localized: "appearance_system_mode")
|
||||||
|
case .light: return String(localized: "appearance_light_mode")
|
||||||
|
case .dark: return String(localized: "appearance_dark_mode")
|
||||||
|
}
|
||||||
|
}
|
||||||
264
apple/VniDrop/Features/Settings/SettingsSections.swift
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Settings section detail views, rebuilt as native `Form` content. Each view is
|
||||||
|
/// placed inside a parent `Form`, so it returns `Section`s / rows directly.
|
||||||
|
|
||||||
|
struct PreferencesSettings: View {
|
||||||
|
@ObservedObject var model: SettingsModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section(String(localized: "field_username")) {
|
||||||
|
TextField(String(localized: "field_username"),
|
||||||
|
text: Binding(get: { model.state.username }, set: { model.setUsername($0) }))
|
||||||
|
}
|
||||||
|
if model.state.supportsCustomReceiveFolders {
|
||||||
|
Section(String(localized: "preferences_receive_folder_title")) {
|
||||||
|
Text(model.state.receiveFolder?.displayName ?? String(localized: "value_unavailable"))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Button(String(localized: "button_choose_folder"), action: model.chooseReceiveFolder)
|
||||||
|
Button(String(localized: "button_reset_default"), action: model.resetReceiveFolder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AppearanceSettings: View {
|
||||||
|
@ObservedObject var model: SettingsModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section {
|
||||||
|
Picker(String(localized: "appearance_title"),
|
||||||
|
selection: Binding(get: { model.state.themeMode }, set: { model.setThemeMode($0) })) {
|
||||||
|
ForEach(ThemeMode.allCases, id: \.self) { mode in
|
||||||
|
Text(themeModeLabel(mode)).tag(mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.inline)
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NotificationSettings: View {
|
||||||
|
@ObservedObject var model: SettingsModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section {
|
||||||
|
Toggle(isOn: Binding(
|
||||||
|
get: { model.state.notificationsEnabled },
|
||||||
|
set: { model.setNotificationsEnabled($0) }
|
||||||
|
)) {
|
||||||
|
Text(LocalizedStringKey("notifications_local_title"))
|
||||||
|
}
|
||||||
|
if model.state.notificationPermission == .denied {
|
||||||
|
Button(String(localized: "button_open_settings"), action: model.openNotificationSettings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StorageSettings: View {
|
||||||
|
@ObservedObject var model: SettingsModel
|
||||||
|
@State private var showDeleteConfirmation = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section {
|
||||||
|
if let storage = model.state.storage {
|
||||||
|
LabeledContent(String(localized: "storage_received_files"), value: formatBytes(storage.receivedFiles))
|
||||||
|
LabeledContent(String(localized: "storage_transfer_data"), value: formatBytes(storage.transferData))
|
||||||
|
LabeledContent(String(localized: "storage_temporary"), value: formatBytes(storage.temporary))
|
||||||
|
LabeledContent(String(localized: "storage_total")) {
|
||||||
|
Text(formatBytes(storage.total)).fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HStack {
|
||||||
|
Text(LocalizedStringKey("storage_calculating")).foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text(LocalizedStringKey("storage_footer"))
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
showDeleteConfirmation = true
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(model.state.isDeletingTransfers
|
||||||
|
? String(localized: "storage_deleting")
|
||||||
|
: String(localized: "storage_delete_transfers"))
|
||||||
|
if model.state.isDeletingTransfers {
|
||||||
|
Spacer()
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(model.state.isDeletingTransfers)
|
||||||
|
}
|
||||||
|
.onAppear { model.loadStorageUsage() }
|
||||||
|
.confirmationDialog(
|
||||||
|
Text(LocalizedStringKey("storage_delete_transfers")),
|
||||||
|
isPresented: $showDeleteConfirmation,
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button(String(localized: "storage_delete_transfers"), role: .destructive) {
|
||||||
|
model.deleteAllTransfers()
|
||||||
|
}
|
||||||
|
Button(String(localized: "button_cancel"), role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text(LocalizedStringKey("storage_delete_transfers_description"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AboutSettings: View {
|
||||||
|
@ObservedObject var model: SettingsModel
|
||||||
|
|
||||||
|
private static let privacyPolicyURL = URL(string: "https://github.com/vnidrop/vnidrop")!
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section {
|
||||||
|
Text(LocalizedStringKey("about_tagline")).font(.headline)
|
||||||
|
Text(LocalizedStringKey("about_description")).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(String(localized: "about_is_title")) {
|
||||||
|
AboutPoint("about_is_direct", "paperplane")
|
||||||
|
AboutPoint("about_is_no_account", "person.crop.circle.badge.xmark")
|
||||||
|
AboutPoint("about_is_in_control", "checkmark.shield")
|
||||||
|
AboutPoint("about_is_encrypted", "lock")
|
||||||
|
AboutPoint("about_is_open", "chevron.left.forwardslash.chevron.right")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(String(localized: "about_isnt_title")) {
|
||||||
|
AboutPoint("about_isnt_cloud", "icloud.slash")
|
||||||
|
AboutPoint("about_isnt_sync", "arrow.triangle.2.circlepath")
|
||||||
|
AboutPoint("about_isnt_public", "megaphone")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(String(localized: "about_privacy_title")) {
|
||||||
|
AboutPoint("about_privacy_capability", "qrcode")
|
||||||
|
AboutPoint("about_privacy_deny", "hand.raised")
|
||||||
|
AboutPoint("about_privacy_relay", "antenna.radiowaves.left.and.right")
|
||||||
|
AboutPoint("about_privacy_local", "internaldrive")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(String(localized: "about_title")) {
|
||||||
|
LabeledContent(String(localized: "version_title"), value: model.state.appVersion)
|
||||||
|
if let device = model.state.deviceInfo {
|
||||||
|
LabeledContent(String(localized: "device_model_title"), value: device.deviceModel ?? "—")
|
||||||
|
LabeledContent(String(localized: "os_version_title"), value: device.operatingSystem)
|
||||||
|
}
|
||||||
|
LabeledContent(String(localized: "about_license_label"), value: "Apache 2.0")
|
||||||
|
Link(destination: Self.privacyPolicyURL) {
|
||||||
|
Label(String(localized: "about_privacy_policy_label"), systemImage: "hand.raised")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if DiagnosticsBuildConfig.included {
|
||||||
|
Section {
|
||||||
|
Toggle(isOn: Binding(
|
||||||
|
get: { model.state.diagnosticsEnabled },
|
||||||
|
set: { model.setDiagnosticsEnabled($0) }
|
||||||
|
)) {
|
||||||
|
Text(LocalizedStringKey("diagnostics_title"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bug report presented as a sheet from About. Can be dismissed by swipe only
|
||||||
|
/// when empty; otherwise the Cancel button is required.
|
||||||
|
struct BugReportSheet: View {
|
||||||
|
@ObservedObject var model: SettingsModel
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
private var isEmpty: Bool {
|
||||||
|
model.state.bugWhatHappened.isEmpty && model.state.bugExpected.isEmpty
|
||||||
|
&& model.state.bugSteps.isEmpty && model.state.bugContact.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
BugReportSettings(model: model, onSubmitted: { dismiss() })
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
.navigationTitle(Text(LocalizedStringKey("about_bug_report")))
|
||||||
|
#if os(iOS)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
#endif
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button(String(localized: "button_cancel")) { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.interactiveDismissDisabled(!isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A bullet-style informational row with an SF Symbol and wrapping localized text.
|
||||||
|
private struct AboutPoint: View {
|
||||||
|
let key: String
|
||||||
|
let symbol: String
|
||||||
|
|
||||||
|
init(_ key: String, _ symbol: String) {
|
||||||
|
self.key = key
|
||||||
|
self.symbol = symbol
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Label {
|
||||||
|
Text(LocalizedStringKey(key))
|
||||||
|
.font(.subheadline)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: symbol).foregroundStyle(.tint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BugReportSettings: View {
|
||||||
|
@ObservedObject var model: SettingsModel
|
||||||
|
var onSubmitted: () -> Void = {}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section(String(localized: "bug_report_what_label")) {
|
||||||
|
TextField("", text: Binding(get: { model.state.bugWhatHappened }, set: { model.setBugWhatHappened($0) }),
|
||||||
|
prompt: Text(LocalizedStringKey("bug_report_what_hint")), axis: .vertical)
|
||||||
|
.lineLimit(3, reservesSpace: true)
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
Section(String(localized: "bug_report_expected_label")) {
|
||||||
|
TextField("", text: Binding(get: { model.state.bugExpected }, set: { model.setBugExpected($0) }),
|
||||||
|
prompt: Text(LocalizedStringKey("bug_report_expected_hint")), axis: .vertical)
|
||||||
|
.lineLimit(3, reservesSpace: true)
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
Section(String(localized: "bug_report_steps_label")) {
|
||||||
|
TextField("", text: Binding(get: { model.state.bugSteps }, set: { model.setBugSteps($0) }),
|
||||||
|
prompt: Text(LocalizedStringKey("bug_report_steps_hint")), axis: .vertical)
|
||||||
|
.lineLimit(3, reservesSpace: true)
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
Section(String(localized: "bug_report_contact_label")) {
|
||||||
|
TextField("", text: Binding(get: { model.state.bugContact }, set: { model.setBugContact($0) }),
|
||||||
|
prompt: Text(LocalizedStringKey("bug_report_contact_hint")))
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
Toggle(isOn: Binding(get: { model.state.bugIncludeLogs }, set: { model.setBugIncludeLogs($0) })) {
|
||||||
|
Text(LocalizedStringKey("bug_report_include_logs"))
|
||||||
|
}
|
||||||
|
Button(action: { model.submitBugReport(onSuccess: onSubmitted) }) {
|
||||||
|
Text(model.state.isSubmittingBugReport
|
||||||
|
? String(localized: "bug_report_submitting") : String(localized: "bug_report_submit"))
|
||||||
|
}
|
||||||
|
.disabled(model.state.isSubmittingBugReport)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
apple/VniDrop/Platform/AppDependencies+iOS.swift
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
#if os(iOS)
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// Builds the iOS dependency graph, ported from `rememberIosAppDependencies`.
|
||||||
|
@MainActor
|
||||||
|
func makeAppDependencies(externalInvitations: ExternalInvitationController) -> AppDependencies {
|
||||||
|
let device = UIDevice.current
|
||||||
|
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.1.0"
|
||||||
|
let env = PlatformEnvironment(
|
||||||
|
name: "\(device.systemName) \(device.systemVersion)",
|
||||||
|
appVersion: version,
|
||||||
|
defaultCoreDataDir: applicationDataDirectory(),
|
||||||
|
defaultUsername: device.name.isEmpty ? "Receiver" : device.name
|
||||||
|
)
|
||||||
|
return AppDependencies(
|
||||||
|
environment: env,
|
||||||
|
deviceInfoProvider: IosDeviceInfoProvider(),
|
||||||
|
fileSystemService: IosFileSystemService(),
|
||||||
|
notificationService: LocalNotificationService(),
|
||||||
|
externalInvitations: externalInvitations
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applicationDataDirectory() -> String {
|
||||||
|
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||||
|
return base.appendingPathComponent("VniDrop").path
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct IosDeviceInfoProvider: DeviceInfoProvider {
|
||||||
|
@MainActor
|
||||||
|
func load() async -> DeviceInfo {
|
||||||
|
let device = UIDevice.current
|
||||||
|
let battery: String? = {
|
||||||
|
let wasMonitoring = device.isBatteryMonitoringEnabled
|
||||||
|
device.isBatteryMonitoringEnabled = true
|
||||||
|
defer { device.isBatteryMonitoringEnabled = wasMonitoring }
|
||||||
|
let level = device.batteryLevel
|
||||||
|
return level >= 0 ? "\(Int(level * 100))%" : nil
|
||||||
|
}()
|
||||||
|
return DeviceInfo(
|
||||||
|
deviceName: device.name,
|
||||||
|
deviceModel: device.model,
|
||||||
|
operatingSystem: "\(device.systemName) \(device.systemVersion)",
|
||||||
|
network: nil,
|
||||||
|
batteryLevel: battery
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
50
apple/VniDrop/Platform/AppDependencies+macOS.swift
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
#if os(macOS)
|
||||||
|
import Foundation
|
||||||
|
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 host = Host.current().localizedName ?? "Mac"
|
||||||
|
let env = PlatformEnvironment(
|
||||||
|
name: "macOS " + ProcessInfo.processInfo.operatingSystemVersionString,
|
||||||
|
appVersion: version,
|
||||||
|
defaultCoreDataDir: applicationDataDirectory(),
|
||||||
|
defaultUsername: host
|
||||||
|
)
|
||||||
|
return AppDependencies(
|
||||||
|
environment: env,
|
||||||
|
deviceInfoProvider: MacDeviceInfoProvider(),
|
||||||
|
fileSystemService: MacFileSystemService(),
|
||||||
|
notificationService: LocalNotificationService(),
|
||||||
|
externalInvitations: externalInvitations
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applicationDataDirectory() -> String {
|
||||||
|
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||||
|
return base.appendingPathComponent("VniDrop").path
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MacDeviceInfoProvider: DeviceInfoProvider {
|
||||||
|
func load() async -> DeviceInfo {
|
||||||
|
DeviceInfo(
|
||||||
|
deviceName: Host.current().localizedName,
|
||||||
|
deviceModel: modelIdentifier(),
|
||||||
|
operatingSystem: "macOS " + ProcessInfo.processInfo.operatingSystemVersionString,
|
||||||
|
network: nil,
|
||||||
|
batteryLevel: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func modelIdentifier() -> String? {
|
||||||
|
var size = 0
|
||||||
|
sysctlbyname("hw.model", nil, &size, nil, 0)
|
||||||
|
guard size > 0 else { return nil }
|
||||||
|
var model = [CChar](repeating: 0, count: size)
|
||||||
|
sysctlbyname("hw.model", &model, &size, nil, 0)
|
||||||
|
return String(cString: model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
98
apple/VniDrop/Platform/FileSystemService+iOS.swift
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
#if os(iOS)
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
import VnidropCore
|
||||||
|
|
||||||
|
/// Native iOS file system service.
|
||||||
|
/// App-owned Documents is the fixed receive folder; custom folders are not
|
||||||
|
/// supported because raw external picker URLs do not survive relaunch.
|
||||||
|
struct IosFileSystemService: FileSystemService {
|
||||||
|
var supportsCustomReceiveFolders: Bool { false }
|
||||||
|
|
||||||
|
func defaultReceiveFolder() -> ReceiveFolder {
|
||||||
|
let path = FileManager.default
|
||||||
|
.urls(for: .documentDirectory, in: .userDomainMask)
|
||||||
|
.first?.path ?? ""
|
||||||
|
return ReceiveFolder(kind: .fileSystemPath, value: path, displayName: "Documents")
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateReceiveFolder(_ folder: ReceiveFolder) async -> FolderAccessStatus {
|
||||||
|
switch folder.kind {
|
||||||
|
case .fileSystemPath:
|
||||||
|
return FileManager.default.isWritableFile(atPath: folder.value) ? .writable : .unavailable
|
||||||
|
case .iosSecurityScopedUrl:
|
||||||
|
return validateSecurityScopedUrl(folder.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool {
|
||||||
|
folder.kind == .fileSystemPath
|
||||||
|
&& folder.value.trimmingTrailingSlash == defaultReceiveFolder().value.trimmingTrailingSlash
|
||||||
|
}
|
||||||
|
|
||||||
|
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error> {
|
||||||
|
guard canRevealReceiveFolder(folder) else {
|
||||||
|
return .failure(InvitationError.message("The receive folder is not VniDrop Documents"))
|
||||||
|
}
|
||||||
|
guard let url = URL(string: "shareddocuments://\(folder.value)") else {
|
||||||
|
return .failure(InvitationError.message("The Files location URL is unavailable"))
|
||||||
|
}
|
||||||
|
let opened = await withCheckedContinuation { continuation in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
UIApplication.shared.open(url, options: [:]) { success in
|
||||||
|
continuation.resume(returning: success)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return opened ? .success(()) : .failure(InvitationError.message("Could not open VniDrop Documents in Files"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func discardPickedFiles(_ files: [PickedShareFile]) async {
|
||||||
|
let paths = Set(files.filter { $0.isTemporaryCopy }.map { $0.value })
|
||||||
|
for path in paths {
|
||||||
|
try? FileManager.default.removeItem(atPath: path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sharePickedFiles(
|
||||||
|
repository: CoreGateway,
|
||||||
|
files: [PickedShareFile],
|
||||||
|
transferName: String,
|
||||||
|
senderName: String,
|
||||||
|
accessPolicy: ShareAccessPolicy
|
||||||
|
) async -> Result<Share, Error> {
|
||||||
|
guard !files.isEmpty else {
|
||||||
|
return .failure(InvitationError.message("Select at least one file to share"))
|
||||||
|
}
|
||||||
|
let sources = files.map { $0.toIosShareSource() }
|
||||||
|
return await repository.shareSources(
|
||||||
|
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func validateSecurityScopedUrl(_ value: String) -> FolderAccessStatus {
|
||||||
|
let url = URL(string: value) ?? URL(fileURLWithPath: value)
|
||||||
|
let started = url.startAccessingSecurityScopedResource()
|
||||||
|
defer { if started { url.stopAccessingSecurityScopedResource() } }
|
||||||
|
if FileManager.default.isWritableFile(atPath: url.path) {
|
||||||
|
return .writable
|
||||||
|
}
|
||||||
|
return .permissionRequired
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PickedShareFile {
|
||||||
|
/// iOS shares by filesystem path (from `asCopy` picker temp files).
|
||||||
|
func toIosShareSource() -> ShareSource {
|
||||||
|
ShareSource(kind: .path, value: value, displayName: displayName, isDirectory: isDirectory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension String {
|
||||||
|
var trimmingTrailingSlash: String {
|
||||||
|
var s = self
|
||||||
|
while s.hasSuffix("/") { s.removeLast() }
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
55
apple/VniDrop/Platform/FileSystemService+macOS.swift
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
#if os(macOS)
|
||||||
|
import Foundation
|
||||||
|
import AppKit
|
||||||
|
import VnidropCore
|
||||||
|
|
||||||
|
/// macOS file system service. Mirrors the desktop JVM behavior: default Downloads
|
||||||
|
/// receive folder, custom folders enabled via security-scoped bookmarks, reveal in
|
||||||
|
/// Finder. The Rust core streams bytes; Swift passes filesystem paths.
|
||||||
|
struct MacFileSystemService: FileSystemService {
|
||||||
|
var supportsCustomReceiveFolders: Bool { true }
|
||||||
|
|
||||||
|
func defaultReceiveFolder() -> ReceiveFolder {
|
||||||
|
let url = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
|
||||||
|
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Downloads")
|
||||||
|
return ReceiveFolder(kind: .fileSystemPath, value: url.path, displayName: url.lastPathComponent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateReceiveFolder(_ folder: ReceiveFolder) async -> FolderAccessStatus {
|
||||||
|
FileManager.default.isWritableFile(atPath: folder.value) ? .writable : .unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { true }
|
||||||
|
|
||||||
|
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error> {
|
||||||
|
let url = URL(fileURLWithPath: folder.value, isDirectory: true)
|
||||||
|
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||||
|
return .success(())
|
||||||
|
}
|
||||||
|
|
||||||
|
func discardPickedFiles(_ files: [PickedShareFile]) async {
|
||||||
|
let paths = Set(files.filter { $0.isTemporaryCopy }.map { $0.value })
|
||||||
|
for path in paths {
|
||||||
|
try? FileManager.default.removeItem(atPath: path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sharePickedFiles(
|
||||||
|
repository: CoreGateway,
|
||||||
|
files: [PickedShareFile],
|
||||||
|
transferName: String,
|
||||||
|
senderName: String,
|
||||||
|
accessPolicy: ShareAccessPolicy
|
||||||
|
) async -> Result<Share, Error> {
|
||||||
|
guard !files.isEmpty else {
|
||||||
|
return .failure(InvitationError.message("Select at least one file to share"))
|
||||||
|
}
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
20
apple/VniDrop/Platform/InvitationFile.swift
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Builds a `.vnd` invitation filename from a transfer name.
|
||||||
|
func invitationFileName(_ transferName: String) -> String {
|
||||||
|
let trimmed = transferName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let base = trimmed.isEmpty ? "invitation" : trimmed
|
||||||
|
let safe = base.components(separatedBy: CharacterSet.alphanumerics.inverted.subtracting(CharacterSet(charactersIn: "-_ ")))
|
||||||
|
.joined()
|
||||||
|
.replacingOccurrences(of: " ", with: "-")
|
||||||
|
let name = safe.isEmpty ? "invitation" : safe
|
||||||
|
return "\(name).\(vniDropInvitationExtension)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes a temporary `.vnd` file for sharing/exporting.
|
||||||
|
func writeTemporaryInvitation(ticket: String, transferName: String) throws -> URL {
|
||||||
|
let dir = FileManager.default.temporaryDirectory
|
||||||
|
let url = dir.appendingPathComponent(invitationFileName(transferName))
|
||||||
|
try ticket.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
return url
|
||||||
|
}
|
||||||
126
apple/VniDrop/Platform/PlatformPickers.swift
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
/// Root-level pickers that are NOT triggered from inside a sheet. Currently just
|
||||||
|
/// the receive-folder picker (Settings is presented in the tab's navigation
|
||||||
|
/// stack, not a sheet, so presenting from the root works).
|
||||||
|
struct PlatformPickers: ViewModifier {
|
||||||
|
@ObservedObject var settingsModel: SettingsModel
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.fileImporter(
|
||||||
|
isPresented: Binding(get: { settingsModel.pendingReceiveFolderPick }, set: { settingsModel.pendingReceiveFolderPick = $0 }),
|
||||||
|
allowedContentTypes: [.folder],
|
||||||
|
allowsMultipleSelection: false
|
||||||
|
) { result in
|
||||||
|
switch result {
|
||||||
|
case .success(let urls):
|
||||||
|
guard let url = urls.first else { return }
|
||||||
|
settingsModel.onReceiveFolderPicked(PickerSupport.receiveFolder(from: url))
|
||||||
|
case .failure(let error):
|
||||||
|
if !error.isUserCancellation { settingsModel.onReceiveFolderPickFailed(error.technicalDetail) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send file/folder pickers. Must be attached to the composer view so the picker
|
||||||
|
/// presents from the composer's sheet, not the already-presenting root controller.
|
||||||
|
struct SendPickers: ViewModifier {
|
||||||
|
@ObservedObject var model: SendModel
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
// A single .fileImporter, switched between files and folders. Stacking two
|
||||||
|
// .fileImporter modifiers on one view silently breaks on iOS/macOS < 27:
|
||||||
|
// the second shadows the first, so "Choose files" never presents.
|
||||||
|
content
|
||||||
|
.fileImporter(
|
||||||
|
isPresented: Binding(
|
||||||
|
get: { model.pendingFilePick || model.pendingFolderPick },
|
||||||
|
set: { presented in
|
||||||
|
if !presented {
|
||||||
|
model.pendingFilePick = false
|
||||||
|
model.pendingFolderPick = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
allowedContentTypes: model.pendingFolderPick ? [.folder] : [.item],
|
||||||
|
allowsMultipleSelection: !model.pendingFolderPick
|
||||||
|
) { result in
|
||||||
|
let isDirectory = model.pendingFolderPick
|
||||||
|
model.pendingFilePick = false
|
||||||
|
model.pendingFolderPick = false
|
||||||
|
handleShareSelection(result, isDirectory: isDirectory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleShareSelection(_ result: Result<[URL], Error>, isDirectory: Bool) {
|
||||||
|
switch result {
|
||||||
|
case .success(let urls):
|
||||||
|
let files = urls.compactMap { PickerSupport.pickedFile(from: $0, isDirectory: isDirectory) }
|
||||||
|
if files.isEmpty {
|
||||||
|
model.onFilePickFailed("The selected document could not be opened")
|
||||||
|
} else {
|
||||||
|
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)
|
||||||
|
// External receive folders on iOS use security-scoped URLs; the core holds
|
||||||
|
// access while streaming. Store the URL string.
|
||||||
|
return ReceiveFolder(kind: .iosSecurityScopedUrl, value: url.absoluteString, displayName: url.lastPathComponent)
|
||||||
|
#else
|
||||||
|
return ReceiveFolder(kind: .fileSystemPath, value: url.path, displayName: url.lastPathComponent)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
static func pickedFile(from url: URL, isDirectory: Bool) -> PickedShareFile? {
|
||||||
|
let started = url.startAccessingSecurityScopedResource()
|
||||||
|
defer { if started { url.stopAccessingSecurityScopedResource() } }
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
// Copy into a temporary sandbox location so the core can read the file
|
||||||
|
// after the picker/security scope ends. Folders are passed by path.
|
||||||
|
if isDirectory {
|
||||||
|
return PickedShareFile(value: url.path, displayName: url.lastPathComponent, isDirectory: true)
|
||||||
|
}
|
||||||
|
let tempDir = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("share-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||||
|
let dest = tempDir.appendingPathComponent(url.lastPathComponent)
|
||||||
|
do {
|
||||||
|
try FileManager.default.copyItem(at: url, to: dest)
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let size = (try? dest.resourceValues(forKeys: [.fileSizeKey]))?.fileSize.map { UInt64($0) }
|
||||||
|
return PickedShareFile(
|
||||||
|
value: dest.path, displayName: url.lastPathComponent, sizeBytes: size,
|
||||||
|
isTemporaryCopy: true, isDirectory: false
|
||||||
|
)
|
||||||
|
#else
|
||||||
|
let size = isDirectory ? nil : (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize.map { UInt64($0) }
|
||||||
|
return PickedShareFile(
|
||||||
|
value: url.path, displayName: url.lastPathComponent, sizeBytes: size,
|
||||||
|
isTemporaryCopy: false, isDirectory: isDirectory
|
||||||
|
)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func platformPickers(settingsModel: SettingsModel) -> some View {
|
||||||
|
modifier(PlatformPickers(settingsModel: settingsModel))
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendPickers(model: SendModel) -> some View {
|
||||||
|
modifier(SendPickers(model: model))
|
||||||
|
}
|
||||||
|
}
|
||||||
268
apple/VniDrop/Platform/ReceiveInvitationActions+iOS.swift
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
@preconcurrency import AVFoundation
|
||||||
|
@preconcurrency import CoreNFC
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func makeReceiveInvitationActions() -> ReceiveInvitationActions { IosReceiveInvitationActions() }
|
||||||
|
|
||||||
|
/// iOS invitation acquisition:
|
||||||
|
/// document picker, camera QR scanner, and NFC read.
|
||||||
|
final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UIDocumentPickerDelegate {
|
||||||
|
private var documentResult: ((Result<String, Error>) -> Void)?
|
||||||
|
private var nfcReader: InvitationNfcReader?
|
||||||
|
private var qrController: QrScannerViewController?
|
||||||
|
|
||||||
|
var fileAvailability: ReceiveMethodAvailability { .available }
|
||||||
|
var qrAvailability: ReceiveMethodAvailability {
|
||||||
|
AVCaptureDevice.default(for: .video) != nil ? .available : .unavailable
|
||||||
|
}
|
||||||
|
var nfcAvailability: ReceiveMethodAvailability {
|
||||||
|
NFCNDEFReaderSession.readingAvailable ? .available : .unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickInvitation(onResult: @escaping (Result<String, Error>) -> Void) {
|
||||||
|
cancel()
|
||||||
|
documentResult = onResult
|
||||||
|
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.data], asCopy: true)
|
||||||
|
picker.delegate = self
|
||||||
|
picker.modalPresentationStyle = .formSheet
|
||||||
|
guard let presenter = topPresenter() else {
|
||||||
|
return onResult(.failure(InvitationError.message("Could not find an iOS view controller")))
|
||||||
|
}
|
||||||
|
presenter.present(picker, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
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")))
|
||||||
|
}
|
||||||
|
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")))
|
||||||
|
}
|
||||||
|
let scanner = QrScannerViewController { result in
|
||||||
|
self.qrController = nil
|
||||||
|
onResult(result)
|
||||||
|
}
|
||||||
|
self.qrController = scanner
|
||||||
|
scanner.modalPresentationStyle = .fullScreen
|
||||||
|
presenter.present(scanner, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")))
|
||||||
|
}
|
||||||
|
let reader = InvitationNfcReader { [weak self] result in
|
||||||
|
self?.nfcReader = nil
|
||||||
|
onResult(result)
|
||||||
|
}
|
||||||
|
nfcReader = reader
|
||||||
|
reader.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
nfcReader?.cancel()
|
||||||
|
nfcReader = nil
|
||||||
|
qrController?.cancelScan()
|
||||||
|
qrController = nil
|
||||||
|
documentResult = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UIDocumentPickerDelegate
|
||||||
|
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
||||||
|
let result = documentResult
|
||||||
|
documentResult = nil
|
||||||
|
result?(Result {
|
||||||
|
guard let url = urls.first else { throw InvitationError.message("The selected invitation URL was invalid") }
|
||||||
|
let started = url.startAccessingSecurityScopedResource()
|
||||||
|
defer { if started { url.stopAccessingSecurityScopedResource() } }
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
guard data.count <= maxVniDropInvitationBytes else { throw InvitationError.tooLarge }
|
||||||
|
return try decodeInvitationBytes(data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
|
||||||
|
documentResult = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureCameraAccess(_ completion: @escaping (Bool) -> Void) {
|
||||||
|
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||||
|
case .authorized:
|
||||||
|
completion(true)
|
||||||
|
case .notDetermined:
|
||||||
|
// The permission callback is delivered back on the main queue.
|
||||||
|
nonisolated(unsafe) let completion = completion
|
||||||
|
AVCaptureDevice.requestAccess(for: .video) { granted in
|
||||||
|
DispatchQueue.main.async { completion(granted) }
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
completion(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full-screen camera QR scanner, ported from `QrScannerViewController`.
|
||||||
|
final class QrScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
|
||||||
|
private let onResult: (Result<String, Error>) -> Void
|
||||||
|
private let session = AVCaptureSession()
|
||||||
|
private var previewLayer: AVCaptureVideoPreviewLayer?
|
||||||
|
private var finished = false
|
||||||
|
|
||||||
|
init(onResult: @escaping (Result<String, Error>) -> Void) {
|
||||||
|
self.onResult = onResult
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
view.backgroundColor = .black
|
||||||
|
|
||||||
|
let hint = UILabel(frame: view.bounds)
|
||||||
|
hint.text = "Point the camera at a VniDrop QR code"
|
||||||
|
hint.textColor = .white
|
||||||
|
hint.textAlignment = .center
|
||||||
|
hint.numberOfLines = 0
|
||||||
|
hint.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||||
|
view.addSubview(hint)
|
||||||
|
|
||||||
|
let close = UIButton(type: .system)
|
||||||
|
close.setTitle("Cancel", for: .normal)
|
||||||
|
close.setTitleColor(.white, for: .normal)
|
||||||
|
close.frame = CGRect(x: 16, y: 52, width: 88, height: 36)
|
||||||
|
close.addAction(UIAction { [weak self] _ in self?.cancelScan() }, for: .touchUpInside)
|
||||||
|
view.addSubview(close)
|
||||||
|
|
||||||
|
configureSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLayoutSubviews() {
|
||||||
|
super.viewDidLayoutSubviews()
|
||||||
|
previewLayer?.frame = view.bounds
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
|
super.viewWillDisappear(animated)
|
||||||
|
if session.isRunning { session.stopRunning() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelScan() {
|
||||||
|
finish(.failure(InvitationError.message("QR scanning was 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")))
|
||||||
|
}
|
||||||
|
session.addInput(input)
|
||||||
|
let output = AVCaptureMetadataOutput()
|
||||||
|
guard session.canAddOutput(output) else {
|
||||||
|
return finish(.failure(InvitationError.message("Could not configure the QR scanner")))
|
||||||
|
}
|
||||||
|
session.addOutput(output)
|
||||||
|
output.setMetadataObjectsDelegate(self, queue: .main)
|
||||||
|
output.metadataObjectTypes = [.qr]
|
||||||
|
|
||||||
|
let layer = AVCaptureVideoPreviewLayer(session: session)
|
||||||
|
layer.videoGravity = .resizeAspectFill
|
||||||
|
layer.frame = view.bounds
|
||||||
|
view.layer.insertSublayer(layer, at: 0)
|
||||||
|
previewLayer = layer
|
||||||
|
session.sessionPreset = .high
|
||||||
|
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async { [session] in session.startRunning() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The metadata output delegate queue is `.main`, so hop back onto the main
|
||||||
|
// actor to touch view-controller state.
|
||||||
|
nonisolated func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
|
||||||
|
let value = metadataObjects
|
||||||
|
.compactMap { $0 as? AVMetadataMachineReadableCodeObject }
|
||||||
|
.first { $0.type == .qr }?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
guard !value.isEmpty else { return }
|
||||||
|
MainActor.assumeIsolated { finish(.success(value)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finish(_ result: Result<String, Error>) {
|
||||||
|
if finished { return }
|
||||||
|
finished = true
|
||||||
|
if session.isRunning { session.stopRunning() }
|
||||||
|
if presentingViewController != nil {
|
||||||
|
dismiss(animated: true) { self.onResult(result) }
|
||||||
|
} else {
|
||||||
|
onResult(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NFC invitation reader, ported from `InvitationNfcReader`.
|
||||||
|
// Runs entirely on the NFC session's `.main` delegate queue.
|
||||||
|
final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate, @unchecked Sendable {
|
||||||
|
private let onResult: (Result<String, Error>) -> Void
|
||||||
|
private var session: NFCNDEFReaderSession?
|
||||||
|
private var finished = false
|
||||||
|
|
||||||
|
init(onResult: @escaping (Result<String, Error>) -> Void) {
|
||||||
|
self.onResult = onResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
let reader = NFCNDEFReaderSession(delegate: self, queue: .main, invalidateAfterFirstRead: true)
|
||||||
|
reader.alertMessage = "Hold your iPhone near a VniDrop invitation tag"
|
||||||
|
session = reader
|
||||||
|
reader.begin()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
session?.invalidate()
|
||||||
|
session = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {
|
||||||
|
let result = Result<String, Error> {
|
||||||
|
let ticket = messages
|
||||||
|
.flatMap { $0.records }
|
||||||
|
.compactMap { payloadAsInvitation($0) }
|
||||||
|
.first
|
||||||
|
guard let ticket else { throw InvitationError.message("This NFC tag does not contain a VniDrop invitation") }
|
||||||
|
return ticket
|
||||||
|
}
|
||||||
|
session.invalidate()
|
||||||
|
finish(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finish(_ result: Result<String, Error>) {
|
||||||
|
if finished { return }
|
||||||
|
finished = true
|
||||||
|
session = nil
|
||||||
|
DispatchQueue.main.async { self.onResult(result) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func payloadAsInvitation(_ payload: NFCNDEFPayload) -> String? {
|
||||||
|
guard let type = String(data: payload.type, encoding: .utf8) else { return nil }
|
||||||
|
let data = payload.payload
|
||||||
|
if payload.typeNameFormat == .media && (type == vniDropInvitationMimeType || type.hasPrefix("text/")) {
|
||||||
|
return try? decodeInvitationBytes(data)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
#endif
|
||||||
45
apple/VniDrop/Platform/ReceiveInvitationActions+macOS.swift
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
#if os(macOS)
|
||||||
|
import AppKit
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func makeReceiveInvitationActions() -> ReceiveInvitationActions { MacReceiveInvitationActions() }
|
||||||
|
|
||||||
|
/// macOS invitation acquisition: file picker only. QR (camera) and NFC are hidden
|
||||||
|
/// on the desktop, matching the availability model.
|
||||||
|
final class MacReceiveInvitationActions: ReceiveInvitationActions {
|
||||||
|
var fileAvailability: ReceiveMethodAvailability { .available }
|
||||||
|
var qrAvailability: ReceiveMethodAvailability { .hidden }
|
||||||
|
var nfcAvailability: ReceiveMethodAvailability { .hidden }
|
||||||
|
|
||||||
|
func pickInvitation(onResult: @escaping (Result<String, Error>) -> Void) {
|
||||||
|
let panel = NSOpenPanel()
|
||||||
|
panel.canChooseFiles = true
|
||||||
|
panel.canChooseDirectories = false
|
||||||
|
panel.allowsMultipleSelection = false
|
||||||
|
if let vnd = UTType(filenameExtension: vniDropInvitationExtension) {
|
||||||
|
panel.allowedContentTypes = [vnd, .data, .text]
|
||||||
|
}
|
||||||
|
panel.begin { response in
|
||||||
|
guard response == .OK, let url = panel.url else {
|
||||||
|
onResult(.failure(InvitationError.message("cancelled")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onResult(Result {
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
return try decodeInvitationBytes(data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanQrCode(onResult: @escaping (Result<String, Error>) -> Void) {
|
||||||
|
onResult(.failure(InvitationError.message("QR scanning is unavailable on macOS")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func readNfcInvitation(onResult: @escaping (Result<String, Error>) -> Void) {
|
||||||
|
onResult(.failure(InvitationError.message("NFC is unavailable on macOS")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() {}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
158
apple/VniDrop/Platform/TransferShareActions+iOS.swift
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
@preconcurrency import CoreNFC
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func makePlatformShareActions() -> TransferShareActions { IosTransferShareActions() }
|
||||||
|
|
||||||
|
/// iOS invitation delivery: export via
|
||||||
|
/// document picker, native share via `UIActivityViewController`, and NFC write.
|
||||||
|
final class IosTransferShareActions: NSObject, TransferShareActions {
|
||||||
|
private var nfcWriter: InvitationNfcWriter?
|
||||||
|
|
||||||
|
var canUseNativeShare: Bool { true }
|
||||||
|
var nfcAvailability: NfcShareAvailability {
|
||||||
|
NFCNDEFReaderSession.readingAvailable ? .available : .unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportInvitation(ticket: String, transferName: String, onResult: @escaping (Result<Void, Error>) -> Void) {
|
||||||
|
onResult(Result {
|
||||||
|
let url = try writeTemporaryInvitation(ticket: ticket, transferName: transferName)
|
||||||
|
let picker = UIDocumentPickerViewController(forExporting: [url], asCopy: true)
|
||||||
|
picker.modalPresentationStyle = .formSheet
|
||||||
|
try present(picker)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func shareInvitation(ticket: String, transferName: String, onResult: @escaping (Result<Void, Error>) -> Void) {
|
||||||
|
onResult(Result {
|
||||||
|
let url = try writeTemporaryInvitation(ticket: ticket, transferName: transferName)
|
||||||
|
let controller = UIActivityViewController(activityItems: [url], applicationActivities: nil)
|
||||||
|
controller.modalPresentationStyle = .formSheet
|
||||||
|
try present(controller)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let writer = InvitationNfcWriter(ticket: ticket) { [weak self] result in
|
||||||
|
self?.nfcWriter = nil
|
||||||
|
onResult(result)
|
||||||
|
}
|
||||||
|
nfcWriter = writer
|
||||||
|
writer.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelNfcWrite() {
|
||||||
|
nfcWriter?.cancel()
|
||||||
|
nfcWriter = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func present(_ controller: UIViewController) throws {
|
||||||
|
guard let presenter = topPresenter() else {
|
||||||
|
throw InvitationError.message("Could not find an iOS view controller")
|
||||||
|
}
|
||||||
|
presenter.present(controller, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes a VniDrop invitation to a writable NDEF tag.
|
||||||
|
// Runs entirely on the NFC session's `.main` delegate queue.
|
||||||
|
final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate, @unchecked Sendable {
|
||||||
|
private let ticket: String
|
||||||
|
private let onResult: (Result<Void, Error>) -> Void
|
||||||
|
private var session: NFCNDEFReaderSession?
|
||||||
|
private var finished = false
|
||||||
|
|
||||||
|
init(ticket: String, onResult: @escaping (Result<Void, Error>) -> Void) {
|
||||||
|
self.ticket = ticket
|
||||||
|
self.onResult = onResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
let reader = NFCNDEFReaderSession(delegate: self, queue: .main, invalidateAfterFirstRead: false)
|
||||||
|
reader.alertMessage = "Hold your iPhone near a writable NFC tag"
|
||||||
|
session = reader
|
||||||
|
reader.begin()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
session?.invalidate()
|
||||||
|
session = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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)))
|
||||||
|
}
|
||||||
|
|
||||||
|
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")))
|
||||||
|
}
|
||||||
|
// CoreNFC completion handlers run on the session's `.main` queue; these
|
||||||
|
// framework values are safe to use there.
|
||||||
|
nonisolated(unsafe) let session = session
|
||||||
|
nonisolated(unsafe) let tag = firstTag
|
||||||
|
session.connect(to: tag) { [weak self] connectError in
|
||||||
|
guard let self else { return }
|
||||||
|
if let connectError { return self.finish(.failure(connectError)) }
|
||||||
|
tag.queryNDEFStatus { status, _, queryError in
|
||||||
|
if let queryError { return self.finish(.failure(queryError)) }
|
||||||
|
switch status {
|
||||||
|
case .notSupported:
|
||||||
|
self.finish(.failure(InvitationError.message("This NFC tag does not support NDEF")))
|
||||||
|
case .readOnly:
|
||||||
|
self.finish(.failure(InvitationError.message("This NFC tag is read-only")))
|
||||||
|
default:
|
||||||
|
guard let message = self.invitationMessage() else {
|
||||||
|
return self.finish(.failure(InvitationError.message("Could not encode the invitation for NFC")))
|
||||||
|
}
|
||||||
|
tag.writeNDEF(message) { writeError in
|
||||||
|
if let writeError {
|
||||||
|
self.finish(.failure(writeError))
|
||||||
|
} else {
|
||||||
|
session.alertMessage = "Invitation written"
|
||||||
|
session.invalidate()
|
||||||
|
self.finish(.success(()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func invitationMessage() -> NFCNDEFMessage? {
|
||||||
|
guard let type = vniDropInvitationMimeType.data(using: .utf8),
|
||||||
|
let payload = ticket.data(using: .utf8) else { return nil }
|
||||||
|
let record = NFCNDEFPayload(format: .media, type: type, identifier: Data(), payload: payload)
|
||||||
|
return NFCNDEFMessage(records: [record])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finish(_ result: Result<Void, Error>) {
|
||||||
|
if finished { return }
|
||||||
|
finished = true
|
||||||
|
session = nil
|
||||||
|
DispatchQueue.main.async { self.onResult(result) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func topPresenter() -> UIViewController? {
|
||||||
|
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
|
||||||
|
let keyWindow = scenes.flatMap { $0.windows }.first { $0.isKeyWindow }
|
||||||
|
var controller = keyWindow?.rootViewController
|
||||||
|
while let presented = controller?.presentedViewController {
|
||||||
|
controller = presented
|
||||||
|
}
|
||||||
|
return controller
|
||||||
|
}
|
||||||
|
#endif
|
||||||
48
apple/VniDrop/Platform/TransferShareActions+macOS.swift
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
#if os(macOS)
|
||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func makePlatformShareActions() -> TransferShareActions { MacTransferShareActions() }
|
||||||
|
|
||||||
|
/// macOS invitation delivery, mirroring the iOS actions: save panel export and
|
||||||
|
/// `NSSharingServicePicker` native share. NFC is unavailable on macOS.
|
||||||
|
final class MacTransferShareActions: TransferShareActions {
|
||||||
|
var canUseNativeShare: Bool { true }
|
||||||
|
var nfcAvailability: NfcShareAvailability { .hidden }
|
||||||
|
|
||||||
|
func exportInvitation(ticket: String, transferName: String, onResult: @escaping (Result<Void, Error>) -> Void) {
|
||||||
|
let panel = NSSavePanel()
|
||||||
|
panel.nameFieldStringValue = invitationFileName(transferName)
|
||||||
|
panel.allowedContentTypes = []
|
||||||
|
panel.begin { response in
|
||||||
|
guard response == .OK, let url = panel.url else {
|
||||||
|
onResult(.failure(InvitationError.message("cancelled")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onResult(Result { try ticket.write(to: url, atomically: true, encoding: .utf8) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shareInvitation(ticket: String, transferName: String, onResult: @escaping (Result<Void, Error>) -> Void) {
|
||||||
|
do {
|
||||||
|
let url = try writeTemporaryInvitation(ticket: ticket, transferName: transferName)
|
||||||
|
guard let view = NSApp.keyWindow?.contentView else {
|
||||||
|
onResult(.failure(InvitationError.message("No window available")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let picker = NSSharingServicePicker(items: [url])
|
||||||
|
picker.show(relativeTo: .zero, of: view, preferredEdge: .minY)
|
||||||
|
onResult(.success(()))
|
||||||
|
} catch {
|
||||||
|
onResult(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeInvitationToNfc(ticket: String, onResult: @escaping (Result<Void, Error>) -> Void) {
|
||||||
|
onResult(.failure(InvitationError.message("NFC is unavailable on macOS")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelNfcWrite() {}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"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 }
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
1
apple/VniDrop/Resources/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{ "info" : { "author" : "xcode", "version" : 1 } }
|
||||||
112
apple/VniDrop/Resources/Info.plist
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<?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">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleLocalizations</key>
|
||||||
|
<array>
|
||||||
|
<string>en</string>
|
||||||
|
<string>fr</string>
|
||||||
|
<string>es</string>
|
||||||
|
<string>it</string>
|
||||||
|
<string>de</string>
|
||||||
|
<string>pt</string>
|
||||||
|
<string>pl</string>
|
||||||
|
<string>nl</string>
|
||||||
|
<string>ru</string>
|
||||||
|
</array>
|
||||||
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
|
<true/>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>VniDrop</string>
|
||||||
|
<key>CFBundleDocumentTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleTypeName</key>
|
||||||
|
<string>VniDrop Invitation</string>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>Viewer</string>
|
||||||
|
<key>LSHandlerRank</key>
|
||||||
|
<string>Owner</string>
|
||||||
|
<key>LSItemContentTypes</key>
|
||||||
|
<array>
|
||||||
|
<string>com.vnidrop.app.invitation</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>LSApplicationCategoryType</key>
|
||||||
|
<string>public.app-category.utilities</string>
|
||||||
|
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||||
|
<true/>
|
||||||
|
<key>NFCReaderUsageDescription</key>
|
||||||
|
<string>VniDrop uses NFC to read transfer invitation tags.</string>
|
||||||
|
<key>NSBonjourServices</key>
|
||||||
|
<array>
|
||||||
|
<string></string>
|
||||||
|
</array>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>VniDrop uses the camera to scan transfer QR codes.</string>
|
||||||
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
|
<string>VniDrop needs local network access to send to other local devices if needed.</string>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>fetch</string>
|
||||||
|
<string>processing</string>
|
||||||
|
<string>remote-notification</string>
|
||||||
|
</array>
|
||||||
|
<key>UIFileSharingEnabled</key>
|
||||||
|
<true/>
|
||||||
|
<key>UILaunchScreen</key>
|
||||||
|
<dict/>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
|
<true/>
|
||||||
|
<key>UTExportedTypeDeclarations</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>UTTypeConformsTo</key>
|
||||||
|
<array>
|
||||||
|
<string>public.data</string>
|
||||||
|
</array>
|
||||||
|
<key>UTTypeDescription</key>
|
||||||
|
<string>VniDrop Invitation</string>
|
||||||
|
<key>UTTypeIdentifier</key>
|
||||||
|
<string>com.vnidrop.app.invitation</string>
|
||||||
|
<key>UTTypeTagSpecification</key>
|
||||||
|
<dict>
|
||||||
|
<key>public.filename-extension</key>
|
||||||
|
<array>
|
||||||
|
<string>vnd</string>
|
||||||
|
</array>
|
||||||
|
<key>public.mime-type</key>
|
||||||
|
<string>application/vnd.vnidrop.transfer</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
15326
apple/VniDrop/Resources/Localizable.xcstrings
Normal file
24
apple/VniDrop/Resources/VniDrop.entitlements
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?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">
|
||||||
|
<dict>
|
||||||
|
<!-- iOS: NFC NDEF reading. -->
|
||||||
|
<key>com.apple.developer.nfc.readersession.formats</key>
|
||||||
|
<array>
|
||||||
|
<string>NDEF</string>
|
||||||
|
</array>
|
||||||
|
|
||||||
|
<!-- macOS App Sandbox: user-selected files for share/receive, and network
|
||||||
|
client/server for the local P2P transfer. These keys are ignored on iOS. -->
|
||||||
|
<key>com.apple.security.app-sandbox</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.files.user-selected.read-write</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.files.downloads.read-write</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.network.client</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.network.server</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
66
apple/VniDrop/UI/Components/AdaptiveDrawer.swift
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Presents modal content in a native sheet. On phones it uses medium/large
|
||||||
|
/// detents with a grabber; on wider layouts the sheet is form-sized. Content is
|
||||||
|
/// wrapped in a `NavigationStack` so it gets a native title bar + Close button.
|
||||||
|
struct AdaptiveDrawer<DrawerContent: View>: ViewModifier {
|
||||||
|
@Binding var isPresented: Bool
|
||||||
|
let windowClass: WindowClass
|
||||||
|
let onDismiss: () -> Void
|
||||||
|
@ViewBuilder let drawerContent: () -> DrawerContent
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content.sheet(
|
||||||
|
isPresented: Binding(get: { isPresented }, set: { if !$0 { onDismiss() } })
|
||||||
|
) {
|
||||||
|
SheetChrome(onClose: onDismiss) { drawerContent() }
|
||||||
|
.modifier(PhoneDetents(enabled: windowClass == .phone))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PhoneDetents: ViewModifier {
|
||||||
|
let enabled: Bool
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
if enabled {
|
||||||
|
content
|
||||||
|
.presentationDetents([.medium, .large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
} else {
|
||||||
|
content.frame(minWidth: 460, minHeight: 480)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SheetChrome<Content: View>: View {
|
||||||
|
let onClose: () -> Void
|
||||||
|
@ViewBuilder let content: () -> Content
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView { content().padding(.top, 4) }
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button(String(localized: "button_close"), action: onClose)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func adaptiveDrawer<DrawerContent: View>(
|
||||||
|
isPresented: Binding<Bool>,
|
||||||
|
windowClass: WindowClass,
|
||||||
|
onDismiss: @escaping () -> Void,
|
||||||
|
@ViewBuilder content: @escaping () -> DrawerContent
|
||||||
|
) -> some View {
|
||||||
|
modifier(AdaptiveDrawer(
|
||||||
|
isPresented: isPresented, windowClass: windowClass,
|
||||||
|
onDismiss: onDismiss, drawerContent: content
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
50
apple/VniDrop/UI/Components/Buttons.swift
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Native SwiftUI button styles. Purple accent comes from the app-wide `.tint`.
|
||||||
|
|
||||||
|
/// Full-width filled button (`.borderedProminent`). Apply `.fixedSize()` at the
|
||||||
|
/// call site to shrink it to its content.
|
||||||
|
struct PrimaryButton: View {
|
||||||
|
let title: String
|
||||||
|
let action: () -> Void
|
||||||
|
var enabled: Bool = true
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Text(title).frame(maxWidth: .infinity).frame(minHeight: 22)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.controlSize(.large)
|
||||||
|
.disabled(!enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full-width bordered (secondary) button.
|
||||||
|
struct SecondaryButton: View {
|
||||||
|
let title: String
|
||||||
|
let action: () -> Void
|
||||||
|
var enabled: Bool = true
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Text(title).frame(maxWidth: .infinity).frame(minHeight: 22)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.large)
|
||||||
|
.disabled(!enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Borderless tinted text button.
|
||||||
|
struct QuietButton: View {
|
||||||
|
let title: String
|
||||||
|
let action: () -> Void
|
||||||
|
var enabled: Bool = true
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(title, action: action)
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.disabled(!enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
96
apple/VniDrop/UI/Components/Components.swift
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - StatusPill
|
||||||
|
|
||||||
|
enum PillTone { case neutral, success, warning, destructive, brand }
|
||||||
|
|
||||||
|
struct StatusPill: View {
|
||||||
|
let label: String
|
||||||
|
var tone: PillTone = .neutral
|
||||||
|
|
||||||
|
private var color: Color {
|
||||||
|
switch tone {
|
||||||
|
case .neutral: return .secondary
|
||||||
|
case .success, .brand: return VniDropColors.brandPurple
|
||||||
|
case .warning: return .orange
|
||||||
|
case .destructive: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Circle().fill(color).frame(width: 6, height: 6)
|
||||||
|
Text(label).font(.caption).fontWeight(.medium).foregroundStyle(color).lineLimit(1)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 9).padding(.vertical, 4)
|
||||||
|
.background(color.opacity(0.14), in: Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ProgressRow
|
||||||
|
|
||||||
|
struct ProgressRow: View {
|
||||||
|
let labelKey: String
|
||||||
|
let progress: Double?
|
||||||
|
var detail: String? = nil
|
||||||
|
/// Pre-resolved label; when set it overrides `labelKey`.
|
||||||
|
var labelText: String? = nil
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
label.font(.subheadline).lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
if let progress {
|
||||||
|
Text("\(Int(progress * 100))%").font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let detail {
|
||||||
|
Text(detail).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||||
|
}
|
||||||
|
if let progress {
|
||||||
|
ProgressView(value: progress)
|
||||||
|
} else {
|
||||||
|
ProgressView().progressViewStyle(.linear)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var label: some View {
|
||||||
|
if let labelText {
|
||||||
|
Text(labelText)
|
||||||
|
} else {
|
||||||
|
Text(LocalizedStringKey(labelKey))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Field
|
||||||
|
|
||||||
|
/// Labeled text field using native styling. Renders cleanly both inside a `Form`
|
||||||
|
/// row and standalone (e.g. inside a sheet).
|
||||||
|
struct Field: View {
|
||||||
|
let label: String
|
||||||
|
@Binding var value: String
|
||||||
|
var minLines: Int = 1
|
||||||
|
var enabled: Bool = true
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(label).font(.subheadline).foregroundStyle(.secondary)
|
||||||
|
Group {
|
||||||
|
if minLines > 1 {
|
||||||
|
TextField(label, text: $value, axis: .vertical)
|
||||||
|
.lineLimit(minLines, reservesSpace: true)
|
||||||
|
} else {
|
||||||
|
TextField(label, text: $value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.disabled(!enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
20
apple/VniDrop/UI/Components/PlatformImage.swift
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Cross-platform decoding of raw image bytes into a SwiftUI `Image`.
|
||||||
|
enum PlatformImage {
|
||||||
|
static func from(data: Data) -> Image? {
|
||||||
|
#if os(iOS)
|
||||||
|
guard let ui = UIImage(data: data) else { return nil }
|
||||||
|
return Image(uiImage: ui)
|
||||||
|
#else
|
||||||
|
guard let ns = NSImage(data: data) else { return nil }
|
||||||
|
return Image(nsImage: ns)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#else
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
79
apple/VniDrop/UI/Feedback/SnackbarHost.swift
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Bottom toast host driven by `UiMessageController`, ported from
|
||||||
|
/// `ui/feedback/VniDropSnackbarHost.kt`. Tone drives the accent color; errors get
|
||||||
|
/// a longer display duration.
|
||||||
|
struct SnackbarHost: View {
|
||||||
|
@ObservedObject var controller: UiMessageController
|
||||||
|
@State private var dismissTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
if let message = controller.current {
|
||||||
|
content(for: message)
|
||||||
|
.frame(maxWidth: 520)
|
||||||
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 14).stroke(.gray.opacity(0.25), lineWidth: 0.5))
|
||||||
|
.shadow(color: .black.opacity(0.15), radius: 8, y: 2)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
|
.id(message.id)
|
||||||
|
.onAppear { scheduleDismiss(for: message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: controller.current?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func content(for message: UiMessage) -> some View {
|
||||||
|
let accent: Color = {
|
||||||
|
switch message.tone {
|
||||||
|
case .info: return VniDropColors.brandPurple
|
||||||
|
case .success: return .green
|
||||||
|
case .warning: return .orange
|
||||||
|
case .error: return .red
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
HStack(alignment: .center, spacing: 8) {
|
||||||
|
Circle().fill(accent).frame(width: 8, height: 8)
|
||||||
|
Text(message.text.resolved())
|
||||||
|
.font(.subheadline)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
if let actionLabel = message.actionLabel {
|
||||||
|
Button(action: {
|
||||||
|
message.onAction?()
|
||||||
|
dismiss()
|
||||||
|
}) {
|
||||||
|
Text(actionLabel.resolved()).fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
Button(action: dismiss) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.footnote.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.leading, 16)
|
||||||
|
.padding(.trailing, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleDismiss(for message: UiMessage) {
|
||||||
|
dismissTask?.cancel()
|
||||||
|
let seconds: UInt64 = message.tone == .error ? 6 : 4
|
||||||
|
dismissTask = Task {
|
||||||
|
try? await Task.sleep(nanoseconds: seconds * 1_000_000_000)
|
||||||
|
if !Task.isCancelled { controller.advance() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dismiss() {
|
||||||
|
dismissTask?.cancel()
|
||||||
|
controller.advance()
|
||||||
|
}
|
||||||
|
}
|
||||||
78
apple/VniDrop/UI/Feedback/UiMessage.swift
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// A localizable UI string: either a catalog key or dynamic text, ported from
|
||||||
|
/// `UiText` in `ui/feedback/UiMessageController.kt`.
|
||||||
|
enum UiText: Equatable {
|
||||||
|
case resource(String) // Localizable.xcstrings key
|
||||||
|
case dynamic(String)
|
||||||
|
|
||||||
|
/// Resolves to display text. Keys go through the string catalog.
|
||||||
|
func resolved() -> String {
|
||||||
|
switch self {
|
||||||
|
case .dynamic(let value): return value
|
||||||
|
case .resource(let key): return String(localized: String.LocalizationValue(key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UiMessageTone {
|
||||||
|
case info
|
||||||
|
case success
|
||||||
|
case warning
|
||||||
|
case error
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UiMessage: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let text: UiText
|
||||||
|
var tone: UiMessageTone = .info
|
||||||
|
var actionLabel: UiText? = nil
|
||||||
|
var onAction: (() -> Void)? = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queues user-facing messages (snackbars) and dismissal requests. Ported from
|
||||||
|
/// `UiMessageController.kt`. Errors that are user cancellations are suppressed.
|
||||||
|
@MainActor
|
||||||
|
final class UiMessageController: ObservableObject {
|
||||||
|
@Published private(set) var current: UiMessage?
|
||||||
|
private var queue: [UiMessage] = []
|
||||||
|
|
||||||
|
func show(_ message: UiMessage) {
|
||||||
|
if current == nil {
|
||||||
|
current = message
|
||||||
|
} else {
|
||||||
|
queue.append(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func tryShow(_ message: UiMessage) -> Bool {
|
||||||
|
show(message)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called by the host when the current message is dismissed or times out.
|
||||||
|
func advance() {
|
||||||
|
if queue.isEmpty {
|
||||||
|
current = nil
|
||||||
|
} else {
|
||||||
|
current = queue.removeFirst()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Surfaces a user-facing error. Logs the technical detail; suppresses user
|
||||||
|
/// cancellations. Mirrors `UiMessageController.error(Throwable)`.
|
||||||
|
func error(_ error: Error) {
|
||||||
|
if error.isUserCancellation {
|
||||||
|
AppLogger.info("ui", "suppressed user cancellation", ["detail": error.technicalDetail])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
AppLogger.error("ui", "user-facing error", error)
|
||||||
|
show(UiMessage(text: error.toUiText(), tone: .error))
|
||||||
|
}
|
||||||
|
|
||||||
|
func error(_ text: UiText) {
|
||||||
|
show(UiMessage(text: text, tone: .error))
|
||||||
|
}
|
||||||
|
}
|
||||||
126
apple/VniDrop/UI/Feedback/UserFacingError.swift
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import Foundation
|
||||||
|
import VnidropCore
|
||||||
|
|
||||||
|
/// Maps technical failures to stable, user-facing catalog keys. Ported from
|
||||||
|
/// `ui/feedback/UserFacingError.kt`. Never exposes raw `reason=` blobs.
|
||||||
|
extension Error {
|
||||||
|
func toUiText() -> UiText {
|
||||||
|
if let vni = self as? VnidropError {
|
||||||
|
switch vni {
|
||||||
|
case .Ticket:
|
||||||
|
return .resource("error_invalid_ticket")
|
||||||
|
case .Permission:
|
||||||
|
return .resource("error_permission")
|
||||||
|
case .Filesystem:
|
||||||
|
return .resource("error_filesystem")
|
||||||
|
case .Transfer(let reason):
|
||||||
|
return transferUiText(reason)
|
||||||
|
case .Repository:
|
||||||
|
return .resource("error_repository")
|
||||||
|
case .Initialization(let reason):
|
||||||
|
return initializationUiText(reason)
|
||||||
|
case .Internal(let reason):
|
||||||
|
return reasonHints(reason) ?? .resource("error_generic")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reasonHints(technicalDetail) ?? .resource("error_generic")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when the user intentionally backed out of a flow.
|
||||||
|
var isUserCancellation: Bool {
|
||||||
|
let haystack = technicalDetail.lowercased()
|
||||||
|
if haystack.isEmpty {
|
||||||
|
// URLError / CocoaError cancellation without a message.
|
||||||
|
if let urlError = self as? URLError, urlError.code == .cancelled { return true }
|
||||||
|
return (self as NSError).code == NSUserCancelledError
|
||||||
|
}
|
||||||
|
return haystack.contains("cancelled")
|
||||||
|
|| haystack.contains("canceled")
|
||||||
|
|| haystack.contains("user cancelled")
|
||||||
|
|| haystack.contains("user canceled")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prefers a `VnidropError` reason; else the localized description.
|
||||||
|
var technicalDetail: String {
|
||||||
|
if let vni = self as? VnidropError {
|
||||||
|
switch vni {
|
||||||
|
case .Initialization(let r), .Ticket(let r), .Filesystem(let r),
|
||||||
|
.Transfer(let r), .Permission(let r), .Repository(let r), .Internal(let r):
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (self as? LocalizedError)?.errorDescription ?? (self as NSError).localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func transferUiText(_ reason: String) -> UiText {
|
||||||
|
let detail = reason.lowercased()
|
||||||
|
if detail.contains("refused") || detail.contains("denied") || detail.contains("not approved") {
|
||||||
|
return .resource("error_permission")
|
||||||
|
}
|
||||||
|
return .resource("error_transfer")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func initializationUiText(_ reason: String) -> UiText {
|
||||||
|
let detail = reason.lowercased()
|
||||||
|
if detail.contains("native") && detail.contains("library") {
|
||||||
|
return .resource("error_missing_native_library")
|
||||||
|
}
|
||||||
|
if detail.contains("socket") || detail.contains("bind") {
|
||||||
|
return .resource("error_socket_bind")
|
||||||
|
}
|
||||||
|
return .resource("error_initialization")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reasonHints(_ detailRaw: String) -> UiText? {
|
||||||
|
let detail = detailRaw.lowercased()
|
||||||
|
if detail.isEmpty { return nil }
|
||||||
|
|
||||||
|
if detail.contains("still starting") || detail.contains("starting up") {
|
||||||
|
return .resource("error_starting_up")
|
||||||
|
}
|
||||||
|
if detail.contains("empty") && (detail.contains("invitation") || detail.contains("ticket") || detail.contains("qr")) {
|
||||||
|
return .resource("error_invitation_empty")
|
||||||
|
}
|
||||||
|
if detail.contains("select at least one") || detail.contains("no files found") {
|
||||||
|
return .resource("error_share_empty")
|
||||||
|
}
|
||||||
|
if detail.contains("camera") {
|
||||||
|
return .resource("error_camera")
|
||||||
|
}
|
||||||
|
if detail.contains("nfc") || detail.contains("ndef")
|
||||||
|
|| (detail.contains("read-only") && detail.contains("tag"))
|
||||||
|
|| detail.contains("tag is too small") || detail.contains("no nfc tag") {
|
||||||
|
return .resource("error_nfc")
|
||||||
|
}
|
||||||
|
if detail.contains("native") && detail.contains("library") {
|
||||||
|
return .resource("error_missing_native_library")
|
||||||
|
}
|
||||||
|
if detail.contains("socket") || detail.contains("bind") {
|
||||||
|
return .resource("error_socket_bind")
|
||||||
|
}
|
||||||
|
if detail.contains("device information") || detail.contains("device info") {
|
||||||
|
return .resource("error_device_info")
|
||||||
|
}
|
||||||
|
if detail.contains("refused") || detail.contains("denied") || detail.contains("permission")
|
||||||
|
|| detail.contains("not approved") || detail.contains("waiting for approval") {
|
||||||
|
return .resource("error_permission")
|
||||||
|
}
|
||||||
|
if detail.contains("invalid ticket") || detail.contains("ticket error")
|
||||||
|
|| detail.contains("could not be read") || detail.contains("malformed")
|
||||||
|
|| detail.contains("invitation could not be opened") {
|
||||||
|
return .resource("error_invalid_ticket")
|
||||||
|
}
|
||||||
|
if detail.contains("selected")
|
||||||
|
&& (detail.contains("file") || detail.contains("folder") || detail.contains("document") || detail.contains("open")) {
|
||||||
|
return .resource("error_selection_failed")
|
||||||
|
}
|
||||||
|
if detail.contains("could not open the selected") || detail.contains("could not open selected") {
|
||||||
|
return .resource("error_selection_failed")
|
||||||
|
}
|
||||||
|
if detail.contains("document picker") || detail.contains("folder picker") || detail.contains("file descriptor")
|
||||||
|
|| detail.contains("view controller") {
|
||||||
|
return .resource("error_selection_failed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
27
apple/VniDrop/UI/Navigation/AppDestination.swift
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Top-level destinations, ported from `ui/navigation/AppDestination.kt`.
|
||||||
|
enum AppDestination: String, CaseIterable, Identifiable {
|
||||||
|
case send
|
||||||
|
case receive
|
||||||
|
case settings
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var labelKey: String {
|
||||||
|
switch self {
|
||||||
|
case .send: return "nav_send"
|
||||||
|
case .receive: return "nav_receive"
|
||||||
|
case .settings: return "nav_settings"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SF Symbol approximating the Compose line icon.
|
||||||
|
var systemImage: String {
|
||||||
|
switch self {
|
||||||
|
case .send: return "paperplane"
|
||||||
|
case .receive: return "tray.and.arrow.down"
|
||||||
|
case .settings: return "gearshape"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apple/VniDrop/UI/Theme/Typography.swift
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Semantic type scale mapped from the Material typography styles the Compose UI
|
||||||
|
/// uses, so screens reference the same names during the port.
|
||||||
|
enum VniType {
|
||||||
|
static let titleLarge = Font.system(size: 22, weight: .semibold)
|
||||||
|
static let bodyLarge = Font.system(size: 16)
|
||||||
|
static let bodyMedium = Font.system(size: 14)
|
||||||
|
static let bodySmall = Font.system(size: 12)
|
||||||
|
static let labelSmall = Font.system(size: 11, weight: .medium)
|
||||||
|
}
|
||||||
189
apple/VniDrop/UI/Theme/VniDropColors.swift
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Direct Compose port of the VniDrop semantic color tokens
|
||||||
|
/// (`shared/.../ui/theme/VniDropTheme.kt`). The app uses these semantic tokens
|
||||||
|
/// directly because a single SwiftUI/Material color scheme cannot represent the
|
||||||
|
/// full surface, border, and foreground stack.
|
||||||
|
struct VniDropColors {
|
||||||
|
let backgroundDefault: Color
|
||||||
|
let backgroundDashCanvas: Color
|
||||||
|
let backgroundDashSidebar: Color
|
||||||
|
let backgroundSurface75: Color
|
||||||
|
let backgroundSurface100: Color
|
||||||
|
let backgroundSurface200: Color
|
||||||
|
let backgroundSurface300: Color
|
||||||
|
let backgroundSurface400: Color
|
||||||
|
let backgroundMuted: Color
|
||||||
|
let backgroundControl: Color
|
||||||
|
let backgroundSelection: Color
|
||||||
|
let backgroundButton: Color
|
||||||
|
let backgroundOverlayHover: Color
|
||||||
|
let backgroundDialog: Color
|
||||||
|
let borderDefault: Color
|
||||||
|
let borderStrong: Color
|
||||||
|
let borderStronger: Color
|
||||||
|
let borderMuted: Color
|
||||||
|
let borderControl: Color
|
||||||
|
let foregroundDefault: Color
|
||||||
|
let foregroundLight: Color
|
||||||
|
let foregroundLighter: Color
|
||||||
|
let foregroundMuted: Color
|
||||||
|
let foregroundContrast: Color
|
||||||
|
let brandLink: Color
|
||||||
|
let brandButton: Color
|
||||||
|
let brandDefault: Color
|
||||||
|
let brand600: Color
|
||||||
|
let brand500: Color
|
||||||
|
let brand400: Color
|
||||||
|
let brand300: Color
|
||||||
|
let brand200: Color
|
||||||
|
let warningDefault: Color
|
||||||
|
let warning200: Color
|
||||||
|
let warning300: Color
|
||||||
|
let warning400: Color
|
||||||
|
let warning500: Color
|
||||||
|
let warning600: Color
|
||||||
|
let destructiveDefault: Color
|
||||||
|
let destructive200: Color
|
||||||
|
let destructive300: Color
|
||||||
|
let destructive400: Color
|
||||||
|
let destructive500: Color
|
||||||
|
let destructive600: Color
|
||||||
|
}
|
||||||
|
|
||||||
|
extension VniDropColors {
|
||||||
|
/// The single brand accent used app-wide as the SwiftUI tint.
|
||||||
|
static let brandPurple = Color.hsl(271, 91, 65)
|
||||||
|
|
||||||
|
static let light = VniDropColors(
|
||||||
|
backgroundDefault: .hsl(0, 0, 98.8),
|
||||||
|
backgroundDashCanvas: .hsl(0, 0, 97.3),
|
||||||
|
backgroundDashSidebar: .hsl(0, 0, 98.8),
|
||||||
|
backgroundSurface75: .hsl(0, 0, 100),
|
||||||
|
backgroundSurface100: .hsl(0, 0, 98.8),
|
||||||
|
backgroundSurface200: .hsl(0, 0, 95.3),
|
||||||
|
backgroundSurface300: .hsl(0, 0, 92.9),
|
||||||
|
backgroundSurface400: .hsl(0, 0, 89.8),
|
||||||
|
backgroundMuted: .hsl(0, 0, 96.9),
|
||||||
|
backgroundControl: .hsl(0, 0, 95.3),
|
||||||
|
backgroundSelection: .hsl(0, 0, 92.9),
|
||||||
|
backgroundButton: .hsl(0, 0, 91),
|
||||||
|
backgroundOverlayHover: .hsl(0, 0, 95.3),
|
||||||
|
backgroundDialog: .hsl(0, 0, 100),
|
||||||
|
borderDefault: .hsl(0, 0, 87.5),
|
||||||
|
borderStrong: .hsl(0, 0, 83.1),
|
||||||
|
borderStronger: .hsl(0, 0, 56.1),
|
||||||
|
borderMuted: .hsl(0, 0, 92.9),
|
||||||
|
borderControl: .hsl(0, 0, 78),
|
||||||
|
foregroundDefault: .hsl(0, 0, 9),
|
||||||
|
foregroundLight: .hsl(0, 0, 32.2),
|
||||||
|
foregroundLighter: .hsl(0, 0, 43.9),
|
||||||
|
foregroundMuted: .hsl(0, 0, 69.8),
|
||||||
|
foregroundContrast: .hsl(0, 0, 98.4),
|
||||||
|
brandLink: .hsl(271, 91, 65),
|
||||||
|
brandButton: .hsl(270, 95, 75),
|
||||||
|
brandDefault: .hsl(271, 91, 65),
|
||||||
|
brand600: .hsl(271, 81, 56),
|
||||||
|
brand500: .hsl(271, 91, 65),
|
||||||
|
brand400: .hsl(270, 95, 75),
|
||||||
|
brand300: .hsl(269, 97, 85),
|
||||||
|
brand200: .hsl(269, 100, 92),
|
||||||
|
warningDefault: .hsl(38.9, 100, 57.1),
|
||||||
|
warning200: .hsl(40, 81.8, 97.8),
|
||||||
|
warning300: .hsl(44.3, 100, 91.8),
|
||||||
|
warning400: .hsl(41.9, 100, 81.8),
|
||||||
|
warning500: .hsl(36.3, 85.7, 67.1),
|
||||||
|
warning600: .hsl(30.3, 80.3, 47.8),
|
||||||
|
destructiveDefault: .hsl(10.2, 77.9, 53.9),
|
||||||
|
destructive200: .hsl(0, 100, 99.4),
|
||||||
|
destructive300: .hsl(7.1, 100, 96.7),
|
||||||
|
destructive400: .hsl(7.1, 91.3, 91),
|
||||||
|
destructive500: .hsl(10.4, 77.1, 79.4),
|
||||||
|
destructive600: .hsl(9.9, 82, 43.5)
|
||||||
|
)
|
||||||
|
|
||||||
|
static let dark = VniDropColors(
|
||||||
|
backgroundDefault: .hsl(0, 0, 7.1),
|
||||||
|
backgroundDashCanvas: .hsl(0, 0, 7.1),
|
||||||
|
backgroundDashSidebar: .hsl(0, 0, 9),
|
||||||
|
backgroundSurface75: .hsl(0, 0, 9),
|
||||||
|
backgroundSurface100: .hsl(0, 0, 12.2),
|
||||||
|
backgroundSurface200: .hsl(0, 0, 12.9),
|
||||||
|
backgroundSurface300: .hsl(0, 0, 16.1),
|
||||||
|
backgroundSurface400: .hsl(0, 0, 16.1),
|
||||||
|
backgroundMuted: .hsl(0, 0, 14.1),
|
||||||
|
backgroundControl: .hsl(0, 0, 14.1),
|
||||||
|
backgroundSelection: .hsl(0, 0, 19.2),
|
||||||
|
backgroundButton: .hsl(0, 0, 18),
|
||||||
|
backgroundOverlayHover: .hsl(0, 0, 18),
|
||||||
|
backgroundDialog: .hsl(0, 0, 7.1),
|
||||||
|
borderDefault: .hsl(0, 0, 18),
|
||||||
|
borderStrong: .hsl(0, 0, 21.2),
|
||||||
|
borderStronger: .hsl(0, 0, 27.1),
|
||||||
|
borderMuted: .hsl(0, 0, 14.1),
|
||||||
|
borderControl: .hsl(0, 0, 22.4),
|
||||||
|
foregroundDefault: .hsl(0, 0, 98),
|
||||||
|
foregroundLight: .hsl(0, 0, 70.6),
|
||||||
|
foregroundLighter: .hsl(0, 0, 53.7),
|
||||||
|
foregroundMuted: .hsl(0, 0, 30.2),
|
||||||
|
foregroundContrast: .hsl(0, 0, 8.6),
|
||||||
|
brandLink: .hsl(270, 95, 75),
|
||||||
|
brandButton: .hsl(271, 81, 56),
|
||||||
|
brandDefault: .hsl(270, 95, 75),
|
||||||
|
brand600: .hsl(271, 91, 65),
|
||||||
|
brand500: .hsl(271, 81, 56),
|
||||||
|
brand400: .hsl(273, 67, 39),
|
||||||
|
brand300: .hsl(274, 66, 32),
|
||||||
|
brand200: .hsl(274, 87, 21),
|
||||||
|
warningDefault: .hsl(38.9, 100, 42.9),
|
||||||
|
warning200: .hsl(36.6, 100, 8),
|
||||||
|
warning300: .hsl(32.3, 100, 10.2),
|
||||||
|
warning400: .hsl(33.2, 100, 14.5),
|
||||||
|
warning500: .hsl(34.8, 90.9, 21.6),
|
||||||
|
warning600: .hsl(38.9, 100, 42.9),
|
||||||
|
destructiveDefault: .hsl(10.2, 77.9, 53.9),
|
||||||
|
destructive200: .hsl(10.9, 23.4, 9.2),
|
||||||
|
destructive300: .hsl(7.5, 51.3, 15.3),
|
||||||
|
destructive400: .hsl(6.7, 60, 20.6),
|
||||||
|
destructive500: .hsl(7.9, 71.6, 29),
|
||||||
|
destructive600: .hsl(9.7, 85.2, 62.9)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Color {
|
||||||
|
/// HSL constructor matching the Compose `hsl()` helper (hue in degrees,
|
||||||
|
/// saturation and lightness in percent).
|
||||||
|
static func hsl(_ hue: Double, _ saturation: Double, _ lightness: Double) -> Color {
|
||||||
|
let h = (hue.truncatingRemainder(dividingBy: 360) + 360)
|
||||||
|
.truncatingRemainder(dividingBy: 360) / 360
|
||||||
|
let s = min(max(saturation, 0), 100) / 100
|
||||||
|
let l = min(max(lightness, 0), 100) / 100
|
||||||
|
if s == 0 {
|
||||||
|
return Color(red: l, green: l, blue: l)
|
||||||
|
}
|
||||||
|
let q = l < 0.5 ? l * (1 + s) : l + s - l * s
|
||||||
|
let p = 2 * l - q
|
||||||
|
return Color(
|
||||||
|
red: hueToRgb(p, q, h + 1.0 / 3.0),
|
||||||
|
green: hueToRgb(p, q, h),
|
||||||
|
blue: hueToRgb(p, q, h - 1.0 / 3.0)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hueToRgb(_ p: Double, _ q: Double, _ input: Double) -> Double {
|
||||||
|
var t = input
|
||||||
|
if t < 0 { t += 1 }
|
||||||
|
if t > 1 { t -= 1 }
|
||||||
|
let value: Double
|
||||||
|
if t < 1.0 / 6.0 {
|
||||||
|
value = p + (q - p) * 6 * t
|
||||||
|
} else if t < 1.0 / 2.0 {
|
||||||
|
value = q
|
||||||
|
} else if t < 2.0 / 3.0 {
|
||||||
|
value = p + (q - p) * (2.0 / 3.0 - t) * 6
|
||||||
|
} else {
|
||||||
|
value = p
|
||||||
|
}
|
||||||
|
return min(1, max(0, value))
|
||||||
|
}
|
||||||
56
apple/VniDrop/UI/Theme/VniDropTheme.swift
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// User-facing theme selection, mirrors `ThemeMode` in the Compose theme.
|
||||||
|
enum ThemeMode: String, CaseIterable, Codable, Sendable {
|
||||||
|
case system
|
||||||
|
case light
|
||||||
|
case dark
|
||||||
|
|
||||||
|
/// SwiftUI color-scheme override (`nil` follows the system).
|
||||||
|
var preferredColorScheme: ColorScheme? {
|
||||||
|
switch self {
|
||||||
|
case .system: return nil
|
||||||
|
case .light: return .light
|
||||||
|
case .dark: return .dark
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveDarkTheme(_ mode: ThemeMode, systemDark: Bool) -> Bool {
|
||||||
|
switch mode {
|
||||||
|
case .system: return systemDark
|
||||||
|
case .light: return false
|
||||||
|
case .dark: return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct VniDropColorsKey: EnvironmentKey {
|
||||||
|
static let defaultValue = VniDropColors.light
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EnvironmentValues {
|
||||||
|
/// Semantic VniDrop tokens for the active theme. Read with
|
||||||
|
/// `@Environment(\.vniColors) private var colors`.
|
||||||
|
var vniColors: VniDropColors {
|
||||||
|
get { self[VniDropColorsKey.self] }
|
||||||
|
set { self[VniDropColorsKey.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provides the semantic token set matching the resolved light/dark theme to the
|
||||||
|
/// whole subtree. Apply once near the app root.
|
||||||
|
struct VniDropTheme: ViewModifier {
|
||||||
|
let isDark: Bool
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.environment(\.vniColors, isDark ? .dark : .light)
|
||||||
|
.tint(VniDropColors.brandPurple)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func vniDropTheme(isDark: Bool) -> some View {
|
||||||
|
modifier(VniDropTheme(isDark: isDark))
|
||||||
|
}
|
||||||
|
}
|
||||||
30
apple/VnidropCore/Package.swift
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// swift-tools-version:5.9
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
// Local package wrapping the VniDrop Rust core:
|
||||||
|
// - `vnidrop` binary target: the xcframework of static libraries + the FFI
|
||||||
|
// C module (`vnidropFFI`), produced by apple/scripts/build-core.sh.
|
||||||
|
// - `VnidropCore` target: the generated Swift bindings (`Vnidrop.swift`).
|
||||||
|
//
|
||||||
|
// Both `vnidrop.xcframework` and `Sources/VnidropCore/Vnidrop.swift` are build
|
||||||
|
// outputs and are gitignored; run apple/scripts/build-core.sh to (re)generate.
|
||||||
|
let package = Package(
|
||||||
|
name: "VnidropCore",
|
||||||
|
platforms: [
|
||||||
|
.iOS(.v16),
|
||||||
|
.macOS(.v13),
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
.library(name: "VnidropCore", targets: ["VnidropCore"]),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.binaryTarget(
|
||||||
|
name: "vnidrop",
|
||||||
|
path: "vnidrop.xcframework"
|
||||||
|
),
|
||||||
|
.target(
|
||||||
|
name: "VnidropCore",
|
||||||
|
dependencies: ["vnidrop"]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
25
apple/VnidropCore/README.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# VnidropCore (Swift package)
|
||||||
|
|
||||||
|
Swift bindings for the VniDrop Rust transfer core (`crates/vnidrop`), generated
|
||||||
|
with UniFFI in library mode. The Rust crate is never modified for this — the
|
||||||
|
Swift surface is produced from the compiled staticlib.
|
||||||
|
|
||||||
|
## Regenerate
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apple/scripts/build-core.sh release # or: debug
|
||||||
|
```
|
||||||
|
|
||||||
|
This builds `libvnidrop.a` for `aarch64-apple-ios`, `aarch64-apple-ios-sim`, and
|
||||||
|
`aarch64-apple-darwin`, generates `Sources/VnidropCore/Vnidrop.swift`, and
|
||||||
|
assembles `vnidrop.xcframework`.
|
||||||
|
|
||||||
|
## Generated / ignored artifacts
|
||||||
|
|
||||||
|
- `vnidrop.xcframework/`
|
||||||
|
- `Sources/VnidropCore/Vnidrop.swift`
|
||||||
|
|
||||||
|
Both are gitignored. A clean checkout must run the build script before opening
|
||||||
|
the Xcode project.
|
||||||
81
apple/project.yml
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# XcodeGen spec for the native SwiftUI VniDrop app (iOS/iPadOS/macOS).
|
||||||
|
# Regenerate the project with: xcodegen generate (run from apple/)
|
||||||
|
# Requires the Rust core first: apple/scripts/build-core.sh debug
|
||||||
|
name: VniDrop
|
||||||
|
options:
|
||||||
|
bundleIdPrefix: com.vnidrop
|
||||||
|
deploymentTarget:
|
||||||
|
iOS: "18.2"
|
||||||
|
macOS: "15.0"
|
||||||
|
createIntermediateGroups: true
|
||||||
|
|
||||||
|
packages:
|
||||||
|
VnidropCore:
|
||||||
|
path: VnidropCore
|
||||||
|
|
||||||
|
targets:
|
||||||
|
VniDrop:
|
||||||
|
type: application
|
||||||
|
supportedDestinations: [iOS, macOS]
|
||||||
|
configFiles:
|
||||||
|
Debug: Signing.xcconfig
|
||||||
|
Release: Signing.xcconfig
|
||||||
|
sources:
|
||||||
|
- path: VniDrop
|
||||||
|
excludes:
|
||||||
|
- "Resources/Info.plist"
|
||||||
|
- "Resources/VniDrop.entitlements"
|
||||||
|
- "Resources/**/.DS_Store"
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: com.vnidrop.app
|
||||||
|
MARKETING_VERSION: "0.1.0"
|
||||||
|
CURRENT_PROJECT_VERSION: "1"
|
||||||
|
GENERATE_INFOPLIST_FILE: NO
|
||||||
|
INFOPLIST_FILE: VniDrop/Resources/Info.plist
|
||||||
|
# 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
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType: public.app-category.utilities
|
||||||
|
SWIFT_VERSION: "6.0"
|
||||||
|
SWIFT_STRICT_CONCURRENCY: complete
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING: NO
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
|
configs:
|
||||||
|
debug:
|
||||||
|
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
|
||||||
|
release:
|
||||||
|
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
|
||||||
|
dependencies:
|
||||||
|
- package: VnidropCore
|
||||||
|
- sdk: SystemConfiguration.framework
|
||||||
|
- sdk: Security.framework
|
||||||
|
- sdk: libresolv.tbd
|
||||||
|
|
||||||
|
VniDropTests:
|
||||||
|
type: bundle.unit-test
|
||||||
|
supportedDestinations: [iOS, macOS]
|
||||||
|
configFiles:
|
||||||
|
Debug: Signing.xcconfig
|
||||||
|
Release: Signing.xcconfig
|
||||||
|
sources:
|
||||||
|
- path: Tests
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
|
SWIFT_VERSION: "6.0"
|
||||||
|
SWIFT_STRICT_CONCURRENCY: complete
|
||||||
|
dependencies:
|
||||||
|
- target: VniDrop
|
||||||
|
|
||||||
|
schemes:
|
||||||
|
VniDrop:
|
||||||
|
build:
|
||||||
|
targets:
|
||||||
|
VniDrop: all
|
||||||
|
run:
|
||||||
|
config: Debug
|
||||||
|
test:
|
||||||
|
config: Debug
|
||||||
|
targets:
|
||||||
|
- VniDropTests
|
||||||
105
apple/scripts/build-core.sh
Executable file
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Builds the VniDrop Rust core for Apple platforms and produces:
|
||||||
|
# - apple/VnidropCore/vnidrop.xcframework (static libs for device/sim/macOS)
|
||||||
|
# - apple/VnidropCore/Sources/VnidropCore/Vnidrop.swift (generated bindings)
|
||||||
|
#
|
||||||
|
# The Rust crate (crates/vnidrop) is NOT modified. Bindings are generated in
|
||||||
|
# UniFFI library mode from the compiled staticlib, so the Swift surface always
|
||||||
|
# matches the scaffolding baked into the library.
|
||||||
|
#
|
||||||
|
# Usage: apple/scripts/build-core.sh [debug|release] (default: release)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Default to debug: the workspace `[profile.release]` uses thin LTO, which the
|
||||||
|
# current macOS toolchain miscompiles into corrupt host proc-macro dylibs
|
||||||
|
# ("mis-aligned LINKEDIT string pool"), breaking any release cross-compile. Debug
|
||||||
|
# static libs are correct and adequate for development and the simulator. For a
|
||||||
|
# release build, pass `release` AND disable LTO for proc-macros/build scripts via
|
||||||
|
# CARGO_PROFILE_RELEASE_BUILD_OVERRIDE_LTO=false in a Cargo.toml profile — see the
|
||||||
|
# package README. The Rust crate itself is never modified.
|
||||||
|
PROFILE="${1:-debug}"
|
||||||
|
case "$PROFILE" in
|
||||||
|
debug) CARGO_PROFILE_FLAG="" ;;
|
||||||
|
release) CARGO_PROFILE_FLAG="--release" ;;
|
||||||
|
*) echo "profile must be debug or release" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
APPLE_DIR="$REPO_ROOT/apple"
|
||||||
|
PKG_DIR="$APPLE_DIR/VnidropCore"
|
||||||
|
GEN_DIR="$PKG_DIR/Sources/VnidropCore"
|
||||||
|
TARGET_DIR="$REPO_ROOT/target"
|
||||||
|
BUILD_DIR="$APPLE_DIR/.build-core"
|
||||||
|
|
||||||
|
# Match the SwiftUI app's deployment targets (apple/project.yml) so the static
|
||||||
|
# libs are never built for a newer OS than the app links against.
|
||||||
|
export IPHONEOS_DEPLOYMENT_TARGET="${IPHONEOS_DEPLOYMENT_TARGET:-18.2}"
|
||||||
|
export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-15.0}"
|
||||||
|
|
||||||
|
# The workspace `[profile.dev] strip = "debuginfo"` corrupts host proc-macro
|
||||||
|
# dylibs on the current Apple toolchain ("mis-aligned LINKEDIT string pool"),
|
||||||
|
# which breaks compilation. The Gobley Xcode run-script uses the same override.
|
||||||
|
# This never touches the Rust crate — it only changes how the build is invoked.
|
||||||
|
export CARGO_PROFILE_DEV_STRIP=none
|
||||||
|
|
||||||
|
IOS_TARGET="aarch64-apple-ios"
|
||||||
|
SIM_ARM_TARGET="aarch64-apple-ios-sim"
|
||||||
|
SIM_X64_TARGET="x86_64-apple-ios"
|
||||||
|
MAC_TARGET="aarch64-apple-darwin"
|
||||||
|
|
||||||
|
echo "==> Building vnidrop staticlib ($PROFILE) for Apple targets"
|
||||||
|
for t in "$IOS_TARGET" "$SIM_ARM_TARGET" "$SIM_X64_TARGET" "$MAC_TARGET"; do
|
||||||
|
echo " - $t"
|
||||||
|
rustup target add "$t" >/dev/null 2>&1 || true
|
||||||
|
( cd "$REPO_ROOT" && cargo build -p vnidrop --target "$t" $CARGO_PROFILE_FLAG )
|
||||||
|
done
|
||||||
|
|
||||||
|
LIB_SUBDIR="$PROFILE"
|
||||||
|
[ "$PROFILE" = "debug" ] && LIB_SUBDIR="debug"
|
||||||
|
|
||||||
|
MAC_LIB="$TARGET_DIR/$MAC_TARGET/$LIB_SUBDIR/libvnidrop.a"
|
||||||
|
IOS_LIB="$TARGET_DIR/$IOS_TARGET/$LIB_SUBDIR/libvnidrop.a"
|
||||||
|
|
||||||
|
# Fresh scratch dir for the bindgen output and the universal simulator lib.
|
||||||
|
rm -rf "$BUILD_DIR"
|
||||||
|
mkdir -p "$BUILD_DIR"
|
||||||
|
|
||||||
|
# Combine the two simulator architectures into one universal static library so
|
||||||
|
# the xcframework works on both Apple Silicon and Intel simulators.
|
||||||
|
SIM_LIB="$BUILD_DIR/libvnidrop-sim.a"
|
||||||
|
lipo -create \
|
||||||
|
"$TARGET_DIR/$SIM_ARM_TARGET/$LIB_SUBDIR/libvnidrop.a" \
|
||||||
|
"$TARGET_DIR/$SIM_X64_TARGET/$LIB_SUBDIR/libvnidrop.a" \
|
||||||
|
-output "$SIM_LIB"
|
||||||
|
|
||||||
|
echo "==> Generating Swift bindings (library mode)"
|
||||||
|
( cd "$REPO_ROOT" && cargo run -p uniffi-bindgen -- generate \
|
||||||
|
--library "$MAC_LIB" \
|
||||||
|
--language swift \
|
||||||
|
--out-dir "$BUILD_DIR" )
|
||||||
|
|
||||||
|
# UniFFI emits: Vnidrop.swift, vnidropFFI.h, vnidropFFI.modulemap
|
||||||
|
mkdir -p "$GEN_DIR"
|
||||||
|
cp "$BUILD_DIR/Vnidrop.swift" "$GEN_DIR/Vnidrop.swift"
|
||||||
|
|
||||||
|
# Assemble a headers dir the xcframework can carry as the FFI module.
|
||||||
|
HEADERS_DIR="$BUILD_DIR/headers"
|
||||||
|
mkdir -p "$HEADERS_DIR"
|
||||||
|
cp "$BUILD_DIR/vnidropFFI.h" "$HEADERS_DIR/"
|
||||||
|
# The xcframework module map must be named module.modulemap.
|
||||||
|
cp "$BUILD_DIR/vnidropFFI.modulemap" "$HEADERS_DIR/module.modulemap"
|
||||||
|
|
||||||
|
echo "==> Assembling xcframework"
|
||||||
|
XCFRAMEWORK="$PKG_DIR/vnidrop.xcframework"
|
||||||
|
rm -rf "$XCFRAMEWORK"
|
||||||
|
xcodebuild -create-xcframework \
|
||||||
|
-library "$IOS_LIB" -headers "$HEADERS_DIR" \
|
||||||
|
-library "$SIM_LIB" -headers "$HEADERS_DIR" \
|
||||||
|
-library "$MAC_LIB" -headers "$HEADERS_DIR" \
|
||||||
|
-output "$XCFRAMEWORK"
|
||||||
|
|
||||||
|
echo "==> Done."
|
||||||
|
echo " xcframework: $XCFRAMEWORK"
|
||||||
|
echo " bindings: $GEN_DIR/Vnidrop.swift"
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect width="1024" height="1024" fill="#FFFFFF"/>
|
|
||||||
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M236.68 148H338.36C366.24 148 387.56 170.96 387.56 198.84V564.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.56V198.84C656.52 170.96 677.84 148 705.72 148H781.16C817.24 148 846.76 177.52 846.76 213.6V564.56C846.76 738.4 704.08 879.44 522.04 879.44C340 879.44 194.04 738.4 194.04 564.56V374.32H220.28V305.44C195.68 305.44 176 297.24 176 280.84V246.4C176 231.64 187.48 220.16 202.24 220.16H236.68V148ZM256.36 239.84C251.44 239.84 248.16 244.76 248.16 249.68V275.92C248.16 282.48 253.08 285.76 259.64 285.76H282.6C289.16 285.76 292.44 280.84 292.44 274.28V251.32C292.44 244.76 287.52 239.84 280.96 239.84H256.36Z" fill="url(#paint0_linear_11_12)"/>
|
|
||||||
<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" fill="url(#paint1_linear_11_12)"/>
|
|
||||||
<mask id="mask0_11_12" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="176" y="148" width="671" height="732">
|
|
||||||
<path d="M236.68 148H338.36C366.24 148 387.56 170.96 387.56 198.84V564.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.56V198.84C656.52 170.96 677.84 148 705.72 148H781.16C817.24 148 846.76 177.52 846.76 213.6V564.56C846.76 738.4 704.08 879.44 522.04 879.44C340 879.44 194.04 738.4 194.04 564.56V374.32H220.28V305.44C195.68 305.44 176 297.24 176 280.84V246.4C176 231.64 187.48 220.16 202.24 220.16H236.68V148ZM256.36 239.84C251.44 239.84 248.16 244.76 248.16 249.68V275.92C248.16 282.48 253.08 285.76 259.64 285.76H282.6C289.16 285.76 292.44 280.84 292.44 274.28V251.32C292.44 244.76 287.52 239.84 280.96 239.84H256.36Z" fill="white"/>
|
|
||||||
</mask>
|
|
||||||
<g mask="url(#mask0_11_12)">
|
|
||||||
<path d="M688 148H782C832 148 842 171.8 847.48 210V303.68L688 148Z" fill="url(#paint2_linear_11_12)"/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="paint0_linear_11_12" x1="176" y1="148" x2="904.706" y2="816.252" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#A855F7"/>
|
|
||||||
<stop offset="0.48" stop-color="#9D4DF4"/>
|
|
||||||
<stop offset="1" stop-color="#7C2AEF"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="paint1_linear_11_12" x1="404.439" y1="431.72" x2="707.314" y2="649.286" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#A855F7"/>
|
|
||||||
<stop offset="0.48" stop-color="#9D4DF4"/>
|
|
||||||
<stop offset="1" stop-color="#7C2AEF"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="paint2_linear_11_12" x1="683.48" y1="144.6" x2="793.636" y2="306.833" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#F2DDFF"/>
|
|
||||||
<stop offset="1" stop-color="#C084FC"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 301 KiB |
@@ -1,26 +0,0 @@
|
|||||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M236.68 148H338.36C366.24 148 387.56 170.96 387.56 198.84V564.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.56V198.84C656.52 170.96 677.84 148 705.72 148H781.16C817.24 148 846.76 177.52 846.76 213.6V564.56C846.76 738.4 704.08 879.44 522.04 879.44C340 879.44 194.04 738.4 194.04 564.56V374.32H220.28V305.44C195.68 305.44 176 297.24 176 280.84V246.4C176 231.64 187.48 220.16 202.24 220.16H236.68V148ZM256.36 239.84C251.44 239.84 248.16 244.76 248.16 249.68V275.92C248.16 282.48 253.08 285.76 259.64 285.76H282.6C289.16 285.76 292.44 280.84 292.44 274.28V251.32C292.44 244.76 287.52 239.84 280.96 239.84H256.36Z" fill="url(#paint0_linear_11_12)"/>
|
|
||||||
<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" fill="url(#paint1_linear_11_12)"/>
|
|
||||||
<mask id="mask0_11_12" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="176" y="148" width="671" height="732">
|
|
||||||
<path d="M236.68 148H338.36C366.24 148 387.56 170.96 387.56 198.84V564.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.56V198.84C656.52 170.96 677.84 148 705.72 148H781.16C817.24 148 846.76 177.52 846.76 213.6V564.56C846.76 738.4 704.08 879.44 522.04 879.44C340 879.44 194.04 738.4 194.04 564.56V374.32H220.28V305.44C195.68 305.44 176 297.24 176 280.84V246.4C176 231.64 187.48 220.16 202.24 220.16H236.68V148ZM256.36 239.84C251.44 239.84 248.16 244.76 248.16 249.68V275.92C248.16 282.48 253.08 285.76 259.64 285.76H282.6C289.16 285.76 292.44 280.84 292.44 274.28V251.32C292.44 244.76 287.52 239.84 280.96 239.84H256.36Z" fill="white"/>
|
|
||||||
</mask>
|
|
||||||
<g mask="url(#mask0_11_12)">
|
|
||||||
<path d="M688 148H782C832 148 842 171.8 847.48 210V303.68L688 148Z" fill="url(#paint2_linear_11_12)"/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="paint0_linear_11_12" x1="176" y1="148" x2="904.706" y2="816.252" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#A855F7"/>
|
|
||||||
<stop offset="0.48" stop-color="#9D4DF4"/>
|
|
||||||
<stop offset="1" stop-color="#7C2AEF"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="paint1_linear_11_12" x1="404.439" y1="431.72" x2="707.314" y2="649.286" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#A855F7"/>
|
|
||||||
<stop offset="0.48" stop-color="#9D4DF4"/>
|
|
||||||
<stop offset="1" stop-color="#7C2AEF"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="paint2_linear_11_12" x1="683.48" y1="144.6" x2="793.636" y2="306.833" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#F2DDFF"/>
|
|
||||||
<stop offset="1" stop-color="#C084FC"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 72 KiB |
@@ -1,29 +0,0 @@
|
|||||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect x="40" y="40" width="944" height="944" rx="210" fill="#FFFFFF" stroke="#E9E7F0" stroke-width="8"/>
|
|
||||||
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M236.68 148H338.36C366.24 148 387.56 170.96 387.56 198.84V564.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.56V198.84C656.52 170.96 677.84 148 705.72 148H781.16C817.24 148 846.76 177.52 846.76 213.6V564.56C846.76 738.4 704.08 879.44 522.04 879.44C340 879.44 194.04 738.4 194.04 564.56V374.32H220.28V305.44C195.68 305.44 176 297.24 176 280.84V246.4C176 231.64 187.48 220.16 202.24 220.16H236.68V148ZM256.36 239.84C251.44 239.84 248.16 244.76 248.16 249.68V275.92C248.16 282.48 253.08 285.76 259.64 285.76H282.6C289.16 285.76 292.44 280.84 292.44 274.28V251.32C292.44 244.76 287.52 239.84 280.96 239.84H256.36Z" fill="url(#paint0_linear_11_12)"/>
|
|
||||||
<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" fill="url(#paint1_linear_11_12)"/>
|
|
||||||
<mask id="mask0_11_12" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="176" y="148" width="671" height="732">
|
|
||||||
<path d="M236.68 148H338.36C366.24 148 387.56 170.96 387.56 198.84V564.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.56V198.84C656.52 170.96 677.84 148 705.72 148H781.16C817.24 148 846.76 177.52 846.76 213.6V564.56C846.76 738.4 704.08 879.44 522.04 879.44C340 879.44 194.04 738.4 194.04 564.56V374.32H220.28V305.44C195.68 305.44 176 297.24 176 280.84V246.4C176 231.64 187.48 220.16 202.24 220.16H236.68V148ZM256.36 239.84C251.44 239.84 248.16 244.76 248.16 249.68V275.92C248.16 282.48 253.08 285.76 259.64 285.76H282.6C289.16 285.76 292.44 280.84 292.44 274.28V251.32C292.44 244.76 287.52 239.84 280.96 239.84H256.36Z" fill="white"/>
|
|
||||||
</mask>
|
|
||||||
<g mask="url(#mask0_11_12)">
|
|
||||||
<path d="M688 148H782C832 148 842 171.8 847.48 210V303.68L688 148Z" fill="url(#paint2_linear_11_12)"/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="paint0_linear_11_12" x1="176" y1="148" x2="904.706" y2="816.252" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#A855F7"/>
|
|
||||||
<stop offset="0.48" stop-color="#9D4DF4"/>
|
|
||||||
<stop offset="1" stop-color="#7C2AEF"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="paint1_linear_11_12" x1="404.439" y1="431.72" x2="707.314" y2="649.286" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#A855F7"/>
|
|
||||||
<stop offset="0.48" stop-color="#9D4DF4"/>
|
|
||||||
<stop offset="1" stop-color="#7C2AEF"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="paint2_linear_11_12" x1="683.48" y1="144.6" x2="793.636" y2="306.833" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#F2DDFF"/>
|
|
||||||
<stop offset="1" stop-color="#C084FC"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 301 KiB |
@@ -1,26 +0,0 @@
|
|||||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M236.68 148H338.36C366.24 148 387.56 170.96 387.56 198.84V564.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.56V198.84C656.52 170.96 677.84 148 705.72 148H781.16C817.24 148 846.76 177.52 846.76 213.6V564.56C846.76 738.4 704.08 879.44 522.04 879.44C340 879.44 194.04 738.4 194.04 564.56V374.32H220.28V305.44C195.68 305.44 176 297.24 176 280.84V246.4C176 231.64 187.48 220.16 202.24 220.16H236.68V148ZM256.36 239.84C251.44 239.84 248.16 244.76 248.16 249.68V275.92C248.16 282.48 253.08 285.76 259.64 285.76H282.6C289.16 285.76 292.44 280.84 292.44 274.28V251.32C292.44 244.76 287.52 239.84 280.96 239.84H256.36Z" fill="url(#paint0_linear_11_12)"/>
|
|
||||||
<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" fill="url(#paint1_linear_11_12)"/>
|
|
||||||
<mask id="mask0_11_12" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="176" y="148" width="671" height="732">
|
|
||||||
<path d="M236.68 148H338.36C366.24 148 387.56 170.96 387.56 198.84V564.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.56V198.84C656.52 170.96 677.84 148 705.72 148H781.16C817.24 148 846.76 177.52 846.76 213.6V564.56C846.76 738.4 704.08 879.44 522.04 879.44C340 879.44 194.04 738.4 194.04 564.56V374.32H220.28V305.44C195.68 305.44 176 297.24 176 280.84V246.4C176 231.64 187.48 220.16 202.24 220.16H236.68V148ZM256.36 239.84C251.44 239.84 248.16 244.76 248.16 249.68V275.92C248.16 282.48 253.08 285.76 259.64 285.76H282.6C289.16 285.76 292.44 280.84 292.44 274.28V251.32C292.44 244.76 287.52 239.84 280.96 239.84H256.36Z" fill="white"/>
|
|
||||||
</mask>
|
|
||||||
<g mask="url(#mask0_11_12)">
|
|
||||||
<path d="M688 148H782C832 148 842 171.8 847.48 210V303.68L688 148Z" fill="url(#paint2_linear_11_12)"/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="paint0_linear_11_12" x1="176" y1="148" x2="904.706" y2="816.252" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#A855F7"/>
|
|
||||||
<stop offset="0.48" stop-color="#9D4DF4"/>
|
|
||||||
<stop offset="1" stop-color="#7C2AEF"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="paint1_linear_11_12" x1="404.439" y1="431.72" x2="707.314" y2="649.286" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#A855F7"/>
|
|
||||||
<stop offset="0.48" stop-color="#9D4DF4"/>
|
|
||||||
<stop offset="1" stop-color="#7C2AEF"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="paint2_linear_11_12" x1="683.48" y1="144.6" x2="793.636" y2="306.833" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#F2DDFF"/>
|
|
||||||
<stop offset="1" stop-color="#C084FC"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.8 KiB |
35
config.mk
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# 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).
|
||||||
|
|
||||||
|
ifeq ($(OS),Windows_NT)
|
||||||
|
HOST_OS := windows
|
||||||
|
GRADLE ?= ./gradlew.bat
|
||||||
|
else
|
||||||
|
HOST_UNAME := $(shell uname -s)
|
||||||
|
ifeq ($(HOST_UNAME),Darwin)
|
||||||
|
HOST_OS := macos
|
||||||
|
else ifeq ($(HOST_UNAME),Linux)
|
||||||
|
HOST_OS := linux
|
||||||
|
else
|
||||||
|
HOST_OS := unknown
|
||||||
|
endif
|
||||||
|
GRADLE ?= ./gradlew
|
||||||
|
endif
|
||||||
|
|
||||||
|
CARGO ?= cargo
|
||||||
|
NPM ?= npm
|
||||||
|
BUN ?= bun
|
||||||
|
XCODEBUILD ?= xcodebuild
|
||||||
|
XCODEGEN ?= xcodegen
|
||||||
|
OPEN ?= open
|
||||||
|
POWERSHELL ?= pwsh
|
||||||
|
|
||||||
|
VERSION ?= $(shell sed -n 's/^vnidrop.version=//p' $(ROOT)/gradle.properties)
|
||||||
|
APPLE_PROFILE ?= debug
|
||||||
|
APPLE_CONFIGURATION ?= Debug
|
||||||
|
APPLE_DESTINATION ?=
|
||||||
|
APPLE_CODE_SIGNING ?= NO
|
||||||
|
APPLE_DERIVED_DATA ?= $(ROOT)/apple/DerivedData
|
||||||
|
|
||||||
|
GRADLE_FLAGS ?= --no-daemon --stacktrace
|
||||||
|
GRADLE_RELEASE_FLAGS ?= --no-daemon --no-configuration-cache --stacktrace
|
||||||
16
crates/uniffi-bindgen/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "uniffi-bindgen"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
license = "Apache-2.0"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
# Standalone UniFFI bindings generator used to produce the Swift bindings for the
|
||||||
|
# Apple app. It intentionally pins the same UniFFI version as `crates/vnidrop` so
|
||||||
|
# generated bindings never drift from the scaffolding compiled into the library.
|
||||||
|
[[bin]]
|
||||||
|
name = "uniffi-bindgen"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
uniffi = { version = "=0.29.4", features = ["cli"] }
|
||||||
3
crates/uniffi-bindgen/src/main.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
uniffi::uniffi_bindgen_main()
|
||||||
|
}
|
||||||