diff --git a/.github/workflows/apple.yml b/.github/workflows/apple.yml index 55e9d26..0157181 100644 --- a/.github/workflows/apple.yml +++ b/.github/workflows/apple.yml @@ -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 diff --git a/.github/workflows/diagnostics-api.yml b/.github/workflows/diagnostics-api.yml index b656860..59b00a2 100644 --- a/.github/workflows/diagnostics-api.yml +++ b/.github/workflows/diagnostics-api.yml @@ -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 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 8fa433c..ab2c4e8 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -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 diff --git a/.github/workflows/linux-packages.yml b/.github/workflows/linux-packages.yml index 1e0c60a..1fa6f28 100644 --- a/.github/workflows/linux-packages.yml +++ b/.github/workflows/linux-packages.yml @@ -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' diff --git a/.github/workflows/rust-core.yml b/.github/workflows/rust-core.yml index 759ec1f..7eb59bc 100644 --- a/.github/workflows/rust-core.yml +++ b/.github/workflows/rust-core.yml @@ -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 diff --git a/.github/workflows/shared-kmp.yml b/.github/workflows/shared-kmp.yml index de5d103..3f94afb 100644 --- a/.github/workflows/shared-kmp.yml +++ b/.github/workflows/shared-kmp.yml @@ -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 diff --git a/.gitignore b/.gitignore index 1bab3e9..d3bdd2e 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ captures node_modules/ target/ .junie +config.override.mk # Local design export scratch output/ diff --git a/AGENTS.md b/AGENTS.md index ddf1ccc..b09f4a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,13 +13,14 @@ Nested guides take precedence when editing under those trees: ## Project overview -VniDrop is a cross-platform **local P2P file transfer** app (Android, iOS, Desktop). +VniDrop is a cross-platform **local P2P file transfer** app. | Layer | Path | Responsibility | |-------|------|----------------| | Rust core | `crates/vnidrop/` | Iroh endpoint, blobs, SQLite, tickets, approval, streaming | -| Shared KMP | `shared/` | Compose UI, ViewModels, expect/actual platform bridges | -| Hosts | `androidApp/`, `iosApp/`, `desktopApp/` | Thin app shells | +| Shared KMP | `shared/` | Compose UI and platform bridges for Android, Windows, and Linux | +| Compose hosts | `androidApp/`, `desktopApp/` | Thin Android and Windows/Linux app shells | +| Apple app | `apple/` | Native SwiftUI UI using generated Rust/UniFFI Swift bindings | **Invariant:** UI/platform opens files and handles pickers; **Rust streams bytes**. Do not design features that move transfer payloads through Kotlin heap by default. @@ -52,67 +53,62 @@ Domain docs (reference, do not paste into PRs): ## Build and test -Install prerequisites when missing: Rust stable + rustfmt + clippy, JDK 17, -Android NDK/SDK only if building Android, Xcode only for iOS. +Install prerequisites when missing: GNU Make + Bash, Rust stable + rustfmt + clippy, JDK 17, +Android NDK/SDK only if building Android, Xcode only for the native Apple app. ### Rust core (`crates/vnidrop` or workspace root) Run from the **repo root** (Cargo workspace): ```bash -cargo fmt --all -- --check -cargo clippy --workspace --all-targets -- -D warnings -cargo test --workspace --all-targets +make check-rust ``` Focused: ```bash -cargo test -p vnidrop -cargo test -p vnidrop --test output_sink -cargo test -p vnidrop --test transfer -cargo test -p vnidrop --test approval -cargo test -p vnidrop --test lifecycle +make test-rust +make test-rust-output-sink +make test-rust-transfer +make test-rust-approval +make test-rust-lifecycle ``` After finishing Rust edits, format: ```bash -cargo fmt --all +make format ``` -CI also runs `cargo doc --workspace --no-deps` with `RUSTDOCFLAGS=-D warnings` -(see `.github/workflows/rust-core.yml`). Run it before large Rust public-API changes. +`make check-rust` includes documentation with warnings denied, matching +`.github/workflows/rust-core.yml`. ### Shared KMP / Compose (`shared/`) ```bash -./gradlew :shared:jvmTest -./gradlew :shared:compileKotlinJvm +make check-shared ``` Other targets (slower / machine-dependent): ```bash -./gradlew :shared:testAndroidHostTest -./gradlew :shared:iosSimulatorArm64Test # macOS + Xcode -./gradlew :androidApp:assembleDebug -./gradlew :desktopApp:run +make test-android-host +make check-android +make run-desktop ``` -**Note:** `jvmTest` CI currently runs on **macOS**. Gobley host cargo is enabled -for the current host and architecture, so local Linux and Windows builds embed -their matching desktop Rust library. Prefer macOS only when exact CI parity is -required. +**Note:** `jvmTest` CI runs on **Linux**. Gobley host cargo is enabled for the +current host and architecture, so local desktop builds embed their matching +Rust library. ### What to run before finishing | You changed… | Minimum verification | |--------------|----------------------| -| `crates/vnidrop/**` only | `cargo fmt`, `cargo clippy … -D warnings`, `cargo test -p vnidrop` | -| Cancel / export / sinks | Above + `cargo test -p vnidrop --test output_sink` | -| `shared/**` only | `./gradlew :shared:jvmTest` | -| Both | Rust suite + `:shared:jvmTest` | +| `crates/vnidrop/**` only | `make check-rust` | +| Cancel / export / sinks | Above + `make test-rust-output-sink` | +| `shared/**` only | `make test-shared` | +| Both | `make test-rust test-shared` | | Docs only | No suite required; verify links/paths | Do not kill long `cargo` / Gradle runs mid-flight unless they hang past several @@ -144,12 +140,12 @@ shared/src/commonMain/kotlin/com/vnidrop/app/ core/ # CoreGateway, models, pickers interfaces feature/send|receive|approvals|settings|app/ ui/theme|components|navigation|feedback|state/ -androidMain|iosMain|jvmMain/ # expect/actual implementations +androidMain|jvmMain/ # expect/actual implementations ``` ### Platform file rules (do not violate) -- Desktop / path-based iOS: paths; directory walk in Rust when `is_directory`. +- Windows/Linux desktop: paths; directory walk in Rust when `is_directory`. - Android **share**: ParcelFileDescriptor **file** FDs only — never a directory FD. Folder share expands SAF trees in Kotlin to per-file FDs + relative names. - Android **receive** default: MediaStore Downloads sink; custom trees via SAF write. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 58aaf30..35f2b3f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5cf49b3 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 454ee63..3172ea0 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,8 @@ people, especially when using **Anyone with this transfer**. - Per-receiver requests, approvals, progress, and delivery status - Cancel, stop sharing, and local transfer history - Safe receive destinations that do not silently overwrite existing files -- Android, iOS, and desktop apps built from a shared Compose Multiplatform UI +- Native SwiftUI apps on iOS, iPadOS, and macOS; Compose apps on Android, + Windows, and Linux - Opt-in diagnostics with transfer contents, invitations, and file paths excluded @@ -117,14 +118,21 @@ if you want to try the current version. git clone https://github.com/vnidrop/vnidrop.git cd vnidrop -# Desktop -./gradlew :desktopApp:run +# List the supported development commands and check prerequisites +make help +make doctor + +# Windows/Linux desktop +make run-desktop # Android debug build -./gradlew :androidApp:assembleDebug +make build-android -# iOS -open iosApp/iosApp.xcodeproj +# Build and launch the macOS app +make open-apple + +# Open the native project for iOS, iPadOS, or Xcode development +make open-apple-project ``` See [`CONTRIBUTING.md`](CONTRIBUTING.md) for prerequisites, development setup, diff --git a/apple/README.md b/apple/README.md index 205e549..4912d53 100644 --- a/apple/README.md +++ b/apple/README.md @@ -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), diff --git a/apple/Signing.xcconfig b/apple/Signing.xcconfig index 39d058f..da695ba 100644 --- a/apple/Signing.xcconfig +++ b/apple/Signing.xcconfig @@ -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" diff --git a/apple/VniDrop/App/VniDropApp.swift b/apple/VniDrop/App/VniDropApp.swift index a684007..0119980 100644 --- a/apple/VniDrop/App/VniDropApp.swift +++ b/apple/VniDrop/App/VniDropApp.swift @@ -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 { diff --git a/apple/VniDrop/Core/LocalNotificationService.swift b/apple/VniDrop/Core/LocalNotificationService.swift index 5ab903c..4f04f68 100644 --- a/apple/VniDrop/Core/LocalNotificationService.swift +++ b/apple/VniDrop/Core/LocalNotificationService.swift @@ -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 diff --git a/apple/VniDrop/Features/Receive/ReceiveInvitationActions.swift b/apple/VniDrop/Features/Receive/ReceiveInvitationActions.swift index 06e4db8..9fb38b7 100644 --- a/apple/VniDrop/Features/Receive/ReceiveInvitationActions.swift +++ b/apple/VniDrop/Features/Receive/ReceiveInvitationActions.swift @@ -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 } diff --git a/apple/VniDrop/Features/Send/FilePreviewRepository.swift b/apple/VniDrop/Features/Send/FilePreviewRepository.swift index 8c6333b..f1bc969 100644 --- a/apple/VniDrop/Features/Send/FilePreviewRepository.swift +++ b/apple/VniDrop/Features/Send/FilePreviewRepository.swift @@ -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 diff --git a/apple/VniDrop/Features/Send/TransferShareActions.swift b/apple/VniDrop/Features/Send/TransferShareActions.swift index 02b93f5..73ca4df 100644 --- a/apple/VniDrop/Features/Send/TransferShareActions.swift +++ b/apple/VniDrop/Features/Send/TransferShareActions.swift @@ -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 { diff --git a/apple/VniDrop/Platform/FileSystemService+iOS.swift b/apple/VniDrop/Platform/FileSystemService+iOS.swift index e9f4a37..b0fcc51 100644 --- a/apple/VniDrop/Platform/FileSystemService+iOS.swift +++ b/apple/VniDrop/Platform/FileSystemService+iOS.swift @@ -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 { diff --git a/apple/VniDrop/Platform/InvitationFile.swift b/apple/VniDrop/Platform/InvitationFile.swift index 25a77cb..743f606 100644 --- a/apple/VniDrop/Platform/InvitationFile.swift +++ b/apple/VniDrop/Platform/InvitationFile.swift @@ -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 diff --git a/apple/VniDrop/Platform/ReceiveInvitationActions+iOS.swift b/apple/VniDrop/Platform/ReceiveInvitationActions+iOS.swift index ba0ba3f..e0610d0 100644 --- a/apple/VniDrop/Platform/ReceiveInvitationActions+iOS.swift +++ b/apple/VniDrop/Platform/ReceiveInvitationActions+iOS.swift @@ -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) -> Void)? diff --git a/apple/VniDrop/Platform/TransferShareActions+iOS.swift b/apple/VniDrop/Platform/TransferShareActions+iOS.swift index 2f4229c..c9fe5fd 100644 --- a/apple/VniDrop/Platform/TransferShareActions+iOS.swift +++ b/apple/VniDrop/Platform/TransferShareActions+iOS.swift @@ -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 diff --git a/apple/VniDrop/Resources/VniDrop.entitlements b/apple/VniDrop/Resources/VniDrop.entitlements index f6d6b13..09743a1 100644 --- a/apple/VniDrop/Resources/VniDrop.entitlements +++ b/apple/VniDrop/Resources/VniDrop.entitlements @@ -2,7 +2,7 @@ - + com.apple.developer.nfc.readersession.formats NDEF diff --git a/apple/project.yml b/apple/project.yml index 3f643f6..ab0d4c5 100644 --- a/apple/project.yml +++ b/apple/project.yml @@ -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 diff --git a/assets/ios/app-icon.png b/assets/ios/app-icon.png deleted file mode 100644 index 91e521f..0000000 Binary files a/assets/ios/app-icon.png and /dev/null differ diff --git a/assets/ios/app-icon.svg b/assets/ios/app-icon.svg deleted file mode 100644 index b2a33e3..0000000 --- a/assets/ios/app-icon.svg +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/assets/ios/foreground.png b/assets/ios/foreground.png deleted file mode 100644 index b2540f9..0000000 Binary files a/assets/ios/foreground.png and /dev/null differ diff --git a/assets/ios/foreground.svg b/assets/ios/foreground.svg deleted file mode 100644 index 5598da1..0000000 --- a/assets/ios/foreground.svg +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/assets/macos/app-icon.icns b/assets/macos/app-icon.icns deleted file mode 100644 index 5d93c92..0000000 Binary files a/assets/macos/app-icon.icns and /dev/null differ diff --git a/assets/macos/app-icon.png b/assets/macos/app-icon.png deleted file mode 100644 index 9a21223..0000000 Binary files a/assets/macos/app-icon.png and /dev/null differ diff --git a/assets/macos/app-icon.svg b/assets/macos/app-icon.svg deleted file mode 100644 index f8eb118..0000000 --- a/assets/macos/app-icon.svg +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/assets/macos/foreground.png b/assets/macos/foreground.png deleted file mode 100644 index b2540f9..0000000 Binary files a/assets/macos/foreground.png and /dev/null differ diff --git a/assets/macos/foreground.svg b/assets/macos/foreground.svg deleted file mode 100644 index 5598da1..0000000 --- a/assets/macos/foreground.svg +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/config.mk b/config.mk new file mode 100644 index 0000000..1d14e66 --- /dev/null +++ b/config.mk @@ -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 diff --git a/crates/vnidrop/AGENTS.md b/crates/vnidrop/AGENTS.md index ec71e19..da2c853 100644 --- a/crates/vnidrop/AGENTS.md +++ b/crates/vnidrop/AGENTS.md @@ -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 ``` diff --git a/crates/vnidrop/CORE_FLOW.md b/crates/vnidrop/CORE_FLOW.md index 84e31ad..54ea299 100644 --- a/crates/vnidrop/CORE_FLOW.md +++ b/crates/vnidrop/CORE_FLOW.md @@ -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 diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 75fa1f5..41dcc83 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -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")) } diff --git a/desktopApp/src/main/kotlin/com/vnidrop/app/MacOsAppKitAppearance.kt b/desktopApp/src/main/kotlin/com/vnidrop/app/MacOsAppKitAppearance.kt deleted file mode 100644 index 9e3efb8..0000000 --- a/desktopApp/src/main/kotlin/com/vnidrop/app/MacOsAppKitAppearance.kt +++ /dev/null @@ -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 = 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) -} diff --git a/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt b/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt index b9a56b6..fe96cf2 100644 --- a/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt +++ b/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt @@ -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) { val externalInvitations = ExternalInvitationController() - val macOs = DesktopAppearanceBridge.isMacOs() val linux = DesktopAppearanceBridge.isLinux() - val customWindowChrome = macOs || linux - configureMacOsNativeAppearance() configureInvitationOpenHandler(externalInvitations) args.asSequence() .map(::File) .filter { it.extension.equals(VniDropInvitationExtension, ignoreCase = true) } .forEach { externalInvitations.openFile(it) } - DesktopAppearanceBridge.applyNativeAppearance = MacOsAppKitAppearance::apply - if (macOs) { - DesktopShareBridge.shareFile = MacOsShareSheet::share - } application { val windowState = rememberWindowState() Window( @@ -83,29 +75,21 @@ fun main(args: Array) { ) { App( dependencies = rememberJvmAppDependencies(externalInvitations), - windowChromeTopInset = when { - macOs -> MacOsTitleBarHeight - linux -> LinuxTitleBarHeight - else -> 0.dp - }, - windowContentTopStartRadius = if (customWindowChrome) DesktopContentCornerRadius else 0.dp, - windowChrome = when { - macOs -> { - { MacOsTitleBar() } + windowChromeTopInset = if (linux) LinuxTitleBarHeight else 0.dp, + windowContentTopStartRadius = if (linux) DesktopContentCornerRadius else 0.dp, + windowChrome = if (linux) { + { + LinuxTitleBar( + isMaximized = windowState.placement == WindowPlacement.Maximized, + onMinimize = { windowState.isMinimized = true }, + onToggleMaximize = { + windowState.placement = toggledWindowPlacement(windowState.placement) + }, + onClose = ::exitApplication, + ) } - linux -> { - { - LinuxTitleBar( - isMaximized = windowState.placement == WindowPlacement.Maximized, - onMinimize = { windowState.isMinimized = true }, - onToggleMaximize = { - windowState.placement = toggledWindowPlacement(windowState.placement) - }, - onClose = ::exitApplication, - ) - } - } - else -> null + } else { + null }, ) } @@ -130,55 +114,11 @@ private fun ExternalInvitationController.openFile(file: File) { } } -private fun configureMacOsNativeAppearance() { - if (!DesktopAppearanceBridge.isMacOs()) return - // AWT reads this before creating the first native window. Runtime theme - // changes are handled in the JVM platform appearance hook. - System.setProperty("apple.awt.application.appearance", "system") -} - -private val MacOsTitleBarHeight = 28.dp -private val MacOsTrafficLightsWidth = 76.dp private val LinuxTitleBarHeight = 40.dp private val LinuxWindowControlWidth = 46.dp private val LinuxWindowControlsWidth = 138.dp private val DesktopContentCornerRadius = 20.dp -@Composable -@OptIn(ExperimentalComposeUiApi::class) -private fun WindowScope.MacOsTitleBar() { - val colors = LocalVniDropColors.current - Box( - modifier = Modifier - .fillMaxWidth() - .height(MacOsTitleBarHeight) - .background(colors.backgroundSurface200), - ) { - WindowDraggableArea( - modifier = Modifier - .fillMaxSize() - .padding(start = MacOsTrafficLightsWidth) - .onPointerEvent(PointerEventType.Press) { event -> - if (event.awtEventOrNull?.clickCount == 2) { - DesktopAppearanceBridge.toggleMaximized(window) - } - }, - ) { - Box(modifier = Modifier.fillMaxSize().padding(end = MacOsTrafficLightsWidth)) { - BasicText( - text = "VniDrop", - modifier = Modifier.align(Alignment.Center), - style = TextStyle( - color = colors.foregroundDefault, - fontSize = 13.sp, - fontWeight = FontWeight.SemiBold, - ), - ) - } - } - } -} - @Composable @OptIn(ExperimentalComposeUiApi::class) private fun WindowScope.LinuxTitleBar( diff --git a/desktopApp/src/test/kotlin/com/vnidrop/app/MacOsShareSheetTest.kt b/desktopApp/src/test/kotlin/com/vnidrop/app/MacOsShareSheetTest.kt deleted file mode 100644 index 65b0aaf..0000000 --- a/desktopApp/src/test/kotlin/com/vnidrop/app/MacOsShareSheetTest.kt +++ /dev/null @@ -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()) - } -} diff --git a/docs/README.md b/docs/README.md index ef71cba..7ffe337 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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. diff --git a/gradle.properties b/gradle.properties index 35d3b69..6d5c097 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2fc1758..d7fca50 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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] diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig deleted file mode 100644 index 92bef3e..0000000 --- a/iosApp/Configuration/Config.xcconfig +++ /dev/null @@ -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 diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj deleted file mode 100644 index b50b454..0000000 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ /dev/null @@ -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 = ""; - }; - B93EE283488EDD1107331E67 /* Configuration */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = Configuration; - sourceTree = ""; - }; -/* 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 = ""; - }; - C13A067056BA9F38FD87A539 /* Products */ = { - isa = PBXGroup; - children = ( - FA325F1B4E7D8FFDF19A5C4A /* VniDrop.app */, - ); - name = Products; - sourceTree = ""; - }; -/* 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 */; -} diff --git a/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index fe1aa71..0000000 --- a/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - \ No newline at end of file diff --git a/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json b/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index 48599ed..0000000 --- a/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "colors": [ - { - "idiom": "universal" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 91523fc..0000000 --- a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -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 - } -} diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon.png deleted file mode 100644 index 91e521f..0000000 Binary files a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon.png and /dev/null differ diff --git a/iosApp/iosApp/Assets.xcassets/Contents.json b/iosApp/iosApp/Assets.xcassets/Contents.json deleted file mode 100644 index 73c0059..0000000 --- a/iosApp/iosApp/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift deleted file mode 100644 index 30fd270..0000000 --- a/iosApp/iosApp/ContentView.swift +++ /dev/null @@ -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" - } - } -} diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist deleted file mode 100644 index f74686a..0000000 --- a/iosApp/iosApp/Info.plist +++ /dev/null @@ -1,69 +0,0 @@ - - - - - CADisableMinimumFrameDurationOnPhone - - CFBundleDocumentTypes - - - CFBundleTypeName - VniDrop Invitation - CFBundleTypeRole - Viewer - LSHandlerRank - Owner - LSItemContentTypes - - com.vnidrop.app.invitation - - - - UIBackgroundModes - - fetch - processing - remote-notification - - UIViewControllerBasedStatusBarAppearance - - UTExportedTypeDeclarations - - - UTTypeConformsTo - - public.data - - UTTypeDescription - VniDrop Invitation - UTTypeIdentifier - com.vnidrop.app.invitation - UTTypeTagSpecification - - public.filename-extension - - vnd - - public.mime-type - application/vnd.vnidrop.transfer - - - - LSSupportsOpeningDocumentsInPlace - - UIFileSharingEnabled - - NSCameraUsageDescription - VniDrop uses the camera to scan transfer QR codes. - NFCReaderUsageDescription - VniDrop uses NFC to read transfer invitation tags. - UIViewControllerBasedStatusBarAppearance - - NSLocalNetworkUsageDescription - VniDrop needs local network access to send to other local devices if needed. - NSBonjourServices - - - - - diff --git a/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json b/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json deleted file mode 100644 index d458f1c..0000000 --- a/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info": { - "author": "xcode", - "version": 1 - } -} diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift deleted file mode 100644 index 08210e4..0000000 --- a/iosApp/iosApp/iOSApp.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Shared -import SwiftUI - -@main -struct iOSApp: App { - private let externalInvitations = ExternalInvitationController() - - var body: some Scene { - WindowGroup { - ContentView(externalInvitations: externalInvitations) - } - } -} diff --git a/iosApp/iosApp/vnidrop.entitlements b/iosApp/iosApp/vnidrop.entitlements deleted file mode 100644 index 5f7b942..0000000 --- a/iosApp/iosApp/vnidrop.entitlements +++ /dev/null @@ -1,10 +0,0 @@ - - - - - com.apple.developer.nfc.readersession.formats - - NDEF - - - diff --git a/localization/README.md b/localization/README.md index d277173..14111e5 100644 --- a/localization/README.md +++ b/localization/README.md @@ -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 diff --git a/make/release.mk b/make/release.mk new file mode 100644 index 0000000..27680a6 --- /dev/null +++ b/make/release.mk @@ -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 diff --git a/packaging/linux/README.md b/packaging/linux/README.md index 53a3030..4cf8244 100644 --- a/packaging/linux/README.md +++ b/packaging/linux/README.md @@ -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. diff --git a/services/diagnostics-api/README.md b/services/diagnostics-api/README.md index 3e77b7b..3f48f08 100644 --- a/services/diagnostics-api/README.md +++ b/services/diagnostics-api/README.md @@ -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 ``` diff --git a/shared/AGENTS.md b/shared/AGENTS.md index e9c3f10..6add30a 100644 --- a/shared/AGENTS.md +++ b/shared/AGENTS.md @@ -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 ``` --- diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 9ce57a7..ff3f84b 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -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 { 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) diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt index 81f9db3..079d36a 100644 --- a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt @@ -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( diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt index bbff3d3..c3e7e2f 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt @@ -134,13 +134,6 @@ interface CoreGateway { senderName: String, accessPolicy: ShareAccessPolicy, ): Result - suspend fun shareSecurityScopedFileUrl( - fileUrl: String, - displayName: String, - transferName: String, - senderName: String, - accessPolicy: ShareAccessPolicy, - ): Result /** Multi-source share used by multi-file pickers. */ suspend fun shareSources( sources: List, @@ -151,7 +144,6 @@ interface CoreGateway { suspend fun inspectTicket(ticket: String): Result suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result - suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String): Result suspend fun cancel(transferId: ULong): Result suspend fun delete(transferId: ULong): Result suspend fun clearReceiveHistory(): Result diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt index 41a4e6e..8744ef3 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt @@ -113,27 +113,6 @@ class CoreRepository( accessPolicy = accessPolicy, ) - override suspend fun shareSecurityScopedFileUrl( - fileUrl: String, - displayName: String, - transferName: String, - senderName: String, - accessPolicy: ShareAccessPolicy, - ): Result = - 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 = 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 = runCore { - withPlatformPathAccess(SourceKind.IOS_SECURITY_SCOPED_URL, outputDirectoryUrl) { - requireCore().receive(ticket, outputDirectoryUrl, receiverName.ifBlank { null }) - } - refreshSnapshot() - } - override suspend fun cancel(transferId: ULong): Result = runCore { requireCore().cancelTransfer(transferId) refreshSnapshot() diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt index badadfe..6203e1d 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt @@ -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, ) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt index 8616582..5c0cd4b 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt @@ -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]. */ diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.kt index f120814..0e52795 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.kt @@ -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 withPlatformPathAccess( kind: SourceKind, value: String, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt index c2223b9..c841f2e 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt @@ -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 = { diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt index c2843d7..b0a95e7 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt @@ -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) } diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt index bb4fafc..bc29f64 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt @@ -89,13 +89,6 @@ class FakeCoreGateway : CoreGateway { senderName: String, accessPolicy: ShareAccessPolicy, ) = Result.failure(UnsupportedOperationException()) - override suspend fun shareSecurityScopedFileUrl( - fileUrl: String, - displayName: String, - transferName: String, - senderName: String, - accessPolicy: ShareAccessPolicy, - ) = Result.failure(UnsupportedOperationException()) override suspend fun shareSources( sources: List, transferName: String, @@ -142,13 +135,6 @@ class FakeCoreGateway : CoreGateway { awaitReceiveIfNeeded() return receiveResult } - override suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String): Result { - receiveCount += 1 - lastReceiveTicket = ticket - lastReceiveReceiverName = receiverName - awaitReceiveIfNeeded() - return receiveResult - } override suspend fun cancel(transferId: ULong): Result { cancelledTransfers += transferId return Result.success(Unit) diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/MainViewController.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/MainViewController.kt deleted file mode 100644 index 830f382..0000000 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/MainViewController.kt +++ /dev/null @@ -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)) } diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/Platform.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/Platform.ios.kt deleted file mode 100644 index 0f592ff..0000000 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/Platform.ios.kt +++ /dev/null @@ -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(), - ) -} diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/core/FilePicker.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/core/FilePicker.ios.kt deleted file mode 100644 index eb38a85..0000000 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/core/FilePicker.ios.kt +++ /dev/null @@ -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) -> 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) -> 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() diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/core/FileSystemService.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/core/FileSystemService.ios.kt deleted file mode 100644 index f350fb3..0000000 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/core/FileSystemService.ios.kt +++ /dev/null @@ -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) { - 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 { - 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()) { 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, - transferName: String, - senderName: String, - accessPolicy: ShareAccessPolicy, - ): Result { - 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, -) diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.ios.kt deleted file mode 100644 index f29dd1b..0000000 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.ios.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.vnidrop.app.core - -import platform.Foundation.NSURL -import uniffi.vnidrop.SourceKind - -internal actual suspend fun 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() - } - } -} diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.ios.kt deleted file mode 100644 index a743197..0000000 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.ios.kt +++ /dev/null @@ -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 { - ensureDirectory() - val names = fileManager.contentsOfDirectoryAtPath(directory, null).orEmpty() - .filterIsInstance() - .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() - .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() -} diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/diagnostics/PlatformCrashHook.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/diagnostics/PlatformCrashHook.ios.kt deleted file mode 100644 index 49d4127..0000000 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/diagnostics/PlatformCrashHook.ios.kt +++ /dev/null @@ -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 -} diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/diagnostics/PlatformHttp.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/diagnostics/PlatformHttp.ios.kt deleted file mode 100644 index 9ba696c..0000000 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/diagnostics/PlatformHttp.ios.kt +++ /dev/null @@ -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, - 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() -} diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.ios.kt deleted file mode 100644 index c435555..0000000 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.ios.kt +++ /dev/null @@ -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) -> 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) -> 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) -> 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) -> 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) -> 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>() - 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) { - 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) -> 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) { - 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) -} diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStore.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStore.ios.kt deleted file mode 100644 index deffba1..0000000 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStore.ios.kt +++ /dev/null @@ -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 { - ensureDirectory() - return files.contentsOfDirectoryAtPath(directory, null).orEmpty().filterIsInstance().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" -} diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.ios.kt deleted file mode 100644 index 130e3d3..0000000 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.ios.kt +++ /dev/null @@ -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) { - 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) { - 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) { - 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, -) : 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) { - 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)) -} diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/logging/PlatformLogStore.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/logging/PlatformLogStore.ios.kt deleted file mode 100644 index d7a8aaf..0000000 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/logging/PlatformLogStore.ios.kt +++ /dev/null @@ -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 { - ensureDirectory() - val names = fileManager.contentsOfDirectoryAtPath(directory, null).orEmpty() - .filterIsInstance() - .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() - 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 -} diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.ios.kt deleted file mode 100644 index 9d52847..0000000 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.ios.kt +++ /dev/null @@ -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 = _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 { - val url = NSURL.URLWithString(UIApplicationOpenNotificationSettingsURLString) - ?: return Result.failure(IllegalStateException("Notification settings URL is unavailable")) - val opened = suspendCancellableCoroutine { continuation -> - UIApplication.sharedApplication.openURL(url, emptyMap()) { 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 = 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() - } -} diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.ios.kt deleted file mode 100644 index aaf3445..0000000 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.ios.kt +++ /dev/null @@ -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"), - ) - } -} diff --git a/shared/src/iosTest/kotlin/com/vnidrop/app/SharedLogicIOSTest.kt b/shared/src/iosTest/kotlin/com/vnidrop/app/SharedLogicIOSTest.kt deleted file mode 100644 index 50b507b..0000000 --- a/shared/src/iosTest/kotlin/com/vnidrop/app/SharedLogicIOSTest.kt +++ /dev/null @@ -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() - } - } -} diff --git a/shared/src/iosTest/kotlin/com/vnidrop/app/core/FileSystemServiceIosTest.kt b/shared/src/iosTest/kotlin/com/vnidrop/app/core/FileSystemServiceIosTest.kt deleted file mode 100644 index ecfc490..0000000 --- a/shared/src/iosTest/kotlin/com/vnidrop/app/core/FileSystemServiceIosTest.kt +++ /dev/null @@ -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) - } -} diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt index 052d82f..0643be2 100644 --- a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt @@ -128,44 +128,11 @@ private fun File.systemIconPng(): ByteArray? = runCatching { } }.getOrNull() -private fun pickDirectory(title: String): File? = - if (isMacOs()) { - val dialog = withMacDirectoryDialog { - nativeFileDialog(title).apply { isVisible = true } - } - try { - val directory = dialog.directory ?: return null - dialog.file - ?.let { File(directory, it) } - ?: File(directory) - } finally { - dialog.dispose() - } - } else { - val chooser = JFileChooser().apply { - dialogTitle = title - fileSelectionMode = JFileChooser.DIRECTORIES_ONLY - isAcceptAllFileFilterUsed = false - } - if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) chooser.selectedFile else null - } - -private fun withMacDirectoryDialog(block: () -> T): T { - if (!isMacOs()) return block() - - val key = "apple.awt.fileDialogForDirectories" - val previous = System.getProperty(key) - System.setProperty(key, "true") - return try { - block() - } finally { - if (previous == null) { - System.clearProperty(key) - } else { - System.setProperty(key, previous) - } +private fun pickDirectory(title: String): File? { + val chooser = JFileChooser().apply { + dialogTitle = title + fileSelectionMode = JFileChooser.DIRECTORIES_ONLY + isAcceptAllFileFilterUsed = false } + return if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) chooser.selectedFile else null } - -private fun isMacOs(): Boolean = - System.getProperty("os.name").startsWith("Mac", ignoreCase = true) diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.jvm.kt index 1935f7c..3c5c099 100644 --- a/shared/src/jvmMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.jvm.kt @@ -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 { it.timestampMillis } + .thenBy { it.id }, + ) } @Synchronized @@ -64,7 +67,10 @@ private class JvmPendingCrashStore( file to report } } - .sortedByDescending { (_, report) -> report.timestampMillis } + .sortedWith( + compareByDescending> { (_, report) -> report.timestampMillis } + .thenBy { (_, report) -> report.id }, + ) reports.forEachIndexed { index, (file, report) -> if (index >= maxCount || report.timestampMillis < olderThanTimestampMillis) file.delete() } diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.jvm.kt index 11029f4..9c8b299 100644 --- a/shared/src/jvmMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.jvm.kt @@ -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) { @@ -31,18 +31,7 @@ actual fun rememberTransferShareActions(): TransferShareActions = remember { } override fun shareInvitation(ticket: String, transferName: String, onResult: (Result) -> Unit) { - EventQueue.invokeLater { - val share = DesktopShareBridge.shareFile - if (share == null) { - onResult(Result.failure(UnsupportedOperationException("System sharing is unavailable on this desktop"))) - return@invokeLater - } - onResult(runCatching { - val directory = File(System.getProperty("java.io.tmpdir"), "vnidrop-share").apply { mkdirs() } - val file = File(directory, invitationFileName(transferName)).apply { writeText(ticket) } - share(file).getOrThrow() - }) - } + onResult(Result.failure(UnsupportedOperationException("System sharing is unavailable on this desktop"))) } override fun writeInvitationToNfc(ticket: String, onResult: (Result) -> Unit) { @@ -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)? = null -} diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.jvm.kt index d40dfec..30abff5 100644 --- a/shared/src/jvmMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.jvm.kt @@ -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) diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/diagnostics/PendingCrashStoreJvmTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/diagnostics/PendingCrashStoreJvmTest.kt index 054abd0..1ef7285 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/diagnostics/PendingCrashStoreJvmTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/diagnostics/PendingCrashStoreJvmTest.kt @@ -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"), diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/platform/DesktopAppearanceBridgeTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/platform/DesktopAppearanceBridgeTest.kt new file mode 100644 index 0000000..8b2932c --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/platform/DesktopAppearanceBridgeTest.kt @@ -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)) + } +} diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/platform/DesktopSystemAppearanceTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/platform/DesktopSystemAppearanceTest.kt deleted file mode 100644 index 4c7bd2f..0000000 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/platform/DesktopSystemAppearanceTest.kt +++ /dev/null @@ -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) - } -}