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;
+ },
+ ),
);