diff --git a/.github/workflows/mobile-build.yml b/.github/workflows/mobile-build.yml new file mode 100644 index 0000000..ec60b28 --- /dev/null +++ b/.github/workflows/mobile-build.yml @@ -0,0 +1,157 @@ +name: Mobile engineering builds + +on: + pull_request: + paths: + - 'desktop/**' + - 'scripts/mobile.py' + - 'tests/test_mobile.py' + - 'tests/mobile_client.test.cjs' + - '.github/workflows/mobile-*.yml' + push: + branches: [main] + paths: + - 'desktop/**' + - 'scripts/mobile.py' + - 'tests/test_mobile.py' + - 'tests/mobile_client.test.cjs' + - '.github/workflows/mobile-*.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: mobile-test-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + android: + name: Android debug APK + AAB, connect and return + runs-on: ubuntu-24.04 + timeout-minutes: 55 + env: + CARGO_INCREMENTAL: '0' + CARGO_PROFILE_DEV_DEBUG: '0' + CARGO_PROFILE_DEV_STRIP: symbols + CARGO_BUILD_JOBS: '2' + NDK_VERSION: 29.0.13846066 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: desktop/package-lock.json + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-linux-android,x86_64-linux-android + - uses: android-actions/setup-android@v3 + with: + packages: platform-tools + cmdline-tools-version: '15859902' # 22.0; understands Android 37.0 package IDs + log-accepted-android-sdk-licenses: false + - name: Install pinned Android SDK and NDK + run: | + sdkmanager 'platforms;android-37.0' 'build-tools;37.0.0' "ndk;$NDK_VERSION" 'platform-tools' + echo "NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION" >> "$GITHUB_ENV" + - name: Install locked Tauri CLI + run: npm ci + working-directory: desktop + - name: Test build/signing guardrails + run: | + python3 -m unittest discover -s tests -p test_mobile.py -v + node --test tests/mobile_client.test.cjs + - name: Build engineering artifacts (no release secrets) + run: python3 scripts/mobile.py build android --variant debug --target aarch64 --target x86_64 --format both --output artifacts/mobile/android + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - name: Sideload APK and verify connection, isolation, and login return + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 35 + target: google_apis + arch: x86_64 + profile: pixel_6 + disable-animations: true + emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -camera-back none + script: python3 scripts/mobile.py smoke-android artifacts/mobile/android/hireferry-android-aarch64-x86_64-debug.apk --output artifacts/mobile/android/smoke + - name: Upload engineering packages and verification evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: hireferry-android-debug-${{ github.sha }} + path: artifacts/mobile/android/ + if-no-files-found: error + retention-days: 14 + + ios-simulator: + name: iOS simulator build (no signing identity) + runs-on: macos-15 + timeout-minutes: 50 + env: + CARGO_INCREMENTAL: '0' + CARGO_PROFILE_DEV_DEBUG: '0' + CARGO_PROFILE_DEV_STRIP: symbols + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: desktop/package-lock.json + - uses: dtolnay/rust-toolchain@stable + - name: Install iOS project tools + run: | + brew list cocoapods >/dev/null 2>&1 || brew install cocoapods + xcodebuild -version + - name: Install locked Tauri CLI + run: npm ci + working-directory: desktop + - name: Build simulator application without distribution credentials + run: python3 scripts/mobile.py build ios --simulator --variant debug --output artifacts/mobile/ios + - name: Boot simulator and install actual application + run: | + python3 - <<'PY' + import json, pathlib, subprocess, tarfile, time + devices = json.loads(subprocess.check_output(['xcrun', 'simctl', 'list', 'devices', 'available', '-j']))['devices'] + phones = [d for runtime, group in devices.items() if 'iOS' in runtime for d in group if d.get('isAvailable') and 'iPhone' in d['name']] + if not phones: + raise SystemExit('No available iPhone simulator runtime on runner') + device = phones[0] + udid = device['udid'] + if device['state'] != 'Booted': + subprocess.run(['xcrun', 'simctl', 'boot', udid], check=True) + subprocess.run(['xcrun', 'simctl', 'bootstatus', udid, '-b'], check=True) + archive = next(pathlib.Path('artifacts/mobile/ios').glob('*.app.tar.gz')) + out = pathlib.Path('artifacts/mobile/ios/smoke') + out.mkdir() + with tarfile.open(archive) as source: + source.extractall(out, filter='data') + app = next(out.glob('*.app')) + subprocess.run(['xcrun', 'simctl', 'install', udid, str(app)], check=True) + subprocess.run(['xcrun', 'simctl', 'launch', udid, 'best.lmm.hireferry'], check=True) + time.sleep(5) + subprocess.run(['xcrun', 'simctl', 'io', udid, 'screenshot', str(out / 'ios-launch.png')], check=True) + subprocess.run(['xcrun', 'simctl', 'openurl', udid, 'hireferry://login-return'], check=True) + time.sleep(2) + subprocess.run(['xcrun', 'simctl', 'io', udid, 'screenshot', str(out / 'ios-login-return.png')], check=True) + (out / 'ios-smoke.json').write_text(json.dumps({'simulator':device['name'], 'installed':True, 'launch_command_succeeded':True, 'login_return_openurl_succeeded':True}, indent=2)) + import shutil + shutil.rmtree(app) + PY + - name: Upload simulator app and launch evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: hireferry-ios-simulator-${{ github.sha }} + path: artifacts/mobile/ios/ + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/mobile-release.yml b/.github/workflows/mobile-release.yml new file mode 100644 index 0000000..8ff0880 --- /dev/null +++ b/.github/workflows/mobile-release.yml @@ -0,0 +1,140 @@ +name: Signed mobile packages + +# Explicit maintainer dispatch only. PR validation uses mobile-build.yml and +# never reads distribution signing credentials or publishes a store release. +on: + workflow_dispatch: + inputs: + platform: + description: Platform to sign + required: true + type: choice + options: [android, ios] + build_number: + description: Positive iOS build number (ignored by Android) + required: true + default: '1' + type: string + ios_export_method: + description: iOS distribution profile type + required: true + default: app-store-connect + type: choice + options: [app-store-connect, release-testing, debugging] + +permissions: + contents: read + +jobs: + android: + if: inputs.platform == 'android' + runs-on: ubuntu-24.04 + environment: mobile-signing + timeout-minutes: 55 + env: + CARGO_INCREMENTAL: '0' + NDK_VERSION: 29.0.13846066 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-linux-android + - uses: android-actions/setup-android@v3 + with: + packages: platform-tools + cmdline-tools-version: '15859902' # 22.0; understands Android 37.0 package IDs + log-accepted-android-sdk-licenses: false + - name: Install pinned Android SDK and NDK + run: | + sdkmanager 'platforms;android-37.0' 'build-tools;37.0.0' "ndk;$NDK_VERSION" 'platform-tools' + echo "NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION" >> "$GITHUB_ENV" + - run: npm ci + working-directory: desktop + - name: Decode maintainer keystore in runner temporary storage + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + run: | + python3 - <<'PY' + import base64, os, pathlib + value = os.environ.get('ANDROID_KEYSTORE_BASE64', '') + if not value: + raise SystemExit('Configure ANDROID_KEYSTORE_BASE64 in the mobile-signing environment') + path = pathlib.Path(os.environ['RUNNER_TEMP']) / 'hireferry-release.keystore' + path.write_bytes(base64.b64decode(value, validate=True)) + path.chmod(0o600) + with open(os.environ['GITHUB_ENV'], 'a') as output: + output.write('HF_ANDROID_KEYSTORE_PATH=' + str(path) + '\n') + PY + - name: Build and verify signed APK and AAB + env: + HF_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + HF_ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + HF_ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + HF_ANDROID_CERT_SHA256: ${{ vars.ANDROID_CERT_SHA256 }} + run: python3 scripts/mobile.py build android --variant release --target aarch64 --format both --output artifacts/mobile/android + - name: Remove decoded keystore + if: always() + run: rm -f "$RUNNER_TEMP/hireferry-release.keystore" + - uses: actions/upload-artifact@v4 + with: + name: hireferry-android-signed-${{ github.sha }} + path: artifacts/mobile/android/ + if-no-files-found: error + retention-days: 14 + + ios: + if: inputs.platform == 'ios' + runs-on: macos-15 + environment: mobile-signing + timeout-minutes: 55 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-ios + - name: Install iOS project tools + run: brew list cocoapods >/dev/null 2>&1 || brew install cocoapods + - run: npm ci + working-directory: desktop + - name: Validate signing inputs + env: + APPLE_DEVELOPMENT_TEAM: ${{ vars.APPLE_DEVELOPMENT_TEAM }} + IOS_CERTIFICATE: ${{ secrets.IOS_CERTIFICATE }} + IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} + IOS_MOBILE_PROVISION: ${{ secrets.IOS_MOBILE_PROVISION }} + BUILD_NUMBER: ${{ inputs.build_number }} + run: | + python3 - <<'PY' + import os + keys = ('APPLE_DEVELOPMENT_TEAM', 'IOS_CERTIFICATE', 'IOS_CERTIFICATE_PASSWORD', 'IOS_MOBILE_PROVISION') + missing = [key for key in keys if not os.environ.get(key)] + if missing: + raise SystemExit('Configure real iOS signing inputs: ' + ', '.join(missing)) + if not os.environ['BUILD_NUMBER'].isdigit() or int(os.environ['BUILD_NUMBER']) < 1: + raise SystemExit('build_number must be a positive integer') + PY + - name: Build and verify signed IPA + env: + APPLE_DEVELOPMENT_TEAM: ${{ vars.APPLE_DEVELOPMENT_TEAM }} + IOS_CERTIFICATE: ${{ secrets.IOS_CERTIFICATE }} + IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} + IOS_MOBILE_PROVISION: ${{ secrets.IOS_MOBILE_PROVISION }} + BUILD_NUMBER: ${{ inputs.build_number }} + EXPORT_METHOD: ${{ inputs.ios_export_method }} + run: python3 scripts/mobile.py build ios --variant release --target aarch64 --build-number "$BUILD_NUMBER" --export-method "$EXPORT_METHOD" --output artifacts/mobile/ios + - uses: actions/upload-artifact@v4 + with: + name: hireferry-ios-signed-${{ github.sha }} + path: artifacts/mobile/ios/ + if-no-files-found: error + retention-days: 14 diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 97452b1..5544ae6 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -491,6 +491,26 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "cookie" version = "0.18.2" @@ -574,6 +594,12 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -796,6 +822,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "dom_query" version = "0.28.0" @@ -1224,6 +1259,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1401,6 +1447,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.17.1" @@ -1435,8 +1487,10 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" name = "hireferry-desktop" version = "0.1.0" dependencies = [ + "serde_json", "tauri", "tauri-build", + "tauri-plugin-deep-link", "tauri-plugin-opener", "url", ] @@ -2365,6 +2419,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -2787,6 +2851,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -3490,6 +3564,27 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-deep-link" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b3c64e24ebcbc63aaa2d88a9c51e2507084b46620baf39ecb0baa0738a08bb4" +dependencies = [ + "dunce", + "plist", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.21", + "tracing", + "url", + "windows-registry", + "windows-result", +] + [[package]] name = "tauri-plugin-opener" version = "2.7.2" @@ -3703,6 +3798,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -4399,6 +4503,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 1e41f2f..cab4235 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -2,12 +2,20 @@ name = "hireferry-desktop" version = "0.1.0" edition = "2024" -rust-version = "1.88" -description = "HireFerry desktop client" +rust-version = "1.90" +description = "HireFerry desktop and mobile client" authors = ["HireFerry contributors"] license = "AGPL-3.0-or-later" repository = "https://github.com/TokenNotIncluded/hireferry" +[lib] +# Mobile platforms load the library; keep the existing desktop binary name. +name = "hireferry_desktop_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[features] +custom-protocol = ["tauri/custom-protocol"] + # The desktop toolchain is deliberately independent of the server/CLI workspace. [workspace] resolver = "3" @@ -16,10 +24,14 @@ resolver = "3" tauri-build = { version = "=2.7.3", features = [] } [dependencies] +serde_json = "1" tauri = { version = "=2.12.4", features = [] } tauri-plugin-opener = "=2.7.2" url = "2.5.8" +[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies] +tauri-plugin-deep-link = "=2.6.2" + [profile.release] opt-level = "z" lto = true diff --git a/desktop/src-tauri/src/desktop.rs b/desktop/src-tauri/src/desktop.rs new file mode 100644 index 0000000..9c6fdde --- /dev/null +++ b/desktop/src-tauri/src/desktop.rs @@ -0,0 +1,229 @@ +use crate::{open_external, policy, require_launcher}; + +use std::sync::atomic::{AtomicU64, Ordering}; +use tauri::{ + AppHandle, Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder, + menu::{MenuBuilder, SubmenuBuilder}, + webview::{NewWindowResponse, PageLoadEvent}, +}; +use url::Url; + +static NEXT_WINDOW: AtomicU64 = AtomicU64::new(1); + +fn show_launcher(app: &AppHandle) { + if let Some(window) = app.get_webview_window("launcher") { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + } +} + +#[tauri::command] +pub async fn connect(window: WebviewWindow, app: AppHandle, server: String) -> Result<(), String> { + require_launcher(&window)?; + let url = policy::server_origin(&server).map_err(str::to_owned)?; + let selected = url.clone(); + let popup_selected = url.clone(); + let navigation_app = app.clone(); + let popup_app = app.clone(); + let home = url.clone(); + let label = format!("workbench-{}", NEXT_WINDOW.fetch_add(1, Ordering::Relaxed)); + let navigation_label = label.clone(); + let popup_label = label.clone(); + let old_windows: Vec<_> = app + .webview_windows() + .into_values() + .filter(|window| window.label().starts_with("workbench-")) + .collect(); + let workbench = WebviewWindowBuilder::new(&app, &label, WebviewUrl::External(url.clone())) + .title(format!( + "HireFerry · {}", + url.origin().ascii_serialization() + )) + .inner_size(1280.0, 860.0) + .min_inner_size(680.0, 540.0) + .center() + // No remote capability is assigned to workbench-* windows. The remote + // service keeps its own CSP; it can never invoke our native commands. + .on_navigation(move |next| { + if let Some(landing) = policy::login_landing(&selected, next) { + navigate_later(&navigation_app, &navigation_label, landing); + return false; + } + if policy::service_navigation(&selected, next) + && !policy::login_authorization(&selected, next) + { + return true; + } + // Approval belongs to the system browser. The original WebView's + // HttpOnly proof alone can redeem the server's one-time grant. + let app = navigation_app.clone(); + let next = next.clone(); + std::thread::spawn(move || { + let _ = open_external(&app, &next); + }); + false + }) + .on_new_window(move |url, _| { + if let Some(landing) = policy::login_landing(&popup_selected, &url) { + navigate_later(&popup_app, &popup_label, landing); + } else { + let app = popup_app.clone(); + std::thread::spawn(move || { + let _ = open_external(&app, &url); + }); + } + NewWindowResponse::Deny + }) + .build() + .map_err(|_| "window_failed".to_owned())?; + + let handle = app.clone(); + let closed_label = label.clone(); + let focus_origin = url; + workbench.on_window_event(move |event| { + if matches!(event, tauri::WindowEvent::Focused(true)) { + let app = handle.clone(); + let label = closed_label.clone(); + let origin = focus_origin.clone(); + tauri::async_runtime::spawn(async move { + if let Some(window) = app.get_webview_window(&label) + && window.url().is_ok_and(|current| { + policy::service_navigation(&origin, ¤t) + && current.path() == "/auth/app" + }) + { + let _ = window.eval("window.dispatchEvent(new Event('hireferry-app-return'))"); + } + }); + } + if matches!(event, tauri::WindowEvent::Destroyed) + && handle + .webview_windows() + .keys() + .all(|label| !label.starts_with("workbench-") || *label == closed_label) + { + show_launcher(&handle); + } + }); + workbench.on_menu_event(move |window, event| { + let Some(webview) = window.get_webview_window(window.label()) else { + return; + }; + match event.id().as_ref() { + "home" => { + let _ = webview.navigate(home.clone()); + } + "reload" => { + let _ = webview.reload(); + } + "settings" => show_launcher(window.app_handle()), + _ => {} + } + }); + for old in old_windows { + let _ = old.destroy(); + } + window.hide().map_err(|_| "window_failed".to_owned())?; + Ok(()) +} + +fn navigate_later(app: &AppHandle, label: &str, url: Url) { + let app = app.clone(); + let label = label.to_owned(); + tauri::async_runtime::spawn(async move { + if let Some(window) = app.get_webview_window(&label) { + let _ = window.navigate(url); + } + }); +} + +pub fn run() { + let args: Vec<_> = std::env::args().skip(1).collect(); + if args.iter().any(|arg| arg == "--version" || arg == "-V") { + println!("hireferry-desktop {}", env!("CARGO_PKG_VERSION")); + return; + } + let smoke_test = args.iter().any(|arg| arg == "--smoke-test"); + tauri::Builder::default() + .plugin( + tauri_plugin_opener::Builder::new() + .open_js_links_on_click(false) + .build(), + ) + .invoke_handler(tauri::generate_handler![connect, crate::open_browser]) + .setup(move |app| { + let app_menu = SubmenuBuilder::new(app, "HireFerry") + .text("settings", "Connection / 连接设置") + .separator() + .quit() + .build()?; + let edit_menu = SubmenuBuilder::new(app, "Edit / 编辑") + .undo() + .redo() + .separator() + .cut() + .copy() + .paste() + .select_all() + .build()?; + let view_menu = SubmenuBuilder::new(app, "View / 视图") + .text("home", "Home / 首页") + .text("reload", "Reload / 刷新") + .separator() + .fullscreen() + .build()?; + let menu = MenuBuilder::new(app) + .items(&[&app_menu, &edit_menu, &view_menu]) + .build()?; + app.set_menu(menu)?; + let handle = app.handle().clone(); + let launcher = + WebviewWindowBuilder::new(app, "launcher", WebviewUrl::App("index.html".into())) + .title("HireFerry · 聘渡") + .inner_size(920.0, 700.0) + .min_inner_size(640.0, 580.0) + .center() + .on_navigation(policy::launcher_url) + .on_new_window(|_, _| NewWindowResponse::Deny) + .on_page_load(move |_, event| { + if smoke_test && matches!(event.event(), PageLoadEvent::Finished) { + println!("HireFerry desktop smoke test: launcher loaded"); + handle.exit(0); + } + }) + .build()?; + let connection_handle = app.handle().clone(); + launcher.on_window_event(move |event| { + if let tauri::WindowEvent::CloseRequested { api, .. } = event + && connection_handle + .webview_windows() + .keys() + .any(|label| label.starts_with("workbench-")) + { + // Keep connection settings available from the menu while + // the user continues working in the remote window. + api.prevent_close(); + if let Some(window) = connection_handle.get_webview_window("launcher") { + let _ = window.hide(); + } + } + }); + launcher.on_menu_event(|window, event| { + if event.id().as_ref() == "settings" { + show_launcher(window.app_handle()); + } + }); + if smoke_test { + let handle = app.handle().clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_secs(20)); + eprintln!("desktop smoke test timed out before the launcher loaded"); + handle.exit(1); + }); + } + Ok(()) + }) + .run(tauri::generate_context!()) + .expect("failed to run HireFerry desktop"); +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs new file mode 100644 index 0000000..634d577 --- /dev/null +++ b/desktop/src-tauri/src/lib.rs @@ -0,0 +1,45 @@ +mod policy; + +#[cfg(desktop)] +mod desktop; +#[cfg(mobile)] +mod mobile; + +use tauri::{AppHandle, WebviewWindow}; +use tauri_plugin_opener::OpenerExt; +use url::Url; + +fn require_launcher(window: &WebviewWindow) -> Result<(), String> { + // The local-only capability is the first boundary; keep an origin check as + // defense in depth even though mobile reuses the same window for the site. + if window.label() != "launcher" || !window.url().is_ok_and(|url| policy::launcher_url(&url)) { + return Err("not_allowed".into()); + } + Ok(()) +} + +fn open_external(app: &AppHandle, url: &Url) -> Result<(), String> { + if !policy::web_url(url) { + return Err("invalid_server".into()); + } + // The registered opener plugin supplies Android/iOS implementations. No + // opener command or remote capability is exposed to the website itself. + app.opener() + .open_url(url.as_str(), None::<&str>) + .map_err(|_| "browser_failed".into()) +} + +#[tauri::command] +async fn open_browser(window: WebviewWindow, app: AppHandle, server: String) -> Result<(), String> { + require_launcher(&window)?; + let url = policy::server_origin(&server).map_err(str::to_owned)?; + open_external(&app, &url) +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + #[cfg(desktop)] + desktop::run(); + #[cfg(mobile)] + mobile::run(); +} diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 2fb0e84..c489f70 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -1,204 +1,5 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -mod policy; - -use std::sync::atomic::{AtomicU64, Ordering}; -use tauri::{ - AppHandle, Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder, - menu::{MenuBuilder, SubmenuBuilder}, - webview::{NewWindowResponse, PageLoadEvent}, -}; -use url::Url; - -static NEXT_WINDOW: AtomicU64 = AtomicU64::new(1); - -fn require_launcher(window: &WebviewWindow) -> Result<(), String> { - if window.label() != "launcher" || !window.url().is_ok_and(|url| policy::launcher_url(&url)) { - return Err("not_allowed".into()); - } - Ok(()) -} - -fn open_external(url: &Url) -> Result<(), String> { - if !policy::web_url(url) { - return Err("invalid_server".into()); - } - tauri_plugin_opener::open_url(url.as_str(), None::<&str>).map_err(|_| "browser_failed".into()) -} - -fn show_launcher(app: &AppHandle) { - if let Some(window) = app.get_webview_window("launcher") { - let _ = window.show(); - let _ = window.unminimize(); - let _ = window.set_focus(); - } -} - -#[tauri::command] -async fn open_browser(window: WebviewWindow, server: String) -> Result<(), String> { - require_launcher(&window)?; - let url = policy::server_origin(&server).map_err(str::to_owned)?; - open_external(&url) -} - -#[tauri::command] -async fn connect(window: WebviewWindow, app: AppHandle, server: String) -> Result<(), String> { - require_launcher(&window)?; - let url = policy::server_origin(&server).map_err(str::to_owned)?; - let origin = url.origin(); - let browser_home = url.clone(); - let home = url.clone(); - let label = format!("workbench-{}", NEXT_WINDOW.fetch_add(1, Ordering::Relaxed)); - let old_windows: Vec<_> = app - .webview_windows() - .into_values() - .filter(|window| window.label().starts_with("workbench-")) - .collect(); - let workbench = WebviewWindowBuilder::new(&app, &label, WebviewUrl::External(url.clone())) - .title(format!( - "HireFerry · {}", - url.origin().ascii_serialization() - )) - .inner_size(1280.0, 860.0) - .min_inner_size(680.0, 540.0) - .center() - // No remote capability is assigned to workbench-* windows. The remote - // service keeps its own CSP; it can never invoke our native commands. - .on_navigation(move |next| { - if next.origin() == origin && policy::web_url(next) { - // A browser session cannot be transferred back to this webview. - // Start Google's entire flow in the browser, including its CSRF cookie. - if next.path() == "/api/auth/google/start" { - let _ = open_external(&browser_home); - return false; - } - return true; - } - let _ = open_external(next); - false - }) - .on_new_window(|url, _| { - let _ = open_external(&url); - NewWindowResponse::Deny - }) - .build() - .map_err(|_| "window_failed".to_owned())?; - - let handle = app.clone(); - let closed_label = label.clone(); - workbench.on_window_event(move |event| { - if matches!(event, tauri::WindowEvent::Destroyed) - && handle - .webview_windows() - .keys() - .all(|label| !label.starts_with("workbench-") || *label == closed_label) - { - show_launcher(&handle); - } - }); - workbench.on_menu_event(move |window, event| { - let Some(webview) = window.get_webview_window(window.label()) else { - return; - }; - match event.id().as_ref() { - "home" => { - let _ = webview.navigate(home.clone()); - } - "reload" => { - let _ = webview.reload(); - } - "settings" => show_launcher(window.app_handle()), - _ => {} - } - }); - for old in old_windows { - let _ = old.destroy(); - } - window.hide().map_err(|_| "window_failed".to_owned())?; - Ok(()) -} - fn main() { - let args: Vec<_> = std::env::args().skip(1).collect(); - if args.iter().any(|arg| arg == "--version" || arg == "-V") { - println!("hireferry-desktop {}", env!("CARGO_PKG_VERSION")); - return; - } - let smoke_test = args.iter().any(|arg| arg == "--smoke-test"); - tauri::Builder::default() - .invoke_handler(tauri::generate_handler![connect, open_browser]) - .setup(move |app| { - let app_menu = SubmenuBuilder::new(app, "HireFerry") - .text("settings", "Connection / 连接设置") - .separator() - .quit() - .build()?; - let edit_menu = SubmenuBuilder::new(app, "Edit / 编辑") - .undo() - .redo() - .separator() - .cut() - .copy() - .paste() - .select_all() - .build()?; - let view_menu = SubmenuBuilder::new(app, "View / 视图") - .text("home", "Home / 首页") - .text("reload", "Reload / 刷新") - .separator() - .fullscreen() - .build()?; - let menu = MenuBuilder::new(app) - .items(&[&app_menu, &edit_menu, &view_menu]) - .build()?; - app.set_menu(menu)?; - let handle = app.handle().clone(); - let launcher = - WebviewWindowBuilder::new(app, "launcher", WebviewUrl::App("index.html".into())) - .title("HireFerry · 聘渡") - .inner_size(920.0, 700.0) - .min_inner_size(640.0, 580.0) - .center() - .on_navigation(policy::launcher_url) - .on_new_window(|_, _| NewWindowResponse::Deny) - .on_page_load(move |_, event| { - if smoke_test && matches!(event.event(), PageLoadEvent::Finished) { - println!("HireFerry desktop smoke test: launcher loaded"); - handle.exit(0); - } - }) - .build()?; - let connection_handle = app.handle().clone(); - launcher.on_window_event(move |event| { - if let tauri::WindowEvent::CloseRequested { api, .. } = event - && connection_handle - .webview_windows() - .keys() - .any(|label| label.starts_with("workbench-")) - { - // Keep connection settings available from the menu while - // the user continues working in the remote window. - api.prevent_close(); - if let Some(window) = connection_handle.get_webview_window("launcher") { - let _ = window.hide(); - } - } - }); - launcher.on_menu_event(|window, event| { - if event.id().as_ref() == "settings" { - show_launcher(window.app_handle()); - } - }); - if smoke_test { - let handle = app.handle().clone(); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_secs(20)); - eprintln!("desktop smoke test timed out before the launcher loaded"); - handle.exit(1); - }); - } - Ok(()) - }) - .run(tauri::generate_context!()) - .expect("failed to run HireFerry desktop"); + hireferry_desktop_lib::run(); } diff --git a/desktop/src-tauri/src/mobile-bootstrap.js b/desktop/src-tauri/src/mobile-bootstrap.js new file mode 100644 index 0000000..2722245 --- /dev/null +++ b/desktop/src-tauri/src/mobile-bootstrap.js @@ -0,0 +1,79 @@ +(() => { + 'use strict'; + // A convenience control, not a security boundary. All authorization remains + // in Rust and the local-only Tauri capability. No credentials enter this script. + const local = location.hostname === 'tauri.localhost' || location.protocol === 'tauri:'; + if (local) { + window.__HIREFERRY_MOBILE__ = true; + return; + } + if (!/^https?:$/.test(location.protocol) || window !== window.top) return; + // Mobile WebViews do not support Tauri's desktop on_new_window callback. + // Route ordinary target=_blank links through the same native navigation + // policy instead; the selected service stays in-app, other HTTPS origins + // open in the system browser, and non-web schemes are denied by Rust. + document.addEventListener('click', event => { + const anchor = event.target instanceof Element ? event.target.closest('a[target="_blank"]') : null; + if (!anchor || event.defaultPrevented || !/^https?:$/.test(anchor.protocol)) return; + event.preventDefault(); + location.assign(anchor.href); + }); + const addReturn = () => { + if (document.getElementById('hireferry-app-settings')) return; + const link = document.createElement('a'); + link.id = 'hireferry-app-settings'; + // Rust substitutes a fixed bundled page for this build's platform. Let the + // WebView perform this navigation normally; no native command, redirect + // payload, or second navigate() inside its navigation callback is needed. + link.href = '__HIREFERRY_LAUNCHER_URL__'; + const zh = navigator.language.startsWith('zh'); + link.textContent = zh ? '连接' : 'Connection'; + link.setAttribute('aria-label', zh ? '返回聘渡应用连接设置' : 'Return to HireFerry connection settings'); + link.style.cssText = 'position:fixed;left:max(12px,env(safe-area-inset-left));bottom:calc(78px + env(safe-area-inset-bottom));z-index:79;box-sizing:border-box;display:flex;align-items:center;justify-content:center;min-width:64px;min-height:44px;padding:8px 12px;border:1px solid #cbcfc9;border-radius:24px;background:#f8f7f3;color:#292824;font:600 13px system-ui,sans-serif;text-decoration:none;box-shadow:0 2px 12px #0002;'; + document.body.append(link); + // The site's feedback control lives on the right and its bottom navigation + // occupies the first 64px. Keep this compact control on the left; when a + // conversation composer grows, lift it above the composer rather than + // covering the text field. Site dialogs/feedback panels remain above it. + let frame = 0; + const observed = new WeakSet(); + const liftedFeedback = new WeakSet(); + const resize = new ResizeObserver(() => schedule()); + const position = () => { + frame = 0; + let bottom = 78; + let composerVisible = false; + document.querySelectorAll('.chat-composer').forEach(composer => { + if (!observed.has(composer)) { observed.add(composer); resize.observe(composer); } + const rect = composer.getBoundingClientRect(); + if (rect.height && rect.bottom > 0 && rect.top < innerHeight) { + composerVisible = true; + bottom = Math.max(bottom, innerHeight - rect.top + 8); + } + }); + link.style.bottom = `max(calc(78px + env(safe-area-inset-bottom)),${Math.min(bottom, Math.max(78, innerHeight - 60))}px)`; + // Preserve a feedback button position the user has dragged. Its default + // mobile position otherwise covers the chat send button, so lift both + // default controls together while the composer is visible. + document.querySelectorAll('.feedback-launch').forEach(feedback => { + if (feedback.style.top && feedback.style.top !== 'auto') return; + if (composerVisible) { + feedback.style.bottom = link.style.bottom; + liftedFeedback.add(feedback); + } else if (liftedFeedback.has(feedback)) { + feedback.style.removeProperty('bottom'); + liftedFeedback.delete(feedback); + } + }); + }; + const schedule = () => { if (!frame) frame = requestAnimationFrame(position); }; + resize.observe(document.body); + new MutationObserver(schedule).observe(document.body, { childList: true, subtree: true }); + addEventListener('resize', schedule); + addEventListener('scroll', schedule, { passive: true, capture: true }); + visualViewport?.addEventListener('resize', schedule); + schedule(); + }; + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', addReturn, { once: true }); + else addReturn(); +})(); diff --git a/desktop/src-tauri/src/mobile.rs b/desktop/src-tauri/src/mobile.rs new file mode 100644 index 0000000..4774abc --- /dev/null +++ b/desktop/src-tauri/src/mobile.rs @@ -0,0 +1,125 @@ +use crate::{open_external, policy, require_launcher}; +use std::sync::RwLock; +use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; +use tauri_plugin_deep_link::DeepLinkExt; +use url::Url; + +#[derive(Default)] +struct Connection(RwLock>); + +#[tauri::command] +async fn connect(window: WebviewWindow, app: AppHandle, server: String) -> Result<(), String> { + require_launcher(&window)?; + let url = policy::server_origin(&server).map_err(str::to_owned)?; + // Production Android/iOS transport policy requires TLS. A debug build can + // use loopback for `adb reverse`, without allowing cleartext LAN servers. + if !cfg!(debug_assertions) && url.scheme() != "https" { + return Err("invalid_server".into()); + } + *app.state::() + .0 + .write() + .map_err(|_| "window_failed".to_owned())? = Some(url.clone()); + window.navigate(url).map_err(|_| "window_failed".into()) +} + +fn launcher_url() -> Url { + #[cfg(target_os = "android")] + let url = "https://tauri.localhost/index.html"; + #[cfg(target_os = "ios")] + let url = "tauri://localhost/index.html"; + Url::parse(url).expect("static local URL") +} + +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_deep_link::init()) + .plugin( + tauri_plugin_opener::Builder::new() + // Website links use our navigation policy. The plugin's + // automatic JS interceptor would require remote IPC permission. + .open_js_links_on_click(false) + .build(), + ) + .manage(Connection::default()) + .invoke_handler(tauri::generate_handler![connect, crate::open_browser]) + .setup(|app| { + let return_app = app.handle().clone(); + app.deep_link().on_open_url(move |event| { + let urls = event.urls(); + if urls.len() != 1 || !policy::login_return(&urls[0]) { + return; + } + // The OS foregrounds the existing app. A fixed, payload-free + // event only asks its already-open login page to poll sooner. + // Never import a URL/session or navigate on a cold start. + let Some(window) = return_app.get_webview_window("launcher") else { + return; + }; + if window.url().is_ok_and(|current| { + current.path() == "/auth/app" + && return_app + .state::() + .0 + .read() + .is_ok_and(|selected| { + selected.as_ref().is_some_and(|origin| { + policy::service_navigation(origin, ¤t) + }) + }) + }) { + let _ = window.eval("window.dispatchEvent(new Event('hireferry-app-return'))"); + } + }); + let navigation_app = app.handle().clone(); + WebviewWindowBuilder::new(app, "launcher", WebviewUrl::App("index.html".into())) + .title("HireFerry · 聘渡") + .use_https_scheme(true) + .initialization_script( + include_str!("mobile-bootstrap.js") + .replace("__HIREFERRY_LAUNCHER_URL__", launcher_url().as_str()), + ) + .on_navigation(move |next| { + if policy::launcher_url(next) { + return true; + } + let selected = navigation_app + .state::() + .0 + .read() + .ok() + .and_then(|selected| selected.clone()); + if let Some(origin) = selected { + if let Some(landing) = policy::login_landing(&origin, next) { + let app = navigation_app.clone(); + tauri::async_runtime::spawn(async move { + if let Some(window) = app.get_webview_window("launcher") { + let _ = window.navigate(landing); + } + }); + return false; + } + if policy::service_navigation(&origin, next) + && !policy::login_authorization(&origin, next) + { + return true; + } + } + if policy::web_url(next) { + let app = navigation_app.clone(); + let url = next.clone(); + // The browser holds its own Google CSRF/session cookies + // and explicitly approves the short-lived app request. + // Only the original WebView can redeem its HttpOnly proof. + std::thread::spawn(move || { + let _ = open_external(&app, &url); + }); + } + false + }) + .build()?; + Ok(()) + }) + .run(tauri::generate_context!()) + .expect("failed to run HireFerry mobile"); +} diff --git a/desktop/src-tauri/src/policy.rs b/desktop/src-tauri/src/policy.rs index 3bb9a06..719bad5 100644 --- a/desktop/src-tauri/src/policy.rs +++ b/desktop/src-tauri/src/policy.rs @@ -31,6 +31,40 @@ pub fn launcher_url(url: &Url) -> bool { && url.username().is_empty() && url.password().is_none() && url.port().is_none() + && matches!(url.path(), "" | "/" | "/index.html") + && url.query().is_none() + && url.fragment().is_none() +} + +#[cfg(any(mobile, test))] +pub fn login_return(url: &Url) -> bool { + url.scheme() == "hireferry" + && url.host_str() == Some("login-return") + && url.username().is_empty() + && url.password().is_none() + && url.port().is_none() + && matches!(url.path(), "" | "/") + && url.query().is_none() + && url.fragment().is_none() +} + +pub fn service_navigation(origin: &Url, next: &Url) -> bool { + web_url(next) && next.origin() == origin.origin() +} + +pub fn login_landing(origin: &Url, next: &Url) -> Option { + if !service_navigation(origin, next) || next.path() != "/api/auth/google/start" { + return None; + } + let mut landing = origin.clone(); + landing.set_path("/auth/app"); + landing.set_query(None); + landing.set_fragment(None); + Some(landing) +} + +pub fn login_authorization(origin: &Url, next: &Url) -> bool { + service_navigation(origin, next) && next.path() == "/auth/app/authorize" } #[cfg(test)] @@ -83,8 +117,89 @@ mod tests { "https://user@tauri.localhost/", "https://localhost/", "tauri://remote/", + "tauri://localhost/index.html?server=https://evil.example", + "tauri://localhost/index.html#token=secret", + "https://tauri.localhost/remote.html", ] { assert!(!launcher_url(&Url::parse(input).unwrap()), "{input}"); } } + + #[test] + fn mobile_service_navigation_keeps_the_exact_selected_origin() { + let selected = server_origin("https://selfhost.example:8443").unwrap(); + assert!(service_navigation( + &selected, + &Url::parse("https://selfhost.example:8443/login").unwrap() + )); + for input in [ + "https://selfhost.example/login", + "https://selfhost.example.evil.test:8443/", + "https://user@selfhost.example:8443/", + "http://selfhost.example:8443/", + "javascript:alert(1)", + "file:///etc/passwd", + ] { + assert!( + !service_navigation(&selected, &Url::parse(input).unwrap()), + "{input}" + ); + } + } + + #[test] + fn login_return_only_wakes_the_app_without_a_payload() { + for input in ["hireferry://login-return", "hireferry://login-return/"] { + assert!(login_return(&Url::parse(input).unwrap())); + } + for input in [ + "hireferry://login-return?code=untrusted", + "hireferry://login-return?server=https://evil.example", + "hireferry://login-return#token=untrusted", + "hireferry://user@login-return", + "hireferry://login-return:123", + "hireferry://login-return/anything", + "hireferry://login-return.evil.example", + "https://login-return", + ] { + assert!(!login_return(&Url::parse(input).unwrap()), "{input}"); + } + } + + #[test] + fn native_login_landing_cannot_import_a_redirect_or_external_origin() { + let selected = server_origin("https://selfhost.example:8443").unwrap(); + let google = Url::parse("https://selfhost.example:8443/api/auth/google/start?redirect=https://evil.test&app_request=untrusted#token").unwrap(); + assert_eq!( + login_landing(&selected, &google).unwrap().as_str(), + "https://selfhost.example:8443/auth/app" + ); + for input in [ + "https://selfhost.example/api/auth/google/start", + "https://selfhost.example.evil.test:8443/api/auth/google/start", + "https://user@selfhost.example:8443/api/auth/google/start", + "https://selfhost.example:8443/api/auth/google/start/extra", + "https://selfhost.example:8443/auth/app/authorize?request=opaque", + ] { + assert!(login_landing(&selected, &Url::parse(input).unwrap()).is_none()); + } + } + + #[test] + fn login_approval_is_the_exact_selected_service_browser_endpoint() { + let selected = server_origin("https://selfhost.example:8443").unwrap(); + assert!(login_authorization( + &selected, + &Url::parse("https://selfhost.example:8443/auth/app/authorize?request=opaque").unwrap() + )); + for input in [ + "https://selfhost.example/auth/app/authorize?request=opaque", + "https://selfhost.example.evil.test:8443/auth/app/authorize", + "https://user@selfhost.example:8443/auth/app/authorize", + "https://selfhost.example:8443/auth/app/authorize/extra", + "https://selfhost.example:8443/auth/app", + ] { + assert!(!login_authorization(&selected, &Url::parse(input).unwrap())); + } + } } diff --git a/desktop/src-tauri/tauri.android.conf.json b/desktop/src-tauri/tauri.android.conf.json new file mode 100644 index 0000000..753f314 --- /dev/null +++ b/desktop/src-tauri/tauri.android.conf.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "android": { + "minSdkVersion": 24, + "debugApplicationIdSuffix": ".debug" + } + } +} diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 94237ed..f4db2c4 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -15,6 +15,11 @@ "csp": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src ipc: http://ipc.localhost; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'" } }, + "plugins": { + "deep-link": { + "mobile": [{ "scheme": ["hireferry"], "host": "login-return", "appLink": false }] + } + }, "bundle": { "active": true, "targets": "all", diff --git a/desktop/src-tauri/tauri.ios.conf.json b/desktop/src-tauri/tauri.ios.conf.json new file mode 100644 index 0000000..1c0af30 --- /dev/null +++ b/desktop/src-tauri/tauri.ios.conf.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "iOS": { + "minimumSystemVersion": "15.0" + } + } +} diff --git a/desktop/ui/app.js b/desktop/ui/app.js index 0c69006..25b76eb 100644 --- a/desktop/ui/app.js +++ b/desktop/ui/app.js @@ -8,7 +8,7 @@ const messages = { connectDescription:'使用官方服务,或连接你自己的聘渡站点。', serverLabel:'服务地址', serverHelp:'使用 HTTPS 地址。本机开发可使用 http://localhost:3000。', connectButton:'进入工作台', browserButton:'在浏览器中打开', - loginNote:'桌面内可使用邮箱验证码登录。Google 登录请在浏览器中完成;浏览器与桌面的登录状态各自独立。', + loginNote:'桌面内可使用邮箱验证码登录。Google 登录请在浏览器中确认账号并批准,再切回此应用继续。自托管站点需支持应用登录授权。', footer:'人类 HR 与 AI,协作同行。', footerHelp:'连接设置可随时在应用菜单中重新打开。', opening:'正在打开工作台…', opened:'工作台已打开。若页面未加载,可在应用菜单中刷新或切换服务地址。', browserOpened:'已在默认浏览器中打开。', invalid_server:'请输入完整的 HTTPS 站点地址,不包含账号、路径、查询参数或片段。本机允许 HTTP。', @@ -24,7 +24,7 @@ const messages = { connectDescription:'Use the official service or connect to your own HireFerry deployment.', serverLabel:'Server address', serverHelp:'Use HTTPS. Local development can use http://localhost:3000.', connectButton:'Open workspace', browserButton:'Open in browser', - loginNote:'Email-code sign-in works in the app. Use your browser for Google sign-in. Browser and desktop sessions stay separate.', + loginNote:'Use email-code sign-in in the app. For Google, confirm your account and approve in your browser, then switch back to this app. Self-hosted servers need app sign-in support.', footer:'Human HR and AI, working together.', footerHelp:'Reopen connection settings from the application menu.', opening:'Opening your workspace…', opened:'Workspace opened. Use the application menu to reload or change servers if the page does not load.', browserOpened:'Opened in your default browser.', invalid_server:'Enter a full HTTPS origin without credentials, paths, query parameters or fragments. HTTP is allowed on loopback only.', @@ -34,21 +34,55 @@ const messages = { unavailable:'Open this page in the HireFerry desktop application.', unexpected:'The action did not complete. Please try again.' } }; +const mobile = window.__HIREFERRY_MOBILE__ === true; +if (mobile) { + document.documentElement.dataset.platform = 'mobile'; + document.getElementById('platform-label').textContent = /Android/i.test(navigator.userAgent) ? 'ANDROID' : 'iOS'; + Object.assign(messages.zh, { + eyebrow:'随身的聘渡工作台', + lead:'在手机上,继续每一次沟通。\n连接你的聘渡,开启下一段旅程。', + serverHelp:'使用 HTTPS 站点地址。自托管站点需要可信的 TLS 证书。', + loginNote:'可在应用内使用邮箱验证码登录。Google 登录会打开系统浏览器,确认账号并批准后,点击“返回应用”即可继续。自托管站点需支持应用登录授权。', + footerHelp:'站点左下方的“连接”可返回这里。若登录返回时看到此页,请连接站点并重新发起登录。', + opened:'正在进入工作台。', + not_allowed:'只能在聘渡应用的连接页面执行此操作。', + unavailable:'请在 HireFerry 应用中打开此页面。' + }); + Object.assign(messages.en, { + eyebrow:'YOUR WORKSPACE, ON THE GO', + lead:'Keep the conversation moving.\nConnect to HireFerry, wherever you are.', + serverHelp:'Use an HTTPS origin. Self-hosted services need a trusted TLS certificate.', + loginNote:'Use email-code sign-in in the app. For Google, confirm your account and approve in your browser, then tap “Return to app”. Self-hosted servers need app sign-in support.', + footerHelp:'Use “Connection” near the bottom-left to return here. If sign-in returns to this screen, reconnect and start sign-in again.', + opened:'Opening your workspace…', + not_allowed:'This action is available only from the HireFerry connection screen.', + unavailable:'Open this page in the HireFerry application.' + }); +} const storage = { get(key) { try { return localStorage.getItem(key); } catch { return null; } }, - set(key, value) { try { localStorage.setItem(key, value); } catch { /* A session still works without storage. */ } } + set(key, value) { try { localStorage.setItem(key, value); } catch { /* A session still works without storage. */ } }, + remove(key) { try { localStorage.removeItem(key); } catch { /* A session still works without storage. */ } } }; let language = storage.get('hireferry.desktop.language') || (navigator.language.startsWith('zh') ? 'zh' : 'en'); if (!messages[language]) language = 'en'; let statusKey = '', statusError = false; const server = document.getElementById('server'); -server.value = storage.get('hireferry.desktop.server') || 'https://hireferry.lmm.best'; +const canonicalServerOrigin = window.HireFerryConnection.canonicalServerOrigin; +try { + server.value = canonicalServerOrigin(storage.get('hireferry.desktop.server') || 'https://hireferry.lmm.best'); +} catch { + // Remove invalid legacy values rather than displaying credentials or query + // tokens saved by an earlier build in the connection form. + storage.remove('hireferry.desktop.server'); + server.value = 'https://hireferry.lmm.best'; +} function render() { document.documentElement.lang = language === 'zh' ? 'zh-CN' : 'en'; for (const element of document.querySelectorAll('[data-i18n]')) { const lines = messages[language][element.dataset.i18n].split('\n'); - element.replaceChildren(...lines.flatMap((text, index) => index ? [document.createElement('br'), document.createTextNode(text)] : [document.createTextNode(text)])); + element.replaceChildren(...lines.flatMap((text, index) => index ? [document.createTextNode(language === 'en' ? ' ' : ''), document.createElement('br'), document.createTextNode(text)] : [document.createTextNode(text)])); } const toggle = document.getElementById('language'); toggle.textContent = language === 'zh' ? 'EN' : '中文'; @@ -67,7 +101,10 @@ async function run(command) { buttons.forEach(button => { button.disabled = true; }); if (command === 'connect') status('opening'); try { - const address = server.value.trim(); + const address = canonicalServerOrigin(server.value); + // Mobile navigation may unload before invoke resolves. Persist only the + // canonical origin, after rejecting credentials, paths, query and fragment. + if (mobile) storage.set('hireferry.desktop.server', address); await invoke(command, { server: address }); storage.set('hireferry.desktop.server', address); status(command === 'connect' ? 'opened' : 'browserOpened'); diff --git a/desktop/ui/index.html b/desktop/ui/index.html index bb4a7c1..0591491 100644 --- a/desktop/ui/index.html +++ b/desktop/ui/index.html @@ -2,9 +2,10 @@ - + HireFerry · 聘渡 + @@ -41,7 +42,7 @@

