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.
This commit is contained in:
l0ng-ai
2026-10-03 20:27:44 +08:00
parent 5aa1bba411
commit 8d8a844e89
11 changed files with 215 additions and 146 deletions
-10
View File
@@ -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",
-1
View File
@@ -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",
-92
View File
@@ -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",
-3
View File
@@ -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.
+13
View File
@@ -24,5 +24,18 @@
compliance question on every TestFlight upload. -->
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<!-- The desktop's pairing code is a tty7pair: link, so the phone's Camera
opens it in tty7 (lib.rs `opened`, `launch_link`). -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>dev.tty7.mobile.pair</string>
<key>CFBundleURLSchemes</key>
<array>
<string>tty7pair</string>
</array>
</dict>
</array>
</dict>
</plist>
+1 -2
View File
@@ -10,7 +10,6 @@
"permissions": [
"barcode-scanner:default",
"biometric:default",
"haptics:default",
"deep-link:default"
"haptics:default"
]
}
@@ -22,21 +22,13 @@
<!-- AndroidTV support -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
<intent-filter >
<!-- The desktop's pairing code is a tty7pair: link (lib.rs `opened`). -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<!-- ChromeOS ARC++ uses a different action for deep links -->
<action android:name="org.chromium.arc.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="tty7pair" />
</intent-filter>
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
</activity>
<provider
+157 -5
View File
@@ -527,6 +527,8 @@ pub fn run() {
// unless a process-wide provider is installed first. `ring` is the one
// already in the tree. An `Err` means one is installed, which is fine.
let _ = rustls::crypto::ring::default_provider().install_default();
#[cfg(target_os = "ios")]
launch_link::catch();
// Links tapped in a pane open in the phone's browser.
let builder = tauri::Builder::default().plugin(tauri_plugin_opener::init());
@@ -536,8 +538,7 @@ pub fn run() {
let builder = builder
.plugin(tauri_plugin_barcode_scanner::init())
.plugin(tauri_plugin_biometric::init())
.plugin(tauri_plugin_haptics::init())
.plugin(tauri_plugin_deep_link::init());
.plugin(tauri_plugin_haptics::init());
builder
.setup(|app| {
@@ -575,10 +576,161 @@ pub fn run() {
pane_close,
appearance,
insets,
to_background
to_background,
opened_link
])
.run(tauri::generate_context!())
.expect("error while running tty7");
.build(tauri::generate_context!())
.expect("error while building tty7")
.run(|_app, _event| {
#[cfg(any(target_os = "ios", target_os = "android"))]
if let tauri::RunEvent::Opened { urls } = _event {
opened(_app, urls);
}
});
}
/// A link the app was opened with that the page has not taken yet: one that
/// launched the app arrives before the page is there to hear it.
static OPENED: std::sync::Mutex<Option<String>> = 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<tauri::Url>) {
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<usize> = OnceLock::new();
static CONNECT: OnceLock<usize> = 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<usize>) {
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<String> {
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
-5
View File
@@ -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",
+3
View File
@@ -109,6 +109,9 @@ export interface Insets {
/** What the system bars cover, in CSS pixels. Android only: zero elsewhere. */
export const insets = () => invoke<Insets>("insets");
/** The `tty7pair:` link the app was opened with and has not handled, once. */
export const openedLink = () => invoke<string | null>("opened_link");
/** Sends the app to the background, as Back from the first screen does. */
export const toBackground = () => invoke<void>("to_background");
+39 -18
View File
@@ -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;
},
),
);