From 68ad2d66ea8eaab990bf5a6e29bc27761046f98e Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Fri, 23 Oct 2026 01:39:45 +0800 Subject: [PATCH 1/6] Add shared Tauri mobile client and Android/iOS build verification Reuse the desktop host with isolated mobile navigation, safe launcher return, Android APK/AAB and iOS simulator/device build paths, signed release gates, and truthful engineering artifact documentation. --- .github/workflows/mobile-build.yml | 146 ++++++++ .github/workflows/mobile-release.yml | 136 +++++++ desktop/src-tauri/Cargo.toml | 12 +- desktop/src-tauri/src/desktop.rs | 188 ++++++++++ desktop/src-tauri/src/lib.rs | 45 +++ desktop/src-tauri/src/main.rs | 201 +---------- desktop/src-tauri/src/mobile-bootstrap.js | 34 ++ desktop/src-tauri/src/mobile.rs | 95 +++++ desktop/src-tauri/src/policy.rs | 65 ++++ desktop/src-tauri/tauri.android.conf.json | 9 + desktop/src-tauri/tauri.ios.conf.json | 8 + desktop/ui/app.js | 30 +- desktop/ui/index.html | 4 +- desktop/ui/style.css | 15 + docs/mobile.md | 105 ++++++ scripts/mobile.py | 413 ++++++++++++++++++++++ tests/test_mobile.py | 77 ++++ 17 files changed, 1378 insertions(+), 205 deletions(-) create mode 100644 .github/workflows/mobile-build.yml create mode 100644 .github/workflows/mobile-release.yml create mode 100644 desktop/src-tauri/src/desktop.rs create mode 100644 desktop/src-tauri/src/lib.rs create mode 100644 desktop/src-tauri/src/mobile-bootstrap.js create mode 100644 desktop/src-tauri/src/mobile.rs create mode 100644 desktop/src-tauri/tauri.android.conf.json create mode 100644 desktop/src-tauri/tauri.ios.conf.json create mode 100644 docs/mobile.md create mode 100644 scripts/mobile.py create mode 100644 tests/test_mobile.py diff --git a/.github/workflows/mobile-build.yml b/.github/workflows/mobile-build.yml new file mode 100644 index 0000000..44740c8 --- /dev/null +++ b/.github/workflows/mobile-build.yml @@ -0,0 +1,146 @@ +name: Mobile engineering builds + +on: + pull_request: + paths: + - 'desktop/**' + - 'scripts/mobile.py' + - 'tests/test_mobile.py' + - '.github/workflows/mobile-*.yml' + push: + branches: [main] + paths: + - 'desktop/**' + - 'scripts/mobile.py' + - 'tests/test_mobile.py' + - '.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, install and launch + 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: '17' + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-linux-android,x86_64-linux-android + - uses: android-actions/setup-android@v3 + - name: Install pinned Android SDK and NDK + run: | + sdkmanager 'platforms;android-37' '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 + - 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 the real WebView launcher + 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) + (out / 'ios-smoke.json').write_text(json.dumps({'simulator':device['name'], 'installed':True, 'launch_command_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..95a0d2e --- /dev/null +++ b/.github/workflows/mobile-release.yml @@ -0,0 +1,136 @@ +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: '17' + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-linux-android + - uses: android-actions/setup-android@v3 + - name: Install pinned Android SDK and NDK + run: | + sdkmanager 'platforms;android-37' '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.toml b/desktop/src-tauri/Cargo.toml index 1e41f2f..395548f 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" diff --git a/desktop/src-tauri/src/desktop.rs b/desktop/src-tauri/src/desktop.rs new file mode 100644 index 0000000..2247749 --- /dev/null +++ b/desktop/src-tauri/src/desktop.rs @@ -0,0 +1,188 @@ +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 origin = url.origin(); + let browser_home = 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 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(&navigation_app, &browser_home); + return false; + } + return true; + } + let _ = open_external(&navigation_app, next); + false + }) + .on_new_window(move |url, _| { + let _ = open_external(&popup_app, &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(()) +} + +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..251a859 --- /dev/null +++ b/desktop/src-tauri/src/mobile-bootstrap.js @@ -0,0 +1,34 @@ +(() => { + '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'; + link.href = 'hireferry-settings://return'; + const zh = navigator.language.startsWith('zh'); + link.textContent = zh ? '聘渡 · 连接设置' : 'HireFerry · Connection'; + link.setAttribute('aria-label', zh ? '返回聘渡应用连接设置' : 'Return to HireFerry connection settings'); + link.style.cssText = 'position:fixed;right:max(12px,env(safe-area-inset-right));bottom:max(12px,env(safe-area-inset-bottom));z-index:2147483647;display:flex;align-items:center;min-height:44px;padding:8px 14px;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); + }; + 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..a5cc378 --- /dev/null +++ b/desktop/src-tauri/src/mobile.rs @@ -0,0 +1,95 @@ +use crate::{open_external, policy, require_launcher}; +use std::sync::RwLock; +use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; +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_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 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")) + .on_navigation(move |next| { + if policy::launcher_url(next) { + return true; + } + // This link only returns to the bundled settings screen. + // It carries no URL, code, token, session, or native action. + if policy::settings_return(next) { + let app = navigation_app.clone(); + tauri::async_runtime::spawn(async move { + if let Some(window) = app.get_webview_window("launcher") { + let _ = window.navigate(launcher_url()); + } + }); + return false; + } + let same_service = + navigation_app + .state::() + .0 + .read() + .is_ok_and(|selected| { + selected + .as_ref() + .is_some_and(|origin| policy::service_navigation(origin, next)) + }); + if same_service && next.path() != "/api/auth/google/start" { + return true; + } + if policy::web_url(next) { + let app = navigation_app.clone(); + let url = next.clone(); + // The full Google flow, including its CSRF cookie, is + // started in the system browser. There is no session + // import into the WebView and no external deep-link ACL. + 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..0a5271f 100644 --- a/desktop/src-tauri/src/policy.rs +++ b/desktop/src-tauri/src/policy.rs @@ -31,6 +31,26 @@ 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 settings_return(url: &Url) -> bool { + url.scheme() == "hireferry-settings" + && url.host_str() == Some("return") + && url.username().is_empty() + && url.password().is_none() + && url.port().is_none() + && matches!(url.path(), "" | "/") + && url.query().is_none() + && url.fragment().is_none() +} + +#[cfg(any(mobile, test))] +pub fn service_navigation(origin: &Url, next: &Url) -> bool { + web_url(next) && next.origin() == origin.origin() } #[cfg(test)] @@ -83,8 +103,53 @@ 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_return_is_a_constant_without_payload_or_login_credentials() { + for input in [ + "hireferry-settings://return", + "hireferry-settings://return/", + ] { + assert!(settings_return(&Url::parse(input).unwrap())); + } + for input in [ + "hireferry-settings://return?url=https://evil.example", + "hireferry-settings://return#session=secret", + "hireferry-settings://user@return", + "hireferry-settings://return/login", + "hireferry-settings://other", + "https://return/", + ] { + assert!(!settings_return(&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}" + ); + } + } } 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.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..214260a 100644 --- a/desktop/ui/app.js +++ b/desktop/ui/app.js @@ -34,6 +34,31 @@ 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 inside the app. Google sign-in opens your system browser. Returning to the app does not transfer your browser session.', + footerHelp:'Use “Connection” at the bottom-right of the site to return here. Relaunching also opens this screen.', + 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. */ } } @@ -48,7 +73,7 @@ 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' : '中文'; @@ -68,6 +93,9 @@ async function run(command) { if (command === 'connect') status('opening'); try { const address = server.value.trim(); + // Mobile navigation unloads this document before an invoke response can + // resolve. Saving an address never connects automatically or grants access. + 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..182f617 100644 --- a/desktop/ui/index.html +++ b/desktop/ui/index.html @@ -2,7 +2,7 @@ - + HireFerry · 聘渡 @@ -41,7 +41,7 @@

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

从这里出发

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

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/mobile.md b/docs/mobile.md new file mode 100644 index 0000000..e5a11dd --- /dev/null +++ b/docs/mobile.md @@ -0,0 +1,105 @@ +# 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 登录入口交给系统浏览器,从 `/api/auth/google/start` 开始整个流程,使浏览器自己持有其 CSRF 挑战 Cookie。**浏览器登录完成后返回应用,不会把浏览器会话同步到 WebView。** 本轮没有实现跨浏览器身份导入,也没有给已有 CLI token 增加 WebView 登录用途。Google 用户可以继续在浏览器使用服务;应用内可独立使用邮箱验证码。以后如果增加自动登录回跳,必须先由服务器提供短时、单次、绑定客户端与 PKCE 的授权交换,不能直接传长期 session。 + +手机站点右下角的“聘渡 · 连接设置”只会回到内置连接页。它使用固定的应用内部导航标记,不接收服务器地址、跳转目标、code 或 token;该协议没有注册为外部系统 deep link。这个 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 17、Android SDK command-line tools。与锁定的 `@tauri-apps/cli` 2.12.2 模板一致,本脚本固定 SDK 37、Build Tools 37.0.0、NDK 29.0.13846066;安装时使用 Android SDK Manager,不隐式接受缺少的 SDK 版本。 + +```sh +cd desktop +npm ci +cd .. + +# ANDROID_HOME 指向已安装的 SDK;JAVA_HOME 指向 JDK 17。 +sdkmanager 'platforms;android-37' '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 连接表单已渲染、检查 AndroidRuntime 致命异常,并保存 UI XML、截图和记录。它不会登录账号或创建业务数据。此检查证明安装与启动;不能代替真机键盘、文件选择、无障碍、断网恢复等功能测试。 + +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..f92669b --- /dev/null +++ b/scripts/mobile.py @@ -0,0 +1,413 @@ +#!/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 +import hashlib +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 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" +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. + 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 [str(binary), *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: + arch = "arm64" if target.startswith("aarch64") else "x86_64" + 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): + artifact = Path(args.artifact).resolve() + metadata = verify_android(artifact) + package = metadata["package_id"] + 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") + deadline = time.monotonic() + 45 + layout = "" + 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) + try: + root = ET.fromstring(layout) + text = " ".join(n.attrib.get("text", "") + " " + n.attrib.get("content-desc", "") for n in root.iter()) + if "hireferry.lmm.best" in text and ("workspace" in text.lower() or "工作台" in text): + break + except ET.ParseError: + pass + time.sleep(1) + else: + raise MobileError("Installed APK did not render the connection screen within 45 seconds") + log = run(command + ["logcat", "-d", "-s", "AndroidRuntime:E"], capture=True) + if "FATAL EXCEPTION" in log: + raise MobileError("Android runtime reported a fatal exception") + output = Path(args.output).resolve() + output.mkdir(parents=True, exist_ok=True) + (output / "android-launch.xml").write_text(layout) + screenshot = subprocess.run([str(x) for x in command] + ["exec-out", "screencap", "-p"], check=True, capture_output=True) + (output / "android-launch.png").write_bytes(screenshot.stdout) + (output / "android-smoke.json").write_text(json.dumps({"package_id": package, "sha256": sha256(artifact), "installed": True, "launcher_rendered": True}, indent=2) + "\n") + print("Android APK installed and rendered the local connection screen") + + +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/test_mobile.py b/tests/test_mobile.py new file mode 100644 index 0000000..b580d54 --- /dev/null +++ b/tests/test_mobile.py @@ -0,0 +1,77 @@ +"""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"}) + + +if __name__ == "__main__": + unittest.main() From 43599d1e031fe2dcb573e33aa6621808c985ae85 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Fri, 23 Oct 2026 01:49:29 +0800 Subject: [PATCH 2/6] Fix native build entry points and validate origins before storage Use the locked npm lifecycle when Xcode and Gradle re-enter Tauri, avoid removed legacy Android SDK tools, and reject credential/query-bearing server addresses before any local preference is saved. --- .github/workflows/mobile-build.yml | 9 +++- .github/workflows/mobile-release.yml | 3 ++ desktop/ui/app.js | 19 +++++--- desktop/ui/index.html | 1 + desktop/ui/server-origin.js | 15 +++++++ scripts/mobile.py | 6 ++- tests/mobile_client.test.cjs | 65 ++++++++++++++++++++++++++++ 7 files changed, 111 insertions(+), 7 deletions(-) create mode 100644 desktop/ui/server-origin.js create mode 100644 tests/mobile_client.test.cjs diff --git a/.github/workflows/mobile-build.yml b/.github/workflows/mobile-build.yml index 44740c8..ac7c333 100644 --- a/.github/workflows/mobile-build.yml +++ b/.github/workflows/mobile-build.yml @@ -6,6 +6,7 @@ on: - 'desktop/**' - 'scripts/mobile.py' - 'tests/test_mobile.py' + - 'tests/mobile_client.test.cjs' - '.github/workflows/mobile-*.yml' push: branches: [main] @@ -13,6 +14,7 @@ on: - 'desktop/**' - 'scripts/mobile.py' - 'tests/test_mobile.py' + - 'tests/mobile_client.test.cjs' - '.github/workflows/mobile-*.yml' workflow_dispatch: @@ -49,6 +51,9 @@ jobs: with: targets: aarch64-linux-android,x86_64-linux-android - uses: android-actions/setup-android@v3 + with: + packages: platform-tools + log-accepted-android-sdk-licenses: false - name: Install pinned Android SDK and NDK run: | sdkmanager 'platforms;android-37' 'build-tools;37.0.0' "ndk;$NDK_VERSION" 'platform-tools' @@ -57,7 +62,9 @@ jobs: run: npm ci working-directory: desktop - name: Test build/signing guardrails - run: python3 -m unittest discover -s tests -p test_mobile.py -v + 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 diff --git a/.github/workflows/mobile-release.yml b/.github/workflows/mobile-release.yml index 95a0d2e..a18cf62 100644 --- a/.github/workflows/mobile-release.yml +++ b/.github/workflows/mobile-release.yml @@ -47,6 +47,9 @@ jobs: with: targets: aarch64-linux-android - uses: android-actions/setup-android@v3 + with: + packages: platform-tools + log-accepted-android-sdk-licenses: false - name: Install pinned Android SDK and NDK run: | sdkmanager 'platforms;android-37' 'build-tools;37.0.0' "ndk;$NDK_VERSION" 'platform-tools' diff --git a/desktop/ui/app.js b/desktop/ui/app.js index 214260a..755953c 100644 --- a/desktop/ui/app.js +++ b/desktop/ui/app.js @@ -61,13 +61,22 @@ if (mobile) { } 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'; @@ -92,9 +101,9 @@ async function run(command) { buttons.forEach(button => { button.disabled = true; }); if (command === 'connect') status('opening'); try { - const address = server.value.trim(); - // Mobile navigation unloads this document before an invoke response can - // resolve. Saving an address never connects automatically or grants access. + 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); diff --git a/desktop/ui/index.html b/desktop/ui/index.html index 182f617..0591491 100644 --- a/desktop/ui/index.html +++ b/desktop/ui/index.html @@ -5,6 +5,7 @@ HireFerry · 聘渡 + 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/scripts/mobile.py b/scripts/mobile.py index f92669b..dc51f2a 100644 --- a/scripts/mobile.py +++ b/scripts/mobile.py @@ -83,10 +83,14 @@ def run(args, *, cwd=DESKTOP, capture=False, env=None): 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 [str(binary), *args] + return ["npm.cmd" if os.name == "nt" else "npm", "run", "tauri", "--", *args] def sdk_root(): 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); + } +}); From 3139fa688fa831af1d2aff773031c0fad5448c95 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Fri, 23 Oct 2026 02:04:17 +0800 Subject: [PATCH 3/6] Declare the JSON dependency required by generated mobile plugin context --- desktop/src-tauri/Cargo.lock | 1 + desktop/src-tauri/Cargo.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 3e3ee9d..5544ae6 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1487,6 +1487,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" name = "hireferry-desktop" version = "0.1.0" dependencies = [ + "serde_json", "tauri", "tauri-build", "tauri-plugin-deep-link", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index f00c035..cab4235 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -24,6 +24,7 @@ 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" From e93ea9312cc4339de34ba0a8e3e0cc054f770658 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Fri, 23 Oct 2026 02:21:46 +0800 Subject: [PATCH 4/6] Use fixed local navigation for mobile settings and complete desktop browser sign-in return --- desktop/src-tauri/src/desktop.rs | 63 ++++++++++++++--- desktop/src-tauri/src/mobile-bootstrap.js | 5 +- desktop/src-tauri/src/mobile.rs | 44 ++++-------- desktop/src-tauri/src/policy.rs | 85 ++++++++++++++--------- desktop/ui/app.js | 8 +-- docs/desktop.md | 4 +- docs/mobile.md | 4 +- scripts/mobile.py | 25 +++++++ tests/test_mobile.py | 13 ++++ 9 files changed, 170 insertions(+), 81 deletions(-) diff --git a/desktop/src-tauri/src/desktop.rs b/desktop/src-tauri/src/desktop.rs index 2247749..9c6fdde 100644 --- a/desktop/src-tauri/src/desktop.rs +++ b/desktop/src-tauri/src/desktop.rs @@ -22,12 +22,14 @@ fn show_launcher(app: &AppHandle) { 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 origin = url.origin(); - let browser_home = url.clone(); + 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() @@ -44,20 +46,33 @@ pub async fn connect(window: WebviewWindow, app: AppHandle, server: String) -> R // 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(&navigation_app, &browser_home); - return false; - } + 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; } - let _ = open_external(&navigation_app, next); + // 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, _| { - let _ = open_external(&popup_app, &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() @@ -65,7 +80,23 @@ pub async fn connect(window: WebviewWindow, app: AppHandle, server: String) -> R 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() @@ -97,6 +128,16 @@ pub async fn connect(window: WebviewWindow, app: AppHandle, server: String) -> R 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") { diff --git a/desktop/src-tauri/src/mobile-bootstrap.js b/desktop/src-tauri/src/mobile-bootstrap.js index d358260..2722245 100644 --- a/desktop/src-tauri/src/mobile-bootstrap.js +++ b/desktop/src-tauri/src/mobile-bootstrap.js @@ -22,7 +22,10 @@ if (document.getElementById('hireferry-app-settings')) return; const link = document.createElement('a'); link.id = 'hireferry-app-settings'; - link.href = 'hireferry-settings://return'; + // 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'); diff --git a/desktop/src-tauri/src/mobile.rs b/desktop/src-tauri/src/mobile.rs index 667933c..4774abc 100644 --- a/desktop/src-tauri/src/mobile.rs +++ b/desktop/src-tauri/src/mobile.rs @@ -75,39 +75,23 @@ pub fn run() { WebviewWindowBuilder::new(app, "launcher", WebviewUrl::App("index.html".into())) .title("HireFerry · 聘渡") .use_https_scheme(true) - .initialization_script(include_str!("mobile-bootstrap.js")) + .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; } - // This link only returns to the bundled settings screen. - // It carries no URL, code, token, session, or native action. - if policy::settings_return(next) { - let app = navigation_app.clone(); - tauri::async_runtime::spawn(async move { - if let Some(window) = app.get_webview_window("launcher") { - let _ = window.navigate(launcher_url()); - } - }); - return false; - } - let same_service = - navigation_app - .state::() - .0 - .read() - .is_ok_and(|selected| { - selected - .as_ref() - .is_some_and(|origin| policy::service_navigation(origin, next)) - }); - if same_service { - if next.path() == "/api/auth/google/start" { + 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(); - let mut landing = next.clone(); - landing.set_path("/auth/app"); - landing.set_query(None); - landing.set_fragment(None); tauri::async_runtime::spawn(async move { if let Some(window) = app.get_webview_window("launcher") { let _ = window.navigate(landing); @@ -115,7 +99,9 @@ pub fn run() { }); return false; } - if next.path() != "/auth/app/authorize" { + if policy::service_navigation(&origin, next) + && !policy::login_authorization(&origin, next) + { return true; } } diff --git a/desktop/src-tauri/src/policy.rs b/desktop/src-tauri/src/policy.rs index 4ef710c..719bad5 100644 --- a/desktop/src-tauri/src/policy.rs +++ b/desktop/src-tauri/src/policy.rs @@ -36,18 +36,6 @@ pub fn launcher_url(url: &Url) -> bool { && url.fragment().is_none() } -#[cfg(any(mobile, test))] -pub fn settings_return(url: &Url) -> bool { - url.scheme() == "hireferry-settings" - && url.host_str() == Some("return") - && url.username().is_empty() - && url.password().is_none() - && url.port().is_none() - && matches!(url.path(), "" | "/") - && url.query().is_none() - && url.fragment().is_none() -} - #[cfg(any(mobile, test))] pub fn login_return(url: &Url) -> bool { url.scheme() == "hireferry" @@ -60,11 +48,25 @@ pub fn login_return(url: &Url) -> bool { && url.fragment().is_none() } -#[cfg(any(mobile, test))] 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)] mod tests { use super::*; @@ -123,26 +125,6 @@ mod tests { } } - #[test] - fn mobile_return_is_a_constant_without_payload_or_login_credentials() { - for input in [ - "hireferry-settings://return", - "hireferry-settings://return/", - ] { - assert!(settings_return(&Url::parse(input).unwrap())); - } - for input in [ - "hireferry-settings://return?url=https://evil.example", - "hireferry-settings://return#session=secret", - "hireferry-settings://user@return", - "hireferry-settings://return/login", - "hireferry-settings://other", - "https://return/", - ] { - assert!(!settings_return(&Url::parse(input).unwrap()), "{input}"); - } - } - #[test] fn mobile_service_navigation_keeps_the_exact_selected_origin() { let selected = server_origin("https://selfhost.example:8443").unwrap(); @@ -183,4 +165,41 @@ mod tests { 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/ui/app.js b/desktop/ui/app.js index 7e0f7c6..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.', @@ -43,7 +43,7 @@ if (mobile) { lead:'在手机上,继续每一次沟通。\n连接你的聘渡,开启下一段旅程。', serverHelp:'使用 HTTPS 站点地址。自托管站点需要可信的 TLS 证书。', loginNote:'可在应用内使用邮箱验证码登录。Google 登录会打开系统浏览器,确认账号并批准后,点击“返回应用”即可继续。自托管站点需支持应用登录授权。', - footerHelp:'站点左下方的“连接”可返回这里。重新启动应用也会打开此页。', + footerHelp:'站点左下方的“连接”可返回这里。若登录返回时看到此页,请连接站点并重新发起登录。', opened:'正在进入工作台。', not_allowed:'只能在聘渡应用的连接页面执行此操作。', unavailable:'请在 HireFerry 应用中打开此页面。' @@ -53,7 +53,7 @@ if (mobile) { 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 of the site to return here. Relaunching also opens this screen.', + 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.' 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 index 1ab1daa..5c34e3d 100644 --- a/docs/mobile.md +++ b/docs/mobile.md @@ -22,7 +22,7 @@ Google 登录需要支持 `/auth/app` 的新版服务器。应用把所选站点 批准页的“返回应用”使用固定 `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;这个设置返回协议没有注册为外部系统 deep link;它与上面的登录唤回协议相互独立。这个 DOM 控件只是方便返回,所选网站可能改变自己的 DOM。若页面加载失败或控件消失,重新启动应用会重新打开本地连接页,不会自动连接历史站点。 +手机站点左下方的“连接”控件只会回到内置连接页。控件至少有 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/)。 @@ -51,7 +51,7 @@ python3 scripts/mobile.py build android --variant debug --target aarch64 --forma 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、截图和记录。测试服务没有真实账号,也不访问生产或创建业务数据。该检查不能替代真实 Google 账号授权、真机键盘、文件选择、无障碍、断网恢复等测试。 +设备必须已经通过 `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。 diff --git a/scripts/mobile.py b/scripts/mobile.py index b526bf3..f2e9020 100644 --- a/scripts/mobile.py +++ b/scripts/mobile.py @@ -342,6 +342,31 @@ def build(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"] diff --git a/tests/test_mobile.py b/tests/test_mobile.py index d97cc56..65d2ce2 100644 --- a/tests/test_mobile.py +++ b/tests/test_mobile.py @@ -72,6 +72,19 @@ def test_remote_site_has_no_native_capability(self): 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" From b16a781cfa43f18811618ae1505f5bb6202de729 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Fri, 23 Oct 2026 09:26:46 +0800 Subject: [PATCH 5/6] fix(mobile): keep hardware-keyboard smoke in app --- scripts/mobile.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/mobile.py b/scripts/mobile.py index f2e9020..65bc36d 100644 --- a/scripts/mobile.py +++ b/scripts/mobile.py @@ -442,7 +442,9 @@ def evidence(name, layout): # 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]) - run(command + ["shell", "input", "keyevent", "KEYCODE_BACK"]) + # 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") From 130d89ef9879308413d63e472256ed6fff63887f Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Fri, 23 Oct 2026 09:38:01 +0800 Subject: [PATCH 6/6] Fix Android login-return smoke launch --- scripts/mobile.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/scripts/mobile.py b/scripts/mobile.py index 65bc36d..0c9b7fd 100644 --- a/scripts/mobile.py +++ b/scripts/mobile.py @@ -429,6 +429,15 @@ def evidence(name, 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): @@ -466,14 +475,14 @@ def evidence(name, layout): # External browser return must foreground the existing WebView, # without navigating away or importing a server/code/session. run(command + ["shell", "input", "keyevent", "KEYCODE_HOME"]) - run(command + ["shell", "am", "start", "-W", "-a", "android.intent.action.VIEW", "-d", "hireferry://login-return", "-p", package], capture=True) + 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]) - run(command + ["shell", "am", "start", "-W", "-a", "android.intent.action.VIEW", "-d", "hireferry://login-return", "-p", package], capture=True) + login_return() root, layout = wait_ui(launcher, "keep a cold login return on the local launcher") evidence("cold-return", layout) finally: