From e88243bfbbb6c59275c8b6d6554f21f74077b232 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 24 Oct 2026 19:10:43 +0200 Subject: [PATCH 01/34] ci(ios): build an App Store package on a macOS runner First probe of the iOS build: bundle id app.voltius, manual signing with the Apple Distribution certificate and App Store profile, purchases hidden (VITE_DISTRIBUTION=appstore). --- .github/workflows/ios.yml | 82 +++++++++++++++++++++++++++++++++++ src-tauri/Cargo.toml | 2 +- src-tauri/tauri.ios.conf.json | 9 ++++ src/services/distribution.ts | 10 +++-- 4 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/ios.yml create mode 100644 src-tauri/tauri.ios.conf.json diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml new file mode 100644 index 000000000..5aaf8bb27 --- /dev/null +++ b/.github/workflows/ios.yml @@ -0,0 +1,82 @@ +# Builds the App Store package (.ipa) for iOS on a macOS runner. Nothing here +# uploads to Apple yet. +# +# Signing is manual: the Apple Distribution certificate (IOS_CERTIFICATE, a +# base64 .p12, and IOS_CERTIFICATE_PASSWORD) and the "Voltius App Store" +# provisioning profile (IOS_MOBILE_PROVISION, base64) are read by the Tauri CLI. +# +# The iOS bundle id is app.voltius (src-tauri/tauri.ios.conf.json): Apple +# reported com.voltius.app as taken by another team. +# +# The build uses VITE_DISTRIBUTION=appstore, which hides the purchase buttons. +name: ios + +on: + push: + branches: [ci/ios] + workflow_dispatch: + inputs: + ref: + description: "Ref to build" + required: true + type: string + +permissions: + contents: read + +jobs: + ipa: + runs-on: macos-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref || github.ref }} + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup node + uses: actions/setup-node@v7 + with: + node-version: lts/* + cache: "pnpm" + + - name: Read toolchain channel from rust-toolchain.toml + id: rust + run: echo "channel=$(sed -nE "s/^channel[[:space:]]*=[[:space:]]*\"([^\"]+)\".*/\1/p" rust-toolchain.toml)" >> "$GITHUB_OUTPUT" + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ steps.rust.outputs.channel }} + targets: aarch64-apple-ios + + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + key: aarch64-apple-ios + workspaces: ". -> target" + + - name: Install frontend dependencies + run: pnpm install + + - name: Generate the Xcode project + run: pnpm tauri ios init --ci + + - name: Build the App Store package + env: + IOS_CERTIFICATE: ${{ secrets.IOS_CERTIFICATE }} + IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} + IOS_MOBILE_PROVISION: ${{ secrets.IOS_MOBILE_PROVISION }} + APPLE_DEVELOPMENT_TEAM: ${{ secrets.APPLE_TEAM_ID }} + VOLTIUS_REQUIRE_SEEDED_PLUGINS: "1" + VITE_DISTRIBUTION: appstore + run: pnpm tauri ios build --export-method app-store-connect --ci + + - name: Upload the package + uses: actions/upload-artifact@v7 + with: + name: voltius-ios-ipa + path: src-tauri/gen/apple/build/arm64/*.ipa + retention-days: 14 diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 43609e65d..c39d90801 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" [lib] name = "voltius_lib" -crate-type = ["cdylib", "rlib"] +crate-type = ["staticlib", "cdylib", "rlib"] # NOTE: profiles are defined at the workspace root (Cargo.toml). Cargo ignores # `[profile.*]` in non-root members, so they must not live here. diff --git a/src-tauri/tauri.ios.conf.json b/src-tauri/tauri.ios.conf.json new file mode 100644 index 000000000..bc03a4a5b --- /dev/null +++ b/src-tauri/tauri.ios.conf.json @@ -0,0 +1,9 @@ +{ + "identifier": "app.voltius", + "bundle": { + "iOS": { + "developmentTeam": "PQ94FFKWHV", + "minimumSystemVersion": "16.0" + } + } +} diff --git a/src/services/distribution.ts b/src/services/distribution.ts index b277756f8..bc58616c9 100644 --- a/src/services/distribution.ts +++ b/src/services/distribution.ts @@ -1,6 +1,10 @@ /** - * Google Play builds (VITE_DISTRIBUTION=play) must not send users to an outside - * payment page, so they show no purchase or billing-portal buttons. Plans are + * Store builds (VITE_DISTRIBUTION=play for Google Play, appstore for the App + * Store) must not send users to an outside payment page, so they show no + * purchase or billing-portal buttons. Plans are * bought on the website or from the desktop app; signing in unlocks them here. */ -export const CAN_OPEN_CHECKOUT: boolean = import.meta.env.VITE_DISTRIBUTION !== "play"; +const STORE_BUILDS = ["play", "appstore"]; +export const CAN_OPEN_CHECKOUT: boolean = !STORE_BUILDS.includes( + import.meta.env.VITE_DISTRIBUTION ?? "", +); From a47e61f91fd3f0a0d116c0a891e31323530fe49e Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 24 Oct 2026 19:15:43 +0200 Subject: [PATCH 02/34] ci(ios): install the provisioning profile where Xcode 16+ looks for it --- .github/workflows/ios.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 5aaf8bb27..102da1178 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -64,6 +64,21 @@ jobs: - name: Generate the Xcode project run: pnpm tauri ios init --ci + # The Tauri CLI installs the profile in ~/Library/MobileDevice, but Xcode 16+ + # reads ~/Library/Developer/Xcode/UserData. Install it in both places. + - name: Install the provisioning profile + env: + IOS_MOBILE_PROVISION: ${{ secrets.IOS_MOBILE_PROVISION }} + run: | + profile="$RUNNER_TEMP/voltius.mobileprovision" + echo "$IOS_MOBILE_PROVISION" | base64 --decode > "$profile" + uuid=$(security cms -D -i "$profile" | plutil -extract UUID raw -o - -) + echo "profile UUID: $uuid" + for dir in "$HOME/Library/MobileDevice/Provisioning Profiles" "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"; do + mkdir -p "$dir" + cp "$profile" "$dir/$uuid.mobileprovision" + done + - name: Build the App Store package env: IOS_CERTIFICATE: ${{ secrets.IOS_CERTIFICATE }} From ebdd0c7a814d90b1e90d4951e96c5a16ffe2cea4 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 24 Oct 2026 19:20:48 +0200 Subject: [PATCH 03/34] ci(ios): print the signing settings when the build fails --- .github/workflows/ios.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 102da1178..6c2613720 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -95,3 +95,11 @@ jobs: name: voltius-ios-ipa path: src-tauri/gen/apple/build/arm64/*.ipa retention-days: 14 + + - name: Show the signing settings + if: failure() + run: | + grep -n -E "CODE_SIGN|PROVISIONING|DEVELOPMENT_TEAM|PRODUCT_BUNDLE_IDENTIFIER|name = (debug|release)|Build configuration list" src-tauri/gen/apple/voltius.xcodeproj/project.pbxproj || true + cat src-tauri/gen/apple/ExportOptions.plist || true + ls -la "$HOME/Library/MobileDevice/Provisioning Profiles" "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles" || true + security find-identity -v -p codesigning || true From 4777c69ee82fba5da4c06bac130f6d6b35b1221a Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 24 Oct 2026 19:25:31 +0200 Subject: [PATCH 04/34] ci(ios): sign at export time with the App Store Connect API key --- .github/workflows/ios.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 6c2613720..6cb393399 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -79,8 +79,22 @@ jobs: cp "$profile" "$dir/$uuid.mobileprovision" done + # With the App Store Connect API key, the Tauri CLI builds and archives + # unsigned, then signs at export time with the certificate and profile + # above. Signing at build time fails: Xcode 26 reports that voltius_iOS + # "requires a provisioning profile" although the profile is installed. + - name: Write the App Store Connect API key + env: + APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} + run: | + printf "%s +" "$APPLE_API_KEY_P8" > "$RUNNER_TEMP/AuthKey.p8" + echo "APPLE_API_KEY_PATH=$RUNNER_TEMP/AuthKey.p8" >> "$GITHUB_ENV" + - name: Build the App Store package env: + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} IOS_CERTIFICATE: ${{ secrets.IOS_CERTIFICATE }} IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} IOS_MOBILE_PROVISION: ${{ secrets.IOS_MOBILE_PROVISION }} @@ -99,6 +113,7 @@ jobs: - name: Show the signing settings if: failure() run: | + rm -f "$RUNNER_TEMP/AuthKey.p8" grep -n -E "CODE_SIGN|PROVISIONING|DEVELOPMENT_TEAM|PRODUCT_BUNDLE_IDENTIFIER|name = (debug|release)|Build configuration list" src-tauri/gen/apple/voltius.xcodeproj/project.pbxproj || true cat src-tauri/gen/apple/ExportOptions.plist || true ls -la "$HOME/Library/MobileDevice/Provisioning Profiles" "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles" || true From 0e5941a7b533938b1dac1cb69911abe48aba90b6 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 24 Oct 2026 19:26:29 +0200 Subject: [PATCH 05/34] ci(ios): fix the API key line --- .github/workflows/ios.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 6cb393399..2ee09609b 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -87,8 +87,7 @@ jobs: env: APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} run: | - printf "%s -" "$APPLE_API_KEY_P8" > "$RUNNER_TEMP/AuthKey.p8" + printf "%s\n" "$APPLE_API_KEY_P8" > "$RUNNER_TEMP/AuthKey.p8" echo "APPLE_API_KEY_PATH=$RUNNER_TEMP/AuthKey.p8" >> "$GITHUB_ENV" - name: Build the App Store package From ab0a4ef58ae8c17f55df144aaf8e703507e48dea Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 24 Oct 2026 19:32:32 +0200 Subject: [PATCH 06/34] feat(ios): Face ID unlock and data-protection keychain - system_auth: the LocalAuthentication backend also runs on iOS. - Register the protected store of apple-native-keyring-store on iOS, so secrets survive restarts. - Info.ios.plist: NSFaceIDUsageDescription (iOS kills the app without it). --- src-tauri/Cargo.toml | 7 +++++++ src-tauri/Info.ios.plist | 8 ++++++++ src-tauri/src/lib.rs | 2 ++ src-tauri/src/system_auth/mod.rs | 4 ++-- 4 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 src-tauri/Info.ios.plist diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c39d90801..d04fe3499 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -123,11 +123,18 @@ gio = "0.18" [target.'cfg(target_os = "macos")'.dependencies] apple-native-keyring-store = { version = "1", features = ["keychain"] } + +# Touch ID / Face ID through LocalAuthentication (system_auth/macos.rs). +[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies] objc2 = "0.6" objc2-foundation = { version = "0.3", features = ["NSString", "NSError"] } objc2-local-authentication = { version = "0.3", features = ["LAContext", "LAError", "block2"] } block2 = "0.6" +[target.'cfg(target_os = "ios")'.dependencies] +# iOS has only the data-protection keychain. +apple-native-keyring-store = { version = "1", features = ["protected"] } + [target.'cfg(target_os = "android")'.dependencies] jni = "0.21" # reqwest's default rustls path uses aws-lc-rs + rustls-platform-verifier, which hangs diff --git a/src-tauri/Info.ios.plist b/src-tauri/Info.ios.plist new file mode 100644 index 000000000..110ec4aa9 --- /dev/null +++ b/src-tauri/Info.ios.plist @@ -0,0 +1,8 @@ + + + + + NSFaceIDUsageDescription + Voltius uses Face ID to unlock your vault. + + diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c74d7697a..93621f95a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -100,6 +100,8 @@ fn init_keychain_store() -> keyring_core::Result<()> { )?); #[cfg(target_os = "android")] keyring_core::set_default_store(crate::keychain_android::Store::new()); + #[cfg(target_os = "ios")] + keyring_core::set_default_store(apple_native_keyring_store::protected::Store::new()?); Ok(()) } diff --git a/src-tauri/src/system_auth/mod.rs b/src-tauri/src/system_auth/mod.rs index 468b65a87..6f6ee481b 100644 --- a/src-tauri/src/system_auth/mod.rs +++ b/src-tauri/src/system_auth/mod.rs @@ -4,7 +4,7 @@ use serde::Serialize; pub mod android; #[cfg(target_os = "linux")] mod linux; -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "ios"))] mod macos; pub mod seal; #[cfg(target_os = "windows")] @@ -14,7 +14,7 @@ mod windows; use android as backend; #[cfg(target_os = "linux")] use linux as backend; -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "ios"))] use macos as backend; #[cfg(target_os = "windows")] use windows as backend; From 24d1592c28274d4601a62e5e1b5ffc585f1905be Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 24 Oct 2026 19:47:22 +0200 Subject: [PATCH 07/34] fix(ios): skip serial port enumeration (IOKit is not available) --- src-tauri/src/serial/connect.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src-tauri/src/serial/connect.rs b/src-tauri/src/serial/connect.rs index 1d8f06d34..6de796593 100644 --- a/src-tauri/src/serial/connect.rs +++ b/src-tauri/src/serial/connect.rs @@ -163,6 +163,9 @@ pub struct SerialPortInfo { pub fn serial_list_ports() -> Result, String> { let mut ports: Vec = Vec::new(); + // iOS has no serial ports, and serialport enumerates them through IOKit, + // which is not linkable there. + #[cfg(not(target_os = "ios"))] if let Ok(detected) = serialport::available_ports() { for p in detected { ports.push(SerialPortInfo { From 5efde33bc5cc5acd723af3c6ee35f8faaab98e62 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 24 Oct 2026 21:07:23 +0200 Subject: [PATCH 08/34] ci(ios): run the app in the simulator and keep screenshots --- .github/workflows/ios.yml | 73 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 2ee09609b..31bf7ddef 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -117,3 +117,76 @@ jobs: cat src-tauri/gen/apple/ExportOptions.plist || true ls -la "$HOME/Library/MobileDevice/Provisioning Profiles" "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles" || true security find-identity -v -p codesigning || true + + # Runs the app in the iOS simulator and keeps screenshots and logs, since + # nobody on the team has an iPhone. + simulator: + runs-on: macos-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref || github.ref }} + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup node + uses: actions/setup-node@v7 + with: + node-version: lts/* + cache: "pnpm" + + - name: Read toolchain channel from rust-toolchain.toml + id: rust + run: echo "channel=$(sed -nE "s/^channel[[:space:]]*=[[:space:]]*\"([^\"]+)\".*/\1/p" rust-toolchain.toml)" >> "$GITHUB_OUTPUT" + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ steps.rust.outputs.channel }} + targets: aarch64-apple-ios-sim + + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + key: aarch64-apple-ios-sim + workspaces: ". -> target" + + - name: Install frontend dependencies + run: pnpm install + + - name: Generate the Xcode project + run: pnpm tauri ios init --ci + + - name: Build for the simulator + env: + VOLTIUS_REQUIRE_SEEDED_PLUGINS: "1" + VITE_DISTRIBUTION: appstore + run: pnpm tauri ios build --target aarch64-sim --ci + + - name: Run in the simulator + run: | + app=$(find src-tauri/gen/apple/build -name "*.app" -maxdepth 3 | head -1) + echo "app: $app" + device=$(xcrun simctl list devices available -j | python3 -c "import json,sys; d=json.load(sys.stdin)[\"devices\"]; print(next(x[\"udid\"] for r in sorted(d, reverse=True) if \"iOS\" in r for x in d[r] if x[\"name\"].startswith(\"iPhone\") and \"Pro\" in x[\"name\"] and \"Max\" not in x[\"name\"]))") + echo "device: $device" + xcrun simctl boot "$device" + xcrun simctl bootstatus "$device" + xcrun simctl install "$device" "$app" + mkdir -p sim + xcrun simctl launch --console-pty "$device" app.voltius > sim/console.log 2>&1 & + for t in 5 15 30 60; do + sleep $((t - ${prev:-0})); prev=$t + xcrun simctl io "$device" screenshot "sim/launch-${t}s.png" + done + xcrun simctl spawn "$device" log show --last 3m --style compact --predicate "process == \"Voltius\"" > sim/system.log || true + tail -50 sim/console.log || true + + - name: Upload screenshots and logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: voltius-ios-simulator + path: sim/ + retention-days: 14 From 4e4fb85ad2393615d9b7923d4875b53b4bfd4c3e Mon Sep 17 00:00:00 2001 From: kipavy Date: Sun, 25 Oct 2026 09:49:35 +0100 Subject: [PATCH 09/34] ci(ios): TestFlight upload on demand, run number as build number Also turn the MCP server off on iOS, as on Android. --- .github/workflows/ios.yml | 24 +++++++++++++++++++++++- src-tauri/src/mcp/mod.rs | 2 +- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 31bf7ddef..9e06d4699 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -20,6 +20,11 @@ on: description: "Ref to build" required: true type: string + testflight: + description: "Upload the package to TestFlight" + required: false + default: false + type: boolean permissions: contents: read @@ -100,7 +105,24 @@ jobs: APPLE_DEVELOPMENT_TEAM: ${{ secrets.APPLE_TEAM_ID }} VOLTIUS_REQUIRE_SEEDED_PLUGINS: "1" VITE_DISTRIBUTION: appstore - run: pnpm tauri ios build --export-method app-store-connect --ci + # CFBundleVersion must grow with every upload and have at most three + # numbers, so it is the workflow run number (--build-number would + # append a fourth one to the app version). + run: >- + pnpm tauri ios build --export-method app-store-connect --ci + --config "{\"bundle\":{\"iOS\":{\"bundleVersion\":\"${{ github.run_number }}\"}}}" + + - name: Upload to TestFlight + if: inputs.testflight + env: + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: | + mkdir -p "$HOME/.appstoreconnect/private_keys" + cp "$APPLE_API_KEY_PATH" "$HOME/.appstoreconnect/private_keys/AuthKey_$APPLE_API_KEY.p8" + ipa=$(ls src-tauri/gen/apple/build/arm64/*.ipa) + xcrun altool --upload-app --type ios --file "$ipa" --apiKey "$APPLE_API_KEY" --apiIssuer "$APPLE_API_ISSUER" --output-format normal + rm -f "$HOME/.appstoreconnect/private_keys/AuthKey_$APPLE_API_KEY.p8" - name: Upload the package uses: actions/upload-artifact@v7 diff --git a/src-tauri/src/mcp/mod.rs b/src-tauri/src/mcp/mod.rs index cff973fc1..28affe3e7 100644 --- a/src-tauri/src/mcp/mod.rs +++ b/src-tauri/src/mcp/mod.rs @@ -11,7 +11,7 @@ pub use bridge::{Bridge, BridgeError}; /// The local MCP server only makes sense where an MCP client can run next to /// the app. On Android nothing can, and the `mcp-server` toggle is a synced /// setting: a desktop that turned it on must not open a socket on the phone. -pub const SUPPORTED: bool = !cfg!(target_os = "android"); +pub const SUPPORTED: bool = !cfg!(any(target_os = "android", target_os = "ios")); pub struct McpState { pub bridge: Bridge, From 9050f257ad0c640fe8aa65eccbe55f6d43ca1a1f Mon Sep 17 00:00:00 2001 From: kipavy Date: Sun, 25 Oct 2026 09:53:17 +0100 Subject: [PATCH 10/34] feat(ios): save SFTP downloads to the Files app On iOS, download_dir_get points at the app Documents folder and download_publish moves the finished download there (with a (2), (3)... suffix on name clashes). UIFileSharingEnabled and LSSupportsOpeningDocumentsInPlace make the folder visible in Files. --- src-tauri/Info.ios.plist | 4 + src-tauri/src/commands/downloads.rs | 123 +++++++++++++++++++++++++++- 2 files changed, 123 insertions(+), 4 deletions(-) diff --git a/src-tauri/Info.ios.plist b/src-tauri/Info.ios.plist index 110ec4aa9..1b4604da2 100644 --- a/src-tauri/Info.ios.plist +++ b/src-tauri/Info.ios.plist @@ -4,5 +4,9 @@ NSFaceIDUsageDescription Voltius uses Face ID to unlock your vault. + UIFileSharingEnabled + + LSSupportsOpeningDocumentsInPlace + diff --git a/src-tauri/src/commands/downloads.rs b/src-tauri/src/commands/downloads.rs index ca1809e5c..5abfd9ca9 100644 --- a/src-tauri/src/commands/downloads.rs +++ b/src-tauri/src/commands/downloads.rs @@ -210,7 +210,17 @@ pub fn download_temp_path( } #[tauri::command] -pub async fn download_dir_get() -> Result, String> { +pub async fn download_dir_get(app: AppHandle) -> Result, String> { + #[cfg(target_os = "ios")] + { + let dir = ios_downloads_dir(&app)?; + Ok(Some(DownloadDirInfo { + uri: dir.to_string_lossy().into_owned(), + display_name: Some("Files \u{203a} Voltius SSH".into()), + })) + } + #[cfg(not(target_os = "ios"))] + let _ = app; #[cfg(target_os = "android")] { match android::get_dir()? { @@ -221,7 +231,7 @@ pub async fn download_dir_get() -> Result, String> { None => Ok(None), } } - #[cfg(not(target_os = "android"))] + #[cfg(not(any(target_os = "android", target_os = "ios")))] { Ok(None) } @@ -260,7 +270,21 @@ pub async fn download_dir_clear() -> Result<(), String> { /// Move a completed temp download (`temp_path`, a file or directory) into the SAF tree under /// `base_name`, then delete the temp. Android-only. #[tauri::command] -pub async fn download_publish(temp_path: String, base_name: String) -> Result<(), String> { +pub async fn download_publish( + app: AppHandle, + temp_path: String, + base_name: String, +) -> Result<(), String> { + #[cfg(target_os = "ios")] + { + let dir = ios_downloads_dir(&app)?; + let dest = unique_destination(&dir, &base_name); + move_tree(Path::new(&temp_path), &dest) + .map_err(|e| format!("Failed to save {base_name} to Files: {e}"))?; + Ok(()) + } + #[cfg(not(target_os = "ios"))] + let _ = &app; #[cfg(target_os = "android")] { if !android::is_writable()? { @@ -278,16 +302,107 @@ pub async fn download_publish(temp_path: String, base_name: String) -> Result<() let _ = std::fs::remove_file(Path::new(&temp_path)); Ok(()) } - #[cfg(not(target_os = "android"))] + #[cfg(not(any(target_os = "android", target_os = "ios")))] { let _ = (temp_path, base_name); Err("download publish is Android-only".into()) } } +/// iOS: downloads land in the app Documents folder, which the Files app shows +/// under "On My iPhone > Voltius SSH" (UIFileSharingEnabled in Info.ios.plist). +#[cfg(target_os = "ios")] +fn ios_downloads_dir(app: &AppHandle) -> Result { + use tauri::Manager; + let dir = app + .path() + .document_dir() + .map_err(|e| format!("Cannot find the Documents folder: {e}"))?; + std::fs::create_dir_all(&dir) + .map_err(|e| format!("Cannot create the Documents folder: {e}"))?; + Ok(dir) +} + +/// `dir/name`, or `dir/name (2)`, `dir/name (3)`... when it already exists. The +/// counter goes before the extension of a file name ("a.txt" -> "a (2).txt"). +#[cfg(any(target_os = "ios", test))] +fn unique_destination(dir: &Path, name: &str) -> PathBuf { + let name = name.replace(['/', '\\'], "_"); + let first = dir.join(&name); + if !first.exists() { + return first; + } + let (stem, ext) = match name.rfind('.') { + Some(i) if i > 0 => (&name[..i], &name[i..]), + _ => (name.as_str(), ""), + }; + (2..) + .map(|n| dir.join(format!("{stem} ({n}){ext}"))) + .find(|p| !p.exists()) + .expect("an unused name exists") +} + +/// Move a file or directory, copying when a rename is not possible. +#[cfg(any(target_os = "ios", test))] +fn move_tree(from: &Path, to: &Path) -> std::io::Result<()> { + if std::fs::rename(from, to).is_ok() { + return Ok(()); + } + if std::fs::metadata(from)?.is_dir() { + std::fs::create_dir_all(to)?; + for entry in std::fs::read_dir(from)? { + let entry = entry?; + if entry.file_type()?.is_symlink() { + continue; + } + move_tree(&entry.path(), &to.join(entry.file_name()))?; + } + std::fs::remove_dir_all(from) + } else { + std::fs::copy(from, to)?; + std::fs::remove_file(from) + } +} + #[cfg(test)] mod tests { use super::*; + + #[test] + fn unique_destination_adds_a_counter_before_the_extension() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!( + unique_destination(dir.path(), "a.txt"), + dir.path().join("a.txt") + ); + std::fs::write(dir.path().join("a.txt"), "x").unwrap(); + assert_eq!( + unique_destination(dir.path(), "a.txt"), + dir.path().join("a (2).txt") + ); + std::fs::write(dir.path().join("a (2).txt"), "x").unwrap(); + assert_eq!( + unique_destination(dir.path(), "a.txt"), + dir.path().join("a (3).txt") + ); + std::fs::create_dir(dir.path().join(".bashrc")).unwrap(); + assert_eq!( + unique_destination(dir.path(), ".bashrc"), + dir.path().join(".bashrc (2)") + ); + } + + #[test] + fn move_tree_moves_a_directory() { + let dir = tempfile::tempdir().unwrap(); + let from = dir.path().join("from"); + std::fs::create_dir_all(from.join("sub")).unwrap(); + std::fs::write(from.join("sub/f"), "data").unwrap(); + let to = dir.path().join("to"); + move_tree(&from, &to).unwrap(); + assert_eq!(std::fs::read_to_string(to.join("sub/f")).unwrap(), "data"); + assert!(!from.exists()); + } use std::fs; #[test] From 69bf653f77e56ecd0052e3412858e1486b205d10 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sun, 25 Oct 2026 09:54:33 +0100 Subject: [PATCH 11/34] ci(ios): drive the app in the simulator end to end Appium (XCUITest) taps the screen and reads the WebView DOM of a debug build; a throwaway asyncssh server on the runner plays the remote host. Each step leaves a screenshot and a DOM dump in the voltius-ios-e2e artifact, with a summary of passed and failed steps. --- .github/workflows/ios-e2e.yml | 131 +++++++++++++ scripts/ios-e2e/package.json | 9 + scripts/ios-e2e/page.js | 179 +++++++++++++++++ scripts/ios-e2e/run.mjs | 354 ++++++++++++++++++++++++++++++++++ scripts/ios-e2e/ssh_server.py | 174 +++++++++++++++++ 5 files changed, 847 insertions(+) create mode 100644 .github/workflows/ios-e2e.yml create mode 100644 scripts/ios-e2e/package.json create mode 100644 scripts/ios-e2e/page.js create mode 100644 scripts/ios-e2e/run.mjs create mode 100644 scripts/ios-e2e/ssh_server.py diff --git a/.github/workflows/ios-e2e.yml b/.github/workflows/ios-e2e.yml new file mode 100644 index 000000000..0590f69c2 --- /dev/null +++ b/.github/workflows/ios-e2e.yml @@ -0,0 +1,131 @@ +# Drives the iOS app in the simulator, step by step, since nobody on the team +# has an iPhone: Appium (XCUITest) taps the screen and reads the WebView DOM, +# a throwaway SSH/SFTP server on the runner plays the remote host. +# +# Every step leaves a screenshot and a DOM dump in the voltius-ios-e2e +# artifact; summary.md lists which steps passed. A failing app step does not +# fail the job (the point is the report); a broken harness does. +# +# The build is a debug build so the WKWebView is inspectable (Appium needs it +# for the WEBVIEW context). +name: ios-e2e + +on: + push: + branches: [ios/e2e, ci/ios] + workflow_dispatch: + inputs: + ref: + description: "Ref to build" + required: true + type: string + +permissions: + contents: read + +jobs: + simulator-e2e: + runs-on: macos-latest + timeout-minutes: 75 + env: + OUT_DIR: ${{ github.workspace }}/e2e-out + SSH_PORT: "2222" + SSH_USER: e2e + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref || github.ref }} + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup node + uses: actions/setup-node@v7 + with: + node-version: lts/* + cache: "pnpm" + + - name: Read toolchain channel from rust-toolchain.toml + id: rust + run: echo "channel=$(sed -nE "s/^channel[[:space:]]*=[[:space:]]*\"([^\"]+)\".*/\1/p" rust-toolchain.toml)" >> "$GITHUB_OUTPUT" + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ steps.rust.outputs.channel }} + targets: aarch64-apple-ios-sim + + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + key: aarch64-apple-ios-sim-debug + workspaces: ". -> target" + + - name: Install frontend dependencies + run: pnpm install + + - name: Generate the Xcode project + run: pnpm tauri ios init --ci + + - name: Build for the simulator (debug, inspectable WebView) + env: + VOLTIUS_REQUIRE_SEEDED_PLUGINS: "1" + VITE_DISTRIBUTION: appstore + run: pnpm tauri ios build --target aarch64-sim --debug --ci + + - name: Install Appium and the XCUITest driver + run: | + npm install --global appium@3 + appium driver install xcuitest + npm install --prefix scripts/ios-e2e --no-package-lock --no-audit --no-fund + + - name: Start the test SSH server + run: | + python3 -m venv "$RUNNER_TEMP/sshvenv" + "$RUNNER_TEMP/sshvenv/bin/pip" install --quiet asyncssh + password="e2e-$(openssl rand -hex 8)" + echo "SSH_PASSWORD=$password" >> "$GITHUB_ENV" + mkdir -p "$OUT_DIR/ssh" + E2E_SSH_USER="$SSH_USER" E2E_SSH_PASSWORD="$password" \ + nohup "$RUNNER_TEMP/sshvenv/bin/python" scripts/ios-e2e/ssh_server.py "$SSH_PORT" "$OUT_DIR/ssh" \ + > "$OUT_DIR/ssh/server-stdout.log" 2>&1 & + for _ in $(seq 1 20); do nc -z 127.0.0.1 "$SSH_PORT" && break; sleep 1; done + nc -z 127.0.0.1 "$SSH_PORT" + + - name: Boot a simulator + run: | + device=$(xcrun simctl list devices available -j | python3 -c "import json,sys; d=json.load(sys.stdin)['devices']; print(next(x['udid'] for r in sorted(d, reverse=True) if 'iOS' in r for x in d[r] if x['name'].startswith('iPhone') and 'Pro' in x['name'] and 'Max' not in x['name']))") + echo "UDID=$device" >> "$GITHUB_ENV" + xcrun simctl boot "$device" + xcrun simctl bootstatus "$device" + app=$(find src-tauri/gen/apple/build -name "*.app" -maxdepth 3 | head -1) + echo "APP_PATH=$GITHUB_WORKSPACE/$app" >> "$GITHUB_ENV" + echo "device $device, app $app" + + - name: Start Appium + run: | + nohup appium --address 127.0.0.1 --port 4723 --log "$OUT_DIR/appium.log" --log-no-colors > /dev/null 2>&1 & + for _ in $(seq 1 60); do curl -fs http://127.0.0.1:4723/status > /dev/null && break; sleep 1; done + curl -fs http://127.0.0.1:4723/status + + - name: Drive the app + env: + SSH_LOG_DIR: ${{ github.workspace }}/e2e-out/ssh + run: node scripts/ios-e2e/run.mjs + + - name: Collect the app logs + if: always() + run: | + [ -n "$UDID" ] || exit 0 + xcrun simctl spawn "$UDID" log show --last 60m --style compact \ + --predicate 'process == "Voltius"' > "$OUT_DIR/app-system.log" 2>/dev/null || true + grep -i -E "error|fault|panic|voltius_lib|tauri|\[voltius" "$OUT_DIR/app-system.log" | grep -v -i "BoardServices" | tail -400 > "$OUT_DIR/app-errors.log" || true + + - name: Upload screenshots and logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: voltius-ios-e2e + path: e2e-out/ + retention-days: 14 diff --git a/scripts/ios-e2e/package.json b/scripts/ios-e2e/package.json new file mode 100644 index 000000000..062b19c6f --- /dev/null +++ b/scripts/ios-e2e/package.json @@ -0,0 +1,9 @@ +{ + "name": "voltius-ios-e2e", + "private": true, + "type": "module", + "description": "Drives the Voltius iOS app in the simulator through Appium (XCUITest + WebView context).", + "dependencies": { + "webdriverio": "9.20.0" + } +} diff --git a/scripts/ios-e2e/page.js b/scripts/ios-e2e/page.js new file mode 100644 index 000000000..2ea6c6c2c --- /dev/null +++ b/scripts/ios-e2e/page.js @@ -0,0 +1,179 @@ +// Helpers run inside the WebView. Each export is self-contained: webdriverio +// serialises the function body, so they cannot share module scope. + +/** Visible text plus every interactive element, for the per-step dump. */ +export function dumpPage() { + const visible = (el) => { + const r = el.getBoundingClientRect(); + const s = getComputedStyle(el); + return r.width > 0 && r.height > 0 && s.visibility !== "hidden" && s.display !== "none" && + r.bottom > 0 && r.right > 0 && r.top < innerHeight && r.left < innerWidth; + }; + const items = []; + for (const el of document.querySelectorAll( + "button,a,input,textarea,select,[role=button],[role=tab],[role=menuitem],[role=option],[role=switch],[role=checkbox],[contenteditable=true]", + )) { + if (!visible(el)) continue; + const r = el.getBoundingClientRect(); + items.push({ + tag: el.tagName.toLowerCase(), + role: el.getAttribute("role") || undefined, + text: (el.innerText || "").trim().replace(/\s+/g, " ").slice(0, 80) || undefined, + aria: el.getAttribute("aria-label") || undefined, + title: el.getAttribute("title") || undefined, + placeholder: el.getAttribute("placeholder") || undefined, + name: el.getAttribute("name") || undefined, + type: el.getAttribute("type") || undefined, + value: el.value !== undefined && el.type !== "password" ? String(el.value).slice(0, 60) || undefined : undefined, + testid: el.getAttribute("data-testid") || undefined, + rect: [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)], + }); + } + return { + url: location.href, + viewport: [innerWidth, innerHeight, devicePixelRatio], + visualViewport: window.visualViewport + ? [visualViewport.offsetLeft, visualViewport.offsetTop, visualViewport.width, visualViewport.height, visualViewport.scale] + : null, + userAgent: navigator.userAgent, + activeElement: document.activeElement ? document.activeElement.outerHTML.slice(0, 200) : null, + text: (document.body.innerText || "").slice(0, 6000), + items, + xtermRows: [...document.querySelectorAll(".xterm-rows > div")].map((d) => d.textContent).join("\n").slice(-3000), + }; +} + +/** + * Finds the best visible element whose text, aria-label, title or placeholder + * matches `pattern` (a RegExp source, case-insensitive). Interactive elements + * and exact matches win; among equals the smallest wins. Remembers it for + * clickLastTarget and returns its rect in CSS pixels, or null. + */ +export function findTarget(pattern) { + const re = new RegExp(pattern, "i"); + const exactRe = new RegExp(`^(?:${pattern})$`, "i"); + // Off-screen elements count (a modal's Save button may be scrolled away); + // the winner is scrolled into view before its rect is returned. + const visible = (el) => { + const r = el.getBoundingClientRect(); + const s = getComputedStyle(el); + return r.width > 0 && r.height > 0 && s.visibility !== "hidden" && s.display !== "none" && s.opacity !== "0"; + }; + const interactive = (el) => + el.matches("button,a,input,textarea,select,label,[role=button],[role=tab],[role=menuitem],[role=option],[role=switch],[tabindex]") || + getComputedStyle(el).cursor === "pointer"; + let best = null; + for (const el of document.body.querySelectorAll("*")) { + const tag = el.tagName.toLowerCase(); + if (tag === "script" || tag === "style" || (el.closest("svg") && tag !== "svg")) continue; + const own = [ + el.getAttribute("aria-label"), + el.getAttribute("title"), + el.getAttribute("placeholder"), + el.children.length === 0 || interactive(el) ? (el.innerText || "").trim() : null, + ].filter(Boolean); + const hit = own.find((t) => re.test(t)); + if (!hit || !visible(el)) continue; + let target = el; + while (target && target !== document.body && !interactive(target)) target = target.parentElement; + if (!target || target === document.body) target = el; + const r = target.getBoundingClientRect(); + const score = (interactive(target) ? 0 : 1e9) + (own.some((t) => exactRe.test(t)) ? 0 : 1e8) + r.width * r.height; + if (!best || score < best.score) { + best = { score, rect: [r.x, r.y, r.width, r.height], tag: target.tagName.toLowerCase(), text: hit.slice(0, 80) }; + window.__e2eTarget = target; + } + } + if (best) { + const el = window.__e2eTarget; + const r0 = el.getBoundingClientRect(); + if (r0.top < 0 || r0.bottom > innerHeight || r0.left < 0 || r0.right > innerWidth) { + el.scrollIntoView({ block: "center", inline: "center" }); + } + const r = el.getBoundingClientRect(); + best.rect = [r.x, r.y, r.width, r.height]; + } + return best; +} + +/** Clicks the element found by the last findTarget (fallback when a native tap misses). */ +export function clickLastTarget() { + const el = window.__e2eTarget; + if (!el || !el.isConnected) return false; + const opts = { bubbles: true, cancelable: true, view: window }; + el.dispatchEvent(new PointerEvent("pointerdown", opts)); + el.dispatchEvent(new MouseEvent("mousedown", opts)); + el.dispatchEvent(new PointerEvent("pointerup", opts)); + el.dispatchEvent(new MouseEvent("mouseup", opts)); + el.click(); + return true; +} + +/** + * Finds the input (or textarea) best described by `pattern`: placeholder, + * aria-label, name, associated