Merge pull request #23 from sudosylabs/feat/remove-apple-kmp-targets

refactor(platform): move Apple apps out of KMP
This commit is contained in:
Hammed Abass
2026-07-21 18:46:21 +02:00
committed by GitHub
93 changed files with 557 additions and 3165 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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'

View File

@@ -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

View File

@@ -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
View File

@@ -19,6 +19,7 @@ captures
node_modules/
target/
.junie
config.override.mk
# Local design export scratch
output/

View File

@@ -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.

View File

@@ -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
View 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

View File

@@ -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,

View File

@@ -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),

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 }

View File

@@ -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

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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

View File

@@ -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)?

View File

@@ -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

View File

@@ -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>

View File

@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

View File

@@ -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

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

View File

@@ -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
View 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

View File

@@ -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
```

View File

@@ -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

View File

@@ -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"))
}

View File

@@ -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)
}

View File

@@ -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,17 +75,9 @@ 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() }
}
linux -> {
windowChromeTopInset = if (linux) LinuxTitleBarHeight else 0.dp,
windowContentTopStartRadius = if (linux) DesktopContentCornerRadius else 0.dp,
windowChrome = if (linux) {
{
LinuxTitleBar(
isMaximized = windowState.placement == WindowPlacement.Maximized,
@@ -104,8 +88,8 @@ fun main(args: Array<String>) {
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(

View File

@@ -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())
}
}

View File

@@ -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.

View File

@@ -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

View File

@@ -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]

View File

@@ -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

View File

@@ -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 */;
}

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -1,11 +0,0 @@
{
"colors": [
{
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}

View File

@@ -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
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -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"
}
}
}

View File

@@ -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>

View File

@@ -1,6 +0,0 @@
{
"info": {
"author": "xcode",
"version": 1
}
}

View File

@@ -1,13 +0,0 @@
import Shared
import SwiftUI
@main
struct iOSApp: App {
private let externalInvitations = ExternalInvitationController()
var body: some Scene {
WindowGroup {
ContentView(externalInvitations: externalInvitations)
}
}
}

View File

@@ -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>

View File

@@ -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
View 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

View File

@@ -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.

View File

@@ -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
```

View File

@@ -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
```
---

View File

@@ -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)

View File

@@ -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(

View File

@@ -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>

View File

@@ -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()

View File

@@ -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,
)

View File

@@ -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]. */

View File

@@ -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,

View File

@@ -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 = {

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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)) }

View File

@@ -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(),
)
}

View File

@@ -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()

View File

@@ -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,
)

View File

@@ -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()
}
}
}

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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()
}

View File

@@ -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)
}

View File

@@ -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"
}

View File

@@ -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))
}

View File

@@ -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
}

View File

@@ -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()
}
}

View File

@@ -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"),
)
}
}

View File

@@ -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()
}
}
}

View File

@@ -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)
}
}

View File

@@ -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 {
private fun pickDirectory(title: String): File? {
val chooser = JFileChooser().apply {
dialogTitle = title
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
isAcceptAllFileFilterUsed = false
}
if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) chooser.selectedFile else null
return 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 isMacOs(): Boolean =
System.getProperty("os.name").startsWith("Mac", ignoreCase = true)

View File

@@ -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()
}

View File

@@ -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()
})
}
}
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
}

View File

@@ -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)

View File

@@ -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"),

View File

@@ -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))
}
}

View File

@@ -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)
}
}