From 8d8a844e89b22d73228dcb377a09641f508d8f57 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:09:57 +0800 Subject: [PATCH] fix(mobile): pairing links without the deep-link plugin, cold launches included The deep-link plugin's build script rewrites the iOS entitlements while Xcode builds, which Xcode refuses ("modified during the build"), and a link that launched the app was lost on the way in any case: UIKit hands it to the first scene as it connects, and the event loop only passes on links that come later. - The tty7pair scheme is declared in Info.ios.plist and the Android manifest, and links are heard as Tauri's own Opened event. - The scene delegate's connect is wrapped, before UIKit starts, to keep a launching link; the page asks for it once it listens, so neither order of arrival loses it. --- mobile/package-lock.json | 10 -- mobile/package.json | 1 - mobile/src-tauri/Cargo.lock | 92 ---------- mobile/src-tauri/Cargo.toml | 3 - mobile/src-tauri/Info.ios.plist | 13 ++ mobile/src-tauri/capabilities/mobile.json | 3 +- .../android/app/src/main/AndroidManifest.xml | 12 +- mobile/src-tauri/src/lib.rs | 162 +++++++++++++++++- mobile/src-tauri/tauri.conf.json | 5 - mobile/src/api.ts | 3 + mobile/src/main.ts | 57 ++++-- 11 files changed, 215 insertions(+), 146 deletions(-) diff --git a/mobile/package-lock.json b/mobile/package-lock.json index c2a36e2e..202a9e11 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -11,7 +11,6 @@ "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-barcode-scanner": "^2.5.0", "@tauri-apps/plugin-biometric": "^2.4.0", - "@tauri-apps/plugin-deep-link": "^2.6.1", "@tauri-apps/plugin-haptics": "^2.4.1", "@tauri-apps/plugin-opener": "^2.6.0", "@xterm/addon-search": "^0.16.0", @@ -541,15 +540,6 @@ "@tauri-apps/api": "^2.12.0" } }, - "node_modules/@tauri-apps/plugin-deep-link": { - "version": "2.6.1", - "resolved": "https://mirrors.cloud.tencent.com/npm/@tauri-apps/plugin-deep-link/-/plugin-deep-link-2.6.1.tgz", - "integrity": "sha512-yomIOIoiiDwXW3Bgy/V5c4frWo9sfWM9WZQew2s7vr7hXtDdbLDCLzvytIMCjMxbnln9O0oJsTI3XdDd2T9XlA==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.12.0" - } - }, "node_modules/@tauri-apps/plugin-haptics": { "version": "2.4.1", "resolved": "https://mirrors.cloud.tencent.com/npm/@tauri-apps/plugin-haptics/-/plugin-haptics-2.4.1.tgz", diff --git a/mobile/package.json b/mobile/package.json index b6b1c133..ac472604 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -12,7 +12,6 @@ "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-barcode-scanner": "^2.5.0", "@tauri-apps/plugin-biometric": "^2.4.0", - "@tauri-apps/plugin-deep-link": "^2.6.1", "@tauri-apps/plugin-haptics": "^2.4.1", "@tauri-apps/plugin-opener": "^2.6.0", "@xterm/addon-search": "^0.16.0", diff --git a/mobile/src-tauri/Cargo.lock b/mobile/src-tauri/Cargo.lock index 749ce837..8aacb981 100644 --- a/mobile/src-tauri/Cargo.lock +++ b/mobile/src-tauri/Cargo.lock @@ -672,26 +672,6 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" -[[package]] -name = "const-random" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" -dependencies = [ - "const-random-macro", -] - -[[package]] -name = "const-random-macro" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "tiny-keccak", -] - [[package]] name = "constant_time_eq" version = "0.4.2" @@ -815,12 +795,6 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - [[package]] name = "crypto-common" version = "0.1.7" @@ -1155,15 +1129,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "dlv-list" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" -dependencies = [ - "const-random", -] - [[package]] name = "dom_query" version = "0.28.0" @@ -1927,12 +1892,6 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - [[package]] name = "hashbrown" version = "0.17.1" @@ -3636,16 +3595,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "ordered-multimap" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" -dependencies = [ - "dlv-list", - "hashbrown 0.14.5", -] - [[package]] name = "ordered-stream" version = "0.2.0" @@ -4259,16 +4208,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rust-ini" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" -dependencies = [ - "cfg-if", - "ordered-multimap", -] - [[package]] name = "rustc-hash" version = "2.1.3" @@ -5306,27 +5245,6 @@ dependencies = [ "thiserror 2.0.21", ] -[[package]] -name = "tauri-plugin-deep-link" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d6b3bf88f2ec8b97c852c2fa1464dc13c0ba3fbfb24af4144f23fbd54c0f14" -dependencies = [ - "dunce", - "plist", - "rust-ini", - "serde", - "serde_json", - "tauri", - "tauri-plugin", - "tauri-utils", - "thiserror 2.0.21", - "tracing", - "url", - "windows-registry", - "windows-result 0.4.1", -] - [[package]] name = "tauri-plugin-haptics" version = "2.4.0" @@ -5565,15 +5483,6 @@ dependencies = [ "time-core", ] -[[package]] -name = "tiny-keccak" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" -dependencies = [ - "crunchy", -] - [[package]] name = "tinystr" version = "0.8.4" @@ -5924,7 +5833,6 @@ dependencies = [ "tauri-build", "tauri-plugin-barcode-scanner", "tauri-plugin-biometric", - "tauri-plugin-deep-link", "tauri-plugin-haptics", "tauri-plugin-opener", "tokio", diff --git a/mobile/src-tauri/Cargo.toml b/mobile/src-tauri/Cargo.toml index b7873c3a..ba6a1c5a 100644 --- a/mobile/src-tauri/Cargo.toml +++ b/mobile/src-tauri/Cargo.toml @@ -40,9 +40,6 @@ tauri-plugin-biometric = "2" # A tap felt under the finger for the keys and answers, as the phone's own # keyboard gives. tauri-plugin-haptics = "2" -# The desktop's pairing QR code is a `tty7pair:` link: the phone's own -# camera opens it straight into the app, which pairs. -tauri-plugin-deep-link = "2" # Reading the system bars' size and leaving for the background (`insets`, # `to_background`), which the Tauri API does not reach. diff --git a/mobile/src-tauri/Info.ios.plist b/mobile/src-tauri/Info.ios.plist index b2c9a55a..56d0312c 100644 --- a/mobile/src-tauri/Info.ios.plist +++ b/mobile/src-tauri/Info.ios.plist @@ -24,5 +24,18 @@ compliance question on every TestFlight upload. --> ITSAppUsesNonExemptEncryption + + CFBundleURLTypes + + + CFBundleURLName + dev.tty7.mobile.pair + CFBundleURLSchemes + + tty7pair + + + diff --git a/mobile/src-tauri/capabilities/mobile.json b/mobile/src-tauri/capabilities/mobile.json index 6305b2a5..f598c3d2 100644 --- a/mobile/src-tauri/capabilities/mobile.json +++ b/mobile/src-tauri/capabilities/mobile.json @@ -10,7 +10,6 @@ "permissions": [ "barcode-scanner:default", "biometric:default", - "haptics:default", - "deep-link:default" + "haptics:default" ] } \ No newline at end of file diff --git a/mobile/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/mobile/src-tauri/gen/android/app/src/main/AndroidManifest.xml index f8ec0499..8c4d452b 100644 --- a/mobile/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/mobile/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -22,21 +22,13 @@ - - + + - - - - - - - - > = std::sync::Mutex::new(None); + +/// The desktop's pairing code is a `tty7pair:` link, so a phone's camera +/// pointed at its QR code opens the app with it (`Info.ios.plist`, the +/// Android manifest). Kept for the page to ask for, and told to it if it is +/// already listening. +#[cfg(any(target_os = "ios", target_os = "android"))] +fn opened(app: &tauri::AppHandle, urls: Vec) { + use tauri::Emitter as _; + let Some(link) = urls + .into_iter() + .map(String::from) + .find(|u| u.starts_with("tty7pair:")) + else { + return; + }; + *OPENED.lock().unwrap_or_else(|e| e.into_inner()) = Some(link.clone()); + let _ = app.emit("opened-link", link); +} + +/// A link that launches the app on iOS. UIKit hands it to the app's first +/// scene as that connects, in the connection options, and the event loop +/// (tao) passes on only the links that come once the scene is up. So its +/// scene delegate's connect is wrapped to keep the link on the way. The +/// delegate class exists once the app delegate does, which is installed as +/// UIKit starts: the wrap goes on then. +#[cfg(target_os = "ios")] +mod launch_link { + use objc2::msg_send; + use objc2::runtime::{AnyClass, AnyObject, Imp, Sel}; + use objc2::sel; + use std::ffi::{CStr, c_char}; + use std::sync::OnceLock; + + static SET_DELEGATE: OnceLock = OnceLock::new(); + static CONNECT: OnceLock = OnceLock::new(); + + type SetDelegate = unsafe extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject); + type Connect = unsafe extern "C-unwind" fn( + *mut AnyObject, + Sel, + *mut AnyObject, + *mut AnyObject, + *mut AnyObject, + ); + + /// Swaps `selector`'s implementation on `class` for `imp`, keeping the + /// one it had in `previous`. + unsafe fn wrap(class: *const AnyClass, selector: Sel, imp: Imp, previous: &OnceLock) { + unsafe { + let method = objc2::ffi::class_getInstanceMethod(class, selector); + if method.is_null() || previous.get().is_some() { + return; + } + if let Some(original) = objc2::ffi::method_setImplementation(method as *mut _, imp) { + let _ = previous.set(original as usize); + } + } + } + + pub fn catch() { + if let Some(app) = AnyClass::get(c"UIApplication") { + unsafe { + let imp: Imp = std::mem::transmute(set_delegate as SetDelegate); + wrap(app, sel!(setDelegate:), imp, &SET_DELEGATE); + } + } + } + + unsafe extern "C-unwind" fn set_delegate( + this: *mut AnyObject, + cmd: Sel, + delegate: *mut AnyObject, + ) { + unsafe { + if let Some(&previous) = SET_DELEGATE.get() { + let previous: SetDelegate = std::mem::transmute(previous); + previous(this, cmd, delegate); + } + if let Some(scene) = AnyClass::get(c"TaoSceneDelegate") { + let imp: Imp = std::mem::transmute(connect as Connect); + wrap( + scene, + sel!(scene:willConnectToSession:options:), + imp, + &CONNECT, + ); + } + } + } + + unsafe extern "C-unwind" fn connect( + this: *mut AnyObject, + cmd: Sel, + scene: *mut AnyObject, + session: *mut AnyObject, + options: *mut AnyObject, + ) { + unsafe { + if !options.is_null() { + let contexts: *mut AnyObject = msg_send![options, URLContexts]; + let context: *mut AnyObject = if contexts.is_null() { + std::ptr::null_mut() + } else { + msg_send![contexts, anyObject] + }; + let url: *mut AnyObject = if context.is_null() { + std::ptr::null_mut() + } else { + msg_send![context, URL] + }; + let text: *mut AnyObject = if url.is_null() { + std::ptr::null_mut() + } else { + msg_send![url, absoluteString] + }; + let utf8: *const c_char = if text.is_null() { + std::ptr::null() + } else { + msg_send![text, UTF8String] + }; + if !utf8.is_null() { + let link = CStr::from_ptr(utf8).to_string_lossy().into_owned(); + if link.starts_with("tty7pair:") { + *super::OPENED.lock().unwrap_or_else(|e| e.into_inner()) = Some(link); + } + } + } + if let Some(&previous) = CONNECT.get() { + let previous: Connect = std::mem::transmute(previous); + previous(this, cmd, scene, session, options); + } + } + } +} + +/// The link the app was opened with, once. +#[tauri::command] +fn opened_link() -> Option { + OPENED.lock().unwrap_or_else(|e| e.into_inner()).take() } /// Light, dark or the system's, for what the page does not draw itself: the diff --git a/mobile/src-tauri/tauri.conf.json b/mobile/src-tauri/tauri.conf.json index ac38064a..f9391b30 100644 --- a/mobile/src-tauri/tauri.conf.json +++ b/mobile/src-tauri/tauri.conf.json @@ -22,11 +22,6 @@ "csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src ipc: http://ipc.localhost" } }, - "plugins": { - "deep-link": { - "mobile": [{ "scheme": ["tty7pair"], "appLink": false }] - } - }, "bundle": { "active": true, "targets": "all", diff --git a/mobile/src/api.ts b/mobile/src/api.ts index 732f935f..19651154 100644 --- a/mobile/src/api.ts +++ b/mobile/src/api.ts @@ -109,6 +109,9 @@ export interface Insets { /** What the system bars cover, in CSS pixels. Android only: zero elsewhere. */ export const insets = () => invoke("insets"); +/** The `tty7pair:` link the app was opened with and has not handled, once. */ +export const openedLink = () => invoke("opened_link"); + /** Sends the app to the background, as Back from the first screen does. */ export const toBackground = () => invoke("to_background"); diff --git a/mobile/src/main.ts b/mobile/src/main.ts index 1cc0c0c2..cc772e27 100644 --- a/mobile/src/main.ts +++ b/mobile/src/main.ts @@ -10,8 +10,8 @@ import type { ITheme } from "@xterm/xterm"; import * as scanner from "@tauri-apps/plugin-barcode-scanner"; import { getVersion, onBackButtonPress } from "@tauri-apps/api/app"; +import { listen } from "@tauri-apps/api/event"; import { impactFeedback, selectionFeedback } from "@tauri-apps/plugin-haptics"; -import { getCurrent as linkedWith, onOpenUrl } from "@tauri-apps/plugin-deep-link"; import * as api from "./api"; import { MAX_UPLOAD } from "./api"; @@ -1112,7 +1112,13 @@ function hostScreen(host: Host, direction: "push" | "pop" = "pop") { draw(); }; const draw = () => { - if (lastTree) body.replaceChildren(...renderTree(host, lastTree, query, picked, pick)); + if (lastTree) + body.replaceChildren( + ...renderTree(host, lastTree, query, picked, pick, (key) => { + picked = key; + remember(`workspace.${host.id}`, key); + }), + ); }; const dock = floatingBar("Search tabs", (q) => { query = q; @@ -1344,6 +1350,7 @@ function renderTree( query: string, picked: string | null, pick: (key: string) => void, + keep: (key: string) => void, ): Node[] { if (query) { const groups = [ @@ -1393,6 +1400,9 @@ function renderTree( }), ]; const on = spaces.find((sp) => sp.key === picked) ?? spaces[0]; + // What is on screen stays on screen: a workspace the desktop opens or + // turns to later does not take its place under the reader's thumb. + if (on.key !== picked) keep(on.key); const out: Node[] = []; if (spaces.length > 1) { @@ -3585,24 +3595,35 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, if (prefs.lock) lock(); // A pairing code opened as a link — the desktop's QR code, read by the -// phone's camera — goes straight to pairing. -const pairingLink = (urls: string[] | null) => urls?.find((url) => url.startsWith("tty7pair:")); -void onOpenUrl((urls) => { - const code = pairingLink(urls); - if (code) pairScreen(code); -}).catch(() => {}); +// phone's camera — goes straight to pairing. The link is taken once, from +// the native side, which holds it until asked: one that launched the app +// can arrive before the page, or between the page's first look and its +// first screen. +let started = false; +const takeLink = () => + api.openedLink().then( + (code) => code && pairScreen(code), + () => {}, + ); +const listening = listen("opened-link", () => started && void takeLink()).catch(() => {}); // Back where it was left: the machine last open, unless it was left for the // list of machines; or pairing, when that is what opened the app. -Promise.all([api.hosts(), linkedWith().catch(() => null)]).then( - ([hosts, urls]) => { - const code = pairingLink(urls); - const last = hosts.find((host) => host.id === remembered("last.host")); - if (code) { +void listening.then(() => + Promise.all([api.hosts(), api.openedLink().catch(() => null)]).then( + ([hosts, code]) => { + const last = hosts.find((host) => host.id === remembered("last.host")); + if (code) { + hostsScreen("push"); + pairScreen(code); + } else if (last) hostScreen(last, "push"); + else hostsScreen("push"); + started = true; + void takeLink(); + }, + () => { hostsScreen("push"); - pairScreen(code); - } else if (last) hostScreen(last, "push"); - else hostsScreen("push"); - }, - () => hostsScreen("push"), + started = true; + }, + ), );