让人才与机会,
在此相遇。 准备就绪 - DESKTOP + DESKTOP

从这里出发

使用官方服务,或连接你自己的聘渡站点。

diff --git a/desktop/ui/server-origin.js b/desktop/ui/server-origin.js new file mode 100644 index 0000000..077f899 --- /dev/null +++ b/desktop/ui/server-origin.js @@ -0,0 +1,15 @@ +(() => { + 'use strict'; + function canonicalServerOrigin(input) { + let url; + try { url = new URL(input.trim()); } catch { throw 'invalid_server'; } + const loopback = url.hostname === 'localhost' || url.hostname === '[::1]' || /^127\.\d+\.\d+\.\d+$/.test(url.hostname); + if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) || + url.username || url.password || url.port === '0' || url.pathname !== '/' || + url.search || url.hash || /[?#]/.test(url.href)) throw 'invalid_server'; + return url.origin; + } + // Input hygiene before persistence. Rust independently enforces navigation + // policy and the capability system enforces native authorization. + window.HireFerryConnection = Object.freeze({ canonicalServerOrigin }); +})(); diff --git a/desktop/ui/style.css b/desktop/ui/style.css index 6e9a682..64bef9b 100644 --- a/desktop/ui/style.css +++ b/desktop/ui/style.css @@ -57,3 +57,18 @@ footer { display:flex; justify-content:space-between; align-items:center; flex-w @media (max-width:760px) { .shell { padding:24px; } main { gap:24px; grid-template-columns:.9fr 1.1fr; padding-top:32px; } .connection { padding:22px; } h1 { font-size:32px; } .brand { font-size:17px; } .lead { font-size:12px; } .voyage { margin-top:15px; } } @media (max-width:580px) { main { grid-template-columns:1fr; padding-top:30px; } .intro .voyage { display:none; } .intro h1 br,.intro .lead br { display:none; } h1 { font-size:30px; margin-bottom:12px; } .eyebrow { margin-bottom:12px; } .connection { max-width:420px; width:100%; margin:auto; } .brand-en { font-size:15px; } } @media (prefers-reduced-motion:reduce) { *,*::before,*::after { animation:none!important; transition:none!important; } } + +/* Shared assets also serve Android/iOS. Respect the camera cutout, keyboard, + accessible touch targets, and iOS input zoom without a second UI bundle. */ +[data-platform="mobile"] .shell { min-height:100svh; padding: max(20px,env(safe-area-inset-top)) max(20px,env(safe-area-inset-right)) max(22px,env(safe-area-inset-bottom)) max(20px,env(safe-area-inset-left)); } +[data-platform="mobile"] main { grid-template-columns:1fr; max-width:540px; margin:0 auto; padding-top:24px; gap:24px; } +[data-platform="mobile"] .voyage { display:none; } +[data-platform="mobile"] .intro .lead br { display:none; } +[data-platform="mobile"] .eyebrow { margin-bottom:12px; } +[data-platform="mobile"] h1 { font-size:clamp(28px,7vw,40px); margin-bottom:12px; } +[data-platform="mobile"] .connection { padding:22px; max-width:none; } +[data-platform="mobile"] input { font-size:16px; min-height:48px; } +[data-platform="mobile"] .primary,[data-platform="mobile"] .browser,[data-platform="mobile"] .language { min-height:44px; font-size:14px; } +[data-platform="mobile"] .field-help,[data-platform="mobile"] .login-note p,[data-platform="mobile"] .form-description { font-size:12px; } +[data-platform="mobile"] footer { font-size:12px; line-height:1.6; } +[data-platform="mobile"] .footer-help { font-size:11px; } diff --git a/docs/desktop.md b/docs/desktop.md index 1ba9747..43b6d19 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -89,7 +89,9 @@ CLI 的一键安装、登录、升级与卸载见 [CLI 文档](cli.md)。它继 远程业务窗口不获得本地文件、Shell 或 Tauri 命令权限。外部站点链接交给系统浏览器;本地启动器仅获得连接站点所需的最小权限。 -桌面窗口内可使用站点提供的邮箱验证码登录。Google 等第三方提供方可能拒绝嵌入式 WebView;系统浏览器与桌面窗口的会话也不自动共享。此版本没有加入浏览器 OAuth 会话回传桥接,使用此类登录时应在浏览器里继续访问站点。不要把“浏览器成功登录”误认为桌面窗口也已登录。 +桌面窗口内可使用站点提供的邮箱验证码登录。Google 登录需要支持 `/auth/app` 的新版服务器:客户端把所选站点的 Google 登录入口导向桌面窗口内的授权过渡页,服务器创建短时请求并将校验凭据放在该窗口的 HttpOnly Cookie 中。授权页在系统浏览器打开,用户确认当前身份并明确批准;原桌面窗口凭自己的 Cookie 轮询、原子兑换新的普通用户会话。浏览器和桌面不共享长期会话,也不通过 URL、localStorage 或原生命令转交 Cookie。管理员、bootstrap 权限和 CLI token 不参与此流程。 + +浏览器批准后切回原桌面窗口即可;窗口重新获得焦点时会提醒现有 `/auth/app` 页面立即查询授权状态。桌面版不注册手机使用的 `hireferry://login-return` 协议,因此请直接切回桌面应用。若已关闭应用或重新连接另一站点,请重新发起授权。旧版自托管服务器需先升级;也可继续使用邮箱验证码登录。外部网页、相似域名或带凭据的 URL 都不能借该流程获得本地权限。 桌面壳的更新通过下载新版本安装包完成,Flatpak 通过重新安装新附件更新。这里的“自动更新”指**发布文档和下载矩阵自动生成**,并没有配置未签名的应用内自动更新器。 diff --git a/docs/mobile.md b/docs/mobile.md new file mode 100644 index 0000000..5c34e3d --- /dev/null +++ b/docs/mobile.md @@ -0,0 +1,107 @@ +# Android 与 iOS 客户端 + +移动客户端复用 `desktop/` 下的 Tauri 2 Rust 核心和静态启动页;手机上使用单个系统 WebView。默认连接 `https://hireferry.lmm.best`,也可手动输入带可信 TLS 证书的自托管 HTTPS 站点。业务数据保留在所选服务器,离线时只能打开本地连接页。 + +**调试包、模拟器包和正式分发包是不同产物。** PR 工作流 `Mobile engineering builds` 生成 Android 调试 APK/AAB 和 iOS 模拟器 `.app.tar.gz`。这些包用于工程验证,不进入桌面 `downloads.json`,也不表示已经上架 Google Play、App Store 或 TestFlight。可下载产物和通过的实际运行记录以对应 GitHub Actions run 的附件为准。 + +## 平台与产物 + +| 平台 | 本轮构建目标 | 产物 | 安装范围 | +| --- | --- | --- | --- | +| Android 7.0 / API 24 起 | ARM64;PR 另含 x86_64 模拟器架构 | `.apk`、`.aab` | 调试 APK 可侧载;AAB 交由商店处理,不能直接安装 | +| iOS 15 起 | iPhone/iPad ARM64 | 维护者签名 `.ipa` | 需要实际 Apple 签名身份、匹配配置文件及分发渠道 | +| iOS 模拟器 | 根据 macOS runner 架构构建 ARM64 或 x86_64 | `.app.tar.gz` | 只适用于相应架构的 Xcode Simulator,不能安装到真机 | + +Rust 使用共享 `lib.rs` 的移动入口,桌面 `main.rs` 继续生成 `hireferry-desktop`,并保留 `--version` / `--smoke-test`。菜单和桌面多窗口代码通过 `cfg(desktop)` 隔离。平台原生工程由锁定的 Tauri CLI 生成到 `desktop/src-tauri/gen/`,构建脚本再加入环境变量签名配置和现有 SVG 生成的图标;生成目录不提交到版本控制。参见 [Tauri 项目结构](https://v2.tauri.app/start/project-structure/)与[移动平台前置条件](https://v2.tauri.app/start/prerequisites/#configure-for-mobile-targets)。 + +## 登录、返回和权限边界 + +在应用 WebView 中使用聘渡的邮箱验证码登录。服务端发放的 HttpOnly 会话 Cookie 留在该 WebView 的所选站点 Cookie 存储中。应用不读取或导出登录 Cookie,不将它放进 URL、localStorage、日志或 native command 参数。 + +Google 登录需要支持 `/auth/app` 的新版服务器。应用把所选站点的 Google 登录入口导向 WebView 内的 `/auth/app` 过渡页,由服务器创建短时授权请求,并把校验凭据放进该 WebView 的 HttpOnly Cookie。随后只将 `/auth/app/authorize?request=…` 打开到系统浏览器。浏览器自己完成 Google OAuth、显示当前身份并要求明确批准;原 WebView 使用自己的 Cookie 轮询同源兑换接口,服务器原子消费授权后设置普通用户会话。管理员或 bootstrap 权限不通过该入口转移,已有 CLI token 也不参与。旧版自托管服务器需先升级;也可继续使用邮箱验证码或在浏览器中独立使用服务。 + +批准页的“返回应用”使用固定 `hireferry://login-return` 唤回应用。协议由移动端 deep-link 插件注册;Rust 只接受无账号、端口、路径、查询或片段的固定标记(也接受末尾斜杠),不会接受 server、redirect、code 或 session。暖启动保持原 WebView 和原站点,只提醒现有 `/auth/app` 页面立即再轮询一次;系统若已回收应用,冷启动仍打开本地连接页,需要重新连接并发起登录。唤回链接本身不证明登录成功,也不授予任何原生命令权限。见 [Tauri Deep Linking](https://v2.tauri.app/plugin/deep-linking/)。 + +手机站点左下方的“连接”控件只会回到内置连接页。控件至少有 44px 点击高度,避开底部安全区和右侧反馈入口;聊天输入区展开时会自动上移。它直接导航到构建时固定的本地资源地址,不接收服务器地址、跳转目标、code 或 token,也不调用原生命令。这个本地导航与上面的系统登录唤回协议相互独立。这个 DOM 控件只是方便返回,所选网站可能改变自己的 DOM。若页面加载失败或控件消失,重新启动应用会重新打开本地连接页,不会自动连接历史站点。 + +只有本地连接页拥有 `connect` 和 `open_browser` 两个命令权限,且 Rust 再检查窗口标签和本地 URL。远程站点即使复用同一个窗口,也不拥有 native capability;不允许从远程内容打开文件、执行 shell 或更改本地设置。工作页只允许所选精确 origin 内的 HTTPS 导航;其他 HTTPS 网页交给系统浏览器,危险协议和含账号密码的 URL 被拒绝。移动端不依赖 Tauri 桌面的 `on_new_window` 回调(该回调不支持 Android/iOS)。普通 `target="_blank"` 链接通过同一导航规则处理。参见 [Tauri Capabilities](https://v2.tauri.app/security/capabilities/)和 [Opener](https://v2.tauri.app/plugin/opener/)。 + +## Android 本地构建 + +需要 Rust **1.90 或更新版本**、Node.js / npm、JDK 21、Android SDK command-line tools 22.0。与锁定的 `@tauri-apps/cli` 2.12.2 模板一致,本脚本固定 API 37(SDK 包路径为 `platforms;android-37.0`)、Build Tools 37.0.0、NDK 29.0.13846066;安装时使用 Android SDK Manager,不隐式接受缺少的 SDK 版本。旧版 command-line tools 16.0 无法识别新的 major.minor SDK 包路径;CI 显式使用 22.0(Google 下载编号 15859902),不依赖 setup action 的旧默认值。 + +```sh +cd desktop +npm ci +cd .. + +# ANDROID_HOME 指向已安装的 SDK;JAVA_HOME 指向 JDK 21。 +sdkmanager 'platforms;android-37.0' 'build-tools;37.0.0' 'ndk;29.0.13846066' 'platform-tools' +export NDK_HOME="$ANDROID_HOME/ndk/29.0.13846066" + +python3 scripts/mobile.py doctor android +python3 scripts/mobile.py build android --variant debug --target aarch64 --format both +``` + +脚本检查前置工具、安装指定 Rust target、运行 `tauri android init`、生成图标、构建 APK/AAB 并验证签名。它把验证后的包、SHA-256 和 `mobile-artifacts.json` 放在 `artifacts/mobile/`。调试包 application ID 为 `best.lmm.hireferry.debug`,与正式包 `best.lmm.hireferry` 分开;调试签名可能随开发机器或 CI runner 改变,因此不能作为可靠的正式更新渠道。 + +要验证模拟器,可构建 `--target x86_64`(或重复指定 ARM64 + x86_64 得到通用包),然后运行: + +```sh +python3 scripts/mobile.py smoke-android artifacts/mobile/hireferry-android-x86_64-debug.apk +``` + +设备必须已经通过 `adb devices` 可见;请使用隔离的测试模拟器。冒烟检查会安装调试 APK、启动 Activity、操作实际 WebView 连接表单,通过 `adb reverse` 连接隔离的本机测试服务,尝试远程 `connect` 并确认拒绝,检查返回与反馈按钮不重叠且反馈可点击,再验证暖启动登录唤回保留页面、返回连接设置、冷启动唤回保持本地页面。它检查 AndroidRuntime 致命异常,并保存各阶段 UI XML、截图和记录;失败时也保留 logcat 与进程退出原因。测试服务没有真实账号,也不访问生产或创建业务数据。该检查不能替代真实 Google 账号授权、真机键盘、文件选择、无障碍、断网恢复等测试。 + +Android 发布版使用可信 HTTPS。调试版额外允许明确的本机 loopback HTTP,供 `adb reverse tcp:3000 tcp:3000` 连接宿主开发服务;`http://192.168.*` 等明文 LAN 地址仍被 Rust 拒绝。生产版不放开全局 cleartext transport。 + +### Android 正式签名 + +不会自动生成或冒充正式签名密钥。维护者须安全保存长期签名身份,并在构建环境设置: + +| 环境变量 | 内容 | +| --- | --- | +| `HF_ANDROID_KEYSTORE_PATH` | 真实 keystore 文件的绝对路径 | +| `HF_ANDROID_KEYSTORE_PASSWORD` | keystore 密码 | +| `HF_ANDROID_KEY_ALIAS` | 签名 key alias | +| `HF_ANDROID_KEY_PASSWORD` | key 密码 | +| `HF_ANDROID_CERT_SHA256` | 预期签名证书 SHA-256,可含冒号 | + +```sh +python3 scripts/mobile.py build android --variant release --target aarch64 --format both +``` + +脚本通过环境变量把凭据交给 Gradle,不写进版本库中的配置。APK 用 `apksigner verify`,AAB 用 `jarsigner` / `keytool` 检查签名;证书摘要必须与维护者给出的值一致。缺少凭据或证书不匹配即失败,不把未签名包标成可安装正式包。Google Play 仍需 Play Console 账号、正确配置 Play App Signing、商店资料与审核;参见 [Tauri Android 签名](https://v2.tauri.app/distribute/sign/android/)和 [Google Play 打包](https://v2.tauri.app/distribute/google-play/)。 + +## iOS 本地构建 + +iOS 原生编译需要 **macOS 和完整 Xcode**,Linux 上不能把交叉编译命令等同于可用 IPA。还需 CocoaPods 以及锁定的 npm CLI。无分发证书时可以先构建模拟器: + +```sh +cd desktop && npm ci && cd .. +brew install cocoapods +python3 scripts/mobile.py doctor ios +python3 scripts/mobile.py build ios --simulator --variant debug +``` + +模拟器架构默认跟随宿主,Apple Silicon 使用 `aarch64-sim`,Intel 使用 `x86_64`。产物是可供 `xcrun simctl install` 安装的 `.app` 压缩包,不是用于 iPhone 侧载的 IPA;无需填写假的 Team ID 或生成假的 Apple 分发身份。 + +真机分发需要实际 Apple Developer Team、签名证书和匹配 `best.lmm.hireferry` 的 provisioning profile,可来自本机 Xcode 签名配置,也可使用锁定 CLI 支持的 `IOS_CERTIFICATE`(Base64 p12)、`IOS_CERTIFICATE_PASSWORD`、`IOS_MOBILE_PROVISION`(Base64 profile)环境变量。`APPLE_DEVELOPMENT_TEAM` 必须为真实 Team ID。 + +```sh +python3 scripts/mobile.py build ios --variant release --target aarch64 \ + --build-number 1 --export-method app-store-connect +``` + +`release-testing` 和 `debugging` 只适用于匹配的 Ad Hoc / Development 配置文件;设备注册、证书和 profile 由维护者管理。构建后脚本展开 IPA,验证 codesign、Team ID、bundle ID 和嵌入的配置文件存在。`mobile-artifacts.json` 记录实际包摘要和签名检查结果。它不自动上传 App Store,也不宣称通过 Apple 审核;参见 [Tauri iOS 签名](https://v2.tauri.app/distribute/sign/ios/)和 [App Store 构建](https://v2.tauri.app/distribute/app-store/#ios)。 + +## CI 与发布凭据 + +`mobile-build.yml` 对修改客户端、脚本和对应工作流的 PR 自动运行,不读取发行签名 secrets。Android job 构建 ARM64 + x86_64 调试 APK/AAB,然后启动 API 35 模拟器侧载 APK,执行上述连接、远程权限隔离和返回闭环。iOS job 在 macOS runner 上构建无发行签名的模拟器应用,执行安装和启动并保存截图。测试附件保留 14 天,文件名和 manifest 明确标记工程测试。 + +`mobile-release.yml` 只允许维护者手动触发,生成签名产物附件,不自动发布到商店。请先在仓库设置 **`mobile-signing` environment**,限制可使用的分支/标签并配置所需审批,再提供凭据: + +- Android secrets:`ANDROID_KEYSTORE_BASE64`、`ANDROID_KEYSTORE_PASSWORD`、`ANDROID_KEY_ALIAS`、`ANDROID_KEY_PASSWORD`;environment variable:`ANDROID_CERT_SHA256`。 +- iOS secrets:`IOS_CERTIFICATE`、`IOS_CERTIFICATE_PASSWORD`、`IOS_MOBILE_PROVISION`;environment variable:`APPLE_DEVELOPMENT_TEAM`。 + +Android keystore 只解码到 runner 临时目录并在任务结束清理。iOS 临时 keychain/profile 由 Tauri 的签名实现管理。不要把签名文件、密码或会话 token 提交到仓库。维护者仍需管理商店账号、版本递增、签名身份连续性和最终发布选择。 diff --git a/scripts/mobile.py b/scripts/mobile.py new file mode 100644 index 0000000..0c9b7fd --- /dev/null +++ b/scripts/mobile.py @@ -0,0 +1,613 @@ +#!/usr/bin/env python3 +"""Build real Tauri mobile artifacts; no credentials are written into the repository. + +PR builds use Android debug signing / unsigned iOS simulator apps. Release builds +require maintainer-owned credentials and remain separate from desktop downloads. +""" +from __future__ import annotations + +import argparse +from contextlib import contextmanager +import hashlib +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +import os +from pathlib import Path +import platform +import plistlib +import re +import shutil +import subprocess +import sys +import tarfile +import tempfile +import threading +import time +import xml.etree.ElementTree as ET +import zipfile + +ROOT = Path(__file__).resolve().parents[1] +DESKTOP = ROOT / "desktop" +TAURI = DESKTOP / "src-tauri" +ANDROID_SDK = "37.0" +ANDROID_BUILD_TOOLS = "37.0.0" +ANDROID_NDK = "29.0.13846066" +ANDROID_TARGETS = { + "aarch64": "aarch64-linux-android", + "x86_64": "x86_64-linux-android", + "armv7": "armv7-linux-androideabi", + "i686": "i686-linux-android", +} +IOS_TARGETS = { + "aarch64": "aarch64-apple-ios", + "aarch64-sim": "aarch64-apple-ios-sim", + "x86_64": "x86_64-apple-ios", +} +ANDROID_SIGNING = ( + "HF_ANDROID_KEYSTORE_PATH", "HF_ANDROID_KEYSTORE_PASSWORD", + "HF_ANDROID_KEY_ALIAS", "HF_ANDROID_KEY_PASSWORD", "HF_ANDROID_CERT_SHA256", +) +SIGNING_MARKER = "// HireFerry release signing (environment only; generated by scripts/mobile.py)." +SIGNING_GRADLE = ''' +// HireFerry release signing (environment only; generated by scripts/mobile.py). +val hfSigningKeys = listOf("HF_ANDROID_KEYSTORE_PATH", "HF_ANDROID_KEYSTORE_PASSWORD", "HF_ANDROID_KEY_ALIAS", "HF_ANDROID_KEY_PASSWORD") +if (hfSigningKeys.all { !System.getenv(it).isNullOrBlank() }) { + android { + signingConfigs { + create("hireferryRelease") { + storeFile = file(System.getenv("HF_ANDROID_KEYSTORE_PATH")) + storePassword = System.getenv("HF_ANDROID_KEYSTORE_PASSWORD") + keyAlias = System.getenv("HF_ANDROID_KEY_ALIAS") + keyPassword = System.getenv("HF_ANDROID_KEY_PASSWORD") + } + } + buildTypes.getByName("release").signingConfig = signingConfigs.getByName("hireferryRelease") + } +} +''' + + +class MobileError(RuntimeError): + pass + + +def run(args, *, cwd=DESKTOP, capture=False, env=None): + # Never echo environment values: signing passwords are intentionally not argv. + print("+ " + " ".join(str(v) for v in args), flush=True) + result = subprocess.run([str(v) for v in args], cwd=cwd, env=env, + text=True, capture_output=capture) + if result.returncode: + if capture: + print(result.stdout, end="", file=sys.stderr) + print(result.stderr, end="", file=sys.stderr) + raise MobileError(f"{args[0]} exited with status {result.returncode}") + return result.stdout if capture else "" + + +def cli(*args): + # Use the checked-in lockfile's CLI; do not let npx silently fetch a version. + # The npm lifecycle environment also lets Tauri generate a working command + # for Xcode/Gradle to re-enter the CLI from their nested project directory. + # Invoking .bin/tauri directly generates `node tauri ...`, which cannot find + # its script when Xcode executes the Build Rust Code phase in gen/apple. + binary = DESKTOP / "node_modules" / ".bin" / ("tauri.cmd" if os.name == "nt" else "tauri") + if not binary.exists(): + raise MobileError("Install the pinned CLI first: cd desktop && npm ci") + return ["npm.cmd" if os.name == "nt" else "npm", "run", "tauri", "--", *args] + + +def sdk_root(): + value = os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT") + if not value or not Path(value).is_dir(): + raise MobileError("Set ANDROID_HOME to an installed Android SDK") + return Path(value) + + +def android_tool(name): + suffix = ".bat" if os.name == "nt" and name == "apksigner" else (".exe" if os.name == "nt" else "") + folder = "platform-tools" if name == "adb" else f"build-tools/{ANDROID_BUILD_TOOLS}" + path = sdk_root() / folder / f"{name}{suffix}" + if not path.is_file(): + raise MobileError(f"Missing Android tool: {path}") + return path + + +def doctor(target_platform): + missing = [] + for command in ("node", "npm", "cargo", "rustup", "java") if target_platform == "android" else ("node", "npm", "cargo", "rustup", "xcodebuild", "xcrun", "pod"): + if not shutil.which(command): + missing.append(command) + if target_platform == "ios" and platform.system() != "Darwin": + missing.append("macOS + full Xcode (iOS builds cannot run on Linux/Windows)") + if target_platform == "android": + try: + root = sdk_root() + ndk = Path(os.environ.get("NDK_HOME", root / "ndk" / ANDROID_NDK)) + for item in (root / "platforms" / f"android-{ANDROID_SDK}", + root / "build-tools" / ANDROID_BUILD_TOOLS, ndk / "source.properties"): + if not item.exists(): + missing.append(str(item)) + except MobileError as error: + missing.append(str(error)) + if missing: + raise MobileError("Missing prerequisites:\n - " + "\n - ".join(missing)) + print(f"{target_platform}: host tools found; compile and device checks run during build/smoke") + + +def prepare_android_signing(project): + path = project / "app" / "build.gradle.kts" + content = path.read_text() + if SIGNING_MARKER not in content: + path.write_text(content + SIGNING_GRADLE) + + +def require_android_signing(): + missing = [key for key in ANDROID_SIGNING if not os.environ.get(key)] + if missing: + raise MobileError("Android release requires maintainer signing: " + ", ".join(missing)) + if not Path(os.environ["HF_ANDROID_KEYSTORE_PATH"]).is_file(): + raise MobileError("HF_ANDROID_KEYSTORE_PATH must identify a real keystore") + fingerprint = os.environ["HF_ANDROID_CERT_SHA256"].replace(":", "").lower() + if not re.fullmatch(r"[0-9a-f]{64}", fingerprint): + raise MobileError("HF_ANDROID_CERT_SHA256 must be the expected certificate SHA-256") + return fingerprint + + +def initialize(target_platform): + doctor(target_platform) + project = TAURI / "gen" / ("android" if target_platform == "android" else "apple") + marker = project / ("settings.gradle" if target_platform == "android" else "project.yml") + # Tauri currently emits settings.gradle, but accept a Kotlin DSL template too. + initialized = marker.exists() or (target_platform == "android" and (project / "settings.gradle.kts").exists()) + if not initialized: + run(cli(target_platform, "init", "--ci", "--skip-targets-install")) + if target_platform == "android": + prepare_android_signing(project) + # Generate platform icons from the checked-in vector without rewriting the + # desktop icon set. The platform project itself is reproducible/ignored. + icons = TAURI / "gen" / "mobile-icons" + run(cli("icon", str(TAURI / "icons" / "icon.svg"), "--output", str(icons))) + if target_platform == "android": + shutil.copytree(icons / "android", project / "app" / "src" / "main" / "res", dirs_exist_ok=True) + else: + shutil.copytree(icons / "ios", project / "Assets.xcassets" / "AppIcon.appiconset", dirs_exist_ok=True) + return project + + +def sha256(path): + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def verify_android(path, expected_cert=None): + if path.suffix == ".apk": + output = run([android_tool("apksigner"), "verify", "--verbose", "--print-certs", path], capture=True) + found = re.search(r"certificate SHA-256 digest:\s*([0-9a-fA-F:]+)", output) + if not found: + raise MobileError("APK signature verifier returned no certificate digest") + fingerprint = found.group(1).replace(":", "").lower() + details = run([android_tool("aapt"), "dump", "badging", path], capture=True) + package = re.search(r"package: name='([^']+)' versionCode='([^']+)' versionName='([^']+)'", details) + if not package: + raise MobileError("Unable to read APK package/version metadata") + metadata = {"package_id": package.group(1), "version_code": int(package.group(2)), "version": package.group(3)} + elif path.suffix == ".aab": + verify_env = dict(os.environ, LC_ALL="C", LANG="C") + output = run(["jarsigner", "-verify", path], capture=True, env=verify_env) + if "jar verified" not in output.lower(): + raise MobileError("AAB is unsigned or its JAR signature did not verify") + certificate = run(["keytool", "-printcert", "-jarfile", path], capture=True, env=verify_env) + found = re.search(r"SHA256:\s*([0-9a-fA-F:]+)", certificate) + if not found: + raise MobileError("AAB signature has no readable SHA-256 certificate digest") + fingerprint = found.group(1).replace(":", "").lower() + metadata = {} + else: + raise MobileError("Android artifact must be .apk or .aab") + if expected_cert and fingerprint != expected_cert: + raise MobileError("Signing certificate differs from HF_ANDROID_CERT_SHA256") + return {**metadata, "certificate_sha256": fingerprint, "signature_verified": True} + + +def verify_ios(path, expected_team): + with tempfile.TemporaryDirectory(prefix="hireferry-ipa-") as directory: + root = Path(directory) + with zipfile.ZipFile(path) as archive: + for name in archive.namelist(): + if Path(name).is_absolute() or ".." in Path(name).parts: + raise MobileError("IPA contains an unsafe archive path") + # Preserve executable bits and framework symlinks for codesign. + run(["ditto", "-x", "-k", path, root]) + applications = list((root / "Payload").glob("*.app")) + if len(applications) != 1: + raise MobileError("IPA must contain exactly one application") + application = applications[0] + run(["codesign", "--verify", "--deep", "--strict", application]) + # codesign emits public signing metadata on stderr, including on success. + result = subprocess.run(["codesign", "-d", "--verbose=4", str(application)], capture_output=True, text=True, check=True) + match = re.search(r"^TeamIdentifier=(.+)$", result.stderr, re.MULTILINE) + if not match or match.group(1) != expected_team: + raise MobileError("IPA signing team differs from APPLE_DEVELOPMENT_TEAM") + with (application / "Info.plist").open("rb") as source: + info = plistlib.load(source) + if info.get("CFBundleIdentifier") != "best.lmm.hireferry": + raise MobileError("IPA application ID differs from best.lmm.hireferry") + profile = application / "embedded.mobileprovision" + if not profile.is_file(): + raise MobileError("Signed iOS application has no embedded provisioning profile") + return {"signature_verified": True, "team_id": expected_team, + "package_id": info["CFBundleIdentifier"], + "version": info.get("CFBundleShortVersionString"), + "build_number": info.get("CFBundleVersion")} + + +def write_manifest(output, records, args): + manifest = { + "schema_version": 1, "platform": args.platform, "variant": args.variant, + "distribution": "engineering-test" if args.variant == "debug" or args.simulator else "maintainer-signed", + "simulator_only": bool(args.platform == "ios" and args.simulator), + "targets": args.target, "artifacts": records, + } + (output / "mobile-artifacts.json").write_text(json.dumps(manifest, indent=2) + "\n") + (output / "SHA256SUMS").write_text("".join(f"{row['sha256']} {row['filename']}\n" for row in records)) + print(f"Verified {len(records)} artifact(s): {output}") + + +def build(args): + if args.platform == "android": + expected = require_android_signing() if args.variant == "release" else None + targets = args.target or ["aarch64"] + mapping = ANDROID_TARGETS + else: + if args.simulator: + targets = args.target or ["aarch64-sim" if platform.machine() == "arm64" else "x86_64"] + if any(t not in ("aarch64-sim", "x86_64") for t in targets): + raise MobileError("--simulator only accepts aarch64-sim or x86_64") + else: + targets = args.target or ["aarch64"] + if targets != ["aarch64"]: + raise MobileError("iOS device builds use --target aarch64") + if not re.fullmatch(r"[A-Z0-9]{10}", os.environ.get("APPLE_DEVELOPMENT_TEAM", "")): + raise MobileError("iOS device signing requires a real APPLE_DEVELOPMENT_TEAM and configured signing credentials") + mapping = IOS_TARGETS + expected = None + if len(set(targets)) != len(targets) or any(t not in mapping for t in targets): + raise MobileError(f"Invalid or duplicate {args.platform} target") + args.target = targets + doctor(args.platform) + run(["rustup", "target", "add", *[mapping[t] for t in targets]]) + project = initialize(args.platform) + command = cli(args.platform, "build", "--ci") + for target in targets: + command += ["--target", target] + if args.variant == "debug": + command.append("--debug") + if args.platform == "android": + for kind in (["apk", "aab"] if args.format == "both" else [args.format]): + command.append(f"--{kind}") + # Only this profile's generated outputs are removed. A stale build must + # never be relabeled as the current verified artifact. + for folder in [project / "app" / "build" / "outputs" / "apk" / "universal" / args.variant, + project / "app" / "build" / "outputs" / "bundle" / ("universal" + args.variant.title())]: + if folder.exists(): + shutil.rmtree(folder) + elif args.simulator: + command.append("--no-sign") + else: + command += ["--export-method", args.export_method] + if args.build_number is not None: + command += ["--build-number", str(args.build_number)] + command += ["--", "--locked"] + run(command) + output = Path(args.output).resolve() + output.mkdir(parents=True, exist_ok=True) + records = [] + if args.platform == "android": + formats = ["apk", "aab"] if args.format == "both" else [args.format] + for kind in formats: + files = list((project / "app" / "build" / "outputs" / ("apk" if kind == "apk" else "bundle")).rglob(f"*.{kind}")) + files = [p for p in files if args.variant.lower() in p.as_posix().lower()] + if len(files) != 1: + raise MobileError(f"Expected exactly one fresh universal {kind}; found {len(files)}") + metadata = verify_android(files[0], expected) + if kind == "apk": + expected_id = "best.lmm.hireferry" + (".debug" if args.variant == "debug" else "") + if metadata["package_id"] != expected_id: + raise MobileError("APK application ID does not match the requested build variant") + destination = output / f"hireferry-android-{'-'.join(targets)}-{args.variant}.{kind}" + shutil.copy2(files[0], destination) + records.append({"filename": destination.name, "bytes": destination.stat().st_size, "sha256": sha256(destination), **metadata}) + else: + for target in targets: + # cargo-mobile2 keeps Apple Silicon simulator output separate from + # the identically named device CPU architecture. + arch = {"aarch64": "arm64", "aarch64-sim": "arm64-sim", "x86_64": "x86_64"}[target] + directory = project / "build" / arch + candidates = list(directory.glob("*.app" if args.simulator else "*.ipa")) + if len(candidates) != 1: + raise MobileError(f"Expected one iOS {'simulator app' if args.simulator else 'IPA'} at {directory}") + if args.simulator: + destination = output / f"hireferry-ios-{target}-simulator.app.tar.gz" + with tarfile.open(destination, "w:gz") as archive: + archive.add(candidates[0], arcname=candidates[0].name) + else: + destination = output / f"hireferry-ios-{target}-{args.export_method}.ipa" + shutil.copy2(candidates[0], destination) + metadata = {"signature_verified": False} if args.simulator else verify_ios(destination, os.environ["APPLE_DEVELOPMENT_TEAM"]) + records.append({"filename": destination.name, "bytes": destination.stat().st_size, "sha256": sha256(destination), **metadata}) + write_manifest(output, records, args) + + +def smoke_android(args): + output = Path(args.output).resolve() + output.mkdir(parents=True, exist_ok=True) + command = [str(android_tool("adb"))] + (["-s", args.serial] if args.serial else []) + try: + _smoke_android(args) + finally: + # Keep native crash/ANR evidence even when a UI assertion fails. The + # CI emulator is isolated and the fixture never has a real account. + for name, tail in [ + ("android-logcat.txt", ["logcat", "-b", "all", "-d", "-v", "threadtime"]), + ("android-exit-info.txt", ["shell", "dumpsys", "activity", "exit-info", "best.lmm.hireferry.debug"]), + ]: + try: + result = subprocess.run(command + tail, capture_output=True, timeout=15) + (output / name).write_bytes(result.stdout + result.stderr) + except (OSError, subprocess.TimeoutExpired) as error: + (output / name).write_text(str(error)) + try: + shot = subprocess.run(command + ["exec-out", "screencap", "-p"], check=True, capture_output=True, timeout=15) + (output / "android-final.png").write_bytes(shot.stdout) + except (OSError, subprocess.SubprocessError): + pass + + +def _smoke_android(args): + artifact = Path(args.artifact).resolve() + metadata = verify_android(artifact) + package = metadata["package_id"] + if not package.endswith(".debug"): + raise MobileError("The loopback smoke service requires an engineering debug APK") + adb = android_tool("adb") + command = [adb] + (["-s", args.serial] if args.serial else []) + run(command + ["wait-for-device"]) + run(command + ["install", "-r", artifact]) + run(command + ["logcat", "-c"]) + run(command + ["shell", "am", "force-stop", package]) + launch = run(command + ["shell", "am", "start", "-W", "-n", f"{package}/best.lmm.hireferry.MainActivity"], capture=True) + if "Status: ok" not in launch: + raise MobileError("Android activity did not report a successful launch") + output = Path(args.output).resolve() + output.mkdir(parents=True, exist_ok=True) + + def label(node): + return node.get("text", "") + " " + node.get("content-desc", "") + + def wait_ui(predicate, description): + deadline = time.monotonic() + 45 + while time.monotonic() < deadline: + run(command + ["shell", "uiautomator", "dump", "/sdcard/hireferry-smoke.xml"], capture=True) + layout = run(command + ["shell", "cat", "/sdcard/hireferry-smoke.xml"], capture=True) + (output / "android-last.xml").write_text(layout) + try: + root = ET.fromstring(layout) + if predicate(root): + return root, layout + except ET.ParseError: + pass + time.sleep(1) + raise MobileError(f"Installed APK did not {description} within 45 seconds") + + def node_with(root, *texts): + # ElementTree leaves are false-y; never use `node_a or node_b` here. + return next((n for n in root.iter() if any(text.lower() in label(n).lower() for text in texts)), None) + + def field(root): + return next((n for n in root.iter() if n.get("class", "").endswith("EditText")), None) + + def launcher(root): + return field(root) is not None and (node_with(root, "Open workspace") is not None or node_with(root, "进入工作台") is not None) + + def bounds(node): + values = re.fullmatch(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", node.get("bounds", "")) + if values is None: + raise MobileError("UI element has no usable screen bounds") + return tuple(map(int, values.groups())) + + def tap(node): + if node is None: + raise MobileError("Expected mobile UI control was not found") + left, top, right, bottom = bounds(node) + run(command + ["shell", "input", "tap", (left + right) // 2, (top + bottom) // 2]) + + def evidence(name, layout): + (output / f"android-{name}.xml").write_text(layout) + screenshot = subprocess.run([str(x) for x in command] + ["exec-out", "screencap", "-p"], check=True, capture_output=True) + (output / f"android-{name}.png").write_bytes(screenshot.stdout) + + def login_return(): + result = run(command + [ + "shell", "am", "start", "-W", "-a", "android.intent.action.VIEW", + "-c", "android.intent.category.BROWSABLE", "-d", "hireferry://login-return", + "-n", f"{package}/best.lmm.hireferry.MainActivity", + ], capture=True) + if "Status: ok" not in result: + raise MobileError("Android login return did not report a successful launch") + + root, layout = wait_ui(launcher, "render the connection screen") + evidence("launch", layout) + with smoke_service() as (service, state): + port = service.server_port + address = f"http://127.0.0.1:{port}" + run(command + ["reverse", f"tcp:{port}", f"tcp:{port}"]) + try: + entry = field(root) + tap(entry) + run(command + ["shell", "input", "keyevent", "KEYCODE_MOVE_END"]) + # Clear the actual field value, then enter only the test origin. + run(command + ["shell", "input", "keyevent", *["KEYCODE_DEL"] * (len(entry.get("text", "")) + 2)]) + run(command + ["shell", "input", "text", address]) + # BACK exits the activity when the emulator uses a hardware + # keyboard and therefore has no soft keyboard to dismiss. + run(command + ["shell", "input", "keyevent", "KEYCODE_ESCAPE"]) + root, _ = wait_ui(launcher, "retain the connection form") + tap(node_with(root, "Open workspace", "进入工作台")) + root, layout = wait_ui(lambda r: node_with(r, "Remote IPC denied") is not None, "load the test service and reject remote native commands") + if not state["ipc_done"].wait(5) or state.get("ipc") not in ("denied", "bridge_absent"): + raise MobileError("Remote native command was not rejected") + returning = node_with(root, "Return to HireFerry", "返回聘渡") + feedback = node_with(root, "Feedback") + if returning is None or feedback is None: + raise MobileError("Remote page did not render both connection and feedback controls") + a, b = bounds(returning), bounds(feedback) + if min(a[2], b[2]) > max(a[0], b[0]) and min(a[3], b[3]) > max(a[1], b[1]): + raise MobileError("Connection and feedback controls overlap") + evidence("remote", layout) + tap(feedback) + root, layout = wait_ui(lambda r: node_with(r, "Feedback is open") is not None, "open feedback beside the native return control") + evidence("feedback", layout) + tap(node_with(root, "Close feedback")) + + # External browser return must foreground the existing WebView, + # without navigating away or importing a server/code/session. + run(command + ["shell", "input", "keyevent", "KEYCODE_HOME"]) + login_return() + root, layout = wait_ui(lambda r: node_with(r, "Remote IPC denied") is not None, "preserve the remote page after a login return") + evidence("warm-return", layout) + tap(node_with(root, "Return to HireFerry", "返回聘渡")) + root, layout = wait_ui(launcher, "return to the local connection screen") + evidence("settings-return", layout) + run(command + ["shell", "am", "force-stop", package]) + login_return() + root, layout = wait_ui(launcher, "keep a cold login return on the local launcher") + evidence("cold-return", layout) + finally: + run(command + ["reverse", "--remove", f"tcp:{port}"]) + log = run(command + ["logcat", "-d", "-s", "AndroidRuntime:E"], capture=True) + if "FATAL EXCEPTION" in log: + raise MobileError("Android runtime reported a fatal exception") + (output / "android-smoke.json").write_text(json.dumps({ + "package_id": package, "sha256": sha256(artifact), "installed": True, + "launcher_rendered": True, "remote_loaded": True, + "remote_native_command": state["ipc"], "feedback_clickable": True, + "controls_do_not_overlap": True, "returned_to_launcher": True, + "warm_login_return_preserved_page": True, "cold_login_return_stayed_local": True, + "fixture_only": True, + }, indent=2) + "\n") + print("Android APK passed install, remote isolation, feedback, and cold/warm return checks") + + +@contextmanager +def smoke_service(): + """An isolated loopback service; never touches production or a real account.""" + state = {"ipc_done": threading.Event()} + html = b''' + +HireFerry mobile verification + +

HireFerry mobile verification

+

Checking remote permissions...

This local fixture contains no account or business data.

+
+ + +''' + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *_): + pass + + def do_GET(self): + if self.path == "/feedback.css": + data, kind = (ROOT / "static" / "feedback.css").read_bytes(), "text/css" + else: + data, kind = html, "text/html; charset=utf-8" + self.send_response(200) + self.send_header("Content-Type", kind) + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(data) + + def do_POST(self): + size = int(self.headers.get("Content-Length", "0")) + if self.path != "/result" or not 0 < size < 1024: + self.send_error(400) + return + try: + payload = json.loads(self.rfile.read(size)) + state["ipc"] = payload.get("ipc") + except (ValueError, TypeError): + self.send_error(400) + return + state["ipc_done"].set() + self.send_response(204) + self.end_headers() + + service = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=service.serve_forever, kwargs={"poll_interval": 0.1}, daemon=True) + thread.start() + try: + yield service, state + finally: + service.shutdown() + service.server_close() + thread.join(timeout=2) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + for name in ("doctor", "init"): + command = commands.add_parser(name) + command.add_argument("platform", choices=("android", "ios")) + command = commands.add_parser("build") + command.add_argument("platform", choices=("android", "ios")) + command.add_argument("--variant", choices=("debug", "release"), default="debug") + command.add_argument("--target", action="append", default=[]) + command.add_argument("--format", choices=("apk", "aab", "both"), default="both") + command.add_argument("--simulator", action="store_true") + command.add_argument("--export-method", choices=("app-store-connect", "release-testing", "debugging"), default="app-store-connect") + command.add_argument("--build-number", type=int) + command.add_argument("--output", default=str(ROOT / "artifacts" / "mobile")) + command = commands.add_parser("smoke-android") + command.add_argument("artifact") + command.add_argument("--serial") + command.add_argument("--output", default=str(ROOT / "artifacts" / "mobile" / "smoke")) + args = parser.parse_args() + try: + if args.command == "doctor": + doctor(args.platform) + elif args.command == "init": + initialize(args.platform) + elif args.command == "build": + if args.platform == "android" and args.simulator: + raise MobileError("Android emulators use --target x86_64, not --simulator") + build(args) + else: + smoke_android(args) + except (MobileError, OSError, subprocess.CalledProcessError) as error: + print(f"mobile: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/mobile_client.test.cjs b/tests/mobile_client.test.cjs new file mode 100644 index 0000000..646d38b --- /dev/null +++ b/tests/mobile_client.test.cjs @@ -0,0 +1,65 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +function client(saved) { + const values = new Map(saved ? [['hireferry.desktop.server', saved]] : []); + const elements = new Map(); + const element = id => { + if (!elements.has(id)) elements.set(id, { value: '', textContent: '', disabled: false, dataset: {}, + classList: { toggle() {} }, checkValidity: () => true, reportValidity() {}, + addEventListener() {}, setAttribute() {}, replaceChildren() {} }); + return elements.get(id); + }; + const invokes = []; + const window = { __HIREFERRY_MOBILE__: true, __TAURI__: { core: { invoke: async (...args) => invokes.push(args) } } }; + const context = vm.createContext({ URL, window, navigator: { language: 'en-US', userAgent: 'Android' }, + document: { documentElement: { dataset: {} }, getElementById: element, querySelectorAll: () => [] }, + localStorage: { getItem: key => values.get(key) || null, setItem: (key,value) => values.set(key,value), removeItem: key => values.delete(key) } }); + for (const name of ['server-origin.js', 'app.js']) vm.runInContext(fs.readFileSync(path.join(__dirname, '../desktop/ui', name), 'utf8'), context); + return { context, values, invokes, element }; +} + +test('mobile does not persist or invoke credential-bearing and non-origin input', async () => { + for (const input of ['https://user:secret@example.test/', 'https://example.test/?token=secret', + 'https://example.test/#session=secret', 'https://example.test/?', 'https://example.test/#', + 'https://example.test/login', 'https://example.test:0', 'http://remote.test/', 'file:///secret']) { + const app = client('https://safe.example'); + app.element('server').value = input; + await app.context.run('connect'); + assert.equal(app.values.get('hireferry.desktop.server'), 'https://safe.example', input); + assert.equal(app.invokes.length, 0, input); + } +}); + +test('mobile persists only the canonical origin before navigation', async () => { + const app = client(); + app.element('server').value = ' https://EXAMPLE.test:443/ '; + await app.context.run('connect'); + assert.equal(app.values.get('hireferry.desktop.server'), 'https://example.test'); + assert.equal(app.invokes[0][1].server, 'https://example.test'); +}); + +test('legacy credential-bearing preferences are removed on startup', () => { + const app = client('https://user:secret@example.test/?token=secret'); + assert.equal(app.values.has('hireferry.desktop.server'), false); + assert.equal(app.element('server').value, 'https://hireferry.lmm.best'); +}); + +test('loopback validation matches actual loopback hosts', async () => { + for (const input of ['http://localhost:3000', 'http://127.0.0.2:3000', 'http://[::1]:3000']) { + const app = client(); + app.element('server').value = input; + await app.context.run('connect'); + assert.equal(app.invokes.length, 1, input); + } + for (const input of ['http://localhost.example:3000', 'http://192.168.0.2:3000', 'http://[::ffff:127.0.0.1]:3000']) { + const app = client(); + app.element('server').value = input; + await app.context.run('connect'); + assert.equal(app.invokes.length, 0, input); + } +}); diff --git a/tests/test_mobile.py b/tests/test_mobile.py new file mode 100644 index 0000000..65d2ce2 --- /dev/null +++ b/tests/test_mobile.py @@ -0,0 +1,108 @@ +"""Guard release identity and prevent stale/unverified mobile artifacts.""" +import importlib.util +import json +import os +from pathlib import Path +import tempfile +import types +import unittest +from unittest.mock import patch + +SPEC = importlib.util.spec_from_file_location("hireferry_mobile", Path(__file__).resolve().parents[1] / "scripts" / "mobile.py") +mobile = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(mobile) + + +class MobilePackagingTests(unittest.TestCase): + def test_release_rejects_missing_credentials_before_build(self): + with patch.dict(os.environ, {}, clear=True): + with self.assertRaisesRegex(mobile.MobileError, "maintainer signing"): + mobile.require_android_signing() + + def test_release_requires_expected_certificate_not_only_a_keystore(self): + with tempfile.TemporaryDirectory() as directory: + key = Path(directory) / "release.keystore" + key.touch() + values = {name: "test-value" for name in mobile.ANDROID_SIGNING} + values["HF_ANDROID_KEYSTORE_PATH"] = str(key) + with patch.dict(os.environ, values, clear=True): + with self.assertRaisesRegex(mobile.MobileError, "SHA-256"): + mobile.require_android_signing() + values["HF_ANDROID_CERT_SHA256"] = ":".join(["AB"] * 32) + with patch.dict(os.environ, values, clear=True): + self.assertEqual(mobile.require_android_signing(), "ab" * 32) + + def test_signing_setup_is_idempotent_and_never_writes_env_secrets(self): + with tempfile.TemporaryDirectory() as directory: + project = Path(directory) + (project / "app").mkdir() + path = project / "app" / "build.gradle.kts" + path.write_text('android { namespace = "best.lmm.hireferry" }\n') + with patch.dict(os.environ, {"HF_ANDROID_KEYSTORE_PASSWORD": "do-not-write-this-secret"}): + mobile.prepare_android_signing(project) + mobile.prepare_android_signing(project) + content = path.read_text() + self.assertEqual(content.count(mobile.SIGNING_MARKER), 1) + self.assertNotIn("do-not-write-this-secret", content) + + def test_apk_certificate_mismatch_is_an_error(self): + responses = ["Signer #1 certificate SHA-256 digest: " + "ab" * 32, + "package: name='best.lmm.hireferry.debug' versionCode='1000' versionName='0.1.0'"] + with patch.object(mobile, "android_tool", return_value=Path("tool")), patch.object(mobile, "run", side_effect=responses): + with self.assertRaisesRegex(mobile.MobileError, "certificate differs"): + mobile.verify_android(Path("debug.apk"), "cd" * 32) + + def test_unsigned_aab_is_not_reported_as_verified(self): + with patch.object(mobile, "run", return_value="jar is unsigned."): + with self.assertRaisesRegex(mobile.MobileError, "unsigned"): + mobile.verify_android(Path("bundle.aab")) + + def test_engineering_manifest_cannot_claim_production_distribution(self): + with tempfile.TemporaryDirectory() as directory: + args = types.SimpleNamespace(platform="android", variant="debug", simulator=False, target=["aarch64"]) + mobile.write_manifest(Path(directory), [{"filename": "test.apk", "sha256": "ab" * 32, "bytes": 42}], args) + manifest = json.loads((Path(directory) / "mobile-artifacts.json").read_text()) + self.assertEqual(manifest["distribution"], "engineering-test") + self.assertEqual((Path(directory) / "SHA256SUMS").read_text(), "ab" * 32 + " test.apk\n") + + def test_remote_site_has_no_native_capability(self): + capability = json.loads((mobile.TAURI / "capabilities" / "launcher.json").read_text()) + self.assertTrue(capability["local"]) + self.assertEqual(capability["windows"], ["launcher"]) + self.assertNotIn("remote", capability) + self.assertEqual(set(capability["permissions"]), {"allow-connect", "allow-open-browser"}) + + def test_failed_native_smoke_preserves_diagnostics_and_still_fails(self): + with tempfile.TemporaryDirectory() as directory: + args = types.SimpleNamespace(output=directory, serial=None) + captured = types.SimpleNamespace(stdout=b"native diagnostic", stderr=b"") + with patch.object(mobile, "android_tool", return_value=Path("adb")), \ + patch.object(mobile, "_smoke_android", side_effect=mobile.MobileError("failed UI")), \ + patch.object(mobile.subprocess, "run", return_value=captured): + with self.assertRaisesRegex(mobile.MobileError, "failed UI"): + mobile.smoke_android(args) + self.assertEqual((Path(directory) / "android-logcat.txt").read_bytes(), b"native diagnostic") + self.assertTrue((Path(directory) / "android-exit-info.txt").exists()) + self.assertTrue((Path(directory) / "android-final.png").exists()) + + def test_apple_silicon_simulator_artifact_is_collected_from_simulator_directory(self): + with tempfile.TemporaryDirectory() as directory: + project = Path(directory) / "apple" + application = project / "build" / "arm64-sim" / "HireFerry.app" + application.mkdir(parents=True) + (application / "Info.plist").write_text("simulator artifact fixture") + output = Path(directory) / "artifacts" + args = types.SimpleNamespace(platform="ios", variant="debug", simulator=True, + target=["aarch64-sim"], output=str(output)) + with patch.object(mobile, "doctor"), patch.object(mobile, "run"), \ + patch.object(mobile, "initialize", return_value=project), \ + patch.object(mobile, "cli", return_value=["npm", "run", "tauri", "--"]): + mobile.build(args) + manifest = json.loads((output / "mobile-artifacts.json").read_text()) + self.assertTrue(manifest["simulator_only"]) + self.assertEqual(manifest["artifacts"][0]["filename"], "hireferry-ios-aarch64-sim-simulator.app.tar.gz") + self.assertFalse(manifest["artifacts"][0]["signature_verified"]) + + +if __name__ == "__main__": + unittest.main()