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/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "Makefile"
|
||||
- "config.mk"
|
||||
- "make/**"
|
||||
- ".github/workflows/apple.yml"
|
||||
push:
|
||||
branches:
|
||||
@@ -18,6 +21,9 @@ on:
|
||||
- "crates/uniffi-bindgen/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "Makefile"
|
||||
- "config.mk"
|
||||
- "make/**"
|
||||
- ".github/workflows/apple.yml"
|
||||
|
||||
permissions:
|
||||
@@ -57,23 +63,5 @@ jobs:
|
||||
- name: Install XcodeGen
|
||||
run: brew install xcodegen
|
||||
|
||||
- name: Build Rust core (xcframework + Swift bindings)
|
||||
working-directory: apple
|
||||
run: ./scripts/build-core.sh debug
|
||||
|
||||
- name: Generate Xcode project
|
||||
working-directory: apple
|
||||
run: xcodegen generate
|
||||
|
||||
- name: Run unit tests (iOS Simulator)
|
||||
working-directory: apple
|
||||
run: |
|
||||
set -euo pipefail
|
||||
DEVICE=$(xcrun simctl list devices available \
|
||||
| grep -oE 'iPhone [0-9]+( Pro)?' | head -1)
|
||||
echo "Testing on: ${DEVICE:-iPhone 16}"
|
||||
xcodebuild test \
|
||||
-project VniDrop.xcodeproj \
|
||||
-scheme VniDrop \
|
||||
-destination "platform=iOS Simulator,name=${DEVICE:-iPhone 16}" \
|
||||
CODE_SIGNING_ALLOWED=NO
|
||||
- name: Build and test Apple app
|
||||
run: make check-apple
|
||||
|
||||
25
.github/workflows/diagnostics-api.yml
vendored
@@ -4,12 +4,18 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "services/diagnostics-api/**"
|
||||
- "Makefile"
|
||||
- "config.mk"
|
||||
- "make/**"
|
||||
- ".github/workflows/diagnostics-api.yml"
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- "services/diagnostics-api/**"
|
||||
- "Makefile"
|
||||
- "config.mk"
|
||||
- "make/**"
|
||||
- ".github/workflows/diagnostics-api.yml"
|
||||
|
||||
permissions:
|
||||
@@ -23,9 +29,6 @@ jobs:
|
||||
quality:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: services/diagnostics-api
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -37,17 +40,5 @@ jobs:
|
||||
cache: npm
|
||||
cache-dependency-path: services/diagnostics-api/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- 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
|
||||
- name: Check diagnostics API
|
||||
run: make check-diagnostics
|
||||
|
||||
19
.github/workflows/docs.yml
vendored
@@ -4,12 +4,18 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "docs/**"
|
||||
- "Makefile"
|
||||
- "config.mk"
|
||||
- "make/**"
|
||||
- ".github/workflows/docs.yml"
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- "docs/**"
|
||||
- "Makefile"
|
||||
- "config.mk"
|
||||
- "make/**"
|
||||
- ".github/workflows/docs.yml"
|
||||
|
||||
permissions:
|
||||
@@ -23,9 +29,6 @@ jobs:
|
||||
quality:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: docs
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -37,11 +40,5 @@ jobs:
|
||||
cache: npm
|
||||
cache-dependency-path: docs/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Type-check
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Check documentation website
|
||||
run: make check-docs
|
||||
|
||||
65
.github/workflows/linux-packages.yml
vendored
@@ -17,6 +17,9 @@ on:
|
||||
- "gradle.properties"
|
||||
- "gradle/**"
|
||||
- "gradlew"
|
||||
- "Makefile"
|
||||
- "config.mk"
|
||||
- "make/**"
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
@@ -92,37 +95,7 @@ jobs:
|
||||
echo "app=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Test and build Debian package
|
||||
run: |
|
||||
./gradlew \
|
||||
:shared:jvmTest \
|
||||
:desktopApp:packageReleaseDeb \
|
||||
-Pvnidrop.version=${{ steps.version.outputs.app }} \
|
||||
-Pvnidrop.desktop.rustVariant=release \
|
||||
-Pvnidrop.diagnostics.included=false \
|
||||
--no-daemon \
|
||||
--no-configuration-cache \
|
||||
--stacktrace
|
||||
|
||||
- name: Validate Debian package
|
||||
id: package
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.app }}
|
||||
run: |
|
||||
mapfile -t packages < <(find desktopApp/build/compose/binaries/main-release/deb -maxdepth 1 -type f -name '*.deb')
|
||||
if (( ${#packages[@]} != 1 )); then
|
||||
echo "Expected exactly one Debian package, found ${#packages[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
output_directory=build/release/linux/deb
|
||||
output_name="vnidrop_${VERSION}-1_amd64.deb"
|
||||
mkdir -p "$output_directory"
|
||||
cp "${packages[0]}" "$output_directory/$output_name"
|
||||
packaging/linux/verify-package.sh deb "$VERSION" "$output_directory/$output_name"
|
||||
(
|
||||
cd "$output_directory"
|
||||
sha256sum "$output_name" > "$output_name.sha256"
|
||||
)
|
||||
run: make package-deb VERSION=${{ steps.version.outputs.app }}
|
||||
|
||||
- name: Upload Debian artifact
|
||||
if: github.event_name != 'pull_request'
|
||||
@@ -225,35 +198,7 @@ jobs:
|
||||
echo "app=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build RPM package
|
||||
run: |
|
||||
./gradlew \
|
||||
:desktopApp:packageReleaseRpm \
|
||||
-Pvnidrop.version=${{ steps.version.outputs.app }} \
|
||||
-Pvnidrop.desktop.rustVariant=release \
|
||||
-Pvnidrop.diagnostics.included=false \
|
||||
--no-daemon \
|
||||
--no-configuration-cache \
|
||||
--stacktrace
|
||||
|
||||
- name: Validate RPM package
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.app }}
|
||||
run: |
|
||||
mapfile -t packages < <(find desktopApp/build/compose/binaries/main-release/rpm -maxdepth 1 -type f -name '*.rpm')
|
||||
if (( ${#packages[@]} != 1 )); then
|
||||
echo "Expected exactly one RPM package, found ${#packages[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
output_directory=build/release/linux/rpm
|
||||
output_name="vnidrop-${VERSION}-1.x86_64.rpm"
|
||||
mkdir -p "$output_directory"
|
||||
cp "${packages[0]}" "$output_directory/$output_name"
|
||||
packaging/linux/verify-package.sh rpm "$VERSION" "$output_directory/$output_name"
|
||||
(
|
||||
cd "$output_directory"
|
||||
sha256sum "$output_name" > "$output_name.sha256"
|
||||
)
|
||||
run: make package-rpm VERSION=${{ steps.version.outputs.app }}
|
||||
|
||||
- name: Upload RPM artifact
|
||||
if: github.event_name != 'pull_request'
|
||||
|
||||
20
.github/workflows/rust-core.yml
vendored
@@ -6,6 +6,9 @@ on:
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/vnidrop/**"
|
||||
- "Makefile"
|
||||
- "config.mk"
|
||||
- "make/**"
|
||||
- ".github/workflows/rust-core.yml"
|
||||
push:
|
||||
# Only after merge (or direct master pushes). Feature-branch work is covered
|
||||
@@ -16,6 +19,9 @@ on:
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/vnidrop/**"
|
||||
- "Makefile"
|
||||
- "config.mk"
|
||||
- "make/**"
|
||||
- ".github/workflows/rust-core.yml"
|
||||
|
||||
permissions:
|
||||
@@ -33,18 +39,10 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Rust quality components
|
||||
run: rustup component add clippy rustfmt
|
||||
- name: Check formatting
|
||||
run: cargo fmt --all -- --check
|
||||
- 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: Check Rust core
|
||||
run: make check-rust
|
||||
- name: Install cargo-audit
|
||||
run: cargo install cargo-audit --locked
|
||||
- name: Audit Rust dependencies
|
||||
# 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"
|
||||
- "androidApp/**"
|
||||
- "desktopApp/**"
|
||||
- "Makefile"
|
||||
- "config.mk"
|
||||
- "make/**"
|
||||
- ".github/workflows/shared-kmp.yml"
|
||||
push:
|
||||
branches:
|
||||
@@ -32,6 +35,9 @@ on:
|
||||
- "gradle.properties"
|
||||
- "androidApp/**"
|
||||
- "desktopApp/**"
|
||||
- "Makefile"
|
||||
- "config.mk"
|
||||
- "make/**"
|
||||
- ".github/workflows/shared-kmp.yml"
|
||||
|
||||
permissions:
|
||||
@@ -44,8 +50,7 @@ concurrency:
|
||||
jobs:
|
||||
jvm-test:
|
||||
# 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: macos-latest
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 75
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -63,7 +68,7 @@ jobs:
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin,aarch64-linux-android,x86_64-linux-android
|
||||
targets: aarch64-linux-android,x86_64-linux-android
|
||||
|
||||
- name: Cache Cargo
|
||||
uses: actions/cache@v4
|
||||
@@ -95,7 +100,7 @@ jobs:
|
||||
echo "ANDROID_NDK_ROOT=${{ steps.setup-ndk.outputs.ndk-path }}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run shared JVM tests
|
||||
run: ./gradlew :shared:jvmTest --no-daemon --stacktrace
|
||||
run: make check-shared
|
||||
|
||||
- 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/
|
||||
target/
|
||||
.junie
|
||||
config.override.mk
|
||||
|
||||
# Local design export scratch
|
||||
output/
|
||||
|
||||
60
AGENTS.md
@@ -13,13 +13,14 @@ Nested guides take precedence when editing under those trees:
|
||||
|
||||
## 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 |
|
||||
|-------|------|----------------|
|
||||
| Rust core | `crates/vnidrop/` | Iroh endpoint, blobs, SQLite, tickets, approval, streaming |
|
||||
| Shared KMP | `shared/` | Compose UI, ViewModels, expect/actual platform bridges |
|
||||
| Hosts | `androidApp/`, `iosApp/`, `desktopApp/` | Thin app shells |
|
||||
| Shared KMP | `shared/` | Compose UI and platform bridges for Android, Windows, and Linux |
|
||||
| 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**.
|
||||
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
|
||||
|
||||
Install prerequisites when missing: Rust stable + rustfmt + clippy, JDK 17,
|
||||
Android NDK/SDK only if building Android, Xcode only for iOS.
|
||||
Install prerequisites when missing: GNU Make + Bash, Rust stable + rustfmt + clippy, JDK 17,
|
||||
Android NDK/SDK only if building Android, Xcode only for the native Apple app.
|
||||
|
||||
### Rust core (`crates/vnidrop` or workspace root)
|
||||
|
||||
Run from the **repo root** (Cargo workspace):
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test --workspace --all-targets
|
||||
make check-rust
|
||||
```
|
||||
|
||||
Focused:
|
||||
|
||||
```bash
|
||||
cargo test -p vnidrop
|
||||
cargo test -p vnidrop --test output_sink
|
||||
cargo test -p vnidrop --test transfer
|
||||
cargo test -p vnidrop --test approval
|
||||
cargo test -p vnidrop --test lifecycle
|
||||
make test-rust
|
||||
make test-rust-output-sink
|
||||
make test-rust-transfer
|
||||
make test-rust-approval
|
||||
make test-rust-lifecycle
|
||||
```
|
||||
|
||||
After finishing Rust edits, format:
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
make format
|
||||
```
|
||||
|
||||
CI also runs `cargo doc --workspace --no-deps` with `RUSTDOCFLAGS=-D warnings`
|
||||
(see `.github/workflows/rust-core.yml`). Run it before large Rust public-API changes.
|
||||
`make check-rust` includes documentation with warnings denied, matching
|
||||
`.github/workflows/rust-core.yml`.
|
||||
|
||||
### Shared KMP / Compose (`shared/`)
|
||||
|
||||
```bash
|
||||
./gradlew :shared:jvmTest
|
||||
./gradlew :shared:compileKotlinJvm
|
||||
make check-shared
|
||||
```
|
||||
|
||||
Other targets (slower / machine-dependent):
|
||||
|
||||
```bash
|
||||
./gradlew :shared:testAndroidHostTest
|
||||
./gradlew :shared:iosSimulatorArm64Test # macOS + Xcode
|
||||
./gradlew :androidApp:assembleDebug
|
||||
./gradlew :desktopApp:run
|
||||
make test-android-host
|
||||
make check-android
|
||||
make run-desktop
|
||||
```
|
||||
|
||||
**Note:** `jvmTest` CI currently runs on **macOS**. Gobley host cargo is enabled
|
||||
for the current host and architecture, so local Linux and Windows builds embed
|
||||
their matching desktop Rust library. Prefer macOS only when exact CI parity is
|
||||
required.
|
||||
**Note:** `jvmTest` CI runs on **Linux**. Gobley host cargo is enabled for the
|
||||
current host and architecture, so local desktop builds embed their matching
|
||||
Rust library.
|
||||
|
||||
### What to run before finishing
|
||||
|
||||
| You changed… | Minimum verification |
|
||||
|--------------|----------------------|
|
||||
| `crates/vnidrop/**` only | `cargo fmt`, `cargo clippy … -D warnings`, `cargo test -p vnidrop` |
|
||||
| Cancel / export / sinks | Above + `cargo test -p vnidrop --test output_sink` |
|
||||
| `shared/**` only | `./gradlew :shared:jvmTest` |
|
||||
| Both | Rust suite + `:shared:jvmTest` |
|
||||
| `crates/vnidrop/**` only | `make check-rust` |
|
||||
| Cancel / export / sinks | Above + `make test-rust-output-sink` |
|
||||
| `shared/**` only | `make test-shared` |
|
||||
| Both | `make test-rust test-shared` |
|
||||
| Docs only | No suite required; verify links/paths |
|
||||
|
||||
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
|
||||
feature/send|receive|approvals|settings|app/
|
||||
ui/theme|components|navigation|feedback|state/
|
||||
androidMain|iosMain|jvmMain/ # expect/actual implementations
|
||||
androidMain|jvmMain/ # expect/actual implementations
|
||||
```
|
||||
|
||||
### 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.
|
||||
Folder share expands SAF trees in Kotlin to per-file FDs + relative names.
|
||||
- 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:
|
||||
|
||||
- GNU Make and Bash for the root command interface
|
||||
- JDK 17 or newer for Gradle and application builds
|
||||
- Rust stable with `rustfmt` and Clippy for the transfer core
|
||||
- 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
|
||||
|
||||
The first Rust and Gradle builds may take several minutes while dependencies are
|
||||
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
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `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 |
|
||||
| `iosApp/` | iOS application shell |
|
||||
| `desktopApp/` | Desktop JVM application shell |
|
||||
| `desktopApp/` | Windows/Linux JVM application shell |
|
||||
| `apple/` | Native SwiftUI application and Rust/UniFFI integration for Apple platforms |
|
||||
| `services/diagnostics-api/` | Optional Cloudflare diagnostics service |
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test -p vnidrop
|
||||
make format
|
||||
make test-rust
|
||||
```
|
||||
|
||||
For cancel, export, or output-sink changes, also run:
|
||||
|
||||
```bash
|
||||
cargo test -p vnidrop --test output_sink
|
||||
make test-rust-output-sink
|
||||
```
|
||||
|
||||
For broader core changes, run the complete workspace suite:
|
||||
|
||||
```bash
|
||||
cargo test --workspace --all-targets
|
||||
make check-rust
|
||||
```
|
||||
|
||||
### Shared Kotlin and Compose
|
||||
|
||||
```bash
|
||||
./gradlew :shared:jvmTest
|
||||
make test-shared
|
||||
```
|
||||
|
||||
Platform-specific checks may also be appropriate:
|
||||
|
||||
```bash
|
||||
./gradlew :shared:testAndroidHostTest
|
||||
./gradlew :shared:iosSimulatorArm64Test
|
||||
./gradlew :androidApp:assembleDebug
|
||||
make test-android-host
|
||||
make check-android
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
```bash
|
||||
cd services/diagnostics-api
|
||||
npm ci
|
||||
npm run check
|
||||
make check-diagnostics
|
||||
```
|
||||
|
||||
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
|
||||
- Cancel, stop sharing, and local transfer history
|
||||
- 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
|
||||
excluded
|
||||
|
||||
@@ -117,14 +118,21 @@ if you want to try the current version.
|
||||
git clone https://github.com/vnidrop/vnidrop.git
|
||||
cd vnidrop
|
||||
|
||||
# Desktop
|
||||
./gradlew :desktopApp:run
|
||||
# List the supported development commands and check prerequisites
|
||||
make help
|
||||
make doctor
|
||||
|
||||
# Windows/Linux desktop
|
||||
make run-desktop
|
||||
|
||||
# Android debug build
|
||||
./gradlew :androidApp:assembleDebug
|
||||
make build-android
|
||||
|
||||
# iOS
|
||||
open iosApp/iosApp.xcodeproj
|
||||
# Build and launch the macOS app
|
||||
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,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
A native SwiftUI app for Apple platforms, sharing the existing Rust transfer core
|
||||
(`crates/vnidrop`) through UniFFI-generated Swift bindings. The Rust crate is not
|
||||
modified; the Kotlin/Compose app layer is ported to Swift and mirrors the Compose
|
||||
UI screen-for-screen. Android and desktop JVM continue to use `shared/` + Compose.
|
||||
UI screen-for-screen. Android, Windows, and Linux continue to use `shared/` + Compose.
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -30,21 +30,24 @@ Prerequisites: Xcode, Rust with the Apple targets
|
||||
`aarch64-apple-darwin`), and `xcodegen` (`brew install xcodegen`).
|
||||
|
||||
```bash
|
||||
# 1. Build the Rust core and generate the Swift bindings + xcframework.
|
||||
apple/scripts/build-core.sh debug # or: release (see note below)
|
||||
|
||||
# 2. Generate the Xcode project.
|
||||
cd apple && xcodegen generate
|
||||
|
||||
# 3. Open and run, or build from the CLI:
|
||||
open VniDrop.xcodeproj
|
||||
# macOS:
|
||||
xcodebuild -project VniDrop.xcodeproj -scheme VniDrop -destination 'platform=macOS' build
|
||||
# iOS simulator:
|
||||
xcodebuild -project VniDrop.xcodeproj -scheme VniDrop \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 15' build
|
||||
# From the repository root:
|
||||
make apple-core # Rust core, Swift bindings, and XCFramework
|
||||
make apple-project # generate apple/VniDrop.xcodeproj
|
||||
make open-apple-project # generate and open the project in Xcode
|
||||
make build-apple-macos # unsigned macOS build
|
||||
make open-apple # build and launch the macOS app
|
||||
make build-apple-ios # unsigned iOS simulator build
|
||||
make check-apple # iOS simulator tests
|
||||
```
|
||||
|
||||
Use `APPLE_PROFILE=release` to request a release Rust core, or set
|
||||
`APPLE_DESTINATION` to override the automatically selected iOS simulator.
|
||||
Code signing is disabled for the app and test targets; local and CI builds do
|
||||
not require an Apple Development team or provisioning profile. Make builds can
|
||||
opt in with `APPLE_CODE_SIGNING=YES`. For signed builds from Xcode, create the
|
||||
ignored `apple/Local.xcconfig` and override the signing settings there, including
|
||||
the development team.
|
||||
|
||||
## Command-line typecheck & tests
|
||||
|
||||
`Package.swift` builds the same sources as a library (minus the `@main` entry),
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
// Committed signing config. Contains no secrets.
|
||||
//
|
||||
// Per-developer signing (e.g. DEVELOPMENT_TEAM) goes in Local.xcconfig, which is
|
||||
// gitignored. The optional include below means the build still works for anyone
|
||||
// who doesn't have a Local.xcconfig — Xcode automatic signing fills in their team.
|
||||
//
|
||||
// To persist your team across `xcodegen generate`, create apple/Local.xcconfig:
|
||||
// DEVELOPMENT_TEAM = XXXXXXXXXX
|
||||
// VniDrop development and CI builds are intentionally unsigned.
|
||||
CODE_SIGNING_ALLOWED = NO
|
||||
CODE_SIGNING_REQUIRED = NO
|
||||
|
||||
// Signed local builds can opt in through this ignored file.
|
||||
#include? "Local.xcconfig"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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.
|
||||
@main
|
||||
struct VniDropApp: App {
|
||||
|
||||
@@ -16,8 +16,7 @@ struct LocalNotification {
|
||||
let body: String
|
||||
}
|
||||
|
||||
/// Local notification service, ported from `LocalNotificationService.kt` /
|
||||
/// `.ios.kt`, backed by `UNUserNotificationCenter`.
|
||||
/// Local notification service backed by `UNUserNotificationCenter`.
|
||||
@MainActor
|
||||
final class LocalNotificationService: ObservableObject {
|
||||
@Published private(set) var permission: NotificationPermission = .notDetermined
|
||||
|
||||
@@ -2,7 +2,7 @@ import SwiftUI
|
||||
|
||||
enum ReceiveMethodAvailability { case available, unavailable, hidden }
|
||||
|
||||
/// Invitation acquisition actions, ported from `ReceiveInvitationActions` (iosMain).
|
||||
/// Invitation acquisition actions shared by the native Apple feature models.
|
||||
@MainActor
|
||||
protocol ReceiveInvitationActions: AnyObject {
|
||||
var fileAvailability: ReceiveMethodAvailability { get }
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
/// Persisted per-transfer thumbnail store, ported from
|
||||
/// `feature/send/FilePreviewRepository.kt` + `PlatformPreviewStore.ios.kt`.
|
||||
/// Native Apple preview cache and thumbnail loader.
|
||||
/// Only small PNG/JPEG/WEBP previews are retained, under a total quota.
|
||||
struct PreviewStoragePolicy {
|
||||
var maxEntryBytes: Int = 512 * 1024
|
||||
|
||||
@@ -2,7 +2,7 @@ import SwiftUI
|
||||
|
||||
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.
|
||||
@MainActor
|
||||
protocol TransferShareActions: AnyObject {
|
||||
|
||||
@@ -3,7 +3,7 @@ import Foundation
|
||||
import UIKit
|
||||
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
|
||||
/// supported because raw external picker URLs do not survive relaunch.
|
||||
struct IosFileSystemService: FileSystemService {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
/// Builds a `.vnd` invitation filename from a transfer name, mirroring the iOS
|
||||
/// helper in `TransferShareActions.ios.kt`.
|
||||
/// Builds a `.vnd` invitation filename from a transfer name.
|
||||
func invitationFileName(_ transferName: String) -> String {
|
||||
let trimmed = transferName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let base = trimmed.isEmpty ? "invitation" : trimmed
|
||||
|
||||
@@ -7,7 +7,7 @@ import UniformTypeIdentifiers
|
||||
@MainActor
|
||||
func makeReceiveInvitationActions() -> ReceiveInvitationActions { IosReceiveInvitationActions() }
|
||||
|
||||
/// iOS invitation acquisition, ported from `ReceiveInvitationActions.ios.kt`:
|
||||
/// iOS invitation acquisition:
|
||||
/// document picker, camera QR scanner, and NFC read.
|
||||
final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UIDocumentPickerDelegate {
|
||||
private var documentResult: ((Result<String, Error>) -> Void)?
|
||||
|
||||
@@ -5,7 +5,7 @@ import UIKit
|
||||
@MainActor
|
||||
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.
|
||||
final class IosTransferShareActions: NSObject, TransferShareActions {
|
||||
private var nfcWriter: InvitationNfcWriter?
|
||||
@@ -61,8 +61,7 @@ final class IosTransferShareActions: NSObject, TransferShareActions {
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a VniDrop invitation to a writable NDEF tag, ported from
|
||||
/// `InvitationNfcWriter` in `TransferShareActions.ios.kt`.
|
||||
/// Writes a VniDrop invitation to a writable NDEF tag.
|
||||
// Runs entirely on the NFC session's `.main` delegate queue.
|
||||
final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate, @unchecked Sendable {
|
||||
private let ticket: String
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- iOS: NFC NDEF reading (matches the original iosApp entitlements). -->
|
||||
<!-- iOS: NFC NDEF reading. -->
|
||||
<key>com.apple.developer.nfc.readersession.formats</key>
|
||||
<array>
|
||||
<string>NDEF</string>
|
||||
|
||||
@@ -41,7 +41,6 @@ targets:
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
ENABLE_USER_SCRIPT_SANDBOXING: NO
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
configs:
|
||||
debug:
|
||||
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
|
||||
@@ -56,6 +55,9 @@ targets:
|
||||
VniDropTests:
|
||||
type: bundle.unit-test
|
||||
supportedDestinations: [iOS, macOS]
|
||||
configFiles:
|
||||
Debug: Signing.xcconfig
|
||||
Release: Signing.xcconfig
|
||||
sources:
|
||||
- path: Tests
|
||||
settings:
|
||||
@@ -63,7 +65,6 @@ targets:
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
SWIFT_VERSION: "6.0"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
dependencies:
|
||||
- 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:
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test -p vnidrop
|
||||
cargo test --workspace --all-targets
|
||||
make format
|
||||
make test-rust
|
||||
make check-rust
|
||||
```
|
||||
|
||||
Focused integration suites:
|
||||
|
||||
```bash
|
||||
cargo test -p vnidrop --test transfer
|
||||
cargo test -p vnidrop --test approval
|
||||
cargo test -p vnidrop --test lifecycle
|
||||
cargo test -p vnidrop --test output_sink
|
||||
make test-rust-transfer
|
||||
make test-rust-approval
|
||||
make test-rust-lifecycle
|
||||
make test-rust-output-sink
|
||||
```
|
||||
|
||||
Docs (CI uses `-D warnings`):
|
||||
@@ -41,7 +39,7 @@ Docs (CI uses `-D warnings`):
|
||||
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
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test -p vnidrop
|
||||
make check-rust
|
||||
```
|
||||
|
||||
If you touched cancel, export, or sinks, also:
|
||||
|
||||
```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.
|
||||
- Android opens SAF/content URIs in Kotlin and passes a borrowed file
|
||||
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.
|
||||
|
||||
## Durability And Filesystem Policy
|
||||
|
||||
@@ -25,7 +25,6 @@ dependencies {
|
||||
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation(libs.kotlinx.coroutinesSwing)
|
||||
implementation(libs.jna)
|
||||
|
||||
implementation(libs.compose.uiToolingPreview)
|
||||
testImplementation(libs.kotlin.testJunit)
|
||||
@@ -37,16 +36,12 @@ compose.desktop {
|
||||
buildTypes.release.proguard.isEnabled.set(false)
|
||||
|
||||
nativeDistributions {
|
||||
targetFormats(TargetFormat.Dmg, TargetFormat.Deb, TargetFormat.Rpm)
|
||||
targetFormats(TargetFormat.Deb, TargetFormat.Rpm)
|
||||
packageName = "VniDrop"
|
||||
packageVersion = appVersion
|
||||
description = "Send files directly across your devices"
|
||||
vendor = "Sudosy Labs"
|
||||
licenseFile.set(project.file("../LICENSE"))
|
||||
macOS {
|
||||
bundleID = "com.vnidrop.app"
|
||||
iconFile.set(project.file("../assets/macos/app-icon.icns"))
|
||||
}
|
||||
windows {
|
||||
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.application
|
||||
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.MaxVniDropInvitationBytes
|
||||
import com.vnidrop.app.feature.receive.VniDropInvitationExtension
|
||||
import com.vnidrop.app.feature.receive.decodeInvitationBytes
|
||||
import com.vnidrop.app.platform.DesktopAppearanceBridge
|
||||
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||
import java.awt.Desktop
|
||||
import java.io.File
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
val externalInvitations = ExternalInvitationController()
|
||||
val macOs = DesktopAppearanceBridge.isMacOs()
|
||||
val linux = DesktopAppearanceBridge.isLinux()
|
||||
val customWindowChrome = macOs || linux
|
||||
configureMacOsNativeAppearance()
|
||||
configureInvitationOpenHandler(externalInvitations)
|
||||
args.asSequence()
|
||||
.map(::File)
|
||||
.filter { it.extension.equals(VniDropInvitationExtension, ignoreCase = true) }
|
||||
.forEach { externalInvitations.openFile(it) }
|
||||
DesktopAppearanceBridge.applyNativeAppearance = MacOsAppKitAppearance::apply
|
||||
if (macOs) {
|
||||
DesktopShareBridge.shareFile = MacOsShareSheet::share
|
||||
}
|
||||
application {
|
||||
val windowState = rememberWindowState()
|
||||
Window(
|
||||
@@ -83,29 +75,21 @@ fun main(args: Array<String>) {
|
||||
) {
|
||||
App(
|
||||
dependencies = rememberJvmAppDependencies(externalInvitations),
|
||||
windowChromeTopInset = when {
|
||||
macOs -> MacOsTitleBarHeight
|
||||
linux -> LinuxTitleBarHeight
|
||||
else -> 0.dp
|
||||
},
|
||||
windowContentTopStartRadius = if (customWindowChrome) DesktopContentCornerRadius else 0.dp,
|
||||
windowChrome = when {
|
||||
macOs -> {
|
||||
{ MacOsTitleBar() }
|
||||
windowChromeTopInset = if (linux) LinuxTitleBarHeight else 0.dp,
|
||||
windowContentTopStartRadius = if (linux) DesktopContentCornerRadius else 0.dp,
|
||||
windowChrome = if (linux) {
|
||||
{
|
||||
LinuxTitleBar(
|
||||
isMaximized = windowState.placement == WindowPlacement.Maximized,
|
||||
onMinimize = { windowState.isMinimized = true },
|
||||
onToggleMaximize = {
|
||||
windowState.placement = toggledWindowPlacement(windowState.placement)
|
||||
},
|
||||
onClose = ::exitApplication,
|
||||
)
|
||||
}
|
||||
linux -> {
|
||||
{
|
||||
LinuxTitleBar(
|
||||
isMaximized = windowState.placement == WindowPlacement.Maximized,
|
||||
onMinimize = { windowState.isMinimized = true },
|
||||
onToggleMaximize = {
|
||||
windowState.placement = toggledWindowPlacement(windowState.placement)
|
||||
},
|
||||
onClose = ::exitApplication,
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
} 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 LinuxWindowControlWidth = 46.dp
|
||||
private val LinuxWindowControlsWidth = 138.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
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
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
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
# From the repository root:
|
||||
make run-docs
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000).
|
||||
@@ -14,9 +14,7 @@ Open [http://localhost:3000](http://localhost:3000).
|
||||
## Checks
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
npm run build
|
||||
make check-docs
|
||||
```
|
||||
|
||||
The production build is written to `out/` and can be hosted by any static web server.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#Kotlin
|
||||
kotlin.code.style=official
|
||||
kotlin.daemon.jvmargs=-Xmx3072M
|
||||
kotlin.mpp.enableCInteropCommonization=true
|
||||
#Gradle
|
||||
org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8
|
||||
org.gradle.configuration-cache=true
|
||||
|
||||
@@ -18,7 +18,6 @@ kotlin = "2.4.0"
|
||||
kotlinx-coroutines = "1.11.0"
|
||||
material3 = "1.11.0-alpha07"
|
||||
qrcode = "4.5.0"
|
||||
jna = "5.17.0"
|
||||
google-code-scanner = "16.1.0"
|
||||
|
||||
[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-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
|
||||
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" }
|
||||
|
||||
[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
|
||||
|
||||
```bash
|
||||
cd localization
|
||||
bun run src/cli.ts validate # structural checks (run before committing)
|
||||
bun run src/cli.ts generate # regenerate .xcstrings + strings.xml from strings.json
|
||||
bun run src/cli.ts migrate # one-time: rebuild strings.json from existing platform files
|
||||
# From the repository root:
|
||||
make check-localization # structural checks (run before committing)
|
||||
make localization # regenerate .xcstrings + strings.xml from strings.json
|
||||
make localization-migrate # one-time: rebuild strings.json from platform files
|
||||
```
|
||||
|
||||
**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:
|
||||
|
||||
```bash
|
||||
./gradlew :shared:jvmTest :desktopApp:packageReleaseDeb \
|
||||
-Pvnidrop.version=1.0.0 \
|
||||
-Pvnidrop.desktop.rustVariant=release \
|
||||
-Pvnidrop.diagnostics.included=false \
|
||||
--no-daemon --no-configuration-cache --stacktrace
|
||||
|
||||
./gradlew :shared:jvmTest :desktopApp:packageReleaseRpm \
|
||||
-Pvnidrop.version=1.0.0 \
|
||||
-Pvnidrop.desktop.rustVariant=release \
|
||||
-Pvnidrop.diagnostics.included=false \
|
||||
--no-daemon --no-configuration-cache --stacktrace
|
||||
make package-deb VERSION=1.0.0
|
||||
make package-rpm VERSION=1.0.0
|
||||
```
|
||||
|
||||
Compose writes the packages under
|
||||
`desktopApp/build/compose/binaries/main-release/deb/` and
|
||||
`desktopApp/build/compose/binaries/main-release/rpm/`. The workflow then
|
||||
validates package identity, version, architecture, dependencies, bundled JVM,
|
||||
and release Rust payload before publishing anything.
|
||||
The Make targets collect the Compose output under `build/release/linux/`, then
|
||||
validate package identity, version, architecture, dependencies, bundled JVM,
|
||||
and release Rust payload before generating a SHA-256 checksum.
|
||||
|
||||
@@ -85,19 +85,23 @@ npx wrangler r2 bucket create vnidrop-diagnostics
|
||||
```
|
||||
|
||||
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
|
||||
migrations, and configure the R2 retention rule once:
|
||||
by `wrangler d1 create`. Set the ingest key interactively and configure the R2
|
||||
retention rule once:
|
||||
|
||||
```bash
|
||||
npx wrangler secret put INGEST_KEY
|
||||
npm run db:migrate:remote
|
||||
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
|
||||
Wrangler changes the remote Worker.
|
||||
Then apply migrations and deploy from the repository root:
|
||||
|
||||
```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
|
||||
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:
|
||||
|
||||
```bash
|
||||
npm run db:migrate:local
|
||||
npm run dev
|
||||
# From the repository root:
|
||||
make diagnostics-db-local
|
||||
make run-diagnostics
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
|
||||
@@ -7,9 +7,10 @@ still applies; this file wins for UI/KMP work.
|
||||
|
||||
## Purpose
|
||||
|
||||
`shared` is the multiplatform app layer: Compose UI, feature ViewModels, and
|
||||
`expect`/`actual` bridges into Android, iOS, and desktop. Native transfer work
|
||||
goes through UniFFI `VnidropCore` (see `crates/vnidrop`).
|
||||
`shared` is the Compose Multiplatform app layer for Android, Windows, and Linux:
|
||||
Compose UI, feature ViewModels, and `expect`/`actual` platform bridges. Native
|
||||
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%). |
|
||||
| Strings | CMP composeResources / `Res.string.*` — not Android `R` in `commonMain`. |
|
||||
| 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. |
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
./gradlew :shared:jvmTest
|
||||
./gradlew :shared:compileKotlinJvm
|
||||
make check-shared
|
||||
```
|
||||
|
||||
Optional:
|
||||
|
||||
```bash
|
||||
./gradlew :shared:testAndroidHostTest
|
||||
./gradlew :shared:iosSimulatorArm64Test
|
||||
./gradlew :desktopApp:run
|
||||
./gradlew :androidApp:assembleDebug
|
||||
make test-android-host
|
||||
make run-desktop
|
||||
make check-android
|
||||
```
|
||||
|
||||
CI `:shared:jvmTest` currently runs on **macOS**. Gobley host cargo follows the
|
||||
current host and architecture, including Linux and Windows; use macOS only when
|
||||
exact CI parity is required.
|
||||
CI `:shared:jvmTest` runs on **Linux**. Gobley host cargo follows the current
|
||||
host and architecture so local desktop builds embed their matching Rust library.
|
||||
|
||||
When Kotlin changes touch UniFFI-generated APIs, rebuild/test with a full
|
||||
`jvmTest` so Gobley/native pieces stay aligned.
|
||||
@@ -76,7 +74,7 @@ src/
|
||||
feature/send|receive|approvals|settings|app/
|
||||
ui/ # theme, components, navigation, feedback, state helpers
|
||||
commonMain/composeResources/
|
||||
androidMain|iosMain|jvmMain/
|
||||
androidMain|jvmMain/
|
||||
commonTest|jvmTest|...
|
||||
```
|
||||
|
||||
@@ -86,7 +84,8 @@ src/
|
||||
documents with relative `displayName` paths before calling Rust
|
||||
(`FileSystemService.android.kt` / `expandShareDirectory`).
|
||||
- **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.
|
||||
|
||||
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.
|
||||
|
||||
```bash
|
||||
./gradlew :shared:jvmTest
|
||||
make test-shared
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
@file:OptIn(gobley.gradle.InternalGobleyGradleApi::class)
|
||||
|
||||
import gobley.gradle.cargo.dsl.appleMobile
|
||||
import gobley.gradle.cargo.dsl.jvm
|
||||
import gobley.gradle.cargo.tasks.CargoBuildTask
|
||||
import gobley.gradle.cargo.tasks.CargoCheckTask
|
||||
import gobley.gradle.GobleyHost
|
||||
import gobley.gradle.rust.targets.RustAndroidTarget
|
||||
import gobley.gradle.rust.targets.RustAppleMobileTarget
|
||||
import gobley.gradle.rust.targets.RustTarget
|
||||
import gobley.gradle.Variant
|
||||
import org.gradle.api.DefaultTask
|
||||
@@ -113,18 +111,6 @@ val generateDiagnosticsBuildConfig by tasks.registering {
|
||||
}
|
||||
|
||||
kotlin {
|
||||
if (GobleyHost.current.platform == GobleyHost.Platform.MacOS) {
|
||||
listOf(
|
||||
iosArm64(),
|
||||
iosSimulatorArm64()
|
||||
).forEach { iosTarget ->
|
||||
iosTarget.binaries.framework {
|
||||
baseName = "Shared"
|
||||
isStatic = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
androidTarget {
|
||||
compilerOptions {
|
||||
jvmTarget = JvmTarget.JVM_11
|
||||
@@ -190,9 +176,6 @@ android {
|
||||
val hostCargoTargets = buildSet<RustTarget> {
|
||||
add(GobleyHost.current.rustTarget)
|
||||
addAll(RustAndroidTarget.entries)
|
||||
if (GobleyHost.current.platform == GobleyHost.Platform.MacOS) {
|
||||
addAll(RustAppleMobileTarget.entries)
|
||||
}
|
||||
}
|
||||
|
||||
cargo {
|
||||
@@ -207,15 +190,6 @@ cargo {
|
||||
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 {
|
||||
val buildOnCurrentHost = rustTarget in hostCargoTargets
|
||||
installTargetBeforeBuild.set(buildOnCurrentHost)
|
||||
|
||||
@@ -52,15 +52,13 @@ private class AndroidFileSystemService(
|
||||
ReceiveFolderKind.FileSystemPath -> validatePath(folder.value)
|
||||
ReceiveFolderKind.AndroidPublicDownloads -> validatePublicDownloads()
|
||||
ReceiveFolderKind.AndroidTreeUri -> validateTreeUri(folder.value)
|
||||
ReceiveFolderKind.IosSecurityScopedUrl -> FolderAccessStatus.Unavailable
|
||||
}
|
||||
|
||||
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? =
|
||||
when (folder.kind) {
|
||||
ReceiveFolderKind.AndroidPublicDownloads -> AndroidMediaStoreDownloadsSink(context)
|
||||
ReceiveFolderKind.AndroidTreeUri -> AndroidTreeReceiveOutputSink(context, folder.value.toUri())
|
||||
ReceiveFolderKind.FileSystemPath,
|
||||
ReceiveFolderKind.IosSecurityScopedUrl -> null
|
||||
ReceiveFolderKind.FileSystemPath -> null
|
||||
}
|
||||
|
||||
override suspend fun sharePickedFiles(
|
||||
|
||||
@@ -134,13 +134,6 @@ interface CoreGateway {
|
||||
senderName: String,
|
||||
accessPolicy: ShareAccessPolicy,
|
||||
): 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. */
|
||||
suspend fun shareSources(
|
||||
sources: List<uniffi.vnidrop.ShareSource>,
|
||||
@@ -151,7 +144,6 @@ interface CoreGateway {
|
||||
suspend fun inspectTicket(ticket: String): Result<TicketInspectionModel>
|
||||
suspend fun receive(ticket: String, outputDir: String, 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 delete(transferId: ULong): Result<Unit>
|
||||
suspend fun clearReceiveHistory(): Result<ULong>
|
||||
|
||||
@@ -113,27 +113,6 @@ class CoreRepository(
|
||||
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 {
|
||||
requireCore().inspectTicket(ticket).toModel().also { inspection ->
|
||||
_state.update { it.copy(lastInspection = inspection) }
|
||||
@@ -154,17 +133,6 @@ class CoreRepository(
|
||||
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 {
|
||||
requireCore().cancelTransfer(transferId)
|
||||
refreshSnapshot()
|
||||
|
||||
@@ -10,9 +10,9 @@ data class PickedShareFile(
|
||||
/** App-owned picker copy that may be deleted after import or when selection is abandoned. */
|
||||
val isTemporaryCopy: Boolean = false,
|
||||
/**
|
||||
* When true, [value] is a directory (filesystem path, iOS security-scoped
|
||||
* folder URL, or Android document tree URI). Platform share code expands or
|
||||
* walks it; Rust cannot treat an Android FD as a directory.
|
||||
* When true, [value] is a directory (filesystem path or Android document tree
|
||||
* URI). Platform share code expands or walks it; Rust cannot treat an Android
|
||||
* FD as a directory.
|
||||
*/
|
||||
val isDirectory: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -8,7 +8,6 @@ enum class ReceiveFolderKind {
|
||||
/** Shared system Downloads via MediaStore (Android 10+). */
|
||||
AndroidPublicDownloads,
|
||||
AndroidTreeUri,
|
||||
IosSecurityScopedUrl,
|
||||
}
|
||||
|
||||
/** Stable token stored in preferences for [ReceiveFolderKind.AndroidPublicDownloads]. */
|
||||
|
||||
@@ -2,10 +2,8 @@ package com.vnidrop.app.core
|
||||
|
||||
import uniffi.vnidrop.SourceKind
|
||||
|
||||
// Platform file handles have different lifetime rules. Desktop paths need no
|
||||
// extra work, Rust duplicates Android fd sources immediately, and iOS
|
||||
// security-scoped URLs must remain leased while Rust performs the blocking
|
||||
// import/export call.
|
||||
// Desktop paths need no extra work, while Rust duplicates borrowed Android file
|
||||
// descriptors immediately before the platform closes them.
|
||||
internal expect suspend fun <T> withPlatformPathAccess(
|
||||
kind: SourceKind,
|
||||
value: String,
|
||||
|
||||
@@ -161,14 +161,10 @@ class ReceiveViewModel(
|
||||
it.copy(isReceiving = true, lastReceiveError = null, activeReceiveTransferId = null)
|
||||
}
|
||||
val outputSink = fileSystemService.createReceiveOutputSink(folder)
|
||||
val result = when {
|
||||
outputSink != null -> repository.receiveWithOutputSink(current.ticket, outputSink, current.receiverName)
|
||||
folder.kind == ReceiveFolderKind.IosSecurityScopedUrl -> repository.receiveIntoSecurityScopedDirectory(
|
||||
current.ticket,
|
||||
folder.value,
|
||||
current.receiverName,
|
||||
)
|
||||
else -> repository.receive(current.ticket, folder.value, current.receiverName)
|
||||
val result = if (outputSink != null) {
|
||||
repository.receiveWithOutputSink(current.ticket, outputSink, current.receiverName)
|
||||
} else {
|
||||
repository.receive(current.ticket, folder.value, current.receiverName)
|
||||
}
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
|
||||
@@ -217,7 +217,7 @@ class ViewModelsTest {
|
||||
fun settingsUsesDefaultReceiveFolderWhenPlatformDoesNotSupportCustomFolders() = runTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
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 {
|
||||
mutablePreferences.value = mutablePreferences.value.copy(receiveFolder = externalFolder)
|
||||
}
|
||||
|
||||
@@ -89,13 +89,6 @@ class FakeCoreGateway : CoreGateway {
|
||||
senderName: String,
|
||||
accessPolicy: ShareAccessPolicy,
|
||||
) = 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(
|
||||
sources: List<uniffi.vnidrop.ShareSource>,
|
||||
transferName: String,
|
||||
@@ -142,13 +135,6 @@ class FakeCoreGateway : CoreGateway {
|
||||
awaitReceiveIfNeeded()
|
||||
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> {
|
||||
cancelledTransfers += transferId
|
||||
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()
|
||||
|
||||
private fun pickDirectory(title: String): File? =
|
||||
if (isMacOs()) {
|
||||
val dialog = withMacDirectoryDialog {
|
||||
nativeFileDialog(title).apply { isVisible = true }
|
||||
}
|
||||
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)
|
||||
}
|
||||
private fun pickDirectory(title: String): File? {
|
||||
val chooser = JFileChooser().apply {
|
||||
dialogTitle = title
|
||||
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
||||
isAcceptAllFileFilterUsed = false
|
||||
}
|
||||
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
|
||||
.listFiles { file -> file.isFile && file.name.endsWith(".crash") }
|
||||
.orEmpty()
|
||||
.sortedByDescending { it.lastModified() }
|
||||
.mapNotNull { file ->
|
||||
runCatching { CrashReportCodec.decode(file.readText(StandardCharsets.UTF_8)) }.getOrNull()
|
||||
}
|
||||
.sortedWith(
|
||||
compareByDescending<CrashReport> { it.timestampMillis }
|
||||
.thenBy { it.id },
|
||||
)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@@ -64,7 +67,10 @@ private class JvmPendingCrashStore(
|
||||
file to report
|
||||
}
|
||||
}
|
||||
.sortedByDescending { (_, report) -> report.timestampMillis }
|
||||
.sortedWith(
|
||||
compareByDescending<Pair<File, CrashReport>> { (_, report) -> report.timestampMillis }
|
||||
.thenBy { (_, report) -> report.id },
|
||||
)
|
||||
reports.forEachIndexed { index, (file, report) ->
|
||||
if (index >= maxCount || report.timestampMillis < olderThanTimestampMillis) file.delete()
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import java.io.File
|
||||
@Composable
|
||||
actual fun rememberTransferShareActions(): TransferShareActions = remember {
|
||||
object : TransferShareActions {
|
||||
override val canUseNativeShare = DesktopShareBridge.shareFile != null
|
||||
override val canUseNativeShare = false
|
||||
override val nfcAvailability = NfcShareAvailability.Hidden
|
||||
|
||||
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) {
|
||||
EventQueue.invokeLater {
|
||||
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()
|
||||
})
|
||||
}
|
||||
onResult(Result.failure(UnsupportedOperationException("System sharing is unavailable on this desktop")))
|
||||
}
|
||||
|
||||
override fun writeInvitationToNfc(ticket: String, onResult: (Result<Unit>) -> Unit) {
|
||||
@@ -55,8 +44,3 @@ actual fun rememberTransferShareActions(): TransferShareActions = remember {
|
||||
private fun activeFrame(): Frame? =
|
||||
(KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow as? Frame)
|
||||
?: 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
|
||||
|
||||
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.Window
|
||||
import javax.swing.JFrame
|
||||
import javax.swing.JRootPane
|
||||
|
||||
@Composable
|
||||
actual fun PlatformSystemAppearance(isDarkTheme: Boolean) {
|
||||
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
|
||||
}
|
||||
}
|
||||
actual fun PlatformSystemAppearance(isDarkTheme: Boolean) = Unit
|
||||
|
||||
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 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 =
|
||||
osName.startsWith("Linux", ignoreCase = true)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vnidrop.app.diagnostics
|
||||
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.attribute.FileTime
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
@@ -24,6 +25,8 @@ class PendingCrashStoreJvmTest {
|
||||
File(directory, ".orphan.tmp").writeText("partial")
|
||||
store.write(current.copy(id = "../../escape"))
|
||||
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(
|
||||
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)
|
||||
}
|
||||
}
|
||||