release/windows-microsoft-store
ts 137 lines 6.01 KB
Raw
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 }