mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
Compare commits
14 Commits
v1.0.0
...
419c35d6b5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
419c35d6b5 | ||
| 0011dfa175 | |||
|
|
9a3cfb55d0 | ||
| 17099d32f2 | |||
| e35c8c840a | |||
| b1b8fa202c | |||
| bfa489def1 | |||
| ceccfcda71 | |||
| ea376a382b | |||
| 601cbc486e | |||
| c14a2397ae | |||
|
|
6973cc7351 | ||
| ec03f210dc | |||
| d0844068bb |
79
.github/workflows/apple.yml
vendored
Normal file
79
.github/workflows/apple.yml
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
name: Apple
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "apple/**"
|
||||
- "crates/vnidrop/**"
|
||||
- "crates/uniffi-bindgen/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- ".github/workflows/apple.yml"
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- "apple/**"
|
||||
- "crates/vnidrop/**"
|
||||
- "crates/uniffi-bindgen/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- ".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 Rust core (xcframework + Swift bindings)
|
||||
working-directory: apple
|
||||
run: ./scripts/build-core.sh debug
|
||||
|
||||
- name: Generate Xcode project
|
||||
working-directory: apple
|
||||
run: xcodegen generate
|
||||
|
||||
- name: Run unit tests (iOS Simulator)
|
||||
working-directory: apple
|
||||
run: |
|
||||
set -euo pipefail
|
||||
DEVICE=$(xcrun simctl list devices available \
|
||||
| grep -oE 'iPhone [0-9]+( Pro)?' | head -1)
|
||||
echo "Testing on: ${DEVICE:-iPhone 16}"
|
||||
xcodebuild test \
|
||||
-project VniDrop.xcodeproj \
|
||||
-scheme VniDrop \
|
||||
-destination "platform=iOS Simulator,name=${DEVICE:-iPhone 16}" \
|
||||
CODE_SIGNING_ALLOWED=NO
|
||||
344
.github/workflows/linux-packages.yml
vendored
Normal file
344
.github/workflows/linux-packages.yml
vendored
Normal file
@@ -0,0 +1,344 @@
|
||||
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"
|
||||
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: |
|
||||
./gradlew \
|
||||
:shared:jvmTest \
|
||||
:desktopApp:packageReleaseDeb \
|
||||
-Pvnidrop.version=${{ steps.version.outputs.app }} \
|
||||
-Pvnidrop.desktop.rustVariant=release \
|
||||
-Pvnidrop.diagnostics.included=false \
|
||||
--no-daemon \
|
||||
--no-configuration-cache \
|
||||
--stacktrace
|
||||
|
||||
- name: Validate Debian package
|
||||
id: package
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.app }}
|
||||
run: |
|
||||
mapfile -t packages < <(find desktopApp/build/compose/binaries/main-release/deb -maxdepth 1 -type f -name '*.deb')
|
||||
if (( ${#packages[@]} != 1 )); then
|
||||
echo "Expected exactly one Debian package, found ${#packages[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
output_directory=build/release/linux/deb
|
||||
output_name="vnidrop_${VERSION}-1_amd64.deb"
|
||||
mkdir -p "$output_directory"
|
||||
cp "${packages[0]}" "$output_directory/$output_name"
|
||||
packaging/linux/verify-package.sh deb "$VERSION" "$output_directory/$output_name"
|
||||
(
|
||||
cd "$output_directory"
|
||||
sha256sum "$output_name" > "$output_name.sha256"
|
||||
)
|
||||
|
||||
- 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: |
|
||||
./gradlew \
|
||||
:desktopApp:packageReleaseRpm \
|
||||
-Pvnidrop.version=${{ steps.version.outputs.app }} \
|
||||
-Pvnidrop.desktop.rustVariant=release \
|
||||
-Pvnidrop.diagnostics.included=false \
|
||||
--no-daemon \
|
||||
--no-configuration-cache \
|
||||
--stacktrace
|
||||
|
||||
- name: Validate RPM package
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.app }}
|
||||
run: |
|
||||
mapfile -t packages < <(find desktopApp/build/compose/binaries/main-release/rpm -maxdepth 1 -type f -name '*.rpm')
|
||||
if (( ${#packages[@]} != 1 )); then
|
||||
echo "Expected exactly one RPM package, found ${#packages[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
output_directory=build/release/linux/rpm
|
||||
output_name="vnidrop-${VERSION}-1.x86_64.rpm"
|
||||
mkdir -p "$output_directory"
|
||||
cp "${packages[0]}" "$output_directory/$output_name"
|
||||
packaging/linux/verify-package.sh rpm "$VERSION" "$output_directory/$output_name"
|
||||
(
|
||||
cd "$output_directory"
|
||||
sha256sum "$output_name" > "$output_name.sha256"
|
||||
)
|
||||
|
||||
- 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
|
||||
54
Cargo.lock
generated
54
Cargo.lock
generated
@@ -61,6 +61,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.103"
|
||||
@@ -496,6 +502,45 @@ dependencies = [
|
||||
"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]]
|
||||
name = "cmov"
|
||||
version = "0.5.4"
|
||||
@@ -4835,13 +4880,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6d968cb62160c11f2573e6be724ef8b1b18a277aededd17033f8a912d73e2b4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"camino",
|
||||
"cargo_metadata",
|
||||
"clap",
|
||||
"uniffi_bindgen",
|
||||
"uniffi_core",
|
||||
"uniffi_macros",
|
||||
"uniffi_pipeline",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uniffi-bindgen"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"uniffi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uniffi_bindgen"
|
||||
version = "0.29.4"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[workspace]
|
||||
members = ["crates/vnidrop"]
|
||||
members = ["crates/vnidrop", "crates/uniffi-bindgen"]
|
||||
resolver = "2"
|
||||
|
||||
[profile.dev]
|
||||
|
||||
16
apple/.gitignore
vendored
Normal file
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
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"
|
||||
),
|
||||
]
|
||||
)
|
||||
93
apple/README.md
Normal file
93
apple/README.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# 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 and desktop JVM 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
|
||||
# 1. Build the Rust core and generate the Swift bindings + xcframework.
|
||||
apple/scripts/build-core.sh debug # or: release (see note below)
|
||||
|
||||
# 2. Generate the Xcode project.
|
||||
cd apple && xcodegen generate
|
||||
|
||||
# 3. Open and run, or build from the CLI:
|
||||
open VniDrop.xcodeproj
|
||||
# macOS:
|
||||
xcodebuild -project VniDrop.xcodeproj -scheme VniDrop -destination 'platform=macOS' build
|
||||
# iOS simulator:
|
||||
xcodebuild -project VniDrop.xcodeproj -scheme VniDrop \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 15' build
|
||||
```
|
||||
|
||||
## 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.
|
||||
```
|
||||
9
apple/Signing.xcconfig
Normal file
9
apple/Signing.xcconfig
Normal file
@@ -0,0 +1,9 @@
|
||||
// Committed signing config. Contains no secrets.
|
||||
//
|
||||
// Per-developer signing (e.g. DEVELOPMENT_TEAM) goes in Local.xcconfig, which is
|
||||
// gitignored. The optional include below means the build still works for anyone
|
||||
// who doesn't have a Local.xcconfig — Xcode automatic signing fills in their team.
|
||||
//
|
||||
// To persist your team across `xcodegen generate`, create apple/Local.xcconfig:
|
||||
// DEVELOPMENT_TEAM = XXXXXXXXXX
|
||||
#include? "Local.xcconfig"
|
||||
41
apple/Tests/AppModelTests.swift
Normal file
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
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
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
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
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
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
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
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
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
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
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
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
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
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
40
apple/VniDrop/App/VniDropApp.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
import SwiftUI
|
||||
|
||||
/// App entry point for iOS/iPadOS/macOS, ported from `iOSApp.swift` + `App.kt`.
|
||||
/// 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
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
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
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
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
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
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
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
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 }
|
||||
}
|
||||
103
apple/VniDrop/Core/LocalNotificationService.swift
Normal file
103
apple/VniDrop/Core/LocalNotificationService.swift
Normal file
@@ -0,0 +1,103 @@
|
||||
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, ported from `LocalNotificationService.kt` /
|
||||
/// `.ios.kt`, 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
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
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
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
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
146
apple/VniDrop/Features/Receive/ReceiveInvitationActions.swift
Normal file
@@ -0,0 +1,146 @@
|
||||
import SwiftUI
|
||||
|
||||
enum ReceiveMethodAvailability { case available, unavailable, hidden }
|
||||
|
||||
/// Invitation acquisition actions, ported from `ReceiveInvitationActions` (iosMain).
|
||||
@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
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
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
142
apple/VniDrop/Features/Send/FilePreviewRepository.swift
Normal file
142
apple/VniDrop/Features/Send/FilePreviewRepository.swift
Normal file
@@ -0,0 +1,142 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
/// Persisted per-transfer thumbnail store, ported from
|
||||
/// `feature/send/FilePreviewRepository.kt` + `PlatformPreviewStore.ios.kt`.
|
||||
/// 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
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
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
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
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
61
apple/VniDrop/Features/Send/TransferShareActions.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
import SwiftUI
|
||||
|
||||
enum NfcShareAvailability { case available, unavailable, hidden }
|
||||
|
||||
/// Invitation delivery actions, ported from `TransferShareActions` (iosMain).
|
||||
/// 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
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
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
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
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
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
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
98
apple/VniDrop/Platform/FileSystemService+iOS.swift
Normal file
@@ -0,0 +1,98 @@
|
||||
#if os(iOS)
|
||||
import Foundation
|
||||
import UIKit
|
||||
import VnidropCore
|
||||
|
||||
/// iOS file system service, ported from `FileSystemService.ios.kt`.
|
||||
/// 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
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
|
||||
21
apple/VniDrop/Platform/InvitationFile.swift
Normal file
21
apple/VniDrop/Platform/InvitationFile.swift
Normal file
@@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
|
||||
/// Builds a `.vnd` invitation filename from a transfer name, mirroring the iOS
|
||||
/// helper in `TransferShareActions.ios.kt`.
|
||||
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
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
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, ported from `ReceiveInvitationActions.ios.kt`:
|
||||
/// 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
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
|
||||
159
apple/VniDrop/Platform/TransferShareActions+iOS.swift
Normal file
159
apple/VniDrop/Platform/TransferShareActions+iOS.swift
Normal file
@@ -0,0 +1,159 @@
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
@preconcurrency import CoreNFC
|
||||
|
||||
@MainActor
|
||||
func makePlatformShareActions() -> TransferShareActions { IosTransferShareActions() }
|
||||
|
||||
/// iOS invitation delivery, ported from `TransferShareActions.ios.kt`: 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, ported from
|
||||
/// `InvitationNfcWriter` in `TransferShareActions.ios.kt`.
|
||||
// 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
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 }
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
1
apple/VniDrop/Resources/Assets.xcassets/Contents.json
Normal file
1
apple/VniDrop/Resources/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1 @@
|
||||
{ "info" : { "author" : "xcode", "version" : 1 } }
|
||||
100
apple/VniDrop/Resources/Info.plist
Normal file
100
apple/VniDrop/Resources/Info.plist
Normal file
@@ -0,0 +1,100 @@
|
||||
<?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>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>
|
||||
2578
apple/VniDrop/Resources/Localizable.xcstrings
Normal file
2578
apple/VniDrop/Resources/Localizable.xcstrings
Normal file
File diff suppressed because it is too large
Load Diff
24
apple/VniDrop/Resources/VniDrop.entitlements
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 (matches the original iosApp entitlements). -->
|
||||
<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
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
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
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
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
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
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
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
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
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
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
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
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
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.
|
||||
80
apple/project.yml
Normal file
80
apple/project.yml
Normal file
@@ -0,0 +1,80 @@
|
||||
# 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
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
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]
|
||||
sources:
|
||||
- path: Tests
|
||||
settings:
|
||||
base:
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
SWIFT_VERSION: "6.0"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
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
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"
|
||||
16
crates/uniffi-bindgen/Cargo.toml
Normal file
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
3
crates/uniffi-bindgen/src/main.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
uniffi::uniffi_bindgen_main()
|
||||
}
|
||||
@@ -37,11 +37,12 @@ compose.desktop {
|
||||
buildTypes.release.proguard.isEnabled.set(false)
|
||||
|
||||
nativeDistributions {
|
||||
targetFormats(TargetFormat.Dmg, TargetFormat.Deb)
|
||||
targetFormats(TargetFormat.Dmg, TargetFormat.Deb, TargetFormat.Rpm)
|
||||
packageName = "VniDrop"
|
||||
packageVersion = appVersion
|
||||
description = "Send files directly across your devices"
|
||||
vendor = "Sudosy Labs"
|
||||
licenseFile.set(project.file("../LICENSE"))
|
||||
macOS {
|
||||
bundleID = "com.vnidrop.app"
|
||||
iconFile.set(project.file("../assets/macos/app-icon.icns"))
|
||||
@@ -52,6 +53,9 @@ compose.desktop {
|
||||
linux {
|
||||
packageName = "vnidrop"
|
||||
iconFile.set(project.file("../assets/linux/app-icon.png"))
|
||||
debMaintainer = "support@sudosy.fr"
|
||||
appRelease = "1"
|
||||
rpmLicenseType = "Apache-2.0"
|
||||
}
|
||||
fileAssociation(
|
||||
mimeType = "application/vnd.vnidrop.transfer",
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :shared:embedAndSignAppleFrameworkForXcode\n";
|
||||
shellScript = "export PATH=\"$HOME/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:$PATH\"\nexport CARGO_PROFILE_DEV_STRIP=none\nexport JAVA_HOME=$(/usr/libexec/java_home)\n\nif [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :shared:embedAndSignAppleFrameworkForXcode\n";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
@@ -167,6 +167,64 @@
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
5EDFED06EDA142275594F3F7 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReferenceAnchor = B93EE283488EDD1107331E67 /* Configuration */;
|
||||
baseConfigurationReferenceRelativePath = Config.xcconfig;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
6A37768B9DA3138100D19D47 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReferenceAnchor = B93EE283488EDD1107331E67 /* Configuration */;
|
||||
@@ -232,64 +290,6 @@
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
5EDFED06EDA142275594F3F7 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReferenceAnchor = B93EE283488EDD1107331E67 /* Configuration */;
|
||||
baseConfigurationReferenceRelativePath = Config.xcconfig;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
87E567D3634C99D02D035476 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -300,10 +300,11 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = "${TEAM_ID}";
|
||||
DEVELOPMENT_TEAM = A8A4JSMV5D;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = iosApp/Info.plist;
|
||||
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
|
||||
INFOPLIST_KEY_NFCReaderUsageDescription = "VniDrop uses NFC to read transfer invitation tags.";
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "VniDrop uses the camera to scan transfer QR codes.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
@@ -342,10 +343,11 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = "${TEAM_ID}";
|
||||
DEVELOPMENT_TEAM = A8A4JSMV5D;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = iosApp/Info.plist;
|
||||
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
|
||||
INFOPLIST_KEY_NFCReaderUsageDescription = "VniDrop uses NFC to read transfer invitation tags.";
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "VniDrop uses the camera to scan transfer QR codes.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
@@ -377,15 +379,6 @@
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
FE990A962A8E8AC95D51FA0E /* Build configuration list for PBXProject "iosApp" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
6A37768B9DA3138100D19D47 /* Debug */,
|
||||
5EDFED06EDA142275594F3F7 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
94A2E337CA60E8CC4A507E7E /* Build configuration list for PBXNativeTarget "iosApp" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
@@ -395,6 +388,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
FE990A962A8E8AC95D51FA0E /* Build configuration list for PBXProject "iosApp" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
6A37768B9DA3138100D19D47 /* Debug */,
|
||||
5EDFED06EDA142275594F3F7 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = F60687F0AEE04DF31E2599D0 /* Project object */;
|
||||
|
||||
@@ -19,6 +19,14 @@
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
<key>UTExportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict>
|
||||
@@ -51,5 +59,11 @@
|
||||
<string>VniDrop uses NFC to read transfer invitation tags.</string>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>VniDrop needs local network access to send to other local devices if needed.</string>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string></string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
84
packaging/linux/README.md
Normal file
84
packaging/linux/README.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Linux packaging
|
||||
|
||||
VniDrop ships native x64 packages for the two common Linux package families:
|
||||
|
||||
- Debian/Ubuntu: `.deb`
|
||||
- Current Fedora systems: `.rpm`
|
||||
|
||||
Both packages contain the application, its release Rust library, and a private
|
||||
Java runtime. Users do not need to install Java separately. The packages are
|
||||
currently distributed as direct GitHub Release downloads, so they use SHA-256
|
||||
checksums rather than a Linux repository signing key. A future APT or RPM
|
||||
repository should add repository metadata signing and its own update channel.
|
||||
|
||||
## GitHub Actions
|
||||
|
||||
The Linux packages workflow runs for relevant pull requests, release tags
|
||||
matching `vMAJOR.MINOR.PATCH`, and manual dispatches. Each native package is
|
||||
built and validated on its matching distribution family:
|
||||
|
||||
- `.deb` on Ubuntu 22.04 for a conservative glibc baseline
|
||||
- `.rpm` inside Fedora 43 so `jpackage` can discover normal RPM dependencies
|
||||
|
||||
The shared JVM suite runs inside the Debian build job. Package construction and
|
||||
payload validation happen in both build jobs, so there is no separate test
|
||||
runner. Pull requests build and verify both packages but do not retain
|
||||
artifacts. Manual runs retain build artifacts for 14 days. A pushed version tag
|
||||
whose commit is on `master` creates the matching GitHub Release with the `.deb`,
|
||||
`.rpm`, and a combined `SHA256SUMS`.
|
||||
|
||||
The existing `v1.0.0` tag predates this workflow and will not run it
|
||||
retroactively. Use the next version tag after this configuration reaches
|
||||
`master`.
|
||||
|
||||
## Install a downloaded package
|
||||
|
||||
Verify downloads from the directory containing all three release files:
|
||||
|
||||
```bash
|
||||
sha256sum -c SHA256SUMS
|
||||
```
|
||||
|
||||
On Debian or Ubuntu:
|
||||
|
||||
```bash
|
||||
sudo apt install ./vnidrop_VERSION-1_amd64.deb
|
||||
```
|
||||
|
||||
On Fedora:
|
||||
|
||||
```bash
|
||||
sudo dnf install ./vnidrop-VERSION-1.x86_64.rpm
|
||||
```
|
||||
|
||||
The package manager installs declared system-library dependencies and creates
|
||||
the VniDrop desktop launcher. Uninstall with `sudo apt remove vnidrop` or
|
||||
`sudo dnf remove vnidrop`.
|
||||
|
||||
## Manual native builds
|
||||
|
||||
Use JDK 21 and Rust 1.91. Build DEB packages on Debian/Ubuntu with `dpkg` and
|
||||
`fakeroot`; build RPM packages on Fedora with `rpm-build`. Building an RPM on
|
||||
Ubuntu prevents `jpackage` from discovering normal RPM dependencies.
|
||||
|
||||
From the repository root on the matching Linux family, run one of:
|
||||
|
||||
```bash
|
||||
./gradlew :shared:jvmTest :desktopApp:packageReleaseDeb \
|
||||
-Pvnidrop.version=1.0.0 \
|
||||
-Pvnidrop.desktop.rustVariant=release \
|
||||
-Pvnidrop.diagnostics.included=false \
|
||||
--no-daemon --no-configuration-cache --stacktrace
|
||||
|
||||
./gradlew :shared:jvmTest :desktopApp:packageReleaseRpm \
|
||||
-Pvnidrop.version=1.0.0 \
|
||||
-Pvnidrop.desktop.rustVariant=release \
|
||||
-Pvnidrop.diagnostics.included=false \
|
||||
--no-daemon --no-configuration-cache --stacktrace
|
||||
```
|
||||
|
||||
Compose writes the packages under
|
||||
`desktopApp/build/compose/binaries/main-release/deb/` and
|
||||
`desktopApp/build/compose/binaries/main-release/rpm/`. The workflow then
|
||||
validates package identity, version, architecture, dependencies, bundled JVM,
|
||||
and release Rust payload before publishing anything.
|
||||
39
packaging/linux/resolve-version.sh
Executable file
39
packaging/linux/resolve-version.sh
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
version=${1:-1.0.0}
|
||||
|
||||
if [[ ${GITHUB_REF_TYPE:-} == "tag" ]]; then
|
||||
if [[ ! ${GITHUB_REF_NAME:-} =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Linux release tags must use vMAJOR.MINOR.PATCH" >&2
|
||||
exit 1
|
||||
fi
|
||||
version=${GITHUB_REF_NAME#v}
|
||||
fi
|
||||
|
||||
if [[ ! $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Version must use MAJOR.MINOR.PATCH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
IFS=. read -r major minor patch <<< "$version"
|
||||
parts=("$major" "$minor" "$patch")
|
||||
|
||||
for index in "${!parts[@]}"; do
|
||||
part=${parts[$index]}
|
||||
if [[ $part != "0" && $part == 0* ]]; then
|
||||
echo "Version components must be canonical integers without leading zeroes" >&2
|
||||
exit 1
|
||||
fi
|
||||
if (( ${#part} > 5 )) || (( 10#$part > 65535 )); then
|
||||
echo "Version components must be between 0 and 65535" >&2
|
||||
exit 1
|
||||
fi
|
||||
if (( index == 0 && 10#$part == 0 )); then
|
||||
echo "The major version must be non-zero" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
printf '%s\n' "$version"
|
||||
96
packaging/linux/verify-package.sh
Executable file
96
packaging/linux/verify-package.sh
Executable file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if (( $# != 3 )); then
|
||||
echo "Usage: $0 <deb|rpm> <MAJOR.MINOR.PATCH> <package-path>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
format=$1
|
||||
version=$2
|
||||
package_path=$3
|
||||
|
||||
fail() {
|
||||
echo "Package verification failed: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_command() {
|
||||
command -v "$1" >/dev/null 2>&1 || fail "required command '$1' is not installed"
|
||||
}
|
||||
|
||||
[[ -f $package_path ]] || fail "package not found: $package_path"
|
||||
require_command find
|
||||
require_command unzip
|
||||
|
||||
extract_root=$(mktemp -d)
|
||||
trap 'rm -rf "$extract_root"' EXIT
|
||||
|
||||
case $format in
|
||||
deb)
|
||||
require_command dpkg-deb
|
||||
[[ $(dpkg-deb -f "$package_path" Package) == "vnidrop" ]] || fail "unexpected Debian package name"
|
||||
[[ $(dpkg-deb -f "$package_path" Version) == "$version-1" ]] || fail "unexpected Debian package version"
|
||||
[[ $(dpkg-deb -f "$package_path" Architecture) == "amd64" ]] || fail "unexpected Debian architecture"
|
||||
[[ $(dpkg-deb -f "$package_path" Maintainer) == *"support@sudosy.fr"* ]] || fail "unexpected Debian maintainer"
|
||||
deb_dependencies=$(dpkg-deb -f "$package_path" Depends)
|
||||
deb_libc_pattern='(^|,[[:space:]])libc6([[:space:](]|,|$)'
|
||||
deb_xdg_pattern='(^|,[[:space:]])xdg-utils([[:space:](]|,|$)'
|
||||
[[ $deb_dependencies =~ $deb_libc_pattern ]] || fail "Debian package does not declare libc6"
|
||||
[[ $deb_dependencies =~ $deb_xdg_pattern ]] || fail "Debian package does not declare xdg-utils"
|
||||
dpkg-deb -x "$package_path" "$extract_root"
|
||||
;;
|
||||
rpm)
|
||||
require_command rpm
|
||||
require_command rpm2cpio
|
||||
require_command cpio
|
||||
metadata=$(rpm -qp --queryformat '%{NAME}\n%{VERSION}\n%{RELEASE}\n%{ARCH}\n%{LICENSE}\n' "$package_path")
|
||||
mapfile -t fields <<< "$metadata"
|
||||
[[ ${fields[0]:-} == "vnidrop" ]] || fail "unexpected RPM package name"
|
||||
[[ ${fields[1]:-} == "$version" ]] || fail "unexpected RPM package version"
|
||||
[[ ${fields[2]:-} == "1" ]] || fail "unexpected RPM release"
|
||||
[[ ${fields[3]:-} == "x86_64" ]] || fail "unexpected RPM architecture"
|
||||
[[ ${fields[4]:-} == "Apache-2.0" ]] || fail "unexpected RPM license"
|
||||
mapfile -t runtime_requirements < <(rpm -qpR "$package_path" | grep -Ev '^(rpmlib\(|/bin/sh$)' || true)
|
||||
printf '%s\n' "${runtime_requirements[@]}" | grep -Eq '^(glibc($|[[:space:]])|libc\.so\.6)' || fail "RPM package does not declare glibc"
|
||||
printf '%s\n' "${runtime_requirements[@]}" | grep -Fxq 'xdg-utils' || fail "RPM package does not declare xdg-utils"
|
||||
rpm2cpio "$package_path" | (
|
||||
cd "$extract_root"
|
||||
cpio -idm --quiet
|
||||
)
|
||||
;;
|
||||
*)
|
||||
fail "unsupported package format: $format"
|
||||
;;
|
||||
esac
|
||||
|
||||
mapfile -d '' -t launchers < <(find "$extract_root" -type f -iname 'vnidrop' -perm /111 -print0)
|
||||
(( ${#launchers[@]} == 1 )) || fail "expected exactly one executable VniDrop launcher"
|
||||
|
||||
mapfile -d '' -t desktop_entries < <(find "$extract_root" -type f -name '*.desktop' -print0)
|
||||
(( ${#desktop_entries[@]} == 1 )) || fail "expected exactly one desktop entry"
|
||||
grep -Eiq '^Exec=.*/VniDrop([[:space:]]|$)' "${desktop_entries[0]}" || fail "desktop entry does not launch VniDrop"
|
||||
grep -Eq '^MimeType=.*application/vnd\.vnidrop\.transfer(;|$)' "${desktop_entries[0]}" || fail "desktop entry does not register VniDrop invitations"
|
||||
|
||||
mapfile -d '' -t bundled_jvms < <(find "$extract_root" -type f -path '*/lib/runtime/lib/server/libjvm.so' -print0)
|
||||
(( ${#bundled_jvms[@]} == 1 )) || fail "expected exactly one bundled JVM"
|
||||
|
||||
mapfile -d '' -t debug_rust_jars < <(find "$extract_root" -type f -name 'shared-linux-x86-64-debug-*.jar' -print0)
|
||||
(( ${#debug_rust_jars[@]} == 0 )) || fail "package contains a debug Rust runtime JAR"
|
||||
|
||||
mapfile -d '' -t rust_jars < <(
|
||||
find "$extract_root" -type f -name 'shared-linux-x86-64-*.jar' ! -name 'shared-linux-x86-64-debug-*.jar' -print0
|
||||
)
|
||||
(( ${#rust_jars[@]} == 1 )) || fail "expected exactly one release Rust runtime JAR"
|
||||
|
||||
native_entry_size=$(unzip -p "${rust_jars[0]}" 'linux-x86-64/libvnidrop.so' | wc -c)
|
||||
[[ $native_entry_size =~ ^[0-9]+$ ]] || fail "release Rust runtime JAR does not contain libvnidrop.so"
|
||||
(( native_entry_size > 0 )) || fail "release Rust runtime JAR contains an empty libvnidrop.so"
|
||||
|
||||
mapfile -d '' -t shared_jars < <(find "$extract_root" -type f -name 'shared-jvm-*.jar' -print0)
|
||||
(( ${#shared_jars[@]} == 1 )) || fail "expected exactly one shared JVM JAR"
|
||||
unzip -p "${shared_jars[0]}" META-INF/MANIFEST.MF | tr -d '\r' |
|
||||
grep -Fxq "Implementation-Version: $version" || fail "packaged app version does not match $version"
|
||||
|
||||
printf 'Verified %s package: %s\n' "$format" "$package_path"
|
||||
Reference in New Issue
Block a user