|
1
|
import { openUrl } from "@tauri-apps/plugin-opener"; |
|
2
|
|
|
3
|
/** |
|
4
|
* Native-shell hardening for the system WebView. |
|
5
|
* |
|
6
|
* Tauri renders the UI in WKWebView (macOS/iOS) or the Android WebView, both of |
|
7
|
* which ship with browser affordances that immediately read as "this is a web |
|
8
|
* page" inside a shipped app: pinch-to-zoom, ⌘+/− zoom, the browser context |
|
9
|
* menu, dragging text out of the window, and links navigating the app frame |
|
10
|
* away from the UI. |
|
11
|
* |
|
12
|
* App.css handles everything expressible as a passive style (callouts, tap |
|
13
|
* highlights, double-tap zoom, overscroll). The gestures below can only be |
|
14
|
* suppressed with live listeners, because the WebView acts on them before the |
|
15
|
* page sees a scroll or a click. |
|
16
|
* |
|
17
|
* Returns a teardown function; the listeners live for the lifetime of the app, |
|
18
|
* so nothing calls it outside of React's StrictMode double-invoke in dev. |
|
19
|
*/ |
|
20
|
export function installNativeShellBehaviour(): () => void { |
|
21
|
const offs: Array<() => void> = []; |
|
22
|
|
|
23
|
const on = ( |
|
24
|
target: EventTarget, |
|
25
|
type: string, |
|
26
|
handler: (e: never) => void, |
|
27
|
opts?: AddEventListenerOptions |
|
28
|
) => { |
|
29
|
target.addEventListener(type, handler as EventListener, opts); |
|
30
|
offs.push(() => target.removeEventListener(type, handler as EventListener, opts)); |
|
31
|
}; |
|
32
|
|
|
33
|
const swallow = (e: Event) => e.preventDefault(); |
|
34
|
|
|
35
|
// ── Pinch zoom ──────────────────────────────────────────────────────────── |
|
36
|
// WebKit reports trackpad and touch pinches as non-standard `gesture*` |
|
37
|
// events; they bypass `touch-action` and the viewport's `user-scalable=no`, |
|
38
|
// so a two-finger pinch on a Mac (or an iPad trackpad) still scales the UI |
|
39
|
// unless each one is cancelled. Must be non-passive to be cancellable. |
|
40
|
for (const type of ["gesturestart", "gesturechange", "gestureend"]) { |
|
41
|
on(document, type, swallow, { passive: false }); |
|
42
|
} |
|
43
|
|
|
44
|
// Chromium (Android WebView, and Linux/Windows desktop) maps a trackpad |
|
45
|
// pinch and ctrl+scroll onto a wheel event with `ctrlKey` set. |
|
46
|
on( |
|
47
|
window, |
|
48
|
"wheel", |
|
49
|
(e: WheelEvent) => { |
|
50
|
if (e.ctrlKey || e.metaKey) e.preventDefault(); |
|
51
|
}, |
|
52
|
{ passive: false } |
|
53
|
); |
|
54
|
|
|
55
|
// ── Keyboard zoom ───────────────────────────────────────────────────────── |
|
56
|
// `zoomHotkeysEnabled` is off in tauri.conf.json, but the WebView still |
|
57
|
// honours ⌘/Ctrl with +, -, and 0 on some platforms. Only the zoom keys are |
|
58
|
// intercepted — ⌘A/⌘C/⌘V and friends must keep working. |
|
59
|
on(window, "keydown", (e: KeyboardEvent) => { |
|
60
|
if (!(e.metaKey || e.ctrlKey)) return; |
|
61
|
if (["+", "=", "-", "_", "0"].includes(e.key)) e.preventDefault(); |
|
62
|
}); |
|
63
|
|
|
64
|
// ── Browser chrome ──────────────────────────────────────────────────────── |
|
65
|
// Suppress the WebView context menu except where it does something a native |
|
66
|
// app would also offer: editing a text field, or copying a selection. Left |
|
67
|
// intact during `pnpm dev` so "Inspect Element" stays reachable. |
|
68
|
if (!import.meta.env.DEV) { |
|
69
|
on(window, "contextmenu", (e: MouseEvent) => { |
|
70
|
const el = e.target as HTMLElement | null; |
|
71
|
const editable = |
|
72
|
el?.closest("input, textarea, [contenteditable='true']") != null; |
|
73
|
const selection = document.getSelection(); |
|
74
|
const hasSelection = selection != null && !selection.isCollapsed; |
|
75
|
if (!editable && !hasSelection) e.preventDefault(); |
|
76
|
}); |
|
77
|
} |
|
78
|
|
|
79
|
// Nothing in the app is draggable, and a dropped file would otherwise |
|
80
|
// navigate the WebView away from the UI with no way back. |
|
81
|
on(window, "dragstart", swallow); |
|
82
|
on(window, "dragover", swallow); |
|
83
|
on(window, "drop", swallow); |
|
84
|
|
|
85
|
// ── External links ──────────────────────────────────────────────────────── |
|
86
|
// A link that navigates the app frame is unrecoverable — there is no back |
|
87
|
// button. Route anything external to the system browser instead. Components |
|
88
|
// that already handle their own links (Settings) call `preventDefault`, so |
|
89
|
// check for that first to avoid opening the URL twice. |
|
90
|
on(window, "click", (e: MouseEvent) => { |
|
91
|
if (e.defaultPrevented) return; |
|
92
|
const anchor = (e.target as HTMLElement | null)?.closest("a"); |
|
93
|
const href = anchor?.getAttribute("href"); |
|
94
|
if (!href || !/^(https?|mailto):/i.test(href)) return; |
|
95
|
e.preventDefault(); |
|
96
|
void openUrl(href); |
|
97
|
}); |
|
98
|
|
|
99
|
offs.push(installKeyboardInset()); |
|
100
|
return () => offs.forEach((off) => off()); |
|
101
|
} |
|
102
|
|
|
103
|
/** |
|
104
|
* Keep the composer above the on-screen keyboard. |
|
105
|
* |
|
106
|
* iOS does not resize the WKWebView when the keyboard appears — it only shrinks |
|
107
|
* the visual viewport — so `100dvh` keeps reporting the full screen height and |
|
108
|
* the input ends up underneath the keyboard. Publishing the covered height as |
|
109
|
* `--keyboard-inset` lets App.css shrink the app frame the way a native view |
|
110
|
* controller would. Android WebView resizes the window itself, in which case |
|
111
|
* the measured inset stays 0 and this is a no-op. |
|
112
|
*/ |
|
113
|
function installKeyboardInset(): () => void { |
|
114
|
const vv = window.visualViewport; |
|
115
|
if (!vv) return () => {}; |
|
116
|
|
|
117
|
const root = document.documentElement; |
|
118
|
|
|
119
|
const sync = () => { |
|
120
|
// Height of the screen the keyboard (or any other overlay) covers. |
|
121
|
const covered = window.innerHeight - vv.height - vv.offsetTop; |
|
122
|
root.style.setProperty("--keyboard-inset", `${Math.max(0, Math.round(covered))}px`); |
|
123
|
// Focusing an input makes WebKit scroll the (non-scrollable) document to |
|
124
|
// reveal it, leaving the fixed layout shifted under the status bar. |
|
125
|
window.scrollTo(0, 0); |
|
126
|
}; |
|
127
|
|
|
128
|
vv.addEventListener("resize", sync); |
|
129
|
vv.addEventListener("scroll", sync); |
|
130
|
sync(); |
|
131
|
|
|
132
|
return () => { |
|
133
|
vv.removeEventListener("resize", sync); |
|
134
|
vv.removeEventListener("scroll", sync); |
|
135
|
root.style.removeProperty("--keyboard-inset"); |
|
136
|
}; |
|
137
|
} |