Merge pull request #23 from sudosylabs/feat/remove-apple-kmp-targets
refactor(platform): move Apple apps out of KMP
28
.github/workflows/apple.yml
vendored
@@ -8,6 +8,9 @@ on:
|
|||||||
- "crates/uniffi-bindgen/**"
|
- "crates/uniffi-bindgen/**"
|
||||||
- "Cargo.toml"
|
- "Cargo.toml"
|
||||||
- "Cargo.lock"
|
- "Cargo.lock"
|
||||||
|
- "Makefile"
|
||||||
|
- "config.mk"
|
||||||
|
- "make/**"
|
||||||
- ".github/workflows/apple.yml"
|
- ".github/workflows/apple.yml"
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
@@ -18,6 +21,9 @@ on:
|
|||||||
- "crates/uniffi-bindgen/**"
|
- "crates/uniffi-bindgen/**"
|
||||||
- "Cargo.toml"
|
- "Cargo.toml"
|
||||||
- "Cargo.lock"
|
- "Cargo.lock"
|
||||||
|
- "Makefile"
|
||||||
|
- "config.mk"
|
||||||
|
- "make/**"
|
||||||
- ".github/workflows/apple.yml"
|
- ".github/workflows/apple.yml"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -57,23 +63,5 @@ jobs:
|
|||||||
- name: Install XcodeGen
|
- name: Install XcodeGen
|
||||||
run: brew install xcodegen
|
run: brew install xcodegen
|
||||||
|
|
||||||
- name: Build Rust core (xcframework + Swift bindings)
|
- name: Build and test Apple app
|
||||||
working-directory: apple
|
run: make check-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
|
|
||||||
|
|||||||
25
.github/workflows/diagnostics-api.yml
vendored
@@ -4,12 +4,18 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- "services/diagnostics-api/**"
|
- "services/diagnostics-api/**"
|
||||||
|
- "Makefile"
|
||||||
|
- "config.mk"
|
||||||
|
- "make/**"
|
||||||
- ".github/workflows/diagnostics-api.yml"
|
- ".github/workflows/diagnostics-api.yml"
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
paths:
|
paths:
|
||||||
- "services/diagnostics-api/**"
|
- "services/diagnostics-api/**"
|
||||||
|
- "Makefile"
|
||||||
|
- "config.mk"
|
||||||
|
- "make/**"
|
||||||
- ".github/workflows/diagnostics-api.yml"
|
- ".github/workflows/diagnostics-api.yml"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -23,9 +29,6 @@ jobs:
|
|||||||
quality:
|
quality:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: services/diagnostics-api
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -37,17 +40,5 @@ jobs:
|
|||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: services/diagnostics-api/package-lock.json
|
cache-dependency-path: services/diagnostics-api/package-lock.json
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Check diagnostics API
|
||||||
run: npm ci
|
run: make check-diagnostics
|
||||||
|
|
||||||
- name: Verify generated Worker types
|
|
||||||
run: npm run types:check
|
|
||||||
|
|
||||||
- name: Type-check
|
|
||||||
run: npm run typecheck
|
|
||||||
|
|
||||||
- name: Test in the Workers runtime
|
|
||||||
run: npm test
|
|
||||||
|
|
||||||
- name: Validate the deployment bundle
|
|
||||||
run: npm run deploy:dry-run
|
|
||||||
|
|||||||
19
.github/workflows/docs.yml
vendored
@@ -4,12 +4,18 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- "docs/**"
|
- "docs/**"
|
||||||
|
- "Makefile"
|
||||||
|
- "config.mk"
|
||||||
|
- "make/**"
|
||||||
- ".github/workflows/docs.yml"
|
- ".github/workflows/docs.yml"
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
paths:
|
paths:
|
||||||
- "docs/**"
|
- "docs/**"
|
||||||
|
- "Makefile"
|
||||||
|
- "config.mk"
|
||||||
|
- "make/**"
|
||||||
- ".github/workflows/docs.yml"
|
- ".github/workflows/docs.yml"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -23,9 +29,6 @@ jobs:
|
|||||||
quality:
|
quality:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: docs
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -37,11 +40,5 @@ jobs:
|
|||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: docs/package-lock.json
|
cache-dependency-path: docs/package-lock.json
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Check documentation website
|
||||||
run: npm ci
|
run: make check-docs
|
||||||
|
|
||||||
- name: Type-check
|
|
||||||
run: npm run typecheck
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: npm run build
|
|
||||||
|
|||||||
65
.github/workflows/linux-packages.yml
vendored
@@ -17,6 +17,9 @@ on:
|
|||||||
- "gradle.properties"
|
- "gradle.properties"
|
||||||
- "gradle/**"
|
- "gradle/**"
|
||||||
- "gradlew"
|
- "gradlew"
|
||||||
|
- "Makefile"
|
||||||
|
- "config.mk"
|
||||||
|
- "make/**"
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v*.*.*"
|
- "v*.*.*"
|
||||||
@@ -92,37 +95,7 @@ jobs:
|
|||||||
echo "app=$version" >> "$GITHUB_OUTPUT"
|
echo "app=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Test and build Debian package
|
- name: Test and build Debian package
|
||||||
run: |
|
run: make package-deb VERSION=${{ steps.version.outputs.app }}
|
||||||
./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
|
- name: Upload Debian artifact
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
@@ -225,35 +198,7 @@ jobs:
|
|||||||
echo "app=$version" >> "$GITHUB_OUTPUT"
|
echo "app=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Build RPM package
|
- name: Build RPM package
|
||||||
run: |
|
run: make package-rpm VERSION=${{ steps.version.outputs.app }}
|
||||||
./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
|
- name: Upload RPM artifact
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
|
|||||||
20
.github/workflows/rust-core.yml
vendored
@@ -6,6 +6,9 @@ on:
|
|||||||
- "Cargo.toml"
|
- "Cargo.toml"
|
||||||
- "Cargo.lock"
|
- "Cargo.lock"
|
||||||
- "crates/vnidrop/**"
|
- "crates/vnidrop/**"
|
||||||
|
- "Makefile"
|
||||||
|
- "config.mk"
|
||||||
|
- "make/**"
|
||||||
- ".github/workflows/rust-core.yml"
|
- ".github/workflows/rust-core.yml"
|
||||||
push:
|
push:
|
||||||
# Only after merge (or direct master pushes). Feature-branch work is covered
|
# Only after merge (or direct master pushes). Feature-branch work is covered
|
||||||
@@ -16,6 +19,9 @@ on:
|
|||||||
- "Cargo.toml"
|
- "Cargo.toml"
|
||||||
- "Cargo.lock"
|
- "Cargo.lock"
|
||||||
- "crates/vnidrop/**"
|
- "crates/vnidrop/**"
|
||||||
|
- "Makefile"
|
||||||
|
- "config.mk"
|
||||||
|
- "make/**"
|
||||||
- ".github/workflows/rust-core.yml"
|
- ".github/workflows/rust-core.yml"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -33,18 +39,10 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Install Rust quality components
|
- name: Install Rust quality components
|
||||||
run: rustup component add clippy rustfmt
|
run: rustup component add clippy rustfmt
|
||||||
- name: Check formatting
|
- name: Check Rust core
|
||||||
run: cargo fmt --all -- --check
|
run: make check-rust
|
||||||
- name: Run strict Clippy
|
|
||||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
|
||||||
- name: Run unit and integration tests
|
|
||||||
run: cargo test --workspace --all-targets
|
|
||||||
- name: Check documentation
|
|
||||||
env:
|
|
||||||
RUSTDOCFLAGS: -D warnings
|
|
||||||
run: cargo doc --workspace --no-deps
|
|
||||||
- name: Install cargo-audit
|
- name: Install cargo-audit
|
||||||
run: cargo install cargo-audit --locked
|
run: cargo install cargo-audit --locked
|
||||||
- name: Audit Rust dependencies
|
- name: Audit Rust dependencies
|
||||||
# Ignores are listed in .cargo/audit.toml for known transitive issues.
|
# Ignores are listed in .cargo/audit.toml for known transitive issues.
|
||||||
run: cargo audit
|
run: make audit-rust
|
||||||
|
|||||||
15
.github/workflows/shared-kmp.yml
vendored
@@ -15,6 +15,9 @@ on:
|
|||||||
- "gradle.properties"
|
- "gradle.properties"
|
||||||
- "androidApp/**"
|
- "androidApp/**"
|
||||||
- "desktopApp/**"
|
- "desktopApp/**"
|
||||||
|
- "Makefile"
|
||||||
|
- "config.mk"
|
||||||
|
- "make/**"
|
||||||
- ".github/workflows/shared-kmp.yml"
|
- ".github/workflows/shared-kmp.yml"
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
@@ -32,6 +35,9 @@ on:
|
|||||||
- "gradle.properties"
|
- "gradle.properties"
|
||||||
- "androidApp/**"
|
- "androidApp/**"
|
||||||
- "desktopApp/**"
|
- "desktopApp/**"
|
||||||
|
- "Makefile"
|
||||||
|
- "config.mk"
|
||||||
|
- "make/**"
|
||||||
- ".github/workflows/shared-kmp.yml"
|
- ".github/workflows/shared-kmp.yml"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -44,8 +50,7 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
jvm-test:
|
jvm-test:
|
||||||
# Host Rust embedding is enabled only for the current Gobley host target.
|
# Host Rust embedding is enabled only for the current Gobley host target.
|
||||||
# This job stays on macOS to cover the Apple targets as well as JVM tests.
|
runs-on: ubuntu-latest
|
||||||
runs-on: macos-latest
|
|
||||||
timeout-minutes: 75
|
timeout-minutes: 75
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -63,7 +68,7 @@ jobs:
|
|||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
targets: aarch64-apple-darwin,aarch64-linux-android,x86_64-linux-android
|
targets: aarch64-linux-android,x86_64-linux-android
|
||||||
|
|
||||||
- name: Cache Cargo
|
- name: Cache Cargo
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -95,7 +100,7 @@ jobs:
|
|||||||
echo "ANDROID_NDK_ROOT=${{ steps.setup-ndk.outputs.ndk-path }}" >> "$GITHUB_ENV"
|
echo "ANDROID_NDK_ROOT=${{ steps.setup-ndk.outputs.ndk-path }}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Run shared JVM tests
|
- name: Run shared JVM tests
|
||||||
run: ./gradlew :shared:jvmTest --no-daemon --stacktrace
|
run: make check-shared
|
||||||
|
|
||||||
- name: Verify Android native libraries
|
- name: Verify Android native libraries
|
||||||
run: ./gradlew :androidApp:verifyDebugVnidropLibraries --no-daemon --stacktrace
|
run: make verify-android-libs
|
||||||
|
|||||||
1
.gitignore
vendored
@@ -19,6 +19,7 @@ captures
|
|||||||
node_modules/
|
node_modules/
|
||||||
target/
|
target/
|
||||||
.junie
|
.junie
|
||||||
|
config.override.mk
|
||||||
|
|
||||||
# Local design export scratch
|
# Local design export scratch
|
||||||
output/
|
output/
|
||||||
|
|||||||
60
AGENTS.md
@@ -13,13 +13,14 @@ Nested guides take precedence when editing under those trees:
|
|||||||
|
|
||||||
## Project overview
|
## Project overview
|
||||||
|
|
||||||
VniDrop is a cross-platform **local P2P file transfer** app (Android, iOS, Desktop).
|
VniDrop is a cross-platform **local P2P file transfer** app.
|
||||||
|
|
||||||
| Layer | Path | Responsibility |
|
| Layer | Path | Responsibility |
|
||||||
|-------|------|----------------|
|
|-------|------|----------------|
|
||||||
| Rust core | `crates/vnidrop/` | Iroh endpoint, blobs, SQLite, tickets, approval, streaming |
|
| Rust core | `crates/vnidrop/` | Iroh endpoint, blobs, SQLite, tickets, approval, streaming |
|
||||||
| Shared KMP | `shared/` | Compose UI, ViewModels, expect/actual platform bridges |
|
| Shared KMP | `shared/` | Compose UI and platform bridges for Android, Windows, and Linux |
|
||||||
| Hosts | `androidApp/`, `iosApp/`, `desktopApp/` | Thin app shells |
|
| Compose hosts | `androidApp/`, `desktopApp/` | Thin Android and Windows/Linux app shells |
|
||||||
|
| Apple app | `apple/` | Native SwiftUI UI using generated Rust/UniFFI Swift bindings |
|
||||||
|
|
||||||
**Invariant:** UI/platform opens files and handles pickers; **Rust streams bytes**.
|
**Invariant:** UI/platform opens files and handles pickers; **Rust streams bytes**.
|
||||||
Do not design features that move transfer payloads through Kotlin heap by default.
|
Do not design features that move transfer payloads through Kotlin heap by default.
|
||||||
@@ -52,67 +53,62 @@ Domain docs (reference, do not paste into PRs):
|
|||||||
|
|
||||||
## Build and test
|
## Build and test
|
||||||
|
|
||||||
Install prerequisites when missing: Rust stable + rustfmt + clippy, JDK 17,
|
Install prerequisites when missing: GNU Make + Bash, Rust stable + rustfmt + clippy, JDK 17,
|
||||||
Android NDK/SDK only if building Android, Xcode only for iOS.
|
Android NDK/SDK only if building Android, Xcode only for the native Apple app.
|
||||||
|
|
||||||
### Rust core (`crates/vnidrop` or workspace root)
|
### Rust core (`crates/vnidrop` or workspace root)
|
||||||
|
|
||||||
Run from the **repo root** (Cargo workspace):
|
Run from the **repo root** (Cargo workspace):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo fmt --all -- --check
|
make check-rust
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
|
||||||
cargo test --workspace --all-targets
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Focused:
|
Focused:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo test -p vnidrop
|
make test-rust
|
||||||
cargo test -p vnidrop --test output_sink
|
make test-rust-output-sink
|
||||||
cargo test -p vnidrop --test transfer
|
make test-rust-transfer
|
||||||
cargo test -p vnidrop --test approval
|
make test-rust-approval
|
||||||
cargo test -p vnidrop --test lifecycle
|
make test-rust-lifecycle
|
||||||
```
|
```
|
||||||
|
|
||||||
After finishing Rust edits, format:
|
After finishing Rust edits, format:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo fmt --all
|
make format
|
||||||
```
|
```
|
||||||
|
|
||||||
CI also runs `cargo doc --workspace --no-deps` with `RUSTDOCFLAGS=-D warnings`
|
`make check-rust` includes documentation with warnings denied, matching
|
||||||
(see `.github/workflows/rust-core.yml`). Run it before large Rust public-API changes.
|
`.github/workflows/rust-core.yml`.
|
||||||
|
|
||||||
### Shared KMP / Compose (`shared/`)
|
### Shared KMP / Compose (`shared/`)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gradlew :shared:jvmTest
|
make check-shared
|
||||||
./gradlew :shared:compileKotlinJvm
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Other targets (slower / machine-dependent):
|
Other targets (slower / machine-dependent):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gradlew :shared:testAndroidHostTest
|
make test-android-host
|
||||||
./gradlew :shared:iosSimulatorArm64Test # macOS + Xcode
|
make check-android
|
||||||
./gradlew :androidApp:assembleDebug
|
make run-desktop
|
||||||
./gradlew :desktopApp:run
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** `jvmTest` CI currently runs on **macOS**. Gobley host cargo is enabled
|
**Note:** `jvmTest` CI runs on **Linux**. Gobley host cargo is enabled for the
|
||||||
for the current host and architecture, so local Linux and Windows builds embed
|
current host and architecture, so local desktop builds embed their matching
|
||||||
their matching desktop Rust library. Prefer macOS only when exact CI parity is
|
Rust library.
|
||||||
required.
|
|
||||||
|
|
||||||
### What to run before finishing
|
### What to run before finishing
|
||||||
|
|
||||||
| You changed… | Minimum verification |
|
| You changed… | Minimum verification |
|
||||||
|--------------|----------------------|
|
|--------------|----------------------|
|
||||||
| `crates/vnidrop/**` only | `cargo fmt`, `cargo clippy … -D warnings`, `cargo test -p vnidrop` |
|
| `crates/vnidrop/**` only | `make check-rust` |
|
||||||
| Cancel / export / sinks | Above + `cargo test -p vnidrop --test output_sink` |
|
| Cancel / export / sinks | Above + `make test-rust-output-sink` |
|
||||||
| `shared/**` only | `./gradlew :shared:jvmTest` |
|
| `shared/**` only | `make test-shared` |
|
||||||
| Both | Rust suite + `:shared:jvmTest` |
|
| Both | `make test-rust test-shared` |
|
||||||
| Docs only | No suite required; verify links/paths |
|
| Docs only | No suite required; verify links/paths |
|
||||||
|
|
||||||
Do not kill long `cargo` / Gradle runs mid-flight unless they hang past several
|
Do not kill long `cargo` / Gradle runs mid-flight unless they hang past several
|
||||||
@@ -144,12 +140,12 @@ shared/src/commonMain/kotlin/com/vnidrop/app/
|
|||||||
core/ # CoreGateway, models, pickers interfaces
|
core/ # CoreGateway, models, pickers interfaces
|
||||||
feature/send|receive|approvals|settings|app/
|
feature/send|receive|approvals|settings|app/
|
||||||
ui/theme|components|navigation|feedback|state/
|
ui/theme|components|navigation|feedback|state/
|
||||||
androidMain|iosMain|jvmMain/ # expect/actual implementations
|
androidMain|jvmMain/ # expect/actual implementations
|
||||||
```
|
```
|
||||||
|
|
||||||
### Platform file rules (do not violate)
|
### Platform file rules (do not violate)
|
||||||
|
|
||||||
- Desktop / path-based iOS: paths; directory walk in Rust when `is_directory`.
|
- Windows/Linux desktop: paths; directory walk in Rust when `is_directory`.
|
||||||
- Android **share**: ParcelFileDescriptor **file** FDs only — never a directory FD.
|
- Android **share**: ParcelFileDescriptor **file** FDs only — never a directory FD.
|
||||||
Folder share expands SAF trees in Kotlin to per-file FDs + relative names.
|
Folder share expands SAF trees in Kotlin to per-file FDs + relative names.
|
||||||
- Android **receive** default: MediaStore Downloads sink; custom trees via SAF write.
|
- Android **receive** default: MediaStore Downloads sink; custom trees via SAF write.
|
||||||
|
|||||||
@@ -35,24 +35,44 @@ Use a branch name that describes the outcome, such as
|
|||||||
|
|
||||||
Install the tools needed for the area you plan to change:
|
Install the tools needed for the area you plan to change:
|
||||||
|
|
||||||
|
- GNU Make and Bash for the root command interface
|
||||||
- JDK 17 or newer for Gradle and application builds
|
- JDK 17 or newer for Gradle and application builds
|
||||||
- Rust stable with `rustfmt` and Clippy for the transfer core
|
- Rust stable with `rustfmt` and Clippy for the transfer core
|
||||||
- Android SDK and NDK for Android builds
|
- Android SDK and NDK for Android builds
|
||||||
- Xcode on macOS for iOS builds and simulator tests
|
- Xcode and XcodeGen on macOS for native Apple builds and simulator tests
|
||||||
- Node.js 22.12 or newer for the optional diagnostics service
|
- Node.js 22.12 or newer for the optional diagnostics service
|
||||||
|
|
||||||
The first Rust and Gradle builds may take several minutes while dependencies are
|
The first Rust and Gradle builds may take several minutes while dependencies are
|
||||||
downloaded and native components are compiled.
|
downloaded and native components are compiled.
|
||||||
|
|
||||||
|
## Command Interface
|
||||||
|
|
||||||
|
Run development commands through the root `Makefile`. It keeps local and CI
|
||||||
|
commands aligned while continuing to delegate builds to Cargo, Gradle, Xcode,
|
||||||
|
Bun, and npm:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make help # list commands
|
||||||
|
make doctor # report missing host tools
|
||||||
|
make setup # install repository-local JavaScript dependencies
|
||||||
|
make check # portable Rust, shared, localization, docs, and service checks
|
||||||
|
```
|
||||||
|
|
||||||
|
Configuration can be passed on the command line, for example
|
||||||
|
`make package-deb VERSION=1.2.0`, or placed in an ignored
|
||||||
|
`config.override.mk`. Windows use requires GNU Make in a Bash environment; the
|
||||||
|
underlying Gradle and PowerShell entry points remain available when Make is not
|
||||||
|
installed.
|
||||||
|
|
||||||
## Repository Structure
|
## Repository Structure
|
||||||
|
|
||||||
| Path | Purpose |
|
| Path | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `crates/vnidrop/` | Rust transfer core, persistence, approval, and streaming |
|
| `crates/vnidrop/` | Rust transfer core, persistence, approval, and streaming |
|
||||||
| `shared/` | Shared Kotlin Multiplatform UI and platform bridges |
|
| `shared/` | Compose Multiplatform UI and bridges for Android, Windows, and Linux |
|
||||||
| `androidApp/` | Android application shell |
|
| `androidApp/` | Android application shell |
|
||||||
| `iosApp/` | iOS application shell |
|
| `desktopApp/` | Windows/Linux JVM application shell |
|
||||||
| `desktopApp/` | Desktop JVM application shell |
|
| `apple/` | Native SwiftUI application and Rust/UniFFI integration for Apple platforms |
|
||||||
| `services/diagnostics-api/` | Optional Cloudflare diagnostics service |
|
| `services/diagnostics-api/` | Optional Cloudflare diagnostics service |
|
||||||
|
|
||||||
Read the nearest contributor guidance before editing:
|
Read the nearest contributor guidance before editing:
|
||||||
@@ -87,43 +107,48 @@ Run checks from the repository root. Choose the suite for the files you changed.
|
|||||||
### Rust Core
|
### Rust Core
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo fmt --all
|
make format
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
make test-rust
|
||||||
cargo test -p vnidrop
|
|
||||||
```
|
```
|
||||||
|
|
||||||
For cancel, export, or output-sink changes, also run:
|
For cancel, export, or output-sink changes, also run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo test -p vnidrop --test output_sink
|
make test-rust-output-sink
|
||||||
```
|
```
|
||||||
|
|
||||||
For broader core changes, run the complete workspace suite:
|
For broader core changes, run the complete workspace suite:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo test --workspace --all-targets
|
make check-rust
|
||||||
```
|
```
|
||||||
|
|
||||||
### Shared Kotlin and Compose
|
### Shared Kotlin and Compose
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gradlew :shared:jvmTest
|
make test-shared
|
||||||
```
|
```
|
||||||
|
|
||||||
Platform-specific checks may also be appropriate:
|
Platform-specific checks may also be appropriate:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gradlew :shared:testAndroidHostTest
|
make test-android-host
|
||||||
./gradlew :shared:iosSimulatorArm64Test
|
make check-android
|
||||||
./gradlew :androidApp:assembleDebug
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Native Apple App
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make check-apple
|
||||||
|
```
|
||||||
|
|
||||||
|
Override the selected simulator when needed with
|
||||||
|
`make check-apple APPLE_DESTINATION='platform=iOS Simulator,name=iPhone 16'`.
|
||||||
|
|
||||||
### Diagnostics Service
|
### Diagnostics Service
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd services/diagnostics-api
|
make check-diagnostics
|
||||||
npm ci
|
|
||||||
npm run check
|
|
||||||
```
|
```
|
||||||
|
|
||||||
If a required check cannot run on your machine, explain why in the pull request
|
If a required check cannot run on your machine, explain why in the pull request
|
||||||
|
|||||||
181
Makefile
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
ROOT := $(patsubst %/,%,$(dir $(abspath $(lastword $(MAKEFILE_LIST)))))
|
||||||
|
|
||||||
|
SHELL := bash
|
||||||
|
.SHELLFLAGS := -eu -o pipefail -c
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
include $(ROOT)/config.mk
|
||||||
|
-include $(ROOT)/config.override.mk
|
||||||
|
include $(ROOT)/make/release.mk
|
||||||
|
|
||||||
|
.PHONY: help doctor setup setup-localization setup-docs setup-diagnostics
|
||||||
|
.PHONY: format test check check-rust audit-rust test-rust test-rust-all
|
||||||
|
.PHONY: test-rust-transfer test-rust-approval test-rust-lifecycle test-rust-output-sink
|
||||||
|
.PHONY: check-shared test-shared test-android-host check-android verify-android-libs build-android run-desktop
|
||||||
|
.PHONY: apple-core apple-project open-apple-project open-apple build-apple-macos build-apple-ios check-apple
|
||||||
|
.PHONY: check-localization localization localization-migrate
|
||||||
|
.PHONY: check-docs run-docs check-diagnostics run-diagnostics diagnostics-db-local diagnostics-db-remote diagnostics-typegen deploy-diagnostics
|
||||||
|
|
||||||
|
help: ## Show available commands and common configuration variables.
|
||||||
|
@grep -hE '^[A-Za-z0-9_.-]+:.*## ' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*## "} {printf " %-28s %s\n", $$1, $$2}'
|
||||||
|
@printf '\nCommon variables:\n'
|
||||||
|
@printf ' %-28s %s\n' 'VERSION=x.y.z' 'Package version (default: $(VERSION))'
|
||||||
|
@printf ' %-28s %s\n' 'APPLE_PROFILE=debug|release' 'Rust profile for the Apple XCFramework'
|
||||||
|
@printf ' %-28s %s\n' 'APPLE_CONFIGURATION=...' 'Xcode configuration (default: $(APPLE_CONFIGURATION))'
|
||||||
|
@printf ' %-28s %s\n' 'APPLE_DESTINATION=...' 'Optional xcodebuild destination override'
|
||||||
|
@printf ' %-28s %s\n' 'APPLE_CODE_SIGNING=NO|YES' 'Enable Apple code signing (default: $(APPLE_CODE_SIGNING))'
|
||||||
|
|
||||||
|
doctor: ## Check that tools required by the current host are available.
|
||||||
|
@missing=0; \
|
||||||
|
for tool in "$(firstword $(CARGO))" java "$(firstword $(NPM))" "$(firstword $(BUN))"; do \
|
||||||
|
if command -v "$$tool" >/dev/null 2>&1; then \
|
||||||
|
printf 'ok %s\n' "$$tool"; \
|
||||||
|
else \
|
||||||
|
printf 'missing %s\n' "$$tool"; \
|
||||||
|
missing=1; \
|
||||||
|
fi; \
|
||||||
|
done; \
|
||||||
|
if [[ ! -f "$(GRADLE)" ]]; then printf 'missing %s\n' "$(GRADLE)"; missing=1; else printf 'ok %s\n' "$(GRADLE)"; fi; \
|
||||||
|
if [[ "$(HOST_OS)" == macos ]]; then \
|
||||||
|
for tool in "$(firstword $(XCODEBUILD))" "$(firstword $(XCODEGEN))"; do \
|
||||||
|
if command -v "$$tool" >/dev/null 2>&1; then printf 'ok %s\n' "$$tool"; else printf 'missing %s\n' "$$tool"; missing=1; fi; \
|
||||||
|
done; \
|
||||||
|
fi; \
|
||||||
|
exit $$missing
|
||||||
|
|
||||||
|
setup: setup-localization setup-docs setup-diagnostics ## Install repository-local JavaScript dependencies.
|
||||||
|
|
||||||
|
setup-localization: ## Install localization CLI dependencies with Bun.
|
||||||
|
cd $(ROOT)/localization && $(BUN) install --frozen-lockfile
|
||||||
|
|
||||||
|
setup-docs: ## Install documentation website dependencies.
|
||||||
|
cd $(ROOT)/docs && $(NPM) ci
|
||||||
|
|
||||||
|
setup-diagnostics: ## Install diagnostics Worker dependencies.
|
||||||
|
cd $(ROOT)/services/diagnostics-api && $(NPM) ci
|
||||||
|
|
||||||
|
format: ## Format Rust sources.
|
||||||
|
cd $(ROOT) && $(CARGO) fmt --all
|
||||||
|
|
||||||
|
test: test-rust test-shared ## Run the main Rust and shared JVM test suites.
|
||||||
|
|
||||||
|
check: check-rust check-shared check-localization check-docs check-diagnostics ## Run portable pre-PR verification.
|
||||||
|
|
||||||
|
check-rust: ## Run Rust formatting, lint, tests, and documentation checks.
|
||||||
|
cd $(ROOT) && $(CARGO) fmt --all -- --check
|
||||||
|
cd $(ROOT) && $(CARGO) clippy --workspace --all-targets -- -D warnings
|
||||||
|
cd $(ROOT) && $(CARGO) test --workspace --all-targets
|
||||||
|
cd $(ROOT) && RUSTDOCFLAGS='-D warnings' $(CARGO) doc --workspace --no-deps
|
||||||
|
|
||||||
|
audit-rust: ## Audit Rust dependencies (requires cargo-audit).
|
||||||
|
cd $(ROOT) && $(CARGO) audit
|
||||||
|
|
||||||
|
test-rust: ## Run the focused Rust core suite.
|
||||||
|
cd $(ROOT) && $(CARGO) test -p vnidrop
|
||||||
|
|
||||||
|
test-rust-all: ## Run every Rust workspace test target.
|
||||||
|
cd $(ROOT) && $(CARGO) test --workspace --all-targets
|
||||||
|
|
||||||
|
test-rust-transfer: ## Run Rust transfer integration tests.
|
||||||
|
cd $(ROOT) && $(CARGO) test -p vnidrop --test transfer
|
||||||
|
|
||||||
|
test-rust-approval: ## Run Rust approval integration tests.
|
||||||
|
cd $(ROOT) && $(CARGO) test -p vnidrop --test approval
|
||||||
|
|
||||||
|
test-rust-lifecycle: ## Run Rust lifecycle integration tests.
|
||||||
|
cd $(ROOT) && $(CARGO) test -p vnidrop --test lifecycle
|
||||||
|
|
||||||
|
test-rust-output-sink: ## Run Rust output-sink integration tests.
|
||||||
|
cd $(ROOT) && $(CARGO) test -p vnidrop --test output_sink
|
||||||
|
|
||||||
|
check-shared: ## Test and compile the shared Android/JVM module.
|
||||||
|
cd $(ROOT) && $(GRADLE) :shared:jvmTest :shared:compileKotlinJvm $(GRADLE_FLAGS)
|
||||||
|
|
||||||
|
test-shared: ## Run shared JVM tests.
|
||||||
|
cd $(ROOT) && $(GRADLE) :shared:jvmTest $(GRADLE_FLAGS)
|
||||||
|
|
||||||
|
test-android-host: ## Run Android host-side shared tests.
|
||||||
|
cd $(ROOT) && $(GRADLE) :shared:testAndroidHostTest $(GRADLE_FLAGS)
|
||||||
|
|
||||||
|
check-android: ## Build Android debug and verify packaged Rust libraries.
|
||||||
|
cd $(ROOT) && $(GRADLE) :androidApp:assembleDebug :androidApp:verifyDebugVnidropLibraries $(GRADLE_FLAGS)
|
||||||
|
|
||||||
|
verify-android-libs: ## Verify the Rust libraries packaged in the Android debug app.
|
||||||
|
cd $(ROOT) && $(GRADLE) :androidApp:verifyDebugVnidropLibraries $(GRADLE_FLAGS)
|
||||||
|
|
||||||
|
build-android: ## Build the Android debug APK.
|
||||||
|
cd $(ROOT) && $(GRADLE) :androidApp:assembleDebug $(GRADLE_FLAGS)
|
||||||
|
|
||||||
|
run-desktop: ## Run the Windows/Linux Compose desktop app.
|
||||||
|
cd $(ROOT) && $(GRADLE) :desktopApp:run $(GRADLE_FLAGS)
|
||||||
|
|
||||||
|
apple-core: ## Build the Rust XCFramework and generated Swift bindings.
|
||||||
|
@test "$(HOST_OS)" = macos || { printf 'Apple builds require macOS.\n' >&2; exit 1; }
|
||||||
|
cd $(ROOT) && apple/scripts/build-core.sh $(APPLE_PROFILE)
|
||||||
|
|
||||||
|
apple-project: apple-core ## Generate the native Apple Xcode project.
|
||||||
|
cd $(ROOT)/apple && $(XCODEGEN) generate
|
||||||
|
|
||||||
|
open-apple-project: apple-project ## Generate and open the native Apple Xcode project.
|
||||||
|
cd $(ROOT)/apple && $(OPEN) VniDrop.xcodeproj
|
||||||
|
|
||||||
|
build-apple-macos: apple-project ## Build the native macOS app (unsigned by default).
|
||||||
|
cd $(ROOT)/apple && $(XCODEBUILD) -project VniDrop.xcodeproj -scheme VniDrop -configuration $(APPLE_CONFIGURATION) -derivedDataPath "$(APPLE_DERIVED_DATA)" -destination 'platform=macOS' CODE_SIGNING_ALLOWED=$(APPLE_CODE_SIGNING) CODE_SIGNING_REQUIRED=$(APPLE_CODE_SIGNING) build
|
||||||
|
|
||||||
|
open-apple: build-apple-macos ## Build and launch the native macOS app.
|
||||||
|
@test -d "$(APPLE_DERIVED_DATA)/Build/Products/$(APPLE_CONFIGURATION)/VniDrop.app" || { printf 'Built macOS app was not found.\n' >&2; exit 1; }
|
||||||
|
$(OPEN) "$(APPLE_DERIVED_DATA)/Build/Products/$(APPLE_CONFIGURATION)/VniDrop.app"
|
||||||
|
|
||||||
|
build-apple-ios: apple-project ## Build the native iOS simulator app (unsigned by default).
|
||||||
|
@destination="$(APPLE_DESTINATION)"; \
|
||||||
|
if [[ -z "$$destination" ]]; then \
|
||||||
|
device_id="$$(xcrun simctl list devices available | sed -nE '/iPhone/ s/.*\(([0-9A-F-]{36})\) \((Booted|Shutdown)\).*/\1/p' | head -1 || true)"; \
|
||||||
|
[[ -n "$$device_id" ]] || { printf 'No available iPhone simulator found. Set APPLE_DESTINATION explicitly.\n' >&2; exit 1; }; \
|
||||||
|
destination="platform=iOS Simulator,id=$$device_id"; \
|
||||||
|
fi; \
|
||||||
|
cd $(ROOT)/apple && $(XCODEBUILD) -project VniDrop.xcodeproj -scheme VniDrop -configuration $(APPLE_CONFIGURATION) -derivedDataPath "$(APPLE_DERIVED_DATA)" -destination "$$destination" CODE_SIGNING_ALLOWED=$(APPLE_CODE_SIGNING) CODE_SIGNING_REQUIRED=$(APPLE_CODE_SIGNING) build
|
||||||
|
|
||||||
|
check-apple: apple-project ## Build the Apple core and run iOS simulator tests.
|
||||||
|
@destination="$(APPLE_DESTINATION)"; \
|
||||||
|
if [[ -z "$$destination" ]]; then \
|
||||||
|
device_id="$$(xcrun simctl list devices available | sed -nE '/iPhone/ s/.*\(([0-9A-F-]{36})\) \((Booted|Shutdown)\).*/\1/p' | head -1 || true)"; \
|
||||||
|
[[ -n "$$device_id" ]] || { printf 'No available iPhone simulator found. Set APPLE_DESTINATION explicitly.\n' >&2; exit 1; }; \
|
||||||
|
destination="platform=iOS Simulator,id=$$device_id"; \
|
||||||
|
fi; \
|
||||||
|
printf 'Testing on: %s\n' "$$destination"; \
|
||||||
|
cd $(ROOT)/apple && $(XCODEBUILD) test -project VniDrop.xcodeproj -scheme VniDrop -configuration $(APPLE_CONFIGURATION) -derivedDataPath "$(APPLE_DERIVED_DATA)" -destination "$$destination" CODE_SIGNING_ALLOWED=$(APPLE_CODE_SIGNING) CODE_SIGNING_REQUIRED=$(APPLE_CODE_SIGNING)
|
||||||
|
|
||||||
|
check-localization: setup-localization ## Validate the localization source catalog.
|
||||||
|
cd $(ROOT)/localization && $(BUN) run validate
|
||||||
|
|
||||||
|
localization: setup-localization ## Regenerate Apple and KMP localization resources.
|
||||||
|
cd $(ROOT)/localization && $(BUN) run generate
|
||||||
|
|
||||||
|
localization-migrate: setup-localization ## Rebuild strings.json from platform resources.
|
||||||
|
cd $(ROOT)/localization && $(BUN) run migrate
|
||||||
|
|
||||||
|
check-docs: setup-docs ## Lint, type-check, and build the documentation website.
|
||||||
|
cd $(ROOT)/docs && $(NPM) run lint
|
||||||
|
cd $(ROOT)/docs && $(NPM) run typecheck
|
||||||
|
cd $(ROOT)/docs && $(NPM) run build
|
||||||
|
|
||||||
|
run-docs: setup-docs ## Run the documentation development server.
|
||||||
|
cd $(ROOT)/docs && $(NPM) run dev
|
||||||
|
|
||||||
|
check-diagnostics: setup-diagnostics ## Run diagnostics types, tests, and deployment dry-run.
|
||||||
|
cd $(ROOT)/services/diagnostics-api && $(NPM) run check
|
||||||
|
|
||||||
|
run-diagnostics: setup-diagnostics ## Run the diagnostics Worker locally.
|
||||||
|
cd $(ROOT)/services/diagnostics-api && $(NPM) run dev
|
||||||
|
|
||||||
|
diagnostics-db-local: setup-diagnostics ## Apply diagnostics database migrations locally.
|
||||||
|
cd $(ROOT)/services/diagnostics-api && $(NPM) run db:migrate:local
|
||||||
|
|
||||||
|
diagnostics-db-remote: setup-diagnostics ## Apply diagnostics database migrations to the configured remote D1 database.
|
||||||
|
cd $(ROOT)/services/diagnostics-api && $(NPM) run db:migrate:remote
|
||||||
|
|
||||||
|
diagnostics-typegen: setup-diagnostics ## Regenerate diagnostics Worker binding types.
|
||||||
|
cd $(ROOT)/services/diagnostics-api && $(NPM) run typegen
|
||||||
|
|
||||||
|
deploy-diagnostics: setup-diagnostics ## Check and deploy the diagnostics Worker to Cloudflare.
|
||||||
|
cd $(ROOT)/services/diagnostics-api && $(NPM) run deploy
|
||||||
20
README.md
@@ -88,7 +88,8 @@ people, especially when using **Anyone with this transfer**.
|
|||||||
- Per-receiver requests, approvals, progress, and delivery status
|
- Per-receiver requests, approvals, progress, and delivery status
|
||||||
- Cancel, stop sharing, and local transfer history
|
- Cancel, stop sharing, and local transfer history
|
||||||
- Safe receive destinations that do not silently overwrite existing files
|
- Safe receive destinations that do not silently overwrite existing files
|
||||||
- Android, iOS, and desktop apps built from a shared Compose Multiplatform UI
|
- Native SwiftUI apps on iOS, iPadOS, and macOS; Compose apps on Android,
|
||||||
|
Windows, and Linux
|
||||||
- Opt-in diagnostics with transfer contents, invitations, and file paths
|
- Opt-in diagnostics with transfer contents, invitations, and file paths
|
||||||
excluded
|
excluded
|
||||||
|
|
||||||
@@ -117,14 +118,21 @@ if you want to try the current version.
|
|||||||
git clone https://github.com/vnidrop/vnidrop.git
|
git clone https://github.com/vnidrop/vnidrop.git
|
||||||
cd vnidrop
|
cd vnidrop
|
||||||
|
|
||||||
# Desktop
|
# List the supported development commands and check prerequisites
|
||||||
./gradlew :desktopApp:run
|
make help
|
||||||
|
make doctor
|
||||||
|
|
||||||
|
# Windows/Linux desktop
|
||||||
|
make run-desktop
|
||||||
|
|
||||||
# Android debug build
|
# Android debug build
|
||||||
./gradlew :androidApp:assembleDebug
|
make build-android
|
||||||
|
|
||||||
# iOS
|
# Build and launch the macOS app
|
||||||
open iosApp/iosApp.xcodeproj
|
make open-apple
|
||||||
|
|
||||||
|
# Open the native project for iOS, iPadOS, or Xcode development
|
||||||
|
make open-apple-project
|
||||||
```
|
```
|
||||||
|
|
||||||
See [`CONTRIBUTING.md`](CONTRIBUTING.md) for prerequisites, development setup,
|
See [`CONTRIBUTING.md`](CONTRIBUTING.md) for prerequisites, development setup,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
A native SwiftUI app for Apple platforms, sharing the existing Rust transfer core
|
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
|
(`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
|
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.
|
UI screen-for-screen. Android, Windows, and Linux continue to use `shared/` + Compose.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
@@ -30,21 +30,24 @@ Prerequisites: Xcode, Rust with the Apple targets
|
|||||||
`aarch64-apple-darwin`), and `xcodegen` (`brew install xcodegen`).
|
`aarch64-apple-darwin`), and `xcodegen` (`brew install xcodegen`).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Build the Rust core and generate the Swift bindings + xcframework.
|
# From the repository root:
|
||||||
apple/scripts/build-core.sh debug # or: release (see note below)
|
make apple-core # Rust core, Swift bindings, and XCFramework
|
||||||
|
make apple-project # generate apple/VniDrop.xcodeproj
|
||||||
# 2. Generate the Xcode project.
|
make open-apple-project # generate and open the project in Xcode
|
||||||
cd apple && xcodegen generate
|
make build-apple-macos # unsigned macOS build
|
||||||
|
make open-apple # build and launch the macOS app
|
||||||
# 3. Open and run, or build from the CLI:
|
make build-apple-ios # unsigned iOS simulator build
|
||||||
open VniDrop.xcodeproj
|
make check-apple # iOS simulator tests
|
||||||
# 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Use `APPLE_PROFILE=release` to request a release Rust core, or set
|
||||||
|
`APPLE_DESTINATION` to override the automatically selected iOS simulator.
|
||||||
|
Code signing is disabled for the app and test targets; local and CI builds do
|
||||||
|
not require an Apple Development team or provisioning profile. Make builds can
|
||||||
|
opt in with `APPLE_CODE_SIGNING=YES`. For signed builds from Xcode, create the
|
||||||
|
ignored `apple/Local.xcconfig` and override the signing settings there, including
|
||||||
|
the development team.
|
||||||
|
|
||||||
## Command-line typecheck & tests
|
## Command-line typecheck & tests
|
||||||
|
|
||||||
`Package.swift` builds the same sources as a library (minus the `@main` entry),
|
`Package.swift` builds the same sources as a library (minus the `@main` entry),
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
// Committed signing config. Contains no secrets.
|
// VniDrop development and CI builds are intentionally unsigned.
|
||||||
//
|
CODE_SIGNING_ALLOWED = NO
|
||||||
// Per-developer signing (e.g. DEVELOPMENT_TEAM) goes in Local.xcconfig, which is
|
CODE_SIGNING_REQUIRED = NO
|
||||||
// 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.
|
// Signed local builds can opt in through this ignored file.
|
||||||
//
|
|
||||||
// To persist your team across `xcodegen generate`, create apple/Local.xcconfig:
|
|
||||||
// DEVELOPMENT_TEAM = XXXXXXXXXX
|
|
||||||
#include? "Local.xcconfig"
|
#include? "Local.xcconfig"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// App entry point for iOS/iPadOS/macOS, ported from `iOSApp.swift` + `App.kt`.
|
/// Native app entry point for iOS, iPadOS, and macOS.
|
||||||
/// Opens `.vnd` invitations via `onOpenURL` and routes them to the receive flow.
|
/// Opens `.vnd` invitations via `onOpenURL` and routes them to the receive flow.
|
||||||
@main
|
@main
|
||||||
struct VniDropApp: App {
|
struct VniDropApp: App {
|
||||||
|
|||||||
@@ -16,8 +16,7 @@ struct LocalNotification {
|
|||||||
let body: String
|
let body: String
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Local notification service, ported from `LocalNotificationService.kt` /
|
/// Local notification service backed by `UNUserNotificationCenter`.
|
||||||
/// `.ios.kt`, backed by `UNUserNotificationCenter`.
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class LocalNotificationService: ObservableObject {
|
final class LocalNotificationService: ObservableObject {
|
||||||
@Published private(set) var permission: NotificationPermission = .notDetermined
|
@Published private(set) var permission: NotificationPermission = .notDetermined
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import SwiftUI
|
|||||||
|
|
||||||
enum ReceiveMethodAvailability { case available, unavailable, hidden }
|
enum ReceiveMethodAvailability { case available, unavailable, hidden }
|
||||||
|
|
||||||
/// Invitation acquisition actions, ported from `ReceiveInvitationActions` (iosMain).
|
/// Invitation acquisition actions shared by the native Apple feature models.
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol ReceiveInvitationActions: AnyObject {
|
protocol ReceiveInvitationActions: AnyObject {
|
||||||
var fileAvailability: ReceiveMethodAvailability { get }
|
var fileAvailability: ReceiveMethodAvailability { get }
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
/// Persisted per-transfer thumbnail store, ported from
|
/// Native Apple preview cache and thumbnail loader.
|
||||||
/// `feature/send/FilePreviewRepository.kt` + `PlatformPreviewStore.ios.kt`.
|
|
||||||
/// Only small PNG/JPEG/WEBP previews are retained, under a total quota.
|
/// Only small PNG/JPEG/WEBP previews are retained, under a total quota.
|
||||||
struct PreviewStoragePolicy {
|
struct PreviewStoragePolicy {
|
||||||
var maxEntryBytes: Int = 512 * 1024
|
var maxEntryBytes: Int = 512 * 1024
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import SwiftUI
|
|||||||
|
|
||||||
enum NfcShareAvailability { case available, unavailable, hidden }
|
enum NfcShareAvailability { case available, unavailable, hidden }
|
||||||
|
|
||||||
/// Invitation delivery actions, ported from `TransferShareActions` (iosMain).
|
/// Invitation delivery actions shared by the native Apple feature models.
|
||||||
/// Platform implementations perform export, native share, and NFC write.
|
/// Platform implementations perform export, native share, and NFC write.
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol TransferShareActions: AnyObject {
|
protocol TransferShareActions: AnyObject {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Foundation
|
|||||||
import UIKit
|
import UIKit
|
||||||
import VnidropCore
|
import VnidropCore
|
||||||
|
|
||||||
/// iOS file system service, ported from `FileSystemService.ios.kt`.
|
/// Native iOS file system service.
|
||||||
/// App-owned Documents is the fixed receive folder; custom folders are not
|
/// App-owned Documents is the fixed receive folder; custom folders are not
|
||||||
/// supported because raw external picker URLs do not survive relaunch.
|
/// supported because raw external picker URLs do not survive relaunch.
|
||||||
struct IosFileSystemService: FileSystemService {
|
struct IosFileSystemService: FileSystemService {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Builds a `.vnd` invitation filename from a transfer name, mirroring the iOS
|
/// Builds a `.vnd` invitation filename from a transfer name.
|
||||||
/// helper in `TransferShareActions.ios.kt`.
|
|
||||||
func invitationFileName(_ transferName: String) -> String {
|
func invitationFileName(_ transferName: String) -> String {
|
||||||
let trimmed = transferName.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = transferName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let base = trimmed.isEmpty ? "invitation" : trimmed
|
let base = trimmed.isEmpty ? "invitation" : trimmed
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import UniformTypeIdentifiers
|
|||||||
@MainActor
|
@MainActor
|
||||||
func makeReceiveInvitationActions() -> ReceiveInvitationActions { IosReceiveInvitationActions() }
|
func makeReceiveInvitationActions() -> ReceiveInvitationActions { IosReceiveInvitationActions() }
|
||||||
|
|
||||||
/// iOS invitation acquisition, ported from `ReceiveInvitationActions.ios.kt`:
|
/// iOS invitation acquisition:
|
||||||
/// document picker, camera QR scanner, and NFC read.
|
/// document picker, camera QR scanner, and NFC read.
|
||||||
final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UIDocumentPickerDelegate {
|
final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UIDocumentPickerDelegate {
|
||||||
private var documentResult: ((Result<String, Error>) -> Void)?
|
private var documentResult: ((Result<String, Error>) -> Void)?
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import UIKit
|
|||||||
@MainActor
|
@MainActor
|
||||||
func makePlatformShareActions() -> TransferShareActions { IosTransferShareActions() }
|
func makePlatformShareActions() -> TransferShareActions { IosTransferShareActions() }
|
||||||
|
|
||||||
/// iOS invitation delivery, ported from `TransferShareActions.ios.kt`: export via
|
/// iOS invitation delivery: export via
|
||||||
/// document picker, native share via `UIActivityViewController`, and NFC write.
|
/// document picker, native share via `UIActivityViewController`, and NFC write.
|
||||||
final class IosTransferShareActions: NSObject, TransferShareActions {
|
final class IosTransferShareActions: NSObject, TransferShareActions {
|
||||||
private var nfcWriter: InvitationNfcWriter?
|
private var nfcWriter: InvitationNfcWriter?
|
||||||
@@ -61,8 +61,7 @@ final class IosTransferShareActions: NSObject, TransferShareActions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes a VniDrop invitation to a writable NDEF tag, ported from
|
/// Writes a VniDrop invitation to a writable NDEF tag.
|
||||||
/// `InvitationNfcWriter` in `TransferShareActions.ios.kt`.
|
|
||||||
// Runs entirely on the NFC session's `.main` delegate queue.
|
// Runs entirely on the NFC session's `.main` delegate queue.
|
||||||
final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate, @unchecked Sendable {
|
final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate, @unchecked Sendable {
|
||||||
private let ticket: String
|
private let ticket: String
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<!-- iOS: NFC NDEF reading (matches the original iosApp entitlements). -->
|
<!-- iOS: NFC NDEF reading. -->
|
||||||
<key>com.apple.developer.nfc.readersession.formats</key>
|
<key>com.apple.developer.nfc.readersession.formats</key>
|
||||||
<array>
|
<array>
|
||||||
<string>NDEF</string>
|
<string>NDEF</string>
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ targets:
|
|||||||
SWIFT_STRICT_CONCURRENCY: complete
|
SWIFT_STRICT_CONCURRENCY: complete
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING: NO
|
ENABLE_USER_SCRIPT_SANDBOXING: NO
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
CODE_SIGN_STYLE: Automatic
|
|
||||||
configs:
|
configs:
|
||||||
debug:
|
debug:
|
||||||
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
|
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
|
||||||
@@ -56,6 +55,9 @@ targets:
|
|||||||
VniDropTests:
|
VniDropTests:
|
||||||
type: bundle.unit-test
|
type: bundle.unit-test
|
||||||
supportedDestinations: [iOS, macOS]
|
supportedDestinations: [iOS, macOS]
|
||||||
|
configFiles:
|
||||||
|
Debug: Signing.xcconfig
|
||||||
|
Release: Signing.xcconfig
|
||||||
sources:
|
sources:
|
||||||
- path: Tests
|
- path: Tests
|
||||||
settings:
|
settings:
|
||||||
@@ -63,7 +65,6 @@ targets:
|
|||||||
GENERATE_INFOPLIST_FILE: YES
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
SWIFT_VERSION: "6.0"
|
SWIFT_VERSION: "6.0"
|
||||||
SWIFT_STRICT_CONCURRENCY: complete
|
SWIFT_STRICT_CONCURRENCY: complete
|
||||||
CODE_SIGN_STYLE: Automatic
|
|
||||||
dependencies:
|
dependencies:
|
||||||
- target: VniDrop
|
- target: VniDrop
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 49 KiB |
@@ -1,29 +0,0 @@
|
|||||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect width="1024" height="1024" fill="#FFFFFF"/>
|
|
||||||
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M236.68 148H338.36C366.24 148 387.56 170.96 387.56 198.84V564.56C387.56 597.36 372.8 620.32 372.8 646.56C372.8 725.28 436.76 787.6 522.04 787.6C607.32 787.6 668 725.28 668 646.56C668 620.32 656.52 597.36 656.52 564.56V198.84C656.52 170.96 677.84 148 705.72 148H781.16C817.24 148 846.76 177.52 846.76 213.6V564.56C846.76 738.4 704.08 879.44 522.04 879.44C340 879.44 194.04 738.4 194.04 564.56V374.32H220.28V305.44C195.68 305.44 176 297.24 176 280.84V246.4C176 231.64 187.48 220.16 202.24 220.16H236.68V148ZM256.36 239.84C251.44 239.84 248.16 244.76 248.16 249.68V275.92C248.16 282.48 253.08 285.76 259.64 285.76H282.6C289.16 285.76 292.44 280.84 292.44 274.28V251.32C292.44 244.76 287.52 239.84 280.96 239.84H256.36Z" fill="url(#paint0_linear_11_12)"/>
|
|
||||||
<path d="M520.4 431.72C495.8 464.52 443.32 530.12 420.36 577.68C390.84 636.72 403.96 699.04 446.6 731.84C487.6 758.08 549.92 758.08 592.56 730.2C633.56 700.68 646.68 636.72 620.44 577.68C597.48 530.12 546.64 464.52 520.4 431.72Z" fill="url(#paint1_linear_11_12)"/>
|
|
||||||
<mask id="mask0_11_12" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="176" y="148" width="671" height="732">
|
|
||||||
<path d="M236.68 148H338.36C366.24 148 387.56 170.96 387.56 198.84V564.56C387.56 597.36 372.8 620.32 372.8 646.56C372.8 725.28 436.76 787.6 522.04 787.6C607.32 787.6 668 725.28 668 646.56C668 620.32 656.52 597.36 656.52 564.56V198.84C656.52 170.96 677.84 148 705.72 148H781.16C817.24 148 846.76 177.52 846.76 213.6V564.56C846.76 738.4 704.08 879.44 522.04 879.44C340 879.44 194.04 738.4 194.04 564.56V374.32H220.28V305.44C195.68 305.44 176 297.24 176 280.84V246.4C176 231.64 187.48 220.16 202.24 220.16H236.68V148ZM256.36 239.84C251.44 239.84 248.16 244.76 248.16 249.68V275.92C248.16 282.48 253.08 285.76 259.64 285.76H282.6C289.16 285.76 292.44 280.84 292.44 274.28V251.32C292.44 244.76 287.52 239.84 280.96 239.84H256.36Z" fill="white"/>
|
|
||||||
</mask>
|
|
||||||
<g mask="url(#mask0_11_12)">
|
|
||||||
<path d="M688 148H782C832 148 842 171.8 847.48 210V303.68L688 148Z" fill="url(#paint2_linear_11_12)"/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="paint0_linear_11_12" x1="176" y1="148" x2="904.706" y2="816.252" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#A855F7"/>
|
|
||||||
<stop offset="0.48" stop-color="#9D4DF4"/>
|
|
||||||
<stop offset="1" stop-color="#7C2AEF"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="paint1_linear_11_12" x1="404.439" y1="431.72" x2="707.314" y2="649.286" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#A855F7"/>
|
|
||||||
<stop offset="0.48" stop-color="#9D4DF4"/>
|
|
||||||
<stop offset="1" stop-color="#7C2AEF"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="paint2_linear_11_12" x1="683.48" y1="144.6" x2="793.636" y2="306.833" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#F2DDFF"/>
|
|
||||||
<stop offset="1" stop-color="#C084FC"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 301 KiB |
@@ -1,26 +0,0 @@
|
|||||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M236.68 148H338.36C366.24 148 387.56 170.96 387.56 198.84V564.56C387.56 597.36 372.8 620.32 372.8 646.56C372.8 725.28 436.76 787.6 522.04 787.6C607.32 787.6 668 725.28 668 646.56C668 620.32 656.52 597.36 656.52 564.56V198.84C656.52 170.96 677.84 148 705.72 148H781.16C817.24 148 846.76 177.52 846.76 213.6V564.56C846.76 738.4 704.08 879.44 522.04 879.44C340 879.44 194.04 738.4 194.04 564.56V374.32H220.28V305.44C195.68 305.44 176 297.24 176 280.84V246.4C176 231.64 187.48 220.16 202.24 220.16H236.68V148ZM256.36 239.84C251.44 239.84 248.16 244.76 248.16 249.68V275.92C248.16 282.48 253.08 285.76 259.64 285.76H282.6C289.16 285.76 292.44 280.84 292.44 274.28V251.32C292.44 244.76 287.52 239.84 280.96 239.84H256.36Z" fill="url(#paint0_linear_11_12)"/>
|
|
||||||
<path d="M520.4 431.72C495.8 464.52 443.32 530.12 420.36 577.68C390.84 636.72 403.96 699.04 446.6 731.84C487.6 758.08 549.92 758.08 592.56 730.2C633.56 700.68 646.68 636.72 620.44 577.68C597.48 530.12 546.64 464.52 520.4 431.72Z" fill="url(#paint1_linear_11_12)"/>
|
|
||||||
<mask id="mask0_11_12" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="176" y="148" width="671" height="732">
|
|
||||||
<path d="M236.68 148H338.36C366.24 148 387.56 170.96 387.56 198.84V564.56C387.56 597.36 372.8 620.32 372.8 646.56C372.8 725.28 436.76 787.6 522.04 787.6C607.32 787.6 668 725.28 668 646.56C668 620.32 656.52 597.36 656.52 564.56V198.84C656.52 170.96 677.84 148 705.72 148H781.16C817.24 148 846.76 177.52 846.76 213.6V564.56C846.76 738.4 704.08 879.44 522.04 879.44C340 879.44 194.04 738.4 194.04 564.56V374.32H220.28V305.44C195.68 305.44 176 297.24 176 280.84V246.4C176 231.64 187.48 220.16 202.24 220.16H236.68V148ZM256.36 239.84C251.44 239.84 248.16 244.76 248.16 249.68V275.92C248.16 282.48 253.08 285.76 259.64 285.76H282.6C289.16 285.76 292.44 280.84 292.44 274.28V251.32C292.44 244.76 287.52 239.84 280.96 239.84H256.36Z" fill="white"/>
|
|
||||||
</mask>
|
|
||||||
<g mask="url(#mask0_11_12)">
|
|
||||||
<path d="M688 148H782C832 148 842 171.8 847.48 210V303.68L688 148Z" fill="url(#paint2_linear_11_12)"/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="paint0_linear_11_12" x1="176" y1="148" x2="904.706" y2="816.252" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#A855F7"/>
|
|
||||||
<stop offset="0.48" stop-color="#9D4DF4"/>
|
|
||||||
<stop offset="1" stop-color="#7C2AEF"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="paint1_linear_11_12" x1="404.439" y1="431.72" x2="707.314" y2="649.286" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#A855F7"/>
|
|
||||||
<stop offset="0.48" stop-color="#9D4DF4"/>
|
|
||||||
<stop offset="1" stop-color="#7C2AEF"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="paint2_linear_11_12" x1="683.48" y1="144.6" x2="793.636" y2="306.833" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#F2DDFF"/>
|
|
||||||
<stop offset="1" stop-color="#C084FC"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 72 KiB |
@@ -1,29 +0,0 @@
|
|||||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect x="40" y="40" width="944" height="944" rx="210" fill="#FFFFFF" stroke="#E9E7F0" stroke-width="8"/>
|
|
||||||
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M236.68 148H338.36C366.24 148 387.56 170.96 387.56 198.84V564.56C387.56 597.36 372.8 620.32 372.8 646.56C372.8 725.28 436.76 787.6 522.04 787.6C607.32 787.6 668 725.28 668 646.56C668 620.32 656.52 597.36 656.52 564.56V198.84C656.52 170.96 677.84 148 705.72 148H781.16C817.24 148 846.76 177.52 846.76 213.6V564.56C846.76 738.4 704.08 879.44 522.04 879.44C340 879.44 194.04 738.4 194.04 564.56V374.32H220.28V305.44C195.68 305.44 176 297.24 176 280.84V246.4C176 231.64 187.48 220.16 202.24 220.16H236.68V148ZM256.36 239.84C251.44 239.84 248.16 244.76 248.16 249.68V275.92C248.16 282.48 253.08 285.76 259.64 285.76H282.6C289.16 285.76 292.44 280.84 292.44 274.28V251.32C292.44 244.76 287.52 239.84 280.96 239.84H256.36Z" fill="url(#paint0_linear_11_12)"/>
|
|
||||||
<path d="M520.4 431.72C495.8 464.52 443.32 530.12 420.36 577.68C390.84 636.72 403.96 699.04 446.6 731.84C487.6 758.08 549.92 758.08 592.56 730.2C633.56 700.68 646.68 636.72 620.44 577.68C597.48 530.12 546.64 464.52 520.4 431.72Z" fill="url(#paint1_linear_11_12)"/>
|
|
||||||
<mask id="mask0_11_12" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="176" y="148" width="671" height="732">
|
|
||||||
<path d="M236.68 148H338.36C366.24 148 387.56 170.96 387.56 198.84V564.56C387.56 597.36 372.8 620.32 372.8 646.56C372.8 725.28 436.76 787.6 522.04 787.6C607.32 787.6 668 725.28 668 646.56C668 620.32 656.52 597.36 656.52 564.56V198.84C656.52 170.96 677.84 148 705.72 148H781.16C817.24 148 846.76 177.52 846.76 213.6V564.56C846.76 738.4 704.08 879.44 522.04 879.44C340 879.44 194.04 738.4 194.04 564.56V374.32H220.28V305.44C195.68 305.44 176 297.24 176 280.84V246.4C176 231.64 187.48 220.16 202.24 220.16H236.68V148ZM256.36 239.84C251.44 239.84 248.16 244.76 248.16 249.68V275.92C248.16 282.48 253.08 285.76 259.64 285.76H282.6C289.16 285.76 292.44 280.84 292.44 274.28V251.32C292.44 244.76 287.52 239.84 280.96 239.84H256.36Z" fill="white"/>
|
|
||||||
</mask>
|
|
||||||
<g mask="url(#mask0_11_12)">
|
|
||||||
<path d="M688 148H782C832 148 842 171.8 847.48 210V303.68L688 148Z" fill="url(#paint2_linear_11_12)"/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="paint0_linear_11_12" x1="176" y1="148" x2="904.706" y2="816.252" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#A855F7"/>
|
|
||||||
<stop offset="0.48" stop-color="#9D4DF4"/>
|
|
||||||
<stop offset="1" stop-color="#7C2AEF"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="paint1_linear_11_12" x1="404.439" y1="431.72" x2="707.314" y2="649.286" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#A855F7"/>
|
|
||||||
<stop offset="0.48" stop-color="#9D4DF4"/>
|
|
||||||
<stop offset="1" stop-color="#7C2AEF"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="paint2_linear_11_12" x1="683.48" y1="144.6" x2="793.636" y2="306.833" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#F2DDFF"/>
|
|
||||||
<stop offset="1" stop-color="#C084FC"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 301 KiB |
@@ -1,26 +0,0 @@
|
|||||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M236.68 148H338.36C366.24 148 387.56 170.96 387.56 198.84V564.56C387.56 597.36 372.8 620.32 372.8 646.56C372.8 725.28 436.76 787.6 522.04 787.6C607.32 787.6 668 725.28 668 646.56C668 620.32 656.52 597.36 656.52 564.56V198.84C656.52 170.96 677.84 148 705.72 148H781.16C817.24 148 846.76 177.52 846.76 213.6V564.56C846.76 738.4 704.08 879.44 522.04 879.44C340 879.44 194.04 738.4 194.04 564.56V374.32H220.28V305.44C195.68 305.44 176 297.24 176 280.84V246.4C176 231.64 187.48 220.16 202.24 220.16H236.68V148ZM256.36 239.84C251.44 239.84 248.16 244.76 248.16 249.68V275.92C248.16 282.48 253.08 285.76 259.64 285.76H282.6C289.16 285.76 292.44 280.84 292.44 274.28V251.32C292.44 244.76 287.52 239.84 280.96 239.84H256.36Z" fill="url(#paint0_linear_11_12)"/>
|
|
||||||
<path d="M520.4 431.72C495.8 464.52 443.32 530.12 420.36 577.68C390.84 636.72 403.96 699.04 446.6 731.84C487.6 758.08 549.92 758.08 592.56 730.2C633.56 700.68 646.68 636.72 620.44 577.68C597.48 530.12 546.64 464.52 520.4 431.72Z" fill="url(#paint1_linear_11_12)"/>
|
|
||||||
<mask id="mask0_11_12" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="176" y="148" width="671" height="732">
|
|
||||||
<path d="M236.68 148H338.36C366.24 148 387.56 170.96 387.56 198.84V564.56C387.56 597.36 372.8 620.32 372.8 646.56C372.8 725.28 436.76 787.6 522.04 787.6C607.32 787.6 668 725.28 668 646.56C668 620.32 656.52 597.36 656.52 564.56V198.84C656.52 170.96 677.84 148 705.72 148H781.16C817.24 148 846.76 177.52 846.76 213.6V564.56C846.76 738.4 704.08 879.44 522.04 879.44C340 879.44 194.04 738.4 194.04 564.56V374.32H220.28V305.44C195.68 305.44 176 297.24 176 280.84V246.4C176 231.64 187.48 220.16 202.24 220.16H236.68V148ZM256.36 239.84C251.44 239.84 248.16 244.76 248.16 249.68V275.92C248.16 282.48 253.08 285.76 259.64 285.76H282.6C289.16 285.76 292.44 280.84 292.44 274.28V251.32C292.44 244.76 287.52 239.84 280.96 239.84H256.36Z" fill="white"/>
|
|
||||||
</mask>
|
|
||||||
<g mask="url(#mask0_11_12)">
|
|
||||||
<path d="M688 148H782C832 148 842 171.8 847.48 210V303.68L688 148Z" fill="url(#paint2_linear_11_12)"/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="paint0_linear_11_12" x1="176" y1="148" x2="904.706" y2="816.252" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#A855F7"/>
|
|
||||||
<stop offset="0.48" stop-color="#9D4DF4"/>
|
|
||||||
<stop offset="1" stop-color="#7C2AEF"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="paint1_linear_11_12" x1="404.439" y1="431.72" x2="707.314" y2="649.286" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#A855F7"/>
|
|
||||||
<stop offset="0.48" stop-color="#9D4DF4"/>
|
|
||||||
<stop offset="1" stop-color="#7C2AEF"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="paint2_linear_11_12" x1="683.48" y1="144.6" x2="793.636" y2="306.833" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#F2DDFF"/>
|
|
||||||
<stop offset="1" stop-color="#C084FC"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.8 KiB |
35
config.mk
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Default command configuration. Override locally in the ignored
|
||||||
|
# config.override.mk or on the command line (for example: make package-deb VERSION=1.2.0).
|
||||||
|
|
||||||
|
ifeq ($(OS),Windows_NT)
|
||||||
|
HOST_OS := windows
|
||||||
|
GRADLE ?= ./gradlew.bat
|
||||||
|
else
|
||||||
|
HOST_UNAME := $(shell uname -s)
|
||||||
|
ifeq ($(HOST_UNAME),Darwin)
|
||||||
|
HOST_OS := macos
|
||||||
|
else ifeq ($(HOST_UNAME),Linux)
|
||||||
|
HOST_OS := linux
|
||||||
|
else
|
||||||
|
HOST_OS := unknown
|
||||||
|
endif
|
||||||
|
GRADLE ?= ./gradlew
|
||||||
|
endif
|
||||||
|
|
||||||
|
CARGO ?= cargo
|
||||||
|
NPM ?= npm
|
||||||
|
BUN ?= bun
|
||||||
|
XCODEBUILD ?= xcodebuild
|
||||||
|
XCODEGEN ?= xcodegen
|
||||||
|
OPEN ?= open
|
||||||
|
POWERSHELL ?= pwsh
|
||||||
|
|
||||||
|
VERSION ?= $(shell sed -n 's/^vnidrop.version=//p' $(ROOT)/gradle.properties)
|
||||||
|
APPLE_PROFILE ?= debug
|
||||||
|
APPLE_CONFIGURATION ?= Debug
|
||||||
|
APPLE_DESTINATION ?=
|
||||||
|
APPLE_CODE_SIGNING ?= NO
|
||||||
|
APPLE_DERIVED_DATA ?= $(ROOT)/apple/DerivedData
|
||||||
|
|
||||||
|
GRADLE_FLAGS ?= --no-daemon --stacktrace
|
||||||
|
GRADLE_RELEASE_FLAGS ?= --no-daemon --no-configuration-cache --stacktrace
|
||||||
@@ -19,20 +19,18 @@ Read [`CORE_FLOW.md`](CORE_FLOW.md) before changing send/receive/export/cancel.
|
|||||||
Always prefer workspace commands so lockfile/fmt stay consistent:
|
Always prefer workspace commands so lockfile/fmt stay consistent:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo fmt --all
|
make format
|
||||||
cargo fmt --all -- --check
|
make test-rust
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
make check-rust
|
||||||
cargo test -p vnidrop
|
|
||||||
cargo test --workspace --all-targets
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Focused integration suites:
|
Focused integration suites:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo test -p vnidrop --test transfer
|
make test-rust-transfer
|
||||||
cargo test -p vnidrop --test approval
|
make test-rust-approval
|
||||||
cargo test -p vnidrop --test lifecycle
|
make test-rust-lifecycle
|
||||||
cargo test -p vnidrop --test output_sink
|
make test-rust-output-sink
|
||||||
```
|
```
|
||||||
|
|
||||||
Docs (CI uses `-D warnings`):
|
Docs (CI uses `-D warnings`):
|
||||||
@@ -41,7 +39,7 @@ Docs (CI uses `-D warnings`):
|
|||||||
RUSTDOCFLAGS='-D warnings' cargo doc -p vnidrop --no-deps
|
RUSTDOCFLAGS='-D warnings' cargo doc -p vnidrop --no-deps
|
||||||
```
|
```
|
||||||
|
|
||||||
Run `cargo fmt --all` after finishing Rust edits without asking.
|
Run `make format` after finishing Rust edits without asking.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -116,13 +114,11 @@ Details: [`tests/README.md`](tests/README.md).
|
|||||||
## PR / verify checklist for this crate
|
## PR / verify checklist for this crate
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo fmt --all -- --check
|
make check-rust
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
|
||||||
cargo test -p vnidrop
|
|
||||||
```
|
```
|
||||||
|
|
||||||
If you touched cancel, export, or sinks, also:
|
If you touched cancel, export, or sinks, also:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo test -p vnidrop --test output_sink
|
make test-rust-output-sink
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ bytes through Kotlin memory.
|
|||||||
- Desktop uses normal filesystem paths.
|
- Desktop uses normal filesystem paths.
|
||||||
- Android opens SAF/content URIs in Kotlin and passes a borrowed file
|
- Android opens SAF/content URIs in Kotlin and passes a borrowed file
|
||||||
descriptor; Rust duplicates the descriptor before streaming.
|
descriptor; Rust duplicates the descriptor before streaming.
|
||||||
- iOS starts the security-scoped URL lease in Kotlin and keeps it alive while
|
- iOS starts the security-scoped URL lease in Swift and keeps it alive while
|
||||||
Rust streams from the accessible file URL/path.
|
Rust streams from the accessible file URL/path.
|
||||||
|
|
||||||
## Durability And Filesystem Policy
|
## Durability And Filesystem Policy
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ dependencies {
|
|||||||
|
|
||||||
implementation(compose.desktop.currentOs)
|
implementation(compose.desktop.currentOs)
|
||||||
implementation(libs.kotlinx.coroutinesSwing)
|
implementation(libs.kotlinx.coroutinesSwing)
|
||||||
implementation(libs.jna)
|
|
||||||
|
|
||||||
implementation(libs.compose.uiToolingPreview)
|
implementation(libs.compose.uiToolingPreview)
|
||||||
testImplementation(libs.kotlin.testJunit)
|
testImplementation(libs.kotlin.testJunit)
|
||||||
@@ -37,16 +36,12 @@ compose.desktop {
|
|||||||
buildTypes.release.proguard.isEnabled.set(false)
|
buildTypes.release.proguard.isEnabled.set(false)
|
||||||
|
|
||||||
nativeDistributions {
|
nativeDistributions {
|
||||||
targetFormats(TargetFormat.Dmg, TargetFormat.Deb, TargetFormat.Rpm)
|
targetFormats(TargetFormat.Deb, TargetFormat.Rpm)
|
||||||
packageName = "VniDrop"
|
packageName = "VniDrop"
|
||||||
packageVersion = appVersion
|
packageVersion = appVersion
|
||||||
description = "Send files directly across your devices"
|
description = "Send files directly across your devices"
|
||||||
vendor = "Sudosy Labs"
|
vendor = "Sudosy Labs"
|
||||||
licenseFile.set(project.file("../LICENSE"))
|
licenseFile.set(project.file("../LICENSE"))
|
||||||
macOS {
|
|
||||||
bundleID = "com.vnidrop.app"
|
|
||||||
iconFile.set(project.file("../assets/macos/app-icon.icns"))
|
|
||||||
}
|
|
||||||
windows {
|
windows {
|
||||||
iconFile.set(project.file("../assets/windows/app-icon.ico"))
|
iconFile.set(project.file("../assets/windows/app-icon.ico"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
package com.vnidrop.app
|
|
||||||
|
|
||||||
import com.sun.jna.Callback
|
|
||||||
import com.sun.jna.Library
|
|
||||||
import com.sun.jna.Native
|
|
||||||
import com.sun.jna.NativeLibrary
|
|
||||||
import com.sun.jna.Pointer
|
|
||||||
import com.sun.jna.Structure
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
internal object MacOsAppKitAppearance {
|
|
||||||
private val objc: ObjCRuntime? by lazy {
|
|
||||||
runCatching {
|
|
||||||
NativeLibrary.getInstance("AppKit")
|
|
||||||
Native.load("objc", ObjCRuntime::class.java)
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun apply(isDarkTheme: Boolean) {
|
|
||||||
if (!isMacOs()) return
|
|
||||||
runCatching {
|
|
||||||
val runtime = objc ?: return
|
|
||||||
val applicationClass = runtime.objc_getClass("NSApplication") ?: return
|
|
||||||
val appearanceClass = runtime.objc_getClass("NSAppearance") ?: return
|
|
||||||
val application = runtime.objc_msgSend(applicationClass, runtime.sel_registerName("sharedApplication")) ?: return
|
|
||||||
val appearanceName = nsString(runtime, macOsAppearanceName(isDarkTheme)) ?: return
|
|
||||||
val appearance = runtime.objc_msgSend(
|
|
||||||
appearanceClass,
|
|
||||||
runtime.sel_registerName("appearanceNamed:"),
|
|
||||||
appearanceName,
|
|
||||||
) ?: return
|
|
||||||
runtime.objc_msgSend(application, runtime.sel_registerName("setAppearance:"), appearance)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun macOsAppearanceName(isDarkTheme: Boolean): String =
|
|
||||||
if (isDarkTheme) "NSAppearanceNameDarkAqua" else "NSAppearanceNameAqua"
|
|
||||||
|
|
||||||
private fun nsString(runtime: ObjCRuntime, value: String): Pointer? {
|
|
||||||
val stringClass = runtime.objc_getClass("NSString") ?: return null
|
|
||||||
return runtime.objc_msgSend(stringClass, runtime.sel_registerName("stringWithUTF8String:"), value)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isMacOs(): Boolean =
|
|
||||||
System.getProperty("os.name").startsWith("Mac", ignoreCase = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal object MacOsShareSheet {
|
|
||||||
private val objc: ObjCRuntime? by lazy {
|
|
||||||
runCatching {
|
|
||||||
NativeLibrary.getInstance("AppKit")
|
|
||||||
Native.load("objc", ObjCRuntime::class.java)
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
|
||||||
private var retainedPicker: Pointer? = null
|
|
||||||
private val systemLibrary: NativeLibrary? by lazy {
|
|
||||||
runCatching { NativeLibrary.getInstance("System") }.getOrNull()
|
|
||||||
}
|
|
||||||
private val dispatch: DispatchRuntime? by lazy {
|
|
||||||
runCatching { Native.load("System", DispatchRuntime::class.java) }.getOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun share(file: File): Result<Unit> = runCatching {
|
|
||||||
require(file.isFile) { "The invitation file could not be created" }
|
|
||||||
var failure: Throwable? = null
|
|
||||||
val runtime = dispatch ?: error("The macOS main queue is unavailable")
|
|
||||||
// dispatch_get_main_queue() is a C macro on Darwin, so there is no
|
|
||||||
// function for dlsym/JNA to resolve. The macro returns this exported
|
|
||||||
// queue object directly.
|
|
||||||
val queue = systemLibrary?.getGlobalVariableAddress("_dispatch_main_q")
|
|
||||||
?: error("The macOS main queue is unavailable")
|
|
||||||
runtime.dispatch_sync_f(queue, null, DispatchWork { failure = runCatching { show(file) }.exceptionOrNull() })
|
|
||||||
failure?.let { throw it }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun show(file: File) {
|
|
||||||
val runtime = objc ?: error("AppKit is unavailable")
|
|
||||||
val applicationClass = runtime.objc_getClass("NSApplication") ?: error("NSApplication is unavailable")
|
|
||||||
val application = runtime.objc_msgSend(applicationClass, runtime.sel_registerName("sharedApplication"))
|
|
||||||
?: error("NSApplication could not be opened")
|
|
||||||
val window = runtime.objc_msgSend(application, runtime.sel_registerName("keyWindow"))
|
|
||||||
?: runtime.objc_msgSend(application, runtime.sel_registerName("mainWindow"))
|
|
||||||
?: error("No active macOS window")
|
|
||||||
val contentView = runtime.objc_msgSend(window, runtime.sel_registerName("contentView"))
|
|
||||||
?: error("The active window has no content view")
|
|
||||||
val path = nsString(runtime, file.absolutePath) ?: error("The invitation path is invalid")
|
|
||||||
val urlClass = runtime.objc_getClass("NSURL") ?: error("NSURL is unavailable")
|
|
||||||
val url = runtime.objc_msgSend(urlClass, runtime.sel_registerName("fileURLWithPath:"), path)
|
|
||||||
?: error("The invitation URL could not be created")
|
|
||||||
val arrayClass = runtime.objc_getClass("NSArray") ?: error("NSArray is unavailable")
|
|
||||||
val items = runtime.objc_msgSend(arrayClass, runtime.sel_registerName("arrayWithObject:"), url)
|
|
||||||
?: error("The share item could not be created")
|
|
||||||
val pickerClass = runtime.objc_getClass("NSSharingServicePicker") ?: error("The macOS share sheet is unavailable")
|
|
||||||
val allocated = runtime.objc_msgSend(pickerClass, runtime.sel_registerName("alloc"))
|
|
||||||
?: error("The macOS share sheet could not be allocated")
|
|
||||||
val picker = runtime.objc_msgSend(allocated, runtime.sel_registerName("initWithItems:"), items)
|
|
||||||
?: error("The macOS share sheet could not be created")
|
|
||||||
retainedPicker?.let { runtime.objc_msgSend(it, runtime.sel_registerName("release")) }
|
|
||||||
retainedPicker = picker
|
|
||||||
runtime.objc_msgSend(
|
|
||||||
picker,
|
|
||||||
runtime.sel_registerName("showRelativeToRect:ofView:preferredEdge:"),
|
|
||||||
anchorRect(),
|
|
||||||
contentView,
|
|
||||||
3L,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun nsString(runtime: ObjCRuntime, value: String): Pointer? {
|
|
||||||
val stringClass = runtime.objc_getClass("NSString") ?: return null
|
|
||||||
return runtime.objc_msgSend(stringClass, runtime.sel_registerName("stringWithUTF8String:"), value)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun validateNativeRectMapping(): Int = anchorRect().size()
|
|
||||||
internal fun hasNativeMainQueue(): Boolean =
|
|
||||||
runCatching { systemLibrary?.getGlobalVariableAddress("_dispatch_main_q") != null }.getOrDefault(false)
|
|
||||||
|
|
||||||
private fun anchorRect() = NSRectByValue().apply {
|
|
||||||
x = 0.0
|
|
||||||
y = 0.0
|
|
||||||
width = 1.0
|
|
||||||
height = 1.0
|
|
||||||
write()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Structure.FieldOrder("x", "y", "width", "height")
|
|
||||||
internal class NSRectByValue : Structure(), Structure.ByValue {
|
|
||||||
@JvmField var x: Double = 0.0
|
|
||||||
@JvmField var y: Double = 0.0
|
|
||||||
@JvmField var width: Double = 0.0
|
|
||||||
@JvmField var height: Double = 0.0
|
|
||||||
}
|
|
||||||
|
|
||||||
private interface ObjCRuntime : Library {
|
|
||||||
fun objc_getClass(name: String): Pointer?
|
|
||||||
fun sel_registerName(name: String): Pointer
|
|
||||||
fun objc_msgSend(receiver: Pointer?, selector: Pointer?): Pointer?
|
|
||||||
fun objc_msgSend(receiver: Pointer?, selector: Pointer?, argument: Pointer?): Pointer?
|
|
||||||
fun objc_msgSend(receiver: Pointer?, selector: Pointer?, argument: String): Pointer?
|
|
||||||
fun objc_msgSend(
|
|
||||||
receiver: Pointer?,
|
|
||||||
selector: Pointer?,
|
|
||||||
rect: NSRectByValue,
|
|
||||||
view: Pointer?,
|
|
||||||
edge: Long,
|
|
||||||
): Pointer?
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun interface DispatchWork : Callback {
|
|
||||||
fun invoke(context: Pointer?)
|
|
||||||
}
|
|
||||||
|
|
||||||
private interface DispatchRuntime : Library {
|
|
||||||
fun dispatch_sync_f(queue: Pointer?, context: Pointer?, work: DispatchWork)
|
|
||||||
}
|
|
||||||
@@ -47,31 +47,23 @@ import androidx.compose.ui.window.WindowPlacement
|
|||||||
import androidx.compose.ui.window.WindowScope
|
import androidx.compose.ui.window.WindowScope
|
||||||
import androidx.compose.ui.window.application
|
import androidx.compose.ui.window.application
|
||||||
import androidx.compose.ui.window.rememberWindowState
|
import androidx.compose.ui.window.rememberWindowState
|
||||||
import com.vnidrop.app.platform.DesktopAppearanceBridge
|
|
||||||
import com.vnidrop.app.feature.send.DesktopShareBridge
|
|
||||||
import com.vnidrop.app.feature.receive.ExternalInvitationController
|
import com.vnidrop.app.feature.receive.ExternalInvitationController
|
||||||
import com.vnidrop.app.feature.receive.MaxVniDropInvitationBytes
|
import com.vnidrop.app.feature.receive.MaxVniDropInvitationBytes
|
||||||
import com.vnidrop.app.feature.receive.VniDropInvitationExtension
|
import com.vnidrop.app.feature.receive.VniDropInvitationExtension
|
||||||
import com.vnidrop.app.feature.receive.decodeInvitationBytes
|
import com.vnidrop.app.feature.receive.decodeInvitationBytes
|
||||||
|
import com.vnidrop.app.platform.DesktopAppearanceBridge
|
||||||
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||||
import java.awt.Desktop
|
import java.awt.Desktop
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
fun main(args: Array<String>) {
|
fun main(args: Array<String>) {
|
||||||
val externalInvitations = ExternalInvitationController()
|
val externalInvitations = ExternalInvitationController()
|
||||||
val macOs = DesktopAppearanceBridge.isMacOs()
|
|
||||||
val linux = DesktopAppearanceBridge.isLinux()
|
val linux = DesktopAppearanceBridge.isLinux()
|
||||||
val customWindowChrome = macOs || linux
|
|
||||||
configureMacOsNativeAppearance()
|
|
||||||
configureInvitationOpenHandler(externalInvitations)
|
configureInvitationOpenHandler(externalInvitations)
|
||||||
args.asSequence()
|
args.asSequence()
|
||||||
.map(::File)
|
.map(::File)
|
||||||
.filter { it.extension.equals(VniDropInvitationExtension, ignoreCase = true) }
|
.filter { it.extension.equals(VniDropInvitationExtension, ignoreCase = true) }
|
||||||
.forEach { externalInvitations.openFile(it) }
|
.forEach { externalInvitations.openFile(it) }
|
||||||
DesktopAppearanceBridge.applyNativeAppearance = MacOsAppKitAppearance::apply
|
|
||||||
if (macOs) {
|
|
||||||
DesktopShareBridge.shareFile = MacOsShareSheet::share
|
|
||||||
}
|
|
||||||
application {
|
application {
|
||||||
val windowState = rememberWindowState()
|
val windowState = rememberWindowState()
|
||||||
Window(
|
Window(
|
||||||
@@ -83,29 +75,21 @@ fun main(args: Array<String>) {
|
|||||||
) {
|
) {
|
||||||
App(
|
App(
|
||||||
dependencies = rememberJvmAppDependencies(externalInvitations),
|
dependencies = rememberJvmAppDependencies(externalInvitations),
|
||||||
windowChromeTopInset = when {
|
windowChromeTopInset = if (linux) LinuxTitleBarHeight else 0.dp,
|
||||||
macOs -> MacOsTitleBarHeight
|
windowContentTopStartRadius = if (linux) DesktopContentCornerRadius else 0.dp,
|
||||||
linux -> LinuxTitleBarHeight
|
windowChrome = if (linux) {
|
||||||
else -> 0.dp
|
{
|
||||||
},
|
LinuxTitleBar(
|
||||||
windowContentTopStartRadius = if (customWindowChrome) DesktopContentCornerRadius else 0.dp,
|
isMaximized = windowState.placement == WindowPlacement.Maximized,
|
||||||
windowChrome = when {
|
onMinimize = { windowState.isMinimized = true },
|
||||||
macOs -> {
|
onToggleMaximize = {
|
||||||
{ MacOsTitleBar() }
|
windowState.placement = toggledWindowPlacement(windowState.placement)
|
||||||
|
},
|
||||||
|
onClose = ::exitApplication,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
linux -> {
|
} else {
|
||||||
{
|
null
|
||||||
LinuxTitleBar(
|
|
||||||
isMaximized = windowState.placement == WindowPlacement.Maximized,
|
|
||||||
onMinimize = { windowState.isMinimized = true },
|
|
||||||
onToggleMaximize = {
|
|
||||||
windowState.placement = toggledWindowPlacement(windowState.placement)
|
|
||||||
},
|
|
||||||
onClose = ::exitApplication,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> null
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -130,55 +114,11 @@ private fun ExternalInvitationController.openFile(file: File) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun configureMacOsNativeAppearance() {
|
|
||||||
if (!DesktopAppearanceBridge.isMacOs()) return
|
|
||||||
// AWT reads this before creating the first native window. Runtime theme
|
|
||||||
// changes are handled in the JVM platform appearance hook.
|
|
||||||
System.setProperty("apple.awt.application.appearance", "system")
|
|
||||||
}
|
|
||||||
|
|
||||||
private val MacOsTitleBarHeight = 28.dp
|
|
||||||
private val MacOsTrafficLightsWidth = 76.dp
|
|
||||||
private val LinuxTitleBarHeight = 40.dp
|
private val LinuxTitleBarHeight = 40.dp
|
||||||
private val LinuxWindowControlWidth = 46.dp
|
private val LinuxWindowControlWidth = 46.dp
|
||||||
private val LinuxWindowControlsWidth = 138.dp
|
private val LinuxWindowControlsWidth = 138.dp
|
||||||
private val DesktopContentCornerRadius = 20.dp
|
private val DesktopContentCornerRadius = 20.dp
|
||||||
|
|
||||||
@Composable
|
|
||||||
@OptIn(ExperimentalComposeUiApi::class)
|
|
||||||
private fun WindowScope.MacOsTitleBar() {
|
|
||||||
val colors = LocalVniDropColors.current
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(MacOsTitleBarHeight)
|
|
||||||
.background(colors.backgroundSurface200),
|
|
||||||
) {
|
|
||||||
WindowDraggableArea(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(start = MacOsTrafficLightsWidth)
|
|
||||||
.onPointerEvent(PointerEventType.Press) { event ->
|
|
||||||
if (event.awtEventOrNull?.clickCount == 2) {
|
|
||||||
DesktopAppearanceBridge.toggleMaximized(window)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Box(modifier = Modifier.fillMaxSize().padding(end = MacOsTrafficLightsWidth)) {
|
|
||||||
BasicText(
|
|
||||||
text = "VniDrop",
|
|
||||||
modifier = Modifier.align(Alignment.Center),
|
|
||||||
style = TextStyle(
|
|
||||||
color = colors.foregroundDefault,
|
|
||||||
fontSize = 13.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@OptIn(ExperimentalComposeUiApi::class)
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
private fun WindowScope.LinuxTitleBar(
|
private fun WindowScope.LinuxTitleBar(
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
package com.vnidrop.app
|
|
||||||
|
|
||||||
import com.vnidrop.app.platform.DesktopAppearanceBridge
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertTrue
|
|
||||||
import org.junit.Assume.assumeTrue
|
|
||||||
|
|
||||||
class MacOsShareSheetTest {
|
|
||||||
@Test
|
|
||||||
fun nativeAnchorRectHasTheCocoaLayout() {
|
|
||||||
assertEquals(32, MacOsShareSheet.validateNativeRectMapping())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun nativeMainDispatchQueueCanBeResolved() {
|
|
||||||
assumeTrue(DesktopAppearanceBridge.isMacOs())
|
|
||||||
assertTrue(MacOsShareSheet.hasNativeMainQueue())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,8 +5,8 @@ The product website for VniDrop, built with Next.js and exported as a static sit
|
|||||||
## Local development
|
## Local development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
# From the repository root:
|
||||||
npm run dev
|
make run-docs
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000).
|
Open [http://localhost:3000](http://localhost:3000).
|
||||||
@@ -14,9 +14,7 @@ Open [http://localhost:3000](http://localhost:3000).
|
|||||||
## Checks
|
## Checks
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run lint
|
make check-docs
|
||||||
npm run typecheck
|
|
||||||
npm run build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The production build is written to `out/` and can be hosted by any static web server.
|
The production build is written to `out/` and can be hosted by any static web server.
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
#Kotlin
|
#Kotlin
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
kotlin.daemon.jvmargs=-Xmx3072M
|
kotlin.daemon.jvmargs=-Xmx3072M
|
||||||
kotlin.mpp.enableCInteropCommonization=true
|
|
||||||
#Gradle
|
#Gradle
|
||||||
org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8
|
org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8
|
||||||
org.gradle.configuration-cache=true
|
org.gradle.configuration-cache=true
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ kotlin = "2.4.0"
|
|||||||
kotlinx-coroutines = "1.11.0"
|
kotlinx-coroutines = "1.11.0"
|
||||||
material3 = "1.11.0-alpha07"
|
material3 = "1.11.0-alpha07"
|
||||||
qrcode = "4.5.0"
|
qrcode = "4.5.0"
|
||||||
jna = "5.17.0"
|
|
||||||
google-code-scanner = "16.1.0"
|
google-code-scanner = "16.1.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
@@ -47,7 +46,6 @@ kotlinx-coroutinesCore = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-co
|
|||||||
kotlinx-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
|
kotlinx-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
|
||||||
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
|
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
|
||||||
qrcode-kotlin = { module = "io.github.g0dkar:qrcode-kotlin", version.ref = "qrcode" }
|
qrcode-kotlin = { module = "io.github.g0dkar:qrcode-kotlin", version.ref = "qrcode" }
|
||||||
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
|
|
||||||
google-code-scanner = { module = "com.google.android.gms:play-services-code-scanner", version.ref = "google-code-scanner" }
|
google-code-scanner = { module = "com.google.android.gms:play-services-code-scanner", version.ref = "google-code-scanner" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
TEAM_ID=
|
|
||||||
|
|
||||||
PRODUCT_NAME=VniDrop
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER=com.vnidrop.app.vnidrop$(TEAM_ID)
|
|
||||||
|
|
||||||
CURRENT_PROJECT_VERSION=1
|
|
||||||
MARKETING_VERSION=1.0
|
|
||||||
@@ -1,403 +0,0 @@
|
|||||||
// !$*UTF8*$!
|
|
||||||
{
|
|
||||||
archiveVersion = 1;
|
|
||||||
classes = {
|
|
||||||
};
|
|
||||||
objectVersion = 77;
|
|
||||||
objects = {
|
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
|
||||||
FA325F1B4E7D8FFDF19A5C4A /* VniDrop.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VniDrop.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
|
||||||
/* End PBXFileReference section */
|
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
|
||||||
7582E3F916810EEF251FA8C4 /* Exceptions for "iosApp" folder in "iosApp" target */ = {
|
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
|
||||||
membershipExceptions = (
|
|
||||||
Info.plist,
|
|
||||||
);
|
|
||||||
target = B83017A9EC036A038DC1B430 /* iosApp */;
|
|
||||||
};
|
|
||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
|
||||||
244B04A7F91FA6EE0623BFF1 /* iosApp */ = {
|
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
|
||||||
exceptions = (
|
|
||||||
7582E3F916810EEF251FA8C4 /* Exceptions for "iosApp" folder in "iosApp" target */,
|
|
||||||
);
|
|
||||||
path = iosApp;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
B93EE283488EDD1107331E67 /* Configuration */ = {
|
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
|
||||||
path = Configuration;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
|
||||||
2BC80A7FB47F5EF23FB83738 /* Frameworks */ = {
|
|
||||||
isa = PBXFrameworksBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
/* End PBXFrameworksBuildPhase section */
|
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
|
||||||
CBA2EB46BC3B70D303C46327 = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
B93EE283488EDD1107331E67 /* Configuration */,
|
|
||||||
244B04A7F91FA6EE0623BFF1 /* iosApp */,
|
|
||||||
C13A067056BA9F38FD87A539 /* Products */,
|
|
||||||
);
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
C13A067056BA9F38FD87A539 /* Products */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
FA325F1B4E7D8FFDF19A5C4A /* VniDrop.app */,
|
|
||||||
);
|
|
||||||
name = Products;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
/* End PBXGroup section */
|
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
|
||||||
B83017A9EC036A038DC1B430 /* iosApp */ = {
|
|
||||||
isa = PBXNativeTarget;
|
|
||||||
buildConfigurationList = 94A2E337CA60E8CC4A507E7E /* Build configuration list for PBXNativeTarget "iosApp" */;
|
|
||||||
buildPhases = (
|
|
||||||
091163AA7720AC82BC13CE03 /* Compile Kotlin Framework */,
|
|
||||||
C8C88FCA7F60AE3F54E85A8C /* Sources */,
|
|
||||||
2BC80A7FB47F5EF23FB83738 /* Frameworks */,
|
|
||||||
8E81423A1A7357695DF65754 /* Resources */,
|
|
||||||
);
|
|
||||||
buildRules = (
|
|
||||||
);
|
|
||||||
dependencies = (
|
|
||||||
);
|
|
||||||
fileSystemSynchronizedGroups = (
|
|
||||||
244B04A7F91FA6EE0623BFF1 /* iosApp */,
|
|
||||||
);
|
|
||||||
name = iosApp;
|
|
||||||
packageProductDependencies = (
|
|
||||||
);
|
|
||||||
productName = iosApp;
|
|
||||||
productReference = FA325F1B4E7D8FFDF19A5C4A /* VniDrop.app */;
|
|
||||||
productType = "com.apple.product-type.application";
|
|
||||||
};
|
|
||||||
/* End PBXNativeTarget section */
|
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
|
||||||
F60687F0AEE04DF31E2599D0 /* Project object */ = {
|
|
||||||
isa = PBXProject;
|
|
||||||
attributes = {
|
|
||||||
BuildIndependentTargetsInParallel = 1;
|
|
||||||
LastSwiftUpdateCheck = 1620;
|
|
||||||
LastUpgradeCheck = 1620;
|
|
||||||
TargetAttributes = {
|
|
||||||
B83017A9EC036A038DC1B430 = {
|
|
||||||
CreatedOnToolsVersion = 16.2;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
buildConfigurationList = FE990A962A8E8AC95D51FA0E /* Build configuration list for PBXProject "iosApp" */;
|
|
||||||
developmentRegion = en;
|
|
||||||
hasScannedForEncodings = 0;
|
|
||||||
knownRegions = (
|
|
||||||
en,
|
|
||||||
Base,
|
|
||||||
);
|
|
||||||
mainGroup = CBA2EB46BC3B70D303C46327;
|
|
||||||
minimizedProjectReferenceProxies = 1;
|
|
||||||
preferredProjectObjectVersion = 77;
|
|
||||||
productRefGroup = C13A067056BA9F38FD87A539 /* Products */;
|
|
||||||
projectDirPath = "";
|
|
||||||
projectRoot = "";
|
|
||||||
targets = (
|
|
||||||
B83017A9EC036A038DC1B430 /* iosApp */,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
/* End PBXProject section */
|
|
||||||
|
|
||||||
/* Begin PBXResourcesBuildPhase section */
|
|
||||||
8E81423A1A7357695DF65754 /* Resources */ = {
|
|
||||||
isa = PBXResourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
/* End PBXResourcesBuildPhase section */
|
|
||||||
|
|
||||||
/* Begin PBXShellScriptBuildPhase section */
|
|
||||||
091163AA7720AC82BC13CE03 /* Compile Kotlin Framework */ = {
|
|
||||||
isa = PBXShellScriptBuildPhase;
|
|
||||||
alwaysOutOfDate = 1;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
inputFileListPaths = (
|
|
||||||
);
|
|
||||||
inputPaths = (
|
|
||||||
);
|
|
||||||
name = "Compile Kotlin Framework";
|
|
||||||
outputFileListPaths = (
|
|
||||||
);
|
|
||||||
outputPaths = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
shellPath = /bin/sh;
|
|
||||||
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 */
|
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
|
||||||
C8C88FCA7F60AE3F54E85A8C /* Sources */ = {
|
|
||||||
isa = PBXSourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
/* 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 */;
|
|
||||||
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;
|
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
|
||||||
ENABLE_TESTABILITY = YES;
|
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
|
||||||
GCC_DYNAMIC_NO_PIC = NO;
|
|
||||||
GCC_NO_COMMON_BLOCKS = YES;
|
|
||||||
GCC_OPTIMIZATION_LEVEL = 0;
|
|
||||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
|
||||||
"DEBUG=1",
|
|
||||||
"$(inherited)",
|
|
||||||
);
|
|
||||||
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 = INCLUDE_SOURCE;
|
|
||||||
MTL_FAST_MATH = YES;
|
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
|
||||||
SDKROOT = iphoneos;
|
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
|
||||||
};
|
|
||||||
name = Debug;
|
|
||||||
};
|
|
||||||
87E567D3634C99D02D035476 /* Debug */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
ARCHS = arm64;
|
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
|
||||||
CODE_SIGN_ENTITLEMENTS = iosApp/vnidrop.entitlements;
|
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
|
||||||
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;
|
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
|
||||||
"$(inherited)",
|
|
||||||
"@executable_path/Frameworks",
|
|
||||||
);
|
|
||||||
OTHER_LDFLAGS = (
|
|
||||||
"$(inherited)",
|
|
||||||
"-framework",
|
|
||||||
SystemConfiguration,
|
|
||||||
"-framework",
|
|
||||||
Network,
|
|
||||||
"-framework",
|
|
||||||
CoreNFC,
|
|
||||||
"-framework",
|
|
||||||
AVFoundation,
|
|
||||||
);
|
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
|
||||||
};
|
|
||||||
name = Debug;
|
|
||||||
};
|
|
||||||
A9DAF5A8F7787C0F3BAEC312 /* Release */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
ARCHS = arm64;
|
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
|
||||||
CODE_SIGN_ENTITLEMENTS = iosApp/vnidrop.entitlements;
|
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
|
||||||
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;
|
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
|
||||||
"$(inherited)",
|
|
||||||
"@executable_path/Frameworks",
|
|
||||||
);
|
|
||||||
OTHER_LDFLAGS = (
|
|
||||||
"$(inherited)",
|
|
||||||
"-framework",
|
|
||||||
SystemConfiguration,
|
|
||||||
"-framework",
|
|
||||||
Network,
|
|
||||||
"-framework",
|
|
||||||
CoreNFC,
|
|
||||||
"-framework",
|
|
||||||
AVFoundation,
|
|
||||||
);
|
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
|
||||||
};
|
|
||||||
name = Release;
|
|
||||||
};
|
|
||||||
/* End XCBuildConfiguration section */
|
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
|
||||||
94A2E337CA60E8CC4A507E7E /* Build configuration list for PBXNativeTarget "iosApp" */ = {
|
|
||||||
isa = XCConfigurationList;
|
|
||||||
buildConfigurations = (
|
|
||||||
87E567D3634C99D02D035476 /* Debug */,
|
|
||||||
A9DAF5A8F7787C0F3BAEC312 /* Release */,
|
|
||||||
);
|
|
||||||
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 */;
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Workspace
|
|
||||||
version = "1.0">
|
|
||||||
<FileRef
|
|
||||||
location = "self:">
|
|
||||||
</FileRef>
|
|
||||||
</Workspace>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"colors": [
|
|
||||||
{
|
|
||||||
"idiom": "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info": {
|
|
||||||
"author": "xcode",
|
|
||||||
"version": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "app-icon.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"platform" : "ios",
|
|
||||||
"size" : "1024x1024"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"appearances" : [
|
|
||||||
{
|
|
||||||
"appearance" : "luminosity",
|
|
||||||
"value" : "dark"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"idiom" : "universal",
|
|
||||||
"platform" : "ios",
|
|
||||||
"size" : "1024x1024"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"appearances" : [
|
|
||||||
{
|
|
||||||
"appearance" : "luminosity",
|
|
||||||
"value" : "tinted"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"idiom" : "universal",
|
|
||||||
"platform" : "ios",
|
|
||||||
"size" : "1024x1024"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 49 KiB |
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import Shared
|
|
||||||
import SwiftUI
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
// Compose renders the app content, but UIKit owns the status bar style. This
|
|
||||||
// host listens for theme changes from shared Kotlin code and asks iOS to
|
|
||||||
// recompute the status bar contrast.
|
|
||||||
final class VniDropHostViewController: UIViewController {
|
|
||||||
private let composeController: UIViewController
|
|
||||||
private var usesDarkTheme: Bool
|
|
||||||
|
|
||||||
init(composeController: UIViewController) {
|
|
||||||
self.composeController = composeController
|
|
||||||
self.usesDarkTheme = UITraitCollection.current.userInterfaceStyle == .dark
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
|
||||||
usesDarkTheme ? .lightContent : .darkContent
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
|
|
||||||
addChild(composeController)
|
|
||||||
view.addSubview(composeController.view)
|
|
||||||
composeController.view.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
composeController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
||||||
composeController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
||||||
composeController.view.topAnchor.constraint(equalTo: view.topAnchor),
|
|
||||||
composeController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
||||||
])
|
|
||||||
composeController.didMove(toParent: self)
|
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
self,
|
|
||||||
selector: #selector(themeDidChange(_:)),
|
|
||||||
name: Notification.Name("VniDropThemeChanged"),
|
|
||||||
object: nil
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
NotificationCenter.default.removeObserver(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func themeDidChange(_ notification: Notification) {
|
|
||||||
guard let isDark = notification.userInfo?["isDark"] as? String else { return }
|
|
||||||
usesDarkTheme = isDark == "true"
|
|
||||||
setNeedsStatusBarAppearanceUpdate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ComposeView: UIViewControllerRepresentable {
|
|
||||||
let externalInvitations: ExternalInvitationController
|
|
||||||
|
|
||||||
func makeUIViewController(context: Self.Context) -> UIViewController {
|
|
||||||
VniDropHostViewController(
|
|
||||||
composeController: MainViewControllerKt.MainViewController(
|
|
||||||
externalInvitations: externalInvitations
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIViewController(_ uiViewController: UIViewController, context: Self.Context) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ContentView: View {
|
|
||||||
let externalInvitations: ExternalInvitationController
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ComposeView(externalInvitations: externalInvitations)
|
|
||||||
.ignoresSafeArea()
|
|
||||||
.onOpenURL(perform: openInvitation)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func openInvitation(_ url: URL) {
|
|
||||||
guard url.pathExtension.caseInsensitiveCompare("vnd") == .orderedSame else {
|
|
||||||
externalInvitations.reportOpenFailure(message: "This is not a VniDrop invitation")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let hasSecurityAccess = url.startAccessingSecurityScopedResource()
|
|
||||||
defer {
|
|
||||||
if hasSecurityAccess {
|
|
||||||
url.stopAccessingSecurityScopedResource()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
let values = try url.resourceValues(forKeys: [.fileSizeKey])
|
|
||||||
if let fileSize = values.fileSize, fileSize > 65_536 {
|
|
||||||
throw InvitationOpenError.tooLarge
|
|
||||||
}
|
|
||||||
let data = try Data(contentsOf: url, options: .mappedIfSafe)
|
|
||||||
guard data.count <= 65_536 else { throw InvitationOpenError.tooLarge }
|
|
||||||
guard let raw = String(data: data, encoding: .utf8) else {
|
|
||||||
throw InvitationOpenError.invalidEncoding
|
|
||||||
}
|
|
||||||
externalInvitations.openInvitation(raw: raw)
|
|
||||||
} catch {
|
|
||||||
externalInvitations.reportOpenFailure(
|
|
||||||
message: (error as? LocalizedError)?.errorDescription ?? "The invitation could not be opened"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum InvitationOpenError: LocalizedError {
|
|
||||||
case tooLarge
|
|
||||||
case invalidEncoding
|
|
||||||
|
|
||||||
var errorDescription: String? {
|
|
||||||
switch self {
|
|
||||||
case .tooLarge: "The invitation is too large"
|
|
||||||
case .invalidEncoding: "The invitation is not valid text"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
<?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>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>UIBackgroundModes</key>
|
|
||||||
<array>
|
|
||||||
<string>fetch</string>
|
|
||||||
<string>processing</string>
|
|
||||||
<string>remote-notification</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>
|
|
||||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
|
||||||
<true/>
|
|
||||||
<key>UIFileSharingEnabled</key>
|
|
||||||
<true/>
|
|
||||||
<key>NSCameraUsageDescription</key>
|
|
||||||
<string>VniDrop uses the camera to scan transfer QR codes.</string>
|
|
||||||
<key>NFCReaderUsageDescription</key>
|
|
||||||
<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>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"info": {
|
|
||||||
"author": "xcode",
|
|
||||||
"version": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import Shared
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
@main
|
|
||||||
struct iOSApp: App {
|
|
||||||
private let externalInvitations = ExternalInvitationController()
|
|
||||||
|
|
||||||
var body: some Scene {
|
|
||||||
WindowGroup {
|
|
||||||
ContentView(externalInvitations: externalInvitations)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?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>com.apple.developer.nfc.readersession.formats</key>
|
|
||||||
<array>
|
|
||||||
<string>NDEF</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -11,10 +11,10 @@ A Bun CLI generates the platform-native files from it:
|
|||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd localization
|
# From the repository root:
|
||||||
bun run src/cli.ts validate # structural checks (run before committing)
|
make check-localization # structural checks (run before committing)
|
||||||
bun run src/cli.ts generate # regenerate .xcstrings + strings.xml from strings.json
|
make localization # regenerate .xcstrings + strings.xml from strings.json
|
||||||
bun run src/cli.ts migrate # one-time: rebuild strings.json from existing platform files
|
make localization-migrate # one-time: rebuild strings.json from platform files
|
||||||
```
|
```
|
||||||
|
|
||||||
**Never edit the generated `.xcstrings` / `strings.xml` by hand** — edit `strings.json` and
|
**Never edit the generated `.xcstrings` / `strings.xml` by hand** — edit `strings.json` and
|
||||||
|
|||||||
51
make/release.mk
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
.PHONY: package-deb package-rpm package-msix
|
||||||
|
|
||||||
|
package-deb: ## Build and verify a Debian x64 package (VERSION=x.y.z).
|
||||||
|
@test "$(HOST_OS)" = linux || { printf 'Debian packaging requires Linux.\n' >&2; exit 1; }
|
||||||
|
@cd $(ROOT); \
|
||||||
|
version="$$(packaging/linux/resolve-version.sh "$(VERSION)")"; \
|
||||||
|
$(GRADLE) :shared:jvmTest :desktopApp:packageReleaseDeb \
|
||||||
|
-Pvnidrop.version="$$version" \
|
||||||
|
-Pvnidrop.desktop.rustVariant=release \
|
||||||
|
-Pvnidrop.diagnostics.included=false \
|
||||||
|
$(GRADLE_RELEASE_FLAGS); \
|
||||||
|
mapfile -t packages < <(find desktopApp/build/compose/binaries/main-release/deb -maxdepth 1 -type f -name '*.deb'); \
|
||||||
|
(( $${#packages[@]} == 1 )) || { printf 'Expected exactly one Debian package, found %s\n' "$${#packages[@]}" >&2; exit 1; }; \
|
||||||
|
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" ); \
|
||||||
|
printf 'Package: %s/%s\n' "$$output_directory" "$$output_name"
|
||||||
|
|
||||||
|
package-rpm: ## Build and verify an RPM x64 package (VERSION=x.y.z).
|
||||||
|
@test "$(HOST_OS)" = linux || { printf 'RPM packaging requires Linux.\n' >&2; exit 1; }
|
||||||
|
@cd $(ROOT); \
|
||||||
|
version="$$(packaging/linux/resolve-version.sh "$(VERSION)")"; \
|
||||||
|
$(GRADLE) :desktopApp:packageReleaseRpm \
|
||||||
|
-Pvnidrop.version="$$version" \
|
||||||
|
-Pvnidrop.desktop.rustVariant=release \
|
||||||
|
-Pvnidrop.diagnostics.included=false \
|
||||||
|
$(GRADLE_RELEASE_FLAGS); \
|
||||||
|
mapfile -t packages < <(find desktopApp/build/compose/binaries/main-release/rpm -maxdepth 1 -type f -name '*.rpm'); \
|
||||||
|
(( $${#packages[@]} == 1 )) || { printf 'Expected exactly one RPM package, found %s\n' "$${#packages[@]}" >&2; exit 1; }; \
|
||||||
|
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" ); \
|
||||||
|
printf 'Package: %s/%s\n' "$$output_directory" "$$output_name"
|
||||||
|
|
||||||
|
package-msix: ## Build and verify an unsigned Windows Store MSIX (VERSION=x.y.z).
|
||||||
|
@test "$(HOST_OS)" = windows || { printf 'MSIX packaging requires Windows.\n' >&2; exit 1; }
|
||||||
|
cd $(ROOT) && $(GRADLE) :shared:jvmTest :desktopApp:createReleaseDistributable \
|
||||||
|
-Pvnidrop.version="$(VERSION)" \
|
||||||
|
-Pvnidrop.desktop.rustVariant=release \
|
||||||
|
-Pvnidrop.diagnostics.included=false \
|
||||||
|
$(GRADLE_RELEASE_FLAGS)
|
||||||
|
cd $(ROOT) && $(POWERSHELL) -NoProfile -File packaging/windows/build-msix.ps1 \
|
||||||
|
-Version "$(VERSION)" \
|
||||||
|
-AppImage desktopApp/build/compose/binaries/main-release/app/VniDrop \
|
||||||
|
-OutputDirectory build/release/windows
|
||||||
@@ -64,21 +64,10 @@ Ubuntu prevents `jpackage` from discovering normal RPM dependencies.
|
|||||||
From the repository root on the matching Linux family, run one of:
|
From the repository root on the matching Linux family, run one of:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gradlew :shared:jvmTest :desktopApp:packageReleaseDeb \
|
make package-deb VERSION=1.0.0
|
||||||
-Pvnidrop.version=1.0.0 \
|
make package-rpm 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
|
The Make targets collect the Compose output under `build/release/linux/`, then
|
||||||
`desktopApp/build/compose/binaries/main-release/deb/` and
|
validate package identity, version, architecture, dependencies, bundled JVM,
|
||||||
`desktopApp/build/compose/binaries/main-release/rpm/`. The workflow then
|
and release Rust payload before generating a SHA-256 checksum.
|
||||||
validates package identity, version, architecture, dependencies, bundled JVM,
|
|
||||||
and release Rust payload before publishing anything.
|
|
||||||
|
|||||||
@@ -85,19 +85,23 @@ npx wrangler r2 bucket create vnidrop-diagnostics
|
|||||||
```
|
```
|
||||||
|
|
||||||
Replace the placeholder `database_id` in `wrangler.jsonc` with the UUID returned
|
Replace the placeholder `database_id` in `wrangler.jsonc` with the UUID returned
|
||||||
by `wrangler d1 create`. Set the ingest key interactively, apply the tracked D1
|
by `wrangler d1 create`. Set the ingest key interactively and configure the R2
|
||||||
migrations, and configure the R2 retention rule once:
|
retention rule once:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx wrangler secret put INGEST_KEY
|
npx wrangler secret put INGEST_KEY
|
||||||
npm run db:migrate:remote
|
|
||||||
npx wrangler r2 bucket lifecycle add vnidrop-diagnostics diagnostics-retention --expire-days 90
|
npx wrangler r2 bucket lifecycle add vnidrop-diagnostics diagnostics-retention --expire-days 90
|
||||||
npm run check
|
|
||||||
npm run deploy
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`npm run deploy` also runs the complete `check` script automatically before
|
Then apply migrations and deploy from the repository root:
|
||||||
Wrangler changes the remote Worker.
|
|
||||||
|
```bash
|
||||||
|
make diagnostics-db-remote
|
||||||
|
make deploy-diagnostics
|
||||||
|
```
|
||||||
|
|
||||||
|
`make deploy-diagnostics` runs the complete check before Wrangler changes the
|
||||||
|
remote Worker.
|
||||||
|
|
||||||
The lifecycle command changes the remote bucket. Before adding or changing a
|
The lifecycle command changes the remote bucket. Before adding or changing a
|
||||||
rule, inspect the current state with:
|
rule, inspect the current state with:
|
||||||
@@ -117,8 +121,9 @@ INGEST_KEY=local-development-only
|
|||||||
Then initialize the local D1 database and run the Worker:
|
Then initialize the local D1 database and run the Worker:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run db:migrate:local
|
# From the repository root:
|
||||||
npm run dev
|
make diagnostics-db-local
|
||||||
|
make run-diagnostics
|
||||||
```
|
```
|
||||||
|
|
||||||
Wrangler keeps local D1 and R2 state under the ignored `.wrangler/` directory.
|
Wrangler keeps local D1 and R2 state under the ignored `.wrangler/` directory.
|
||||||
@@ -133,7 +138,7 @@ Never edit an applied migration; add the next numbered SQL file instead.
|
|||||||
bindings cannot silently drift from the Worker code:
|
bindings cannot silently drift from the Worker code:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run typegen # regenerate after changing bindings or vars
|
make diagnostics-typegen # from the repository root
|
||||||
npm run types:check # verify the committed file is current
|
npm run types:check # verify the committed file is current
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ still applies; this file wins for UI/KMP work.
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`shared` is the multiplatform app layer: Compose UI, feature ViewModels, and
|
`shared` is the Compose Multiplatform app layer for Android, Windows, and Linux:
|
||||||
`expect`/`actual` bridges into Android, iOS, and desktop. Native transfer work
|
Compose UI, feature ViewModels, and `expect`/`actual` platform bridges. Native
|
||||||
goes through UniFFI `VnidropCore` (see `crates/vnidrop`).
|
transfer work goes through UniFFI `VnidropCore` (see `crates/vnidrop`). Apple
|
||||||
|
platforms use the native SwiftUI app under `apple/`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ lists, animation, accessibility:
|
|||||||
| Theme | Only `LocalVniDropColors` / `VniDropThemeTokens` (`ui/theme/VniDropTheme.kt`). Brand primary light ≈ `#A855F7` (HSL 271, 91%, 65%). |
|
| Theme | Only `LocalVniDropColors` / `VniDropThemeTokens` (`ui/theme/VniDropTheme.kt`). Brand primary light ≈ `#A855F7` (HSL 271, 91%, 65%). |
|
||||||
| Strings | CMP composeResources / `Res.string.*` — not Android `R` in `commonMain`. |
|
| Strings | CMP composeResources / `Res.string.*` — not Android `R` in `commonMain`. |
|
||||||
| DI | Follow existing `AppGraph` construction; no unprompted Hilt/Koin migration. |
|
| DI | Follow existing `AppGraph` construction; no unprompted Hilt/Koin migration. |
|
||||||
| Platform | `androidMain` / `iosMain` / `jvmMain` for pickers, SAF, security-scoped URLs, NFC/QR. |
|
| Platform | `androidMain` / `jvmMain` for pickers, SAF, NFC/QR, and desktop integration. |
|
||||||
| Dependencies | Before adding Jetpack/AndroidX to `commonMain`, verify multiplatform artifacts for all targets. |
|
| Dependencies | Before adding Jetpack/AndroidX to `commonMain`, verify multiplatform artifacts for all targets. |
|
||||||
|
|
||||||
compose-skill “Existing Project Policy”: adapt to this repo; do not force-migrate.
|
compose-skill “Existing Project Policy”: adapt to this repo; do not force-migrate.
|
||||||
@@ -45,22 +46,19 @@ compose-skill “Existing Project Policy”: adapt to this repo; do not force-mi
|
|||||||
From repo root:
|
From repo root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gradlew :shared:jvmTest
|
make check-shared
|
||||||
./gradlew :shared:compileKotlinJvm
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Optional:
|
Optional:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gradlew :shared:testAndroidHostTest
|
make test-android-host
|
||||||
./gradlew :shared:iosSimulatorArm64Test
|
make run-desktop
|
||||||
./gradlew :desktopApp:run
|
make check-android
|
||||||
./gradlew :androidApp:assembleDebug
|
|
||||||
```
|
```
|
||||||
|
|
||||||
CI `:shared:jvmTest` currently runs on **macOS**. Gobley host cargo follows the
|
CI `:shared:jvmTest` runs on **Linux**. Gobley host cargo follows the current
|
||||||
current host and architecture, including Linux and Windows; use macOS only when
|
host and architecture so local desktop builds embed their matching Rust library.
|
||||||
exact CI parity is required.
|
|
||||||
|
|
||||||
When Kotlin changes touch UniFFI-generated APIs, rebuild/test with a full
|
When Kotlin changes touch UniFFI-generated APIs, rebuild/test with a full
|
||||||
`jvmTest` so Gobley/native pieces stay aligned.
|
`jvmTest` so Gobley/native pieces stay aligned.
|
||||||
@@ -76,7 +74,7 @@ src/
|
|||||||
feature/send|receive|approvals|settings|app/
|
feature/send|receive|approvals|settings|app/
|
||||||
ui/ # theme, components, navigation, feedback, state helpers
|
ui/ # theme, components, navigation, feedback, state helpers
|
||||||
commonMain/composeResources/
|
commonMain/composeResources/
|
||||||
androidMain|iosMain|jvmMain/
|
androidMain|jvmMain/
|
||||||
commonTest|jvmTest|...
|
commonTest|jvmTest|...
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -86,7 +84,8 @@ src/
|
|||||||
documents with relative `displayName` paths before calling Rust
|
documents with relative `displayName` paths before calling Rust
|
||||||
(`FileSystemService.android.kt` / `expandShareDirectory`).
|
(`FileSystemService.android.kt` / `expandShareDirectory`).
|
||||||
- **Android receive:** MediaStore Downloads sink and/or SAF tree write sink.
|
- **Android receive:** MediaStore Downloads sink and/or SAF tree write sink.
|
||||||
- **iOS:** keep security-scoped leases alive while Rust reads paths.
|
- **Apple:** lives outside this module under `apple/`; do not add Apple platform
|
||||||
|
behavior back to KMP.
|
||||||
- **Desktop:** filesystem paths; directories may be marked `isDirectory` for Rust walk.
|
- **Desktop:** filesystem paths; directories may be marked `isDirectory` for Rust walk.
|
||||||
|
|
||||||
Never pass a directory as a single Android FD into `SourceKind.FILE_DESCRIPTOR`.
|
Never pass a directory as a single Android FD into `SourceKind.FILE_DESCRIPTOR`.
|
||||||
@@ -113,7 +112,7 @@ Never pass a directory as a single Android FD into `SourceKind.FILE_DESCRIPTOR`.
|
|||||||
- Prefer fakes in `commonTest` support over real UniFFI in pure unit tests.
|
- Prefer fakes in `commonTest` support over real UniFFI in pure unit tests.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gradlew :shared:jvmTest
|
make test-shared
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
@file:OptIn(gobley.gradle.InternalGobleyGradleApi::class)
|
@file:OptIn(gobley.gradle.InternalGobleyGradleApi::class)
|
||||||
|
|
||||||
import gobley.gradle.cargo.dsl.appleMobile
|
|
||||||
import gobley.gradle.cargo.dsl.jvm
|
import gobley.gradle.cargo.dsl.jvm
|
||||||
import gobley.gradle.cargo.tasks.CargoBuildTask
|
import gobley.gradle.cargo.tasks.CargoBuildTask
|
||||||
import gobley.gradle.cargo.tasks.CargoCheckTask
|
import gobley.gradle.cargo.tasks.CargoCheckTask
|
||||||
import gobley.gradle.GobleyHost
|
import gobley.gradle.GobleyHost
|
||||||
import gobley.gradle.rust.targets.RustAndroidTarget
|
import gobley.gradle.rust.targets.RustAndroidTarget
|
||||||
import gobley.gradle.rust.targets.RustAppleMobileTarget
|
|
||||||
import gobley.gradle.rust.targets.RustTarget
|
import gobley.gradle.rust.targets.RustTarget
|
||||||
import gobley.gradle.Variant
|
import gobley.gradle.Variant
|
||||||
import org.gradle.api.DefaultTask
|
import org.gradle.api.DefaultTask
|
||||||
@@ -113,18 +111,6 @@ val generateDiagnosticsBuildConfig by tasks.registering {
|
|||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
if (GobleyHost.current.platform == GobleyHost.Platform.MacOS) {
|
|
||||||
listOf(
|
|
||||||
iosArm64(),
|
|
||||||
iosSimulatorArm64()
|
|
||||||
).forEach { iosTarget ->
|
|
||||||
iosTarget.binaries.framework {
|
|
||||||
baseName = "Shared"
|
|
||||||
isStatic = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
androidTarget {
|
androidTarget {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
jvmTarget = JvmTarget.JVM_11
|
jvmTarget = JvmTarget.JVM_11
|
||||||
@@ -190,9 +176,6 @@ android {
|
|||||||
val hostCargoTargets = buildSet<RustTarget> {
|
val hostCargoTargets = buildSet<RustTarget> {
|
||||||
add(GobleyHost.current.rustTarget)
|
add(GobleyHost.current.rustTarget)
|
||||||
addAll(RustAndroidTarget.entries)
|
addAll(RustAndroidTarget.entries)
|
||||||
if (GobleyHost.current.platform == GobleyHost.Platform.MacOS) {
|
|
||||||
addAll(RustAppleMobileTarget.entries)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cargo {
|
cargo {
|
||||||
@@ -207,15 +190,6 @@ cargo {
|
|||||||
embedRustLibrary.set(rustTarget == GobleyHost.current.rustTarget)
|
embedRustLibrary.set(rustTarget == GobleyHost.current.rustTarget)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
builds.appleMobile {
|
|
||||||
variants {
|
|
||||||
buildTaskProvider.configure {
|
|
||||||
if (rustTarget.cinteropName == "ios") {
|
|
||||||
additionalEnvironment.put("IPHONEOS_DEPLOYMENT_TARGET", "16.0.0")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
builds.configureEach {
|
builds.configureEach {
|
||||||
val buildOnCurrentHost = rustTarget in hostCargoTargets
|
val buildOnCurrentHost = rustTarget in hostCargoTargets
|
||||||
installTargetBeforeBuild.set(buildOnCurrentHost)
|
installTargetBeforeBuild.set(buildOnCurrentHost)
|
||||||
|
|||||||
@@ -52,15 +52,13 @@ private class AndroidFileSystemService(
|
|||||||
ReceiveFolderKind.FileSystemPath -> validatePath(folder.value)
|
ReceiveFolderKind.FileSystemPath -> validatePath(folder.value)
|
||||||
ReceiveFolderKind.AndroidPublicDownloads -> validatePublicDownloads()
|
ReceiveFolderKind.AndroidPublicDownloads -> validatePublicDownloads()
|
||||||
ReceiveFolderKind.AndroidTreeUri -> validateTreeUri(folder.value)
|
ReceiveFolderKind.AndroidTreeUri -> validateTreeUri(folder.value)
|
||||||
ReceiveFolderKind.IosSecurityScopedUrl -> FolderAccessStatus.Unavailable
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? =
|
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? =
|
||||||
when (folder.kind) {
|
when (folder.kind) {
|
||||||
ReceiveFolderKind.AndroidPublicDownloads -> AndroidMediaStoreDownloadsSink(context)
|
ReceiveFolderKind.AndroidPublicDownloads -> AndroidMediaStoreDownloadsSink(context)
|
||||||
ReceiveFolderKind.AndroidTreeUri -> AndroidTreeReceiveOutputSink(context, folder.value.toUri())
|
ReceiveFolderKind.AndroidTreeUri -> AndroidTreeReceiveOutputSink(context, folder.value.toUri())
|
||||||
ReceiveFolderKind.FileSystemPath,
|
ReceiveFolderKind.FileSystemPath -> null
|
||||||
ReceiveFolderKind.IosSecurityScopedUrl -> null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun sharePickedFiles(
|
override suspend fun sharePickedFiles(
|
||||||
|
|||||||
@@ -134,13 +134,6 @@ interface CoreGateway {
|
|||||||
senderName: String,
|
senderName: String,
|
||||||
accessPolicy: ShareAccessPolicy,
|
accessPolicy: ShareAccessPolicy,
|
||||||
): Result<Share>
|
): Result<Share>
|
||||||
suspend fun shareSecurityScopedFileUrl(
|
|
||||||
fileUrl: String,
|
|
||||||
displayName: String,
|
|
||||||
transferName: String,
|
|
||||||
senderName: String,
|
|
||||||
accessPolicy: ShareAccessPolicy,
|
|
||||||
): Result<Share>
|
|
||||||
/** Multi-source share used by multi-file pickers. */
|
/** Multi-source share used by multi-file pickers. */
|
||||||
suspend fun shareSources(
|
suspend fun shareSources(
|
||||||
sources: List<uniffi.vnidrop.ShareSource>,
|
sources: List<uniffi.vnidrop.ShareSource>,
|
||||||
@@ -151,7 +144,6 @@ interface CoreGateway {
|
|||||||
suspend fun inspectTicket(ticket: String): Result<TicketInspectionModel>
|
suspend fun inspectTicket(ticket: String): Result<TicketInspectionModel>
|
||||||
suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result<Unit>
|
suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result<Unit>
|
||||||
suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result<Unit>
|
suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result<Unit>
|
||||||
suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String): Result<Unit>
|
|
||||||
suspend fun cancel(transferId: ULong): Result<Unit>
|
suspend fun cancel(transferId: ULong): Result<Unit>
|
||||||
suspend fun delete(transferId: ULong): Result<Unit>
|
suspend fun delete(transferId: ULong): Result<Unit>
|
||||||
suspend fun clearReceiveHistory(): Result<ULong>
|
suspend fun clearReceiveHistory(): Result<ULong>
|
||||||
|
|||||||
@@ -113,27 +113,6 @@ class CoreRepository(
|
|||||||
accessPolicy = accessPolicy,
|
accessPolicy = accessPolicy,
|
||||||
)
|
)
|
||||||
|
|
||||||
override suspend fun shareSecurityScopedFileUrl(
|
|
||||||
fileUrl: String,
|
|
||||||
displayName: String,
|
|
||||||
transferName: String,
|
|
||||||
senderName: String,
|
|
||||||
accessPolicy: ShareAccessPolicy,
|
|
||||||
): Result<Share> =
|
|
||||||
shareSources(
|
|
||||||
sources = listOf(
|
|
||||||
ShareSource(
|
|
||||||
kind = SourceKind.IOS_SECURITY_SCOPED_URL,
|
|
||||||
value = fileUrl,
|
|
||||||
displayName = displayName.ifBlank { fileUrl.substringAfterLast('/').ifBlank { "transfer" } },
|
|
||||||
isDirectory = false,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
transferName = transferName,
|
|
||||||
senderName = senderName,
|
|
||||||
accessPolicy = accessPolicy,
|
|
||||||
)
|
|
||||||
|
|
||||||
override suspend fun inspectTicket(ticket: String): Result<TicketInspectionModel> = runCore {
|
override suspend fun inspectTicket(ticket: String): Result<TicketInspectionModel> = runCore {
|
||||||
requireCore().inspectTicket(ticket).toModel().also { inspection ->
|
requireCore().inspectTicket(ticket).toModel().also { inspection ->
|
||||||
_state.update { it.copy(lastInspection = inspection) }
|
_state.update { it.copy(lastInspection = inspection) }
|
||||||
@@ -154,17 +133,6 @@ class CoreRepository(
|
|||||||
refreshSnapshot()
|
refreshSnapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun receiveIntoSecurityScopedDirectory(
|
|
||||||
ticket: String,
|
|
||||||
outputDirectoryUrl: String,
|
|
||||||
receiverName: String,
|
|
||||||
): Result<Unit> = runCore {
|
|
||||||
withPlatformPathAccess(SourceKind.IOS_SECURITY_SCOPED_URL, outputDirectoryUrl) {
|
|
||||||
requireCore().receive(ticket, outputDirectoryUrl, receiverName.ifBlank { null })
|
|
||||||
}
|
|
||||||
refreshSnapshot()
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun cancel(transferId: ULong): Result<Unit> = runCore {
|
override suspend fun cancel(transferId: ULong): Result<Unit> = runCore {
|
||||||
requireCore().cancelTransfer(transferId)
|
requireCore().cancelTransfer(transferId)
|
||||||
refreshSnapshot()
|
refreshSnapshot()
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ data class PickedShareFile(
|
|||||||
/** App-owned picker copy that may be deleted after import or when selection is abandoned. */
|
/** App-owned picker copy that may be deleted after import or when selection is abandoned. */
|
||||||
val isTemporaryCopy: Boolean = false,
|
val isTemporaryCopy: Boolean = false,
|
||||||
/**
|
/**
|
||||||
* When true, [value] is a directory (filesystem path, iOS security-scoped
|
* When true, [value] is a directory (filesystem path or Android document tree
|
||||||
* folder URL, or Android document tree URI). Platform share code expands or
|
* URI). Platform share code expands or walks it; Rust cannot treat an Android
|
||||||
* walks it; Rust cannot treat an Android FD as a directory.
|
* FD as a directory.
|
||||||
*/
|
*/
|
||||||
val isDirectory: Boolean = false,
|
val isDirectory: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ enum class ReceiveFolderKind {
|
|||||||
/** Shared system Downloads via MediaStore (Android 10+). */
|
/** Shared system Downloads via MediaStore (Android 10+). */
|
||||||
AndroidPublicDownloads,
|
AndroidPublicDownloads,
|
||||||
AndroidTreeUri,
|
AndroidTreeUri,
|
||||||
IosSecurityScopedUrl,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stable token stored in preferences for [ReceiveFolderKind.AndroidPublicDownloads]. */
|
/** Stable token stored in preferences for [ReceiveFolderKind.AndroidPublicDownloads]. */
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ package com.vnidrop.app.core
|
|||||||
|
|
||||||
import uniffi.vnidrop.SourceKind
|
import uniffi.vnidrop.SourceKind
|
||||||
|
|
||||||
// Platform file handles have different lifetime rules. Desktop paths need no
|
// Desktop paths need no extra work, while Rust duplicates borrowed Android file
|
||||||
// extra work, Rust duplicates Android fd sources immediately, and iOS
|
// descriptors immediately before the platform closes them.
|
||||||
// security-scoped URLs must remain leased while Rust performs the blocking
|
|
||||||
// import/export call.
|
|
||||||
internal expect suspend fun <T> withPlatformPathAccess(
|
internal expect suspend fun <T> withPlatformPathAccess(
|
||||||
kind: SourceKind,
|
kind: SourceKind,
|
||||||
value: String,
|
value: String,
|
||||||
|
|||||||
@@ -161,14 +161,10 @@ class ReceiveViewModel(
|
|||||||
it.copy(isReceiving = true, lastReceiveError = null, activeReceiveTransferId = null)
|
it.copy(isReceiving = true, lastReceiveError = null, activeReceiveTransferId = null)
|
||||||
}
|
}
|
||||||
val outputSink = fileSystemService.createReceiveOutputSink(folder)
|
val outputSink = fileSystemService.createReceiveOutputSink(folder)
|
||||||
val result = when {
|
val result = if (outputSink != null) {
|
||||||
outputSink != null -> repository.receiveWithOutputSink(current.ticket, outputSink, current.receiverName)
|
repository.receiveWithOutputSink(current.ticket, outputSink, current.receiverName)
|
||||||
folder.kind == ReceiveFolderKind.IosSecurityScopedUrl -> repository.receiveIntoSecurityScopedDirectory(
|
} else {
|
||||||
current.ticket,
|
repository.receive(current.ticket, folder.value, current.receiverName)
|
||||||
folder.value,
|
|
||||||
current.receiverName,
|
|
||||||
)
|
|
||||||
else -> repository.receive(current.ticket, folder.value, current.receiverName)
|
|
||||||
}
|
}
|
||||||
result.fold(
|
result.fold(
|
||||||
onSuccess = {
|
onSuccess = {
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ class ViewModelsTest {
|
|||||||
fun settingsUsesDefaultReceiveFolderWhenPlatformDoesNotSupportCustomFolders() = runTest {
|
fun settingsUsesDefaultReceiveFolderWhenPlatformDoesNotSupportCustomFolders() = runTest {
|
||||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
val appDocuments = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/app/Documents", "Documents")
|
val appDocuments = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/app/Documents", "Documents")
|
||||||
val externalFolder = ReceiveFolder(ReceiveFolderKind.IosSecurityScopedUrl, "file:///external", "External")
|
val externalFolder = ReceiveFolder(ReceiveFolderKind.AndroidTreeUri, "content://external", "External")
|
||||||
val preferences = preferences().apply {
|
val preferences = preferences().apply {
|
||||||
mutablePreferences.value = mutablePreferences.value.copy(receiveFolder = externalFolder)
|
mutablePreferences.value = mutablePreferences.value.copy(receiveFolder = externalFolder)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,13 +89,6 @@ class FakeCoreGateway : CoreGateway {
|
|||||||
senderName: String,
|
senderName: String,
|
||||||
accessPolicy: ShareAccessPolicy,
|
accessPolicy: ShareAccessPolicy,
|
||||||
) = Result.failure<Share>(UnsupportedOperationException())
|
) = Result.failure<Share>(UnsupportedOperationException())
|
||||||
override suspend fun shareSecurityScopedFileUrl(
|
|
||||||
fileUrl: String,
|
|
||||||
displayName: String,
|
|
||||||
transferName: String,
|
|
||||||
senderName: String,
|
|
||||||
accessPolicy: ShareAccessPolicy,
|
|
||||||
) = Result.failure<Share>(UnsupportedOperationException())
|
|
||||||
override suspend fun shareSources(
|
override suspend fun shareSources(
|
||||||
sources: List<uniffi.vnidrop.ShareSource>,
|
sources: List<uniffi.vnidrop.ShareSource>,
|
||||||
transferName: String,
|
transferName: String,
|
||||||
@@ -142,13 +135,6 @@ class FakeCoreGateway : CoreGateway {
|
|||||||
awaitReceiveIfNeeded()
|
awaitReceiveIfNeeded()
|
||||||
return receiveResult
|
return receiveResult
|
||||||
}
|
}
|
||||||
override suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String): Result<Unit> {
|
|
||||||
receiveCount += 1
|
|
||||||
lastReceiveTicket = ticket
|
|
||||||
lastReceiveReceiverName = receiverName
|
|
||||||
awaitReceiveIfNeeded()
|
|
||||||
return receiveResult
|
|
||||||
}
|
|
||||||
override suspend fun cancel(transferId: ULong): Result<Unit> {
|
override suspend fun cancel(transferId: ULong): Result<Unit> {
|
||||||
cancelledTransfers += transferId
|
cancelledTransfers += transferId
|
||||||
return Result.success(Unit)
|
return Result.success(Unit)
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
package com.vnidrop.app
|
|
||||||
|
|
||||||
import androidx.compose.ui.window.ComposeUIViewController
|
|
||||||
import com.vnidrop.app.feature.receive.ExternalInvitationController
|
|
||||||
|
|
||||||
fun MainViewController(externalInvitations: ExternalInvitationController) =
|
|
||||||
ComposeUIViewController { App(rememberIosAppDependencies(externalInvitations)) }
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
package com.vnidrop.app
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import com.vnidrop.app.core.rememberFileSystemService
|
|
||||||
import com.vnidrop.app.notifications.IosLocalNotificationService
|
|
||||||
import com.vnidrop.app.feature.receive.ExternalInvitationController
|
|
||||||
import platform.Foundation.NSBundle
|
|
||||||
import platform.Foundation.NSApplicationSupportDirectory
|
|
||||||
import platform.Foundation.NSSearchPathForDirectoriesInDomains
|
|
||||||
import platform.Foundation.NSUserDomainMask
|
|
||||||
import platform.UIKit.UIDevice
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun rememberIosAppDependencies(externalInvitations: ExternalInvitationController): AppDependencies {
|
|
||||||
val fileSystemService = rememberFileSystemService()
|
|
||||||
return remember(fileSystemService) {
|
|
||||||
val device = UIDevice.currentDevice
|
|
||||||
AppDependencies(
|
|
||||||
environment = PlatformEnvironment(
|
|
||||||
name = device.systemName() + " " + device.systemVersion,
|
|
||||||
appVersion = NSBundle.mainBundle.objectForInfoDictionaryKey("CFBundleShortVersionString") as? String ?: "0.1.0",
|
|
||||||
defaultCoreDataDir = iosApplicationDataDirectory(),
|
|
||||||
defaultUsername = device.name.takeIf(String::isNotBlank) ?: "Receiver",
|
|
||||||
),
|
|
||||||
deviceInfoProvider = IosDeviceInfoProvider(device),
|
|
||||||
fileSystemService = fileSystemService,
|
|
||||||
localNotificationService = IosLocalNotificationService(),
|
|
||||||
externalInvitations = externalInvitations,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun iosApplicationDataDirectory(): String =
|
|
||||||
(NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, true).firstOrNull() as? String)
|
|
||||||
?.trimEnd('/')?.plus("/VniDrop")
|
|
||||||
?: error("iOS Application Support directory is unavailable")
|
|
||||||
|
|
||||||
private class IosDeviceInfoProvider(
|
|
||||||
private val device: UIDevice,
|
|
||||||
) : DeviceInfoProvider {
|
|
||||||
override suspend fun load(): DeviceInfo = DeviceInfo(
|
|
||||||
deviceName = device.name,
|
|
||||||
deviceModel = device.model,
|
|
||||||
operatingSystem = device.systemName() + " " + device.systemVersion,
|
|
||||||
network = null,
|
|
||||||
batteryLevel = runCatching {
|
|
||||||
val wasMonitoring = device.batteryMonitoringEnabled
|
|
||||||
try {
|
|
||||||
device.batteryMonitoringEnabled = true
|
|
||||||
device.batteryLevel.takeIf { it >= 0.0 }?.let { "${(it * 100).toInt()}%" }
|
|
||||||
} finally {
|
|
||||||
device.batteryMonitoringEnabled = wasMonitoring
|
|
||||||
}
|
|
||||||
}.getOrNull(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
package com.vnidrop.app.core
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import kotlinx.cinterop.ExperimentalForeignApi
|
|
||||||
import kotlinx.cinterop.readBytes
|
|
||||||
import platform.Foundation.NSURL
|
|
||||||
import platform.Foundation.NSFileManager
|
|
||||||
import platform.Foundation.NSFileSize
|
|
||||||
import platform.Foundation.NSNumber
|
|
||||||
import platform.UIKit.UIApplication
|
|
||||||
import platform.UIKit.UIDocumentPickerDelegateProtocol
|
|
||||||
import platform.UIKit.UIDocumentPickerViewController
|
|
||||||
import platform.UIKit.UIDocumentInteractionController
|
|
||||||
import platform.UIKit.UIImage
|
|
||||||
import platform.UIKit.UIImagePNGRepresentation
|
|
||||||
import platform.UIKit.UIModalPresentationFormSheet
|
|
||||||
import platform.UniformTypeIdentifiers.UTTypeFolder
|
|
||||||
import platform.UniformTypeIdentifiers.UTTypeItem
|
|
||||||
import platform.darwin.NSObject
|
|
||||||
|
|
||||||
private var retainedPickerDelegate: DocumentPickerDelegate? = null
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
actual fun rememberShareFilePicker(
|
|
||||||
onFilesPicked: (List<PickedShareFile>) -> Unit,
|
|
||||||
onError: (String) -> Unit,
|
|
||||||
): ShareFilePicker = remember(onFilesPicked, onError) {
|
|
||||||
object : ShareFilePicker {
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
override fun pickFiles() {
|
|
||||||
val presenter = UIApplication.sharedApplication.keyWindow?.rootViewController
|
|
||||||
if (presenter == null) {
|
|
||||||
onError("Could not find an iOS view controller for the document picker")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// The composer outlives this callback, so Rust imports a sandbox copy instead of a short-lived provider URL.
|
|
||||||
val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeItem), asCopy = true)
|
|
||||||
picker.allowsMultipleSelection = true
|
|
||||||
val delegate = DocumentPickerDelegate(
|
|
||||||
onFilesPicked = onFilesPicked,
|
|
||||||
onError = onError,
|
|
||||||
useFileSystemPaths = true,
|
|
||||||
)
|
|
||||||
retainedPickerDelegate = delegate
|
|
||||||
picker.delegate = delegate
|
|
||||||
picker.modalPresentationStyle = UIModalPresentationFormSheet
|
|
||||||
presenter.presentViewController(picker, animated = true, completion = null)
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
override fun pickFolder() {
|
|
||||||
val presenter = UIApplication.sharedApplication.keyWindow?.rootViewController
|
|
||||||
if (presenter == null) {
|
|
||||||
onError("Could not find an iOS view controller for the folder picker")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeFolder), asCopy = true)
|
|
||||||
val delegate = DocumentPickerDelegate(
|
|
||||||
onFilesPicked = { folders ->
|
|
||||||
val folder = folders.firstOrNull() ?: return@DocumentPickerDelegate
|
|
||||||
onFilesPicked(
|
|
||||||
listOf(
|
|
||||||
folder.copy(isDirectory = true),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onError = onError,
|
|
||||||
forceDirectory = true,
|
|
||||||
useFileSystemPaths = true,
|
|
||||||
)
|
|
||||||
retainedPickerDelegate = delegate
|
|
||||||
picker.delegate = delegate
|
|
||||||
picker.modalPresentationStyle = UIModalPresentationFormSheet
|
|
||||||
presenter.presentViewController(picker, animated = true, completion = null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
actual fun rememberReceiveFolderPicker(
|
|
||||||
onFolderPicked: (ReceiveFolder) -> Unit,
|
|
||||||
onError: (String) -> Unit,
|
|
||||||
): ReceiveFolderPicker = remember(onFolderPicked, onError) {
|
|
||||||
object : ReceiveFolderPicker {
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
override fun pickFolder() {
|
|
||||||
val presenter = UIApplication.sharedApplication.keyWindow?.rootViewController
|
|
||||||
if (presenter == null) {
|
|
||||||
onError("Could not find an iOS view controller for the folder picker")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeFolder), asCopy = false)
|
|
||||||
val delegate = DocumentPickerDelegate(
|
|
||||||
onFilesPicked = { folders ->
|
|
||||||
val folder = folders.firstOrNull() ?: return@DocumentPickerDelegate
|
|
||||||
onFolderPicked(
|
|
||||||
ReceiveFolder(
|
|
||||||
kind = ReceiveFolderKind.IosSecurityScopedUrl,
|
|
||||||
value = folder.value,
|
|
||||||
displayName = folder.displayName,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onError = onError,
|
|
||||||
)
|
|
||||||
retainedPickerDelegate = delegate
|
|
||||||
picker.delegate = delegate
|
|
||||||
picker.modalPresentationStyle = UIModalPresentationFormSheet
|
|
||||||
presenter.presentViewController(picker, animated = true, completion = null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class DocumentPickerDelegate(
|
|
||||||
private val onFilesPicked: (List<PickedShareFile>) -> Unit,
|
|
||||||
private val onError: (String) -> Unit,
|
|
||||||
private val forceDirectory: Boolean = false,
|
|
||||||
private val useFileSystemPaths: Boolean = false,
|
|
||||||
) : NSObject(), UIDocumentPickerDelegateProtocol {
|
|
||||||
override fun documentPicker(controller: UIDocumentPickerViewController, didPickDocumentsAtURLs: List<*>) {
|
|
||||||
val files = didPickDocumentsAtURLs.mapNotNull { raw ->
|
|
||||||
val url = raw as? NSURL ?: return@mapNotNull null
|
|
||||||
val displayName = url.lastPathComponent ?: "transfer"
|
|
||||||
val didStartAccess = url.startAccessingSecurityScopedResource()
|
|
||||||
val sizeBytes = try {
|
|
||||||
if (forceDirectory) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
val attributes = url.path?.let { NSFileManager.defaultManager.attributesOfItemAtPath(it, null) }
|
|
||||||
(attributes?.get(NSFileSize) as? NSNumber)?.unsignedLongLongValue
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (didStartAccess) url.stopAccessingSecurityScopedResource()
|
|
||||||
}
|
|
||||||
PickedShareFile(
|
|
||||||
if (useFileSystemPaths) url.path.orEmpty() else url.absoluteString ?: url.path.orEmpty(),
|
|
||||||
displayName,
|
|
||||||
sizeBytes,
|
|
||||||
nativeFileIcon(url),
|
|
||||||
isTemporaryCopy = useFileSystemPaths,
|
|
||||||
isDirectory = forceDirectory,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (files.isEmpty()) {
|
|
||||||
onError("The selected iOS document URL was invalid")
|
|
||||||
} else {
|
|
||||||
onFilesPicked(files)
|
|
||||||
}
|
|
||||||
retainedPickerDelegate = null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun documentPickerWasCancelled(controller: UIDocumentPickerViewController) {
|
|
||||||
retainedPickerDelegate = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
private fun nativeFileIcon(url: NSURL): ByteArray? = runCatching {
|
|
||||||
val controller = UIDocumentInteractionController.interactionControllerWithURL(url)
|
|
||||||
val icon = controller.icons.lastOrNull() as? UIImage ?: return null
|
|
||||||
val data = UIImagePNGRepresentation(icon) ?: return null
|
|
||||||
data.bytes?.readBytes(data.length.toInt())
|
|
||||||
}.getOrNull()
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
package com.vnidrop.app.core
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
||||||
import platform.Foundation.NSFileManager
|
|
||||||
import platform.Foundation.NSDocumentDirectory
|
|
||||||
import platform.Foundation.NSSearchPathForDirectoriesInDomains
|
|
||||||
import platform.Foundation.NSURL
|
|
||||||
import platform.Foundation.NSUserDomainMask
|
|
||||||
import platform.UIKit.UIApplication
|
|
||||||
import uniffi.vnidrop.ReceiveOutputSink
|
|
||||||
import uniffi.vnidrop.SourceKind
|
|
||||||
import kotlin.coroutines.resume
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
actual fun rememberFileSystemService(): FileSystemService =
|
|
||||||
remember { IosFileSystemService() }
|
|
||||||
|
|
||||||
private class IosFileSystemService : FileSystemService {
|
|
||||||
// App-owned Documents remains durable across launches; raw external picker URLs do not.
|
|
||||||
override val supportsCustomReceiveFolders: Boolean = false
|
|
||||||
|
|
||||||
override fun defaultReceiveFolder(): ReceiveFolder {
|
|
||||||
val path = NSSearchPathForDirectoriesInDomains(
|
|
||||||
NSDocumentDirectory,
|
|
||||||
NSUserDomainMask,
|
|
||||||
true,
|
|
||||||
).firstOrNull() as? String ?: ""
|
|
||||||
return ReceiveFolder(
|
|
||||||
kind = ReceiveFolderKind.FileSystemPath,
|
|
||||||
value = path,
|
|
||||||
displayName = "Documents",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus =
|
|
||||||
when (folder.kind) {
|
|
||||||
ReceiveFolderKind.FileSystemPath -> {
|
|
||||||
if (NSFileManager.defaultManager.isWritableFileAtPath(folder.value)) {
|
|
||||||
FolderAccessStatus.Writable
|
|
||||||
} else {
|
|
||||||
FolderAccessStatus.Unavailable
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ReceiveFolderKind.IosSecurityScopedUrl -> validateSecurityScopedUrl(folder.value)
|
|
||||||
ReceiveFolderKind.AndroidTreeUri,
|
|
||||||
ReceiveFolderKind.AndroidPublicDownloads -> FolderAccessStatus.Unavailable
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null
|
|
||||||
|
|
||||||
override suspend fun discardPickedFiles(files: List<PickedShareFile>) {
|
|
||||||
files.asSequence()
|
|
||||||
.filter(PickedShareFile::isTemporaryCopy)
|
|
||||||
.map(PickedShareFile::value)
|
|
||||||
.distinct()
|
|
||||||
.forEach { path -> NSFileManager.defaultManager.removeItemAtPath(path, null) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun canRevealReceiveFolder(folder: ReceiveFolder): Boolean =
|
|
||||||
folder.kind == ReceiveFolderKind.FileSystemPath &&
|
|
||||||
folder.value.trimEnd('/') == defaultReceiveFolder().value.trimEnd('/')
|
|
||||||
|
|
||||||
override suspend fun revealReceiveFolder(folder: ReceiveFolder): Result<Unit> {
|
|
||||||
if (!canRevealReceiveFolder(folder)) {
|
|
||||||
return Result.failure(IllegalArgumentException("The receive folder is not VniDrop Documents"))
|
|
||||||
}
|
|
||||||
// Files can reveal app-owned Documents after the sharing keys in Info.plist are enabled.
|
|
||||||
val url = NSURL.URLWithString("shareddocuments://${folder.value}")
|
|
||||||
?: return Result.failure(IllegalStateException("The Files location URL is unavailable"))
|
|
||||||
val opened = suspendCancellableCoroutine { continuation ->
|
|
||||||
UIApplication.sharedApplication.openURL(url, emptyMap<Any?, Any>()) { success ->
|
|
||||||
if (continuation.isActive) continuation.resume(success)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return if (opened) {
|
|
||||||
Result.success(Unit)
|
|
||||||
} else {
|
|
||||||
Result.failure(IllegalStateException("Could not open VniDrop Documents in Files"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun sharePickedFiles(
|
|
||||||
repository: CoreGateway,
|
|
||||||
files: List<PickedShareFile>,
|
|
||||||
transferName: String,
|
|
||||||
senderName: String,
|
|
||||||
accessPolicy: ShareAccessPolicy,
|
|
||||||
): Result<Share> {
|
|
||||||
require(files.isNotEmpty()) { "Select at least one file to share" }
|
|
||||||
return repository.shareSources(files.map(PickedShareFile::toIosShareSource), transferName, senderName, accessPolicy)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun validateSecurityScopedUrl(value: String): FolderAccessStatus {
|
|
||||||
val url = NSURL.URLWithString(value) ?: NSURL.fileURLWithPath(value)
|
|
||||||
val didStartAccess = url.startAccessingSecurityScopedResource()
|
|
||||||
return try {
|
|
||||||
val path = url.path
|
|
||||||
if (path != null && NSFileManager.defaultManager.isWritableFileAtPath(path)) {
|
|
||||||
FolderAccessStatus.Writable
|
|
||||||
} else {
|
|
||||||
FolderAccessStatus.PermissionRequired
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (didStartAccess) url.stopAccessingSecurityScopedResource()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun PickedShareFile.toIosShareSource() = uniffi.vnidrop.ShareSource(
|
|
||||||
kind = SourceKind.PATH,
|
|
||||||
value = value,
|
|
||||||
displayName = displayName,
|
|
||||||
isDirectory = isDirectory,
|
|
||||||
)
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package com.vnidrop.app.core
|
|
||||||
|
|
||||||
import platform.Foundation.NSURL
|
|
||||||
import uniffi.vnidrop.SourceKind
|
|
||||||
|
|
||||||
internal actual suspend fun <T> withPlatformPathAccess(
|
|
||||||
kind: SourceKind,
|
|
||||||
value: String,
|
|
||||||
block: suspend () -> T,
|
|
||||||
): T {
|
|
||||||
if (kind != SourceKind.IOS_SECURITY_SCOPED_URL) {
|
|
||||||
return block()
|
|
||||||
}
|
|
||||||
|
|
||||||
val url = NSURL.URLWithString(value) ?: NSURL.fileURLWithPath(value)
|
|
||||||
val didStartAccess = url.startAccessingSecurityScopedResource()
|
|
||||||
return try {
|
|
||||||
block()
|
|
||||||
} finally {
|
|
||||||
if (didStartAccess) {
|
|
||||||
url.stopAccessingSecurityScopedResource()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
package com.vnidrop.app.diagnostics
|
|
||||||
|
|
||||||
import kotlinx.cinterop.BetaInteropApi
|
|
||||||
import kotlinx.cinterop.ExperimentalForeignApi
|
|
||||||
import kotlinx.cinterop.addressOf
|
|
||||||
import kotlinx.cinterop.convert
|
|
||||||
import kotlinx.cinterop.usePinned
|
|
||||||
import platform.Foundation.NSData
|
|
||||||
import platform.Foundation.NSFileManager
|
|
||||||
import platform.Foundation.create
|
|
||||||
import platform.Foundation.dataWithContentsOfFile
|
|
||||||
import platform.Foundation.writeToFile
|
|
||||||
import platform.posix.memcpy
|
|
||||||
|
|
||||||
actual fun createPendingCrashStore(appDataDir: String): PendingCrashStore =
|
|
||||||
IosPendingCrashStore(appDataDir)
|
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
private class IosPendingCrashStore(
|
|
||||||
appDataDir: String,
|
|
||||||
) : PendingCrashStore {
|
|
||||||
private val fileManager = NSFileManager.defaultManager
|
|
||||||
private val directory = appDataDir.trimEnd('/') + "/diagnostics/crashes"
|
|
||||||
|
|
||||||
override fun write(report: CrashReport) {
|
|
||||||
if (!isValidDiagnosticId(report.id)) return
|
|
||||||
ensureDirectory()
|
|
||||||
val path = "$directory/${report.id}.crash"
|
|
||||||
val payload = CrashReportCodec.encode(report)
|
|
||||||
val data = payload.encodeToByteArray().toNSData()
|
|
||||||
data.writeToFile(path, atomically = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun list(): List<CrashReport> {
|
|
||||||
ensureDirectory()
|
|
||||||
val names = fileManager.contentsOfDirectoryAtPath(directory, null).orEmpty()
|
|
||||||
.filterIsInstance<String>()
|
|
||||||
.filter { it.endsWith(".crash") }
|
|
||||||
return names.mapNotNull { name ->
|
|
||||||
val path = "$directory/$name"
|
|
||||||
val data = NSData.dataWithContentsOfFile(path) ?: return@mapNotNull null
|
|
||||||
val text = data.toUtf8String()
|
|
||||||
CrashReportCodec.decode(text)
|
|
||||||
}.sortedByDescending { it.timestampMillis }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun delete(id: String) {
|
|
||||||
if (!isValidDiagnosticId(id)) return
|
|
||||||
fileManager.removeItemAtPath("$directory/$id.crash", null)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun prune(olderThanTimestampMillis: Long, maxCount: Int) {
|
|
||||||
require(maxCount > 0) { "maxCount must be positive" }
|
|
||||||
ensureDirectory()
|
|
||||||
val reports = fileManager.contentsOfDirectoryAtPath(directory, null).orEmpty()
|
|
||||||
.filterIsInstance<String>()
|
|
||||||
.filter { it.endsWith(".crash") }
|
|
||||||
.mapNotNull { name ->
|
|
||||||
val path = "$directory/$name"
|
|
||||||
val report = NSData.dataWithContentsOfFile(path)
|
|
||||||
?.toUtf8String()
|
|
||||||
?.let(CrashReportCodec::decode)
|
|
||||||
if (report == null) {
|
|
||||||
fileManager.removeItemAtPath(path, null)
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
name to report
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.sortedByDescending { (_, report) -> report.timestampMillis }
|
|
||||||
reports.forEachIndexed { index, (name, report) ->
|
|
||||||
if (index >= maxCount || report.timestampMillis < olderThanTimestampMillis) {
|
|
||||||
fileManager.removeItemAtPath("$directory/$name", null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ensureDirectory() {
|
|
||||||
fileManager.createDirectoryAtPath(directory, withIntermediateDirectories = true, attributes = null, error = null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
|
|
||||||
private fun ByteArray.toNSData(): NSData =
|
|
||||||
usePinned { pinned ->
|
|
||||||
NSData.create(bytes = pinned.addressOf(0), length = size.toULong())
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
private fun NSData.toUtf8String(): String {
|
|
||||||
val size = length.toInt()
|
|
||||||
if (size == 0) return ""
|
|
||||||
val result = ByteArray(size)
|
|
||||||
val source = bytes ?: return ""
|
|
||||||
result.usePinned { pinned ->
|
|
||||||
memcpy(pinned.addressOf(0), source, size.convert())
|
|
||||||
}
|
|
||||||
return result.decodeToString()
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
package com.vnidrop.app.diagnostics
|
|
||||||
|
|
||||||
import kotlin.experimental.ExperimentalNativeApi
|
|
||||||
|
|
||||||
@OptIn(ExperimentalNativeApi::class)
|
|
||||||
actual fun installPlatformCrashHook(onCrash: (Throwable) -> Unit) {
|
|
||||||
val previous = setUnhandledExceptionHook { throwable ->
|
|
||||||
runCatching { onCrash(throwable) }
|
|
||||||
// Terminate like the default hook after capture.
|
|
||||||
terminateWithUnhandledException(throwable)
|
|
||||||
}
|
|
||||||
// Keep a reference so the previous hook is not GC'd unused; we intentionally
|
|
||||||
// replace the default with capture-then-terminate.
|
|
||||||
@Suppress("UNUSED_VARIABLE")
|
|
||||||
val ignored = previous
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
package com.vnidrop.app.diagnostics
|
|
||||||
|
|
||||||
import kotlinx.cinterop.BetaInteropApi
|
|
||||||
import kotlinx.cinterop.ExperimentalForeignApi
|
|
||||||
import kotlinx.cinterop.addressOf
|
|
||||||
import kotlinx.cinterop.convert
|
|
||||||
import kotlinx.cinterop.usePinned
|
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
||||||
import platform.Foundation.NSData
|
|
||||||
import platform.Foundation.NSHTTPURLResponse
|
|
||||||
import platform.Foundation.NSMutableURLRequest
|
|
||||||
import platform.Foundation.NSURL
|
|
||||||
import platform.Foundation.NSURLSession
|
|
||||||
import platform.Foundation.create
|
|
||||||
import platform.Foundation.dataTaskWithRequest
|
|
||||||
import platform.Foundation.setHTTPBody
|
|
||||||
import platform.Foundation.setHTTPMethod
|
|
||||||
import platform.Foundation.setValue
|
|
||||||
import platform.posix.memcpy
|
|
||||||
import kotlin.coroutines.resume
|
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
actual suspend fun platformHttpPost(
|
|
||||||
url: String,
|
|
||||||
headers: Map<String, String>,
|
|
||||||
bodyUtf8: String,
|
|
||||||
): PlatformHttpResponse = suspendCancellableCoroutine { cont ->
|
|
||||||
val nsUrl = NSURL.URLWithString(url)
|
|
||||||
if (nsUrl == null) {
|
|
||||||
cont.resume(PlatformHttpResponse(statusCode = 0, body = "invalid_url"))
|
|
||||||
return@suspendCancellableCoroutine
|
|
||||||
}
|
|
||||||
val request = NSMutableURLRequest.requestWithURL(nsUrl).apply {
|
|
||||||
setHTTPMethod("POST")
|
|
||||||
setValue("application/json; charset=utf-8", forHTTPHeaderField = "Content-Type")
|
|
||||||
headers.forEach { (key, value) ->
|
|
||||||
setValue(value, forHTTPHeaderField = key)
|
|
||||||
}
|
|
||||||
setHTTPBody(bodyUtf8.encodeToByteArray().toNSData())
|
|
||||||
}
|
|
||||||
val task = NSURLSession.sharedSession.dataTaskWithRequest(request) { data, response, error ->
|
|
||||||
if (!cont.isActive) return@dataTaskWithRequest
|
|
||||||
if (error != null) {
|
|
||||||
val message = error.localizedDescription
|
|
||||||
cont.resume(PlatformHttpResponse(statusCode = 0, body = message))
|
|
||||||
return@dataTaskWithRequest
|
|
||||||
}
|
|
||||||
val http = response as? NSHTTPURLResponse
|
|
||||||
val status = http?.statusCode?.toInt() ?: 0
|
|
||||||
val body = data?.toUtf8String().orEmpty()
|
|
||||||
cont.resume(PlatformHttpResponse(statusCode = status, body = body))
|
|
||||||
}
|
|
||||||
cont.invokeOnCancellation { task.cancel() }
|
|
||||||
task.resume()
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
|
|
||||||
private fun ByteArray.toNSData(): NSData =
|
|
||||||
usePinned { pinned ->
|
|
||||||
NSData.create(bytes = pinned.addressOf(0), length = size.toULong())
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
private fun NSData.toUtf8String(): String {
|
|
||||||
val size = length.toInt()
|
|
||||||
if (size == 0) return ""
|
|
||||||
val result = ByteArray(size)
|
|
||||||
val source = bytes ?: return ""
|
|
||||||
result.usePinned { pinned ->
|
|
||||||
memcpy(pinned.addressOf(0), source, size.convert())
|
|
||||||
}
|
|
||||||
return result.decodeToString()
|
|
||||||
}
|
|
||||||
@@ -1,384 +0,0 @@
|
|||||||
package com.vnidrop.app.feature.receive
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import kotlinx.cinterop.BetaInteropApi
|
|
||||||
import kotlinx.cinterop.ExperimentalForeignApi
|
|
||||||
import kotlinx.cinterop.ObjCAction
|
|
||||||
import kotlinx.cinterop.ObjCObjectVar
|
|
||||||
import kotlinx.cinterop.alloc
|
|
||||||
import kotlinx.cinterop.memScoped
|
|
||||||
import kotlinx.cinterop.ptr
|
|
||||||
import kotlinx.cinterop.readBytes
|
|
||||||
import kotlinx.cinterop.value
|
|
||||||
import platform.AVFoundation.AVAuthorizationStatusAuthorized
|
|
||||||
import platform.AVFoundation.AVAuthorizationStatusDenied
|
|
||||||
import platform.AVFoundation.AVAuthorizationStatusNotDetermined
|
|
||||||
import platform.AVFoundation.AVAuthorizationStatusRestricted
|
|
||||||
import platform.AVFoundation.AVCaptureDevice
|
|
||||||
import platform.AVFoundation.AVCaptureDeviceInput
|
|
||||||
import platform.AVFoundation.AVCaptureMetadataOutput
|
|
||||||
import platform.AVFoundation.AVCaptureMetadataOutputObjectsDelegateProtocol
|
|
||||||
import platform.AVFoundation.AVCaptureOutput
|
|
||||||
import platform.AVFoundation.AVCaptureConnection
|
|
||||||
import platform.AVFoundation.AVCaptureSession
|
|
||||||
import platform.AVFoundation.AVCaptureSessionPresetHigh
|
|
||||||
import platform.AVFoundation.AVCaptureVideoPreviewLayer
|
|
||||||
import platform.AVFoundation.AVLayerVideoGravityResizeAspectFill
|
|
||||||
import platform.AVFoundation.AVMediaTypeVideo
|
|
||||||
import platform.AVFoundation.AVMetadataMachineReadableCodeObject
|
|
||||||
import platform.AVFoundation.AVMetadataObjectTypeQRCode
|
|
||||||
import platform.AVFoundation.authorizationStatusForMediaType
|
|
||||||
import platform.AVFoundation.requestAccessForMediaType
|
|
||||||
import platform.CoreGraphics.CGRectMake
|
|
||||||
import platform.CoreNFC.NFCNDEFMessage
|
|
||||||
import platform.CoreNFC.NFCNDEFPayload
|
|
||||||
import platform.CoreNFC.NFCNDEFReaderSession
|
|
||||||
import platform.CoreNFC.NFCNDEFReaderSessionDelegateProtocol
|
|
||||||
import platform.CoreNFC.NFCTypeNameFormatMedia
|
|
||||||
import platform.Foundation.NSData
|
|
||||||
import platform.Foundation.NSError
|
|
||||||
import platform.Foundation.NSFileManager
|
|
||||||
import platform.Foundation.NSURL
|
|
||||||
import platform.UIKit.NSTextAlignmentCenter
|
|
||||||
import platform.UIKit.UIApplication
|
|
||||||
import platform.UIKit.UIButton
|
|
||||||
import platform.UIKit.UIButtonTypeSystem
|
|
||||||
import platform.UIKit.UIColor
|
|
||||||
import platform.UIKit.UIControlEventTouchUpInside
|
|
||||||
import platform.UIKit.UIControlStateNormal
|
|
||||||
import platform.UIKit.UIDocumentPickerDelegateProtocol
|
|
||||||
import platform.UIKit.UIDocumentPickerViewController
|
|
||||||
import platform.UIKit.UILabel
|
|
||||||
import platform.UIKit.UIModalPresentationFormSheet
|
|
||||||
import platform.UIKit.UIModalPresentationFullScreen
|
|
||||||
import platform.UIKit.UIViewAutoresizingFlexibleHeight
|
|
||||||
import platform.UIKit.UIViewAutoresizingFlexibleWidth
|
|
||||||
import platform.UIKit.UIViewController
|
|
||||||
import platform.UniformTypeIdentifiers.UTTypeData
|
|
||||||
import platform.darwin.DISPATCH_QUEUE_PRIORITY_DEFAULT
|
|
||||||
import platform.darwin.NSObject
|
|
||||||
import platform.darwin.dispatch_async
|
|
||||||
import platform.darwin.dispatch_get_global_queue
|
|
||||||
import platform.darwin.dispatch_get_main_queue
|
|
||||||
|
|
||||||
private var retainedInvitationDelegate: InvitationDocumentDelegate? = null
|
|
||||||
private var retainedQrScanner: QrScannerViewController? = null
|
|
||||||
private var retainedNfcReader: InvitationNfcReader? = null
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
actual fun rememberReceiveInvitationActions(): ReceiveInvitationActions = remember {
|
|
||||||
object : ReceiveInvitationActions {
|
|
||||||
override val fileAvailability = ReceiveMethodAvailability.Available
|
|
||||||
override val qrAvailability =
|
|
||||||
if (AVCaptureDevice.defaultDeviceWithMediaType(AVMediaTypeVideo) != null) {
|
|
||||||
ReceiveMethodAvailability.Available
|
|
||||||
} else {
|
|
||||||
ReceiveMethodAvailability.Unavailable
|
|
||||||
}
|
|
||||||
override val nfcAvailability =
|
|
||||||
if (NFCNDEFReaderSession.readingAvailable) {
|
|
||||||
ReceiveMethodAvailability.Available
|
|
||||||
} else {
|
|
||||||
ReceiveMethodAvailability.Unavailable
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
override fun pickInvitation(onResult: (Result<String>) -> Unit) {
|
|
||||||
cancel()
|
|
||||||
val presenter = topPresenter()
|
|
||||||
?: return onResult(Result.failure(IllegalStateException("Could not find an iOS view controller")))
|
|
||||||
val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeData), asCopy = true)
|
|
||||||
val delegate = InvitationDocumentDelegate(onResult)
|
|
||||||
retainedInvitationDelegate = delegate
|
|
||||||
picker.delegate = delegate
|
|
||||||
picker.modalPresentationStyle = UIModalPresentationFormSheet
|
|
||||||
presenter.presentViewController(picker, animated = true, completion = null)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun scanQrCode(onResult: (Result<String>) -> Unit) {
|
|
||||||
cancel()
|
|
||||||
val presenter = topPresenter()
|
|
||||||
?: return onResult(Result.failure(IllegalStateException("Could not find an iOS view controller")))
|
|
||||||
ensureCameraAccess { granted ->
|
|
||||||
if (!granted) {
|
|
||||||
onResult(Result.failure(IllegalStateException("Camera access is required to scan QR codes")))
|
|
||||||
return@ensureCameraAccess
|
|
||||||
}
|
|
||||||
val scanner = QrScannerViewController { result ->
|
|
||||||
retainedQrScanner = null
|
|
||||||
onResult(result)
|
|
||||||
}
|
|
||||||
retainedQrScanner = scanner
|
|
||||||
scanner.modalPresentationStyle = UIModalPresentationFullScreen
|
|
||||||
presenter.presentViewController(scanner, animated = true, completion = null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun readNfcInvitation(onResult: (Result<String>) -> Unit) {
|
|
||||||
cancel()
|
|
||||||
if (!NFCNDEFReaderSession.readingAvailable) {
|
|
||||||
onResult(Result.failure(UnsupportedOperationException("NFC reading is unavailable on this device")))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val reader = InvitationNfcReader { result ->
|
|
||||||
retainedNfcReader = null
|
|
||||||
onResult(result)
|
|
||||||
}
|
|
||||||
retainedNfcReader = reader
|
|
||||||
reader.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun cancel() {
|
|
||||||
retainedNfcReader?.cancel()
|
|
||||||
retainedNfcReader = null
|
|
||||||
retainedQrScanner?.cancelScan()
|
|
||||||
retainedQrScanner = null
|
|
||||||
retainedInvitationDelegate = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun topPresenter(): UIViewController? {
|
|
||||||
var controller = UIApplication.sharedApplication.keyWindow?.rootViewController
|
|
||||||
while (controller?.presentedViewController != null) {
|
|
||||||
controller = controller?.presentedViewController
|
|
||||||
}
|
|
||||||
return controller
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ensureCameraAccess(onResult: (Boolean) -> Unit) {
|
|
||||||
when (AVCaptureDevice.authorizationStatusForMediaType(AVMediaTypeVideo)) {
|
|
||||||
AVAuthorizationStatusAuthorized -> onResult(true)
|
|
||||||
AVAuthorizationStatusNotDetermined -> {
|
|
||||||
AVCaptureDevice.requestAccessForMediaType(AVMediaTypeVideo) { granted ->
|
|
||||||
dispatch_async(dispatch_get_main_queue()) { onResult(granted) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AVAuthorizationStatusDenied, AVAuthorizationStatusRestricted -> onResult(false)
|
|
||||||
else -> onResult(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class InvitationDocumentDelegate(
|
|
||||||
private val onResult: (Result<String>) -> Unit,
|
|
||||||
) : NSObject(), UIDocumentPickerDelegateProtocol {
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
override fun documentPicker(controller: UIDocumentPickerViewController, didPickDocumentsAtURLs: List<*>) {
|
|
||||||
onResult(runCatching {
|
|
||||||
val url = didPickDocumentsAtURLs.firstOrNull() as? NSURL
|
|
||||||
?: error("The selected invitation URL was invalid")
|
|
||||||
val path = url.path ?: error("The invitation path was invalid")
|
|
||||||
val data = NSFileManager.defaultManager.contentsAtPath(path) ?: error("The invitation could not be opened")
|
|
||||||
val length = data.length.toInt()
|
|
||||||
require(length <= MaxInvitationBytes) { "The invitation is too large" }
|
|
||||||
val bytes = data.bytes?.readBytes(length) ?: error("The invitation is empty")
|
|
||||||
decodeInvitationBytes(bytes)
|
|
||||||
})
|
|
||||||
retainedInvitationDelegate = null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun documentPickerWasCancelled(controller: UIDocumentPickerViewController) {
|
|
||||||
retainedInvitationDelegate = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
|
|
||||||
private class QrScannerViewController(
|
|
||||||
private val onResult: (Result<String>) -> Unit,
|
|
||||||
) : UIViewController(nibName = null, bundle = null), AVCaptureMetadataOutputObjectsDelegateProtocol {
|
|
||||||
private val session = AVCaptureSession()
|
|
||||||
private var previewLayer: AVCaptureVideoPreviewLayer? = null
|
|
||||||
private var finished = false
|
|
||||||
private val closeTarget = ButtonTarget { cancelScan() }
|
|
||||||
|
|
||||||
override fun viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
view.backgroundColor = UIColor.blackColor
|
|
||||||
|
|
||||||
val hint = UILabel(frame = view.bounds).apply {
|
|
||||||
text = "Point the camera at a VniDrop QR code"
|
|
||||||
textColor = UIColor.whiteColor
|
|
||||||
textAlignment = NSTextAlignmentCenter
|
|
||||||
numberOfLines = 0
|
|
||||||
autoresizingMask = UIViewAutoresizingFlexibleWidth or UIViewAutoresizingFlexibleHeight
|
|
||||||
}
|
|
||||||
view.addSubview(hint)
|
|
||||||
|
|
||||||
val close = UIButton.buttonWithType(UIButtonTypeSystem).apply {
|
|
||||||
setTitle("Cancel", forState = UIControlStateNormal)
|
|
||||||
setTitleColor(UIColor.whiteColor, forState = UIControlStateNormal)
|
|
||||||
addTarget(closeTarget, platform.objc.sel_registerName("invoke"), UIControlEventTouchUpInside)
|
|
||||||
setFrame(CGRectMake(16.0, 52.0, 88.0, 36.0))
|
|
||||||
}
|
|
||||||
view.addSubview(close)
|
|
||||||
configureSession()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun viewDidLayoutSubviews() {
|
|
||||||
super.viewDidLayoutSubviews()
|
|
||||||
previewLayer?.setFrame(view.bounds)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun viewWillDisappear(animated: Boolean) {
|
|
||||||
super.viewWillDisappear(animated)
|
|
||||||
if (session.running) session.stopRunning()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cancelScan() {
|
|
||||||
finish(Result.failure(IllegalStateException("QR scanning was cancelled")))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun configureSession() {
|
|
||||||
val device = AVCaptureDevice.defaultDeviceWithMediaType(AVMediaTypeVideo)
|
|
||||||
?: return finish(Result.failure(IllegalStateException("No camera is available")))
|
|
||||||
|
|
||||||
memScoped {
|
|
||||||
val errorPtr = alloc<ObjCObjectVar<NSError?>>()
|
|
||||||
val input = AVCaptureDeviceInput.deviceInputWithDevice(device, errorPtr.ptr)
|
|
||||||
if (input == null) {
|
|
||||||
finish(
|
|
||||||
Result.failure(
|
|
||||||
IllegalStateException(errorPtr.value?.localizedDescription ?: "Could not open the camera"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!session.canAddInput(input)) {
|
|
||||||
finish(Result.failure(IllegalStateException("Could not configure the camera input")))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
session.addInput(input)
|
|
||||||
}
|
|
||||||
|
|
||||||
val output = AVCaptureMetadataOutput()
|
|
||||||
if (!session.canAddOutput(output)) {
|
|
||||||
finish(Result.failure(IllegalStateException("Could not configure the QR scanner")))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
session.addOutput(output)
|
|
||||||
output.setMetadataObjectsDelegate(this, queue = dispatch_get_main_queue())
|
|
||||||
output.metadataObjectTypes = listOf(AVMetadataObjectTypeQRCode)
|
|
||||||
|
|
||||||
val layer = AVCaptureVideoPreviewLayer(session = session).apply {
|
|
||||||
videoGravity = AVLayerVideoGravityResizeAspectFill
|
|
||||||
setFrame(view.bounds)
|
|
||||||
}
|
|
||||||
view.layer.insertSublayer(layer, atIndex = 0u)
|
|
||||||
previewLayer = layer
|
|
||||||
session.sessionPreset = AVCaptureSessionPresetHigh
|
|
||||||
|
|
||||||
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT.toLong(), 0u)) {
|
|
||||||
session.startRunning()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun captureOutput(
|
|
||||||
output: AVCaptureOutput,
|
|
||||||
didOutputMetadataObjects: List<*>,
|
|
||||||
fromConnection: AVCaptureConnection,
|
|
||||||
) {
|
|
||||||
val code = didOutputMetadataObjects
|
|
||||||
.mapNotNull { it as? AVMetadataMachineReadableCodeObject }
|
|
||||||
.firstOrNull { it.type == AVMetadataObjectTypeQRCode }
|
|
||||||
val value = code?.stringValue?.trim().orEmpty()
|
|
||||||
if (value.isNotEmpty()) {
|
|
||||||
finish(Result.success(value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun finish(result: Result<String>) {
|
|
||||||
if (finished) return
|
|
||||||
finished = true
|
|
||||||
if (session.running) session.stopRunning()
|
|
||||||
if (presentingViewController != null) {
|
|
||||||
dismissViewControllerAnimated(true) { onResult(result) }
|
|
||||||
} else {
|
|
||||||
onResult(result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(BetaInteropApi::class)
|
|
||||||
private class ButtonTarget(
|
|
||||||
private val onClick: () -> Unit,
|
|
||||||
) : NSObject() {
|
|
||||||
@ObjCAction
|
|
||||||
fun invoke() {
|
|
||||||
onClick()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
private class InvitationNfcReader(
|
|
||||||
private val onResult: (Result<String>) -> Unit,
|
|
||||||
) : NSObject(), NFCNDEFReaderSessionDelegateProtocol {
|
|
||||||
private var session: NFCNDEFReaderSession? = null
|
|
||||||
private var finished = false
|
|
||||||
|
|
||||||
fun start() {
|
|
||||||
val reader = NFCNDEFReaderSession(this, dispatch_get_main_queue(), invalidateAfterFirstRead = true)
|
|
||||||
reader.alertMessage = "Hold your iPhone near a VniDrop invitation tag"
|
|
||||||
session = reader
|
|
||||||
reader.beginSession()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cancel() {
|
|
||||||
session?.invalidateSession()
|
|
||||||
session = null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun readerSession(session: NFCNDEFReaderSession, didInvalidateWithError: NSError) {
|
|
||||||
if (finished) return
|
|
||||||
// NFCReaderError.readerSessionInvalidationErrorUserCanceled == 200
|
|
||||||
val cancelled = didInvalidateWithError.code == 200L
|
|
||||||
finish(
|
|
||||||
if (cancelled) {
|
|
||||||
Result.failure(IllegalStateException("NFC reading was cancelled"))
|
|
||||||
} else {
|
|
||||||
Result.failure(
|
|
||||||
IllegalStateException(didInvalidateWithError.localizedDescription ?: "NFC reading failed"),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun readerSession(session: NFCNDEFReaderSession, didDetectNDEFs: List<*>) {
|
|
||||||
val ticket = runCatching {
|
|
||||||
val messages = didDetectNDEFs.mapNotNull { it as? NFCNDEFMessage }
|
|
||||||
messages
|
|
||||||
.flatMap { message -> message.records.mapNotNull { it as? NFCNDEFPayload } }
|
|
||||||
.firstNotNullOfOrNull(::payloadAsInvitation)
|
|
||||||
?: error("This NFC tag does not contain a VniDrop invitation")
|
|
||||||
}
|
|
||||||
session.invalidateSession()
|
|
||||||
finish(ticket)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun finish(result: Result<String>) {
|
|
||||||
if (finished) return
|
|
||||||
finished = true
|
|
||||||
session = null
|
|
||||||
dispatch_async(dispatch_get_main_queue()) { onResult(result) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
private fun payloadAsInvitation(payload: NFCNDEFPayload): String? {
|
|
||||||
val type = payload.type?.toByteArray()?.decodeToString() ?: return null
|
|
||||||
val data = payload.payload?.toByteArray() ?: return null
|
|
||||||
return when {
|
|
||||||
payload.typeNameFormat == NFCTypeNameFormatMedia && type == InvitationMimeType ->
|
|
||||||
decodeInvitationBytes(data)
|
|
||||||
payload.typeNameFormat == NFCTypeNameFormatMedia && type.startsWith("text/") ->
|
|
||||||
decodeInvitationBytes(data)
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
private fun NSData.toByteArray(): ByteArray {
|
|
||||||
val length = this.length.toInt()
|
|
||||||
if (length <= 0) return ByteArray(0)
|
|
||||||
return this.bytes?.readBytes(length) ?: ByteArray(0)
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
package com.vnidrop.app.feature.send
|
|
||||||
|
|
||||||
import kotlinx.cinterop.ExperimentalForeignApi
|
|
||||||
import kotlinx.cinterop.BetaInteropApi
|
|
||||||
import kotlinx.cinterop.addressOf
|
|
||||||
import kotlinx.cinterop.readBytes
|
|
||||||
import kotlinx.cinterop.usePinned
|
|
||||||
import platform.Foundation.NSData
|
|
||||||
import platform.Foundation.NSDate
|
|
||||||
import platform.Foundation.NSFileManager
|
|
||||||
import platform.Foundation.NSFileModificationDate
|
|
||||||
import platform.Foundation.NSFileSize
|
|
||||||
import platform.Foundation.NSNumber
|
|
||||||
import platform.Foundation.create
|
|
||||||
import platform.Foundation.dataWithContentsOfFile
|
|
||||||
import platform.Foundation.timeIntervalSince1970
|
|
||||||
import platform.Foundation.writeToFile
|
|
||||||
|
|
||||||
actual fun createPlatformPreviewStore(appDataDir: String): PlatformPreviewStore =
|
|
||||||
IosPreviewStore(appDataDir.trimEnd('/') + "/ui/previews")
|
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
|
|
||||||
private class IosPreviewStore(private val directory: String) : PlatformPreviewStore {
|
|
||||||
private val files = NSFileManager.defaultManager
|
|
||||||
|
|
||||||
override fun list(): List<PreviewFileInfo> {
|
|
||||||
ensureDirectory()
|
|
||||||
return files.contentsOfDirectoryAtPath(directory, null).orEmpty().filterIsInstance<String>().mapNotNull { name ->
|
|
||||||
val id = name.removeSuffix(".preview").toULongOrNull() ?: return@mapNotNull null
|
|
||||||
val attributes = files.attributesOfItemAtPath("$directory/$name", null) ?: return@mapNotNull null
|
|
||||||
val size = (attributes[NSFileSize] as? NSNumber)?.longLongValue ?: 0L
|
|
||||||
val modified = ((attributes[NSFileModificationDate] as? NSDate)?.timeIntervalSince1970 ?: 0.0) * 1000.0
|
|
||||||
PreviewFileInfo(id, size, modified.toLong())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun read(transferId: ULong): ByteArray? {
|
|
||||||
val data = NSData.dataWithContentsOfFile(path(transferId)) ?: return null
|
|
||||||
return data.bytes?.readBytes(data.length.toInt())
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun writeAtomically(transferId: ULong, bytes: ByteArray): Boolean {
|
|
||||||
ensureDirectory()
|
|
||||||
if (files.fileExistsAtPath(path(transferId))) return true
|
|
||||||
val temporary = "$directory/.$transferId.tmp"
|
|
||||||
val data = bytes.usePinned { pinned -> NSData.create(bytes = pinned.addressOf(0), length = bytes.size.toULong()) }
|
|
||||||
if (!data.writeToFile(temporary, atomically = true)) return false
|
|
||||||
val moved = files.moveItemAtPath(temporary, path(transferId), null)
|
|
||||||
if (!moved) files.removeItemAtPath(temporary, null)
|
|
||||||
return moved
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun delete(transferId: ULong) {
|
|
||||||
files.removeItemAtPath(path(transferId), null)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ensureDirectory() {
|
|
||||||
files.createDirectoryAtPath(directory, withIntermediateDirectories = true, attributes = null, error = null)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun path(transferId: ULong) = "$directory/$transferId.preview"
|
|
||||||
}
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
package com.vnidrop.app.feature.send
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import com.vnidrop.app.feature.receive.VniDropInvitationMimeType
|
|
||||||
import kotlinx.cinterop.BetaInteropApi
|
|
||||||
import kotlinx.cinterop.ExperimentalForeignApi
|
|
||||||
import kotlinx.cinterop.ObjCSignatureOverride
|
|
||||||
import platform.CoreNFC.NFCNDEFMessage
|
|
||||||
import platform.CoreNFC.NFCNDEFPayload
|
|
||||||
import platform.CoreNFC.NFCNDEFReaderSession
|
|
||||||
import platform.CoreNFC.NFCNDEFReaderSessionDelegateProtocol
|
|
||||||
import platform.CoreNFC.NFCNDEFStatusNotSupported
|
|
||||||
import platform.CoreNFC.NFCNDEFStatusReadOnly
|
|
||||||
import platform.CoreNFC.NFCNDEFTagProtocol
|
|
||||||
import platform.CoreNFC.NFCTypeNameFormatMedia
|
|
||||||
import platform.Foundation.NSData
|
|
||||||
import platform.Foundation.NSError
|
|
||||||
import platform.Foundation.NSString
|
|
||||||
import platform.Foundation.NSTemporaryDirectory
|
|
||||||
import platform.Foundation.NSUTF8StringEncoding
|
|
||||||
import platform.Foundation.NSURL
|
|
||||||
import platform.Foundation.create
|
|
||||||
import platform.Foundation.dataUsingEncoding
|
|
||||||
import platform.Foundation.writeToFile
|
|
||||||
import platform.UIKit.UIActivityViewController
|
|
||||||
import platform.UIKit.UIApplication
|
|
||||||
import platform.UIKit.UIDocumentPickerViewController
|
|
||||||
import platform.UIKit.UIModalPresentationFormSheet
|
|
||||||
import platform.darwin.NSObject
|
|
||||||
import platform.darwin.dispatch_get_main_queue
|
|
||||||
|
|
||||||
private var retainedNfcWriter: InvitationNfcWriter? = null
|
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
@Composable
|
|
||||||
actual fun rememberTransferShareActions(): TransferShareActions = remember {
|
|
||||||
object : TransferShareActions {
|
|
||||||
override val canUseNativeShare = true
|
|
||||||
override val nfcAvailability =
|
|
||||||
if (NFCNDEFReaderSession.readingAvailable) {
|
|
||||||
NfcShareAvailability.Available
|
|
||||||
} else {
|
|
||||||
NfcShareAvailability.Unavailable
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun exportInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit) {
|
|
||||||
onResult(runCatching {
|
|
||||||
val url = createInvitation(ticket, transferName)
|
|
||||||
val picker = UIDocumentPickerViewController(forExportingURLs = listOf(url), asCopy = true)
|
|
||||||
present(picker)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun shareInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit) {
|
|
||||||
onResult(runCatching {
|
|
||||||
val url = createInvitation(ticket, transferName)
|
|
||||||
val controller = UIActivityViewController(activityItems = listOf(url), applicationActivities = null)
|
|
||||||
controller.modalPresentationStyle = UIModalPresentationFormSheet
|
|
||||||
presenter().presentViewController(controller, animated = true, completion = null)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun writeInvitationToNfc(ticket: String, onResult: (Result<Unit>) -> Unit) {
|
|
||||||
cancelNfcWrite()
|
|
||||||
if (!NFCNDEFReaderSession.readingAvailable) {
|
|
||||||
onResult(Result.failure(UnsupportedOperationException("NFC is unavailable on this device")))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val writer = InvitationNfcWriter(ticket) { result ->
|
|
||||||
retainedNfcWriter = null
|
|
||||||
onResult(result)
|
|
||||||
}
|
|
||||||
retainedNfcWriter = writer
|
|
||||||
writer.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun cancelNfcWrite() {
|
|
||||||
retainedNfcWriter?.cancel()
|
|
||||||
retainedNfcWriter = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
|
|
||||||
private fun createInvitation(ticket: String, transferName: String): NSURL {
|
|
||||||
val path = NSTemporaryDirectory().trimEnd('/') + "/" + invitationFileName(transferName)
|
|
||||||
val text = NSString.create(string = ticket)
|
|
||||||
require(text.writeToFile(path, atomically = true, encoding = NSUTF8StringEncoding, error = null)) {
|
|
||||||
"The invitation file could not be created"
|
|
||||||
}
|
|
||||||
return NSURL.fileURLWithPath(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun presenter() = UIApplication.sharedApplication.keyWindow?.rootViewController
|
|
||||||
?: error("Could not find an iOS view controller")
|
|
||||||
|
|
||||||
private fun present(controller: platform.UIKit.UIViewController) {
|
|
||||||
presenter().presentViewController(controller, animated = true, completion = null)
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
|
|
||||||
private class InvitationNfcWriter(
|
|
||||||
private val ticket: String,
|
|
||||||
private val onResult: (Result<Unit>) -> Unit,
|
|
||||||
) : NSObject(), NFCNDEFReaderSessionDelegateProtocol {
|
|
||||||
private var session: NFCNDEFReaderSession? = null
|
|
||||||
private var finished = false
|
|
||||||
|
|
||||||
fun start() {
|
|
||||||
val reader = NFCNDEFReaderSession(this, dispatch_get_main_queue(), invalidateAfterFirstRead = false)
|
|
||||||
reader.alertMessage = "Hold your iPhone near a writable NFC tag"
|
|
||||||
session = reader
|
|
||||||
reader.beginSession()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cancel() {
|
|
||||||
session?.invalidateSession()
|
|
||||||
session = null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun readerSession(session: NFCNDEFReaderSession, didInvalidateWithError: NSError) {
|
|
||||||
if (finished) return
|
|
||||||
// NFCReaderError.readerSessionInvalidationErrorUserCanceled == 200
|
|
||||||
val cancelled = didInvalidateWithError.code == 200L
|
|
||||||
finish(
|
|
||||||
if (cancelled) {
|
|
||||||
Result.failure(IllegalStateException("NFC writing was cancelled"))
|
|
||||||
} else {
|
|
||||||
Result.failure(
|
|
||||||
IllegalStateException(didInvalidateWithError.localizedDescription),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ObjCSignatureOverride
|
|
||||||
override fun readerSession(session: NFCNDEFReaderSession, didDetectNDEFs: List<*>) {
|
|
||||||
// Prefer tag-based write path via didDetectTags when available.
|
|
||||||
}
|
|
||||||
|
|
||||||
@ObjCSignatureOverride
|
|
||||||
override fun readerSession(session: NFCNDEFReaderSession, didDetectTags: List<*>) {
|
|
||||||
val tag = didDetectTags.firstOrNull() as? NFCNDEFTagProtocol
|
|
||||||
?: return finish(Result.failure(IllegalStateException("No NFC tag was detected")))
|
|
||||||
|
|
||||||
session.connectToTag(tag) { connectError ->
|
|
||||||
if (connectError != null) {
|
|
||||||
finish(Result.failure(IllegalStateException(connectError.localizedDescription)))
|
|
||||||
return@connectToTag
|
|
||||||
}
|
|
||||||
tag.queryNDEFStatusWithCompletionHandler { status, _, queryError ->
|
|
||||||
if (queryError != null) {
|
|
||||||
finish(Result.failure(IllegalStateException(queryError.localizedDescription)))
|
|
||||||
return@queryNDEFStatusWithCompletionHandler
|
|
||||||
}
|
|
||||||
when (status) {
|
|
||||||
NFCNDEFStatusNotSupported -> {
|
|
||||||
finish(Result.failure(IllegalStateException("This NFC tag does not support NDEF")))
|
|
||||||
}
|
|
||||||
NFCNDEFStatusReadOnly -> {
|
|
||||||
finish(Result.failure(IllegalStateException("This NFC tag is read-only")))
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
val message = invitationNdefMessage(ticket)
|
|
||||||
?: return@queryNDEFStatusWithCompletionHandler finish(
|
|
||||||
Result.failure(IllegalStateException("Could not encode the invitation for NFC")),
|
|
||||||
)
|
|
||||||
tag.writeNDEF(message) { writeError ->
|
|
||||||
if (writeError != null) {
|
|
||||||
finish(Result.failure(IllegalStateException(writeError.localizedDescription)))
|
|
||||||
} else {
|
|
||||||
session.alertMessage = "Invitation written"
|
|
||||||
session.invalidateSession()
|
|
||||||
finish(Result.success(Unit))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun finish(result: Result<Unit>) {
|
|
||||||
if (finished) return
|
|
||||||
finished = true
|
|
||||||
session = null
|
|
||||||
onResult(result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
|
|
||||||
private fun invitationNdefMessage(ticket: String): NFCNDEFMessage? {
|
|
||||||
val type = NSString.create(string = VniDropInvitationMimeType).dataUsingEncoding(NSUTF8StringEncoding) ?: return null
|
|
||||||
val payload = NSString.create(string = ticket).dataUsingEncoding(NSUTF8StringEncoding) ?: return null
|
|
||||||
val record = NFCNDEFPayload(
|
|
||||||
format = NFCTypeNameFormatMedia,
|
|
||||||
type = type,
|
|
||||||
identifier = NSData(),
|
|
||||||
payload = payload,
|
|
||||||
)
|
|
||||||
return NFCNDEFMessage(nDEFRecords = listOf(record))
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
package com.vnidrop.app.logging
|
|
||||||
|
|
||||||
import kotlinx.cinterop.ExperimentalForeignApi
|
|
||||||
import kotlinx.cinterop.addressOf
|
|
||||||
import kotlinx.cinterop.convert
|
|
||||||
import kotlinx.cinterop.usePinned
|
|
||||||
import platform.Foundation.NSData
|
|
||||||
import platform.Foundation.NSDate
|
|
||||||
import platform.Foundation.NSFileManager
|
|
||||||
import platform.Foundation.NSFileModificationDate
|
|
||||||
import platform.Foundation.NSFileSize
|
|
||||||
import platform.Foundation.NSNumber
|
|
||||||
import platform.Foundation.dataWithContentsOfFile
|
|
||||||
import platform.Foundation.timeIntervalSince1970
|
|
||||||
import platform.posix.fclose
|
|
||||||
import platform.posix.fopen
|
|
||||||
import platform.posix.fwrite
|
|
||||||
import platform.posix.memcpy
|
|
||||||
|
|
||||||
actual fun createPlatformLogStore(appDataDir: String, policy: LogRotationPolicy): PlatformLogStore =
|
|
||||||
IosPlatformLogStore(appDataDir, policy)
|
|
||||||
|
|
||||||
actual fun platformNowMillis(): Long =
|
|
||||||
(NSDate().timeIntervalSince1970 * 1000.0).toLong()
|
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
private class IosPlatformLogStore(
|
|
||||||
appDataDir: String,
|
|
||||||
private val policy: LogRotationPolicy,
|
|
||||||
) : PlatformLogStore {
|
|
||||||
private val fileManager = NSFileManager.defaultManager
|
|
||||||
private val directory = appDataDir.trimEnd('/') + "/logs"
|
|
||||||
private val activePath = "$directory/app.log"
|
|
||||||
|
|
||||||
override val logDirectory: String = directory
|
|
||||||
|
|
||||||
override fun append(line: String) {
|
|
||||||
ensureDirectory()
|
|
||||||
val bytes = line.encodeToByteArray()
|
|
||||||
if (policy.shouldRotate(fileSize(activePath), bytes.size.toLong())) {
|
|
||||||
rotate()
|
|
||||||
}
|
|
||||||
val file = fopen(activePath, "ab") ?: return
|
|
||||||
try {
|
|
||||||
bytes.usePinned { pinned ->
|
|
||||||
fwrite(pinned.addressOf(0), 1.convert(), bytes.size.convert(), file)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
fclose(file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun listLogFiles(): List<LogFileInfo> {
|
|
||||||
ensureDirectory()
|
|
||||||
val names = fileManager.contentsOfDirectoryAtPath(directory, null).orEmpty()
|
|
||||||
.filterIsInstance<String>()
|
|
||||||
.filter { it.startsWith("app") && it.endsWith(".log") }
|
|
||||||
return names
|
|
||||||
.map { name ->
|
|
||||||
val path = "$directory/$name"
|
|
||||||
LogFileInfo(name, path, fileSize(path), modifiedAt(path))
|
|
||||||
}
|
|
||||||
.sortedByDescending { it.modifiedAtMillis }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun readLatest(maxBytes: Long): String {
|
|
||||||
if (maxBytes <= 0) return ""
|
|
||||||
ensureDirectory()
|
|
||||||
val paths = listOf(activePath) +
|
|
||||||
(1..policy.maxFiles).map { "$directory/app.$it.log" }
|
|
||||||
val chunks = ArrayList<ByteArray>()
|
|
||||||
var remaining = maxBytes
|
|
||||||
for (path in paths) {
|
|
||||||
if (remaining <= 0 || !fileManager.fileExistsAtPath(path)) continue
|
|
||||||
val slice = readTail(path, remaining)
|
|
||||||
if (slice.isEmpty()) continue
|
|
||||||
chunks.add(0, slice)
|
|
||||||
remaining -= slice.size.toLong()
|
|
||||||
}
|
|
||||||
if (chunks.isEmpty()) return ""
|
|
||||||
val total = chunks.sumOf { it.size }
|
|
||||||
val out = ByteArray(total)
|
|
||||||
var offset = 0
|
|
||||||
for (chunk in chunks) {
|
|
||||||
chunk.copyInto(out, offset)
|
|
||||||
offset += chunk.size
|
|
||||||
}
|
|
||||||
return out.decodeToString()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun rotate() {
|
|
||||||
if (policy.maxFiles == 0) {
|
|
||||||
fileManager.removeItemAtPath(activePath, null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fileManager.removeItemAtPath("$directory/app.${policy.maxFiles}.log", null)
|
|
||||||
for (index in policy.maxFiles - 1 downTo 1) {
|
|
||||||
val source = "$directory/app.$index.log"
|
|
||||||
if (fileManager.fileExistsAtPath(source)) {
|
|
||||||
fileManager.moveItemAtPath(source, "$directory/app.${index + 1}.log", null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (fileManager.fileExistsAtPath(activePath)) {
|
|
||||||
fileManager.moveItemAtPath(activePath, "$directory/app.1.log", null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ensureDirectory() {
|
|
||||||
fileManager.createDirectoryAtPath(directory, withIntermediateDirectories = true, attributes = null, error = null)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun fileSize(path: String): Long {
|
|
||||||
val attributes = fileManager.attributesOfItemAtPath(path, null) ?: return 0L
|
|
||||||
return (attributes[NSFileSize] as? NSNumber)?.longLongValue ?: 0L
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun modifiedAt(path: String): Long {
|
|
||||||
val attributes = fileManager.attributesOfItemAtPath(path, null) ?: return 0L
|
|
||||||
val date = attributes[NSFileModificationDate] as? NSDate ?: return 0L
|
|
||||||
return (date.timeIntervalSince1970 * 1000.0).toLong()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun readTail(path: String, maxBytes: Long): ByteArray {
|
|
||||||
val data = NSData.dataWithContentsOfFile(path) ?: return ByteArray(0)
|
|
||||||
val all = data.toByteArray()
|
|
||||||
if (all.isEmpty() || maxBytes <= 0) return ByteArray(0)
|
|
||||||
if (all.size.toLong() <= maxBytes) return all
|
|
||||||
val start = all.size - maxBytes.toInt()
|
|
||||||
val slice = all.copyOfRange(start, all.size)
|
|
||||||
val newline = slice.indexOf('\n'.code.toByte())
|
|
||||||
return if (newline in 0 until slice.lastIndex) {
|
|
||||||
slice.copyOfRange(newline + 1, slice.size)
|
|
||||||
} else {
|
|
||||||
slice
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
private fun NSData.toByteArray(): ByteArray {
|
|
||||||
val size = length.toInt()
|
|
||||||
if (size == 0) return ByteArray(0)
|
|
||||||
val result = ByteArray(size)
|
|
||||||
val source = bytes ?: return ByteArray(0)
|
|
||||||
result.usePinned { pinned ->
|
|
||||||
memcpy(pinned.addressOf(0), source, size.convert())
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
package com.vnidrop.app.notifications
|
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
||||||
import platform.Foundation.NSURL
|
|
||||||
import platform.UIKit.UIApplication
|
|
||||||
import platform.UIKit.UIApplicationOpenNotificationSettingsURLString
|
|
||||||
import platform.UserNotifications.UNAuthorizationOptionAlert
|
|
||||||
import platform.UserNotifications.UNAuthorizationOptionSound
|
|
||||||
import platform.UserNotifications.UNAuthorizationStatusAuthorized
|
|
||||||
import platform.UserNotifications.UNAuthorizationStatusDenied
|
|
||||||
import platform.UserNotifications.UNAuthorizationStatusEphemeral
|
|
||||||
import platform.UserNotifications.UNAuthorizationStatusNotDetermined
|
|
||||||
import platform.UserNotifications.UNAuthorizationStatusProvisional
|
|
||||||
import platform.UserNotifications.UNMutableNotificationContent
|
|
||||||
import platform.UserNotifications.UNNotificationRequest
|
|
||||||
import platform.UserNotifications.UNUserNotificationCenter
|
|
||||||
import kotlin.coroutines.resume
|
|
||||||
|
|
||||||
class IosLocalNotificationService : LocalNotificationService {
|
|
||||||
private val center = UNUserNotificationCenter.currentNotificationCenter()
|
|
||||||
private val _permission = MutableStateFlow(NotificationPermission.NotDetermined)
|
|
||||||
override val permission: StateFlow<NotificationPermission> = _permission.asStateFlow()
|
|
||||||
|
|
||||||
override suspend fun refreshPermission(): NotificationPermission = suspendCancellableCoroutine { continuation ->
|
|
||||||
center.getNotificationSettingsWithCompletionHandler { settings ->
|
|
||||||
val mapped = when (settings?.authorizationStatus) {
|
|
||||||
UNAuthorizationStatusAuthorized,
|
|
||||||
UNAuthorizationStatusProvisional,
|
|
||||||
UNAuthorizationStatusEphemeral -> NotificationPermission.Granted
|
|
||||||
UNAuthorizationStatusDenied -> NotificationPermission.Denied
|
|
||||||
UNAuthorizationStatusNotDetermined -> NotificationPermission.NotDetermined
|
|
||||||
else -> NotificationPermission.Unsupported
|
|
||||||
}
|
|
||||||
_permission.value = mapped
|
|
||||||
if (continuation.isActive) continuation.resume(mapped)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun requestPermission(): NotificationPermission {
|
|
||||||
val current = refreshPermission()
|
|
||||||
if (current != NotificationPermission.NotDetermined) return current
|
|
||||||
return suspendCancellableCoroutine { continuation ->
|
|
||||||
center.requestAuthorizationWithOptions(
|
|
||||||
options = UNAuthorizationOptionAlert or UNAuthorizationOptionSound,
|
|
||||||
completionHandler = { granted, _ ->
|
|
||||||
val result = if (granted) NotificationPermission.Granted else NotificationPermission.Denied
|
|
||||||
_permission.value = result
|
|
||||||
if (continuation.isActive) continuation.resume(result)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun openSettings(): Result<Unit> {
|
|
||||||
val url = NSURL.URLWithString(UIApplicationOpenNotificationSettingsURLString)
|
|
||||||
?: return Result.failure(IllegalStateException("Notification settings URL is unavailable"))
|
|
||||||
val opened = suspendCancellableCoroutine { continuation ->
|
|
||||||
UIApplication.sharedApplication.openURL(url, emptyMap<Any?, Any>()) { success ->
|
|
||||||
if (continuation.isActive) continuation.resume(success)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return if (opened) Result.success(Unit) else Result.failure(IllegalStateException("Could not open notification settings"))
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun publish(notification: LocalNotification): Result<Unit> = runCatching {
|
|
||||||
check(refreshPermission() == NotificationPermission.Granted) { "Notification permission is not granted" }
|
|
||||||
val content = UNMutableNotificationContent().apply {
|
|
||||||
setTitle(notification.title)
|
|
||||||
setBody(notification.body)
|
|
||||||
setSound(platform.UserNotifications.UNNotificationSound.defaultSound)
|
|
||||||
}
|
|
||||||
val request = UNNotificationRequest.requestWithIdentifier(notification.id, content, null)
|
|
||||||
suspendCancellableCoroutine { continuation ->
|
|
||||||
center.addNotificationRequest(request) { error ->
|
|
||||||
if (!continuation.isActive) return@addNotificationRequest
|
|
||||||
if (error == null) {
|
|
||||||
continuation.resume(Unit)
|
|
||||||
} else {
|
|
||||||
continuation.resumeWith(
|
|
||||||
Result.failure(IllegalStateException(error.localizedDescription)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun cancel(id: String) {
|
|
||||||
center.removePendingNotificationRequestsWithIdentifiers(listOf(id))
|
|
||||||
center.removeDeliveredNotificationsWithIdentifiers(listOf(id))
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun cancelAll() {
|
|
||||||
center.removeAllPendingNotificationRequests()
|
|
||||||
center.removeAllDeliveredNotifications()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package com.vnidrop.app.platform
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.SideEffect
|
|
||||||
import platform.Foundation.NSNotificationCenter
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
actual fun PlatformSystemAppearance(isDarkTheme: Boolean) {
|
|
||||||
SideEffect {
|
|
||||||
// The Swift host owns the actual UIKit status bar style. Compose publishes
|
|
||||||
// the resolved theme here so the wrapper can update without coupling common
|
|
||||||
// UI code to iOS-specific view controller APIs.
|
|
||||||
NSNotificationCenter.defaultCenter.postNotificationName(
|
|
||||||
aName = "VniDropThemeChanged",
|
|
||||||
`object` = null,
|
|
||||||
userInfo = mapOf("isDark" to if (isDarkTheme) "true" else "false"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package com.vnidrop.app
|
|
||||||
|
|
||||||
import platform.Foundation.NSTemporaryDirectory
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertTrue
|
|
||||||
import uniffi.vnidrop.CoreEvent
|
|
||||||
import uniffi.vnidrop.CoreEventSink
|
|
||||||
import uniffi.vnidrop.VnidropCore
|
|
||||||
|
|
||||||
class SharedLogicIOSTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun generatedBindingsCanInitializeRustCore() {
|
|
||||||
val core = VnidropCore.initialize(
|
|
||||||
appDataDir = NSTemporaryDirectory() + "vnidrop-ios-test",
|
|
||||||
eventSink = object : CoreEventSink {
|
|
||||||
override fun onEvent(event: CoreEvent) = Unit
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
|
||||||
assertTrue(core.status().endpointId.isNotBlank())
|
|
||||||
} finally {
|
|
||||||
core.shutdown()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
package com.vnidrop.app.core
|
|
||||||
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertTrue
|
|
||||||
import uniffi.vnidrop.SourceKind
|
|
||||||
|
|
||||||
class FileSystemServiceIosTest {
|
|
||||||
@Test
|
|
||||||
fun sandboxPickerCopyMapsToPathSource() {
|
|
||||||
val picked = PickedShareFile(
|
|
||||||
value = "/tmp/VniDrop/photos",
|
|
||||||
displayName = "photos",
|
|
||||||
isTemporaryCopy = true,
|
|
||||||
isDirectory = true,
|
|
||||||
)
|
|
||||||
|
|
||||||
val source = picked.toIosShareSource()
|
|
||||||
|
|
||||||
assertEquals(SourceKind.PATH, source.kind)
|
|
||||||
assertEquals(picked.value, source.value)
|
|
||||||
assertEquals(picked.displayName, source.displayName)
|
|
||||||
assertTrue(source.isDirectory)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -128,44 +128,11 @@ private fun File.systemIconPng(): ByteArray? = runCatching {
|
|||||||
}
|
}
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
|
|
||||||
private fun pickDirectory(title: String): File? =
|
private fun pickDirectory(title: String): File? {
|
||||||
if (isMacOs()) {
|
val chooser = JFileChooser().apply {
|
||||||
val dialog = withMacDirectoryDialog {
|
dialogTitle = title
|
||||||
nativeFileDialog(title).apply { isVisible = true }
|
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
||||||
}
|
isAcceptAllFileFilterUsed = false
|
||||||
try {
|
|
||||||
val directory = dialog.directory ?: return null
|
|
||||||
dialog.file
|
|
||||||
?.let { File(directory, it) }
|
|
||||||
?: File(directory)
|
|
||||||
} finally {
|
|
||||||
dialog.dispose()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val chooser = JFileChooser().apply {
|
|
||||||
dialogTitle = title
|
|
||||||
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
|
||||||
isAcceptAllFileFilterUsed = false
|
|
||||||
}
|
|
||||||
if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) chooser.selectedFile else null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun <T> withMacDirectoryDialog(block: () -> T): T {
|
|
||||||
if (!isMacOs()) return block()
|
|
||||||
|
|
||||||
val key = "apple.awt.fileDialogForDirectories"
|
|
||||||
val previous = System.getProperty(key)
|
|
||||||
System.setProperty(key, "true")
|
|
||||||
return try {
|
|
||||||
block()
|
|
||||||
} finally {
|
|
||||||
if (previous == null) {
|
|
||||||
System.clearProperty(key)
|
|
||||||
} else {
|
|
||||||
System.setProperty(key, previous)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) chooser.selectedFile else null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isMacOs(): Boolean =
|
|
||||||
System.getProperty("os.name").startsWith("Mac", ignoreCase = true)
|
|
||||||
|
|||||||
@@ -31,10 +31,13 @@ private class JvmPendingCrashStore(
|
|||||||
return directory
|
return directory
|
||||||
.listFiles { file -> file.isFile && file.name.endsWith(".crash") }
|
.listFiles { file -> file.isFile && file.name.endsWith(".crash") }
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
.sortedByDescending { it.lastModified() }
|
|
||||||
.mapNotNull { file ->
|
.mapNotNull { file ->
|
||||||
runCatching { CrashReportCodec.decode(file.readText(StandardCharsets.UTF_8)) }.getOrNull()
|
runCatching { CrashReportCodec.decode(file.readText(StandardCharsets.UTF_8)) }.getOrNull()
|
||||||
}
|
}
|
||||||
|
.sortedWith(
|
||||||
|
compareByDescending<CrashReport> { it.timestampMillis }
|
||||||
|
.thenBy { it.id },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
@@ -64,7 +67,10 @@ private class JvmPendingCrashStore(
|
|||||||
file to report
|
file to report
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sortedByDescending { (_, report) -> report.timestampMillis }
|
.sortedWith(
|
||||||
|
compareByDescending<Pair<File, CrashReport>> { (_, report) -> report.timestampMillis }
|
||||||
|
.thenBy { (_, report) -> report.id },
|
||||||
|
)
|
||||||
reports.forEachIndexed { index, (file, report) ->
|
reports.forEachIndexed { index, (file, report) ->
|
||||||
if (index >= maxCount || report.timestampMillis < olderThanTimestampMillis) file.delete()
|
if (index >= maxCount || report.timestampMillis < olderThanTimestampMillis) file.delete()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import java.io.File
|
|||||||
@Composable
|
@Composable
|
||||||
actual fun rememberTransferShareActions(): TransferShareActions = remember {
|
actual fun rememberTransferShareActions(): TransferShareActions = remember {
|
||||||
object : TransferShareActions {
|
object : TransferShareActions {
|
||||||
override val canUseNativeShare = DesktopShareBridge.shareFile != null
|
override val canUseNativeShare = false
|
||||||
override val nfcAvailability = NfcShareAvailability.Hidden
|
override val nfcAvailability = NfcShareAvailability.Hidden
|
||||||
|
|
||||||
override fun exportInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit) {
|
override fun exportInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit) {
|
||||||
@@ -31,18 +31,7 @@ actual fun rememberTransferShareActions(): TransferShareActions = remember {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun shareInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit) {
|
override fun shareInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit) {
|
||||||
EventQueue.invokeLater {
|
onResult(Result.failure(UnsupportedOperationException("System sharing is unavailable on this desktop")))
|
||||||
val share = DesktopShareBridge.shareFile
|
|
||||||
if (share == null) {
|
|
||||||
onResult(Result.failure(UnsupportedOperationException("System sharing is unavailable on this desktop")))
|
|
||||||
return@invokeLater
|
|
||||||
}
|
|
||||||
onResult(runCatching {
|
|
||||||
val directory = File(System.getProperty("java.io.tmpdir"), "vnidrop-share").apply { mkdirs() }
|
|
||||||
val file = File(directory, invitationFileName(transferName)).apply { writeText(ticket) }
|
|
||||||
share(file).getOrThrow()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun writeInvitationToNfc(ticket: String, onResult: (Result<Unit>) -> Unit) {
|
override fun writeInvitationToNfc(ticket: String, onResult: (Result<Unit>) -> Unit) {
|
||||||
@@ -55,8 +44,3 @@ actual fun rememberTransferShareActions(): TransferShareActions = remember {
|
|||||||
private fun activeFrame(): Frame? =
|
private fun activeFrame(): Frame? =
|
||||||
(KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow as? Frame)
|
(KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow as? Frame)
|
||||||
?: Frame.getFrames().firstOrNull { it.isActive || it.isFocused }
|
?: Frame.getFrames().firstOrNull { it.isActive || it.isFocused }
|
||||||
|
|
||||||
object DesktopShareBridge {
|
|
||||||
@Volatile
|
|
||||||
var shareFile: ((File) -> Result<Unit>)? = null
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,83 +1,14 @@
|
|||||||
package com.vnidrop.app.platform
|
package com.vnidrop.app.platform
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.SideEffect
|
|
||||||
import java.awt.Color
|
|
||||||
import java.awt.EventQueue
|
|
||||||
import java.awt.Frame
|
import java.awt.Frame
|
||||||
import java.awt.Window
|
|
||||||
import javax.swing.JFrame
|
|
||||||
import javax.swing.JRootPane
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
actual fun PlatformSystemAppearance(isDarkTheme: Boolean) {
|
actual fun PlatformSystemAppearance(isDarkTheme: Boolean) = Unit
|
||||||
SideEffect {
|
|
||||||
DesktopSystemAppearance.apply(isDarkTheme)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal object DesktopSystemAppearance {
|
|
||||||
private const val MAC_APPEARANCE_PROPERTY = "apple.awt.application.appearance"
|
|
||||||
private const val FULL_WINDOW_CONTENT_PROPERTY = "apple.awt.fullWindowContent"
|
|
||||||
private const val TRANSPARENT_TITLE_BAR_PROPERTY = "apple.awt.transparentTitleBar"
|
|
||||||
private const val WINDOW_TITLE_VISIBLE_PROPERTY = "apple.awt.windowTitleVisible"
|
|
||||||
|
|
||||||
fun apply(isDarkTheme: Boolean) {
|
|
||||||
if (!DesktopAppearanceBridge.isMacOs()) return
|
|
||||||
System.setProperty(MAC_APPEARANCE_PROPERTY, macOsAppearanceName(isDarkTheme))
|
|
||||||
EventQueue.invokeLater {
|
|
||||||
DesktopAppearanceBridge.applyNativeAppearance?.invoke(isDarkTheme)
|
|
||||||
Window.getWindows().forEach { window ->
|
|
||||||
applyWindowChrome(window, isDarkTheme)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun macOsAppearanceName(isDarkTheme: Boolean): String =
|
|
||||||
if (isDarkTheme) "NSAppearanceNameDarkAqua" else "NSAppearanceNameAqua"
|
|
||||||
|
|
||||||
internal fun usesTransparentTitlebar(): Boolean = true
|
|
||||||
internal fun usesFullWindowContent(): Boolean = true
|
|
||||||
internal fun showsNativeWindowTitle(): Boolean = false
|
|
||||||
|
|
||||||
internal fun titlebarBackground(isDarkTheme: Boolean): Color =
|
|
||||||
if (isDarkTheme) Color(0x21, 0x21, 0x21) else Color(0xF3, 0xF3, 0xF3)
|
|
||||||
|
|
||||||
private fun applyWindowChrome(window: Window, isDarkTheme: Boolean) {
|
|
||||||
val background = titlebarBackground(isDarkTheme)
|
|
||||||
window.background = background
|
|
||||||
(window as? JFrame)?.rootPane?.let { rootPane -> applyRootPaneChrome(rootPane, background) }
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun applyRootPaneChrome(rootPane: JRootPane, background: Color) {
|
|
||||||
// Extending the Compose surface beneath the native titlebar lets the
|
|
||||||
// window chrome and sidebar share one uninterrupted background.
|
|
||||||
rootPane.putClientProperty(FULL_WINDOW_CONTENT_PROPERTY, usesFullWindowContent())
|
|
||||||
rootPane.putClientProperty(TRANSPARENT_TITLE_BAR_PROPERTY, usesTransparentTitlebar())
|
|
||||||
rootPane.putClientProperty(WINDOW_TITLE_VISIBLE_PROPERTY, showsNativeWindowTitle())
|
|
||||||
rootPane.background = background
|
|
||||||
rootPane.contentPane.background = background
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
object DesktopAppearanceBridge {
|
object DesktopAppearanceBridge {
|
||||||
@Volatile
|
|
||||||
var applyNativeAppearance: ((Boolean) -> Unit)? = null
|
|
||||||
|
|
||||||
fun isMacOs(): Boolean = isMacOs(System.getProperty("os.name"))
|
|
||||||
fun isLinux(): Boolean = isLinux(System.getProperty("os.name"))
|
fun isLinux(): Boolean = isLinux(System.getProperty("os.name"))
|
||||||
|
|
||||||
fun toggleMaximized(window: Window) {
|
|
||||||
if (!isMacOs()) return
|
|
||||||
val frame = window as? Frame ?: return
|
|
||||||
EventQueue.invokeLater {
|
|
||||||
frame.extendedState = toggledWindowState(frame.extendedState)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun isMacOs(osName: String): Boolean =
|
|
||||||
osName.startsWith("Mac", ignoreCase = true)
|
|
||||||
|
|
||||||
internal fun isLinux(osName: String): Boolean =
|
internal fun isLinux(osName: String): Boolean =
|
||||||
osName.startsWith("Linux", ignoreCase = true)
|
osName.startsWith("Linux", ignoreCase = true)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.vnidrop.app.diagnostics
|
|||||||
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.attribute.FileTime
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertFalse
|
import kotlin.test.assertFalse
|
||||||
@@ -24,6 +25,8 @@ class PendingCrashStoreJvmTest {
|
|||||||
File(directory, ".orphan.tmp").writeText("partial")
|
File(directory, ".orphan.tmp").writeText("partial")
|
||||||
store.write(current.copy(id = "../../escape"))
|
store.write(current.copy(id = "../../escape"))
|
||||||
val escapedPath = File(directory, "../../escape.crash").canonicalFile
|
val escapedPath = File(directory, "../../escape.crash").canonicalFile
|
||||||
|
Files.setLastModifiedTime(File(directory, "${older.id}.crash").toPath(), FileTime.fromMillis(2_000))
|
||||||
|
Files.setLastModifiedTime(File(directory, "${current.id}.crash").toPath(), FileTime.fromMillis(1_000))
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("replaced", "older"),
|
listOf("replaced", "older"),
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.vnidrop.app.platform
|
||||||
|
|
||||||
|
import java.awt.Frame
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class DesktopAppearanceBridgeTest {
|
||||||
|
@Test
|
||||||
|
fun customWindowChromeIsLimitedToLinux() {
|
||||||
|
assertFalse(DesktopAppearanceBridge.isLinux("Mac OS X"))
|
||||||
|
assertTrue(DesktopAppearanceBridge.isLinux("Linux"))
|
||||||
|
assertFalse(DesktopAppearanceBridge.isLinux("Windows 11"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun titlebarDoubleClickTogglesMaximizedWindowState() {
|
||||||
|
assertEquals(Frame.MAXIMIZED_BOTH, DesktopAppearanceBridge.toggledWindowState(Frame.NORMAL))
|
||||||
|
assertEquals(Frame.NORMAL, DesktopAppearanceBridge.toggledWindowState(Frame.MAXIMIZED_BOTH))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
package com.vnidrop.app.platform
|
|
||||||
|
|
||||||
import java.awt.Color
|
|
||||||
import java.awt.Frame
|
|
||||||
import javax.swing.JRootPane
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertFalse
|
|
||||||
import kotlin.test.assertTrue
|
|
||||||
|
|
||||||
class DesktopSystemAppearanceTest {
|
|
||||||
@Test
|
|
||||||
fun customWindowChromeSupportsMacOsAndLinux() {
|
|
||||||
assertTrue(DesktopAppearanceBridge.isMacOs("Mac OS X"))
|
|
||||||
assertFalse(DesktopAppearanceBridge.isLinux("Mac OS X"))
|
|
||||||
assertTrue(DesktopAppearanceBridge.isLinux("Linux"))
|
|
||||||
assertFalse(DesktopAppearanceBridge.isMacOs("Linux"))
|
|
||||||
assertFalse(DesktopAppearanceBridge.isMacOs("Windows 11"))
|
|
||||||
assertFalse(DesktopAppearanceBridge.isLinux("Windows 11"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun titlebarDoubleClickTogglesMaximizedWindowState() {
|
|
||||||
assertEquals(Frame.MAXIMIZED_BOTH, DesktopAppearanceBridge.toggledWindowState(Frame.NORMAL))
|
|
||||||
assertEquals(Frame.NORMAL, DesktopAppearanceBridge.toggledWindowState(Frame.MAXIMIZED_BOTH))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun macOsAppearanceNamesMatchResolvedTheme() {
|
|
||||||
assertEquals("NSAppearanceNameDarkAqua", DesktopSystemAppearance.macOsAppearanceName(isDarkTheme = true))
|
|
||||||
assertEquals("NSAppearanceNameAqua", DesktopSystemAppearance.macOsAppearanceName(isDarkTheme = false))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun titlebarBackgroundMatchesSidebarSurface() {
|
|
||||||
assertEquals(0x212121, DesktopSystemAppearance.titlebarBackground(isDarkTheme = true).rgb and 0xFFFFFF)
|
|
||||||
assertEquals(0xF3F3F3, DesktopSystemAppearance.titlebarBackground(isDarkTheme = false).rgb and 0xFFFFFF)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun transparentTitlebarIsAlwaysUsedWithAppKitAppearance() {
|
|
||||||
assertEquals(true, DesktopSystemAppearance.usesTransparentTitlebar())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun composeContentExtendsUnderMacOsTitlebar() {
|
|
||||||
val rootPane = JRootPane()
|
|
||||||
val background = Color(0x21, 0x21, 0x21)
|
|
||||||
|
|
||||||
DesktopSystemAppearance.applyRootPaneChrome(rootPane, background)
|
|
||||||
|
|
||||||
assertEquals(true, rootPane.getClientProperty("apple.awt.fullWindowContent"))
|
|
||||||
assertEquals(true, rootPane.getClientProperty("apple.awt.transparentTitleBar"))
|
|
||||||
assertEquals(false, rootPane.getClientProperty("apple.awt.windowTitleVisible"))
|
|
||||||
assertEquals(background, rootPane.background)
|
|
||||||
assertEquals(background, rootPane.contentPane.background)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun runtimeAppearanceCallIsFailSoft() {
|
|
||||||
DesktopSystemAppearance.apply(isDarkTheme = true)
|
|
||||||
DesktopSystemAppearance.apply(isDarkTheme = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||