overmux 0.0.9 → 0.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/dist/bin.js +1 -1
- package/dist/bin.js.map +1 -1
- package/dist/docs/400-reference/200-configuration.md +35 -0
- package/dist/docs/400-reference/500-server/400-notifications.md +4 -0
- package/dist/docs/400-reference/600-client/030-native-web-view.md +166 -0
- package/dist/docs/400-reference/600-client/300-tech-stack-recommendations.md +1 -0
- package/dist/docs/400-reference/700-cli/600-docs.md +1 -1
- package/dist/exports/client.d.ts +43 -7
- package/dist/exports/client.d.ts.map +1 -1
- package/dist/exports/client.js +172 -22
- package/dist/exports/client.js.map +1 -1
- package/dist/exports/index.js +1 -1
- package/dist/exports/index.js.map +1 -1
- package/dist/internal/server/coordinator/server-child.js +25 -1
- package/dist/internal/server/coordinator/server-child.js.map +1 -1
- package/dist/push/THIRD_PARTY_NOTICES.md +30 -0
- package/dist/push/badge.png +0 -0
- package/dist/push/icon.png +0 -0
- package/dist/push/sw.js +88 -0
- package/dist/push/sw.js.map +1 -0
- package/docs/400-reference/200-configuration.md +35 -0
- package/docs/400-reference/500-server/400-notifications.md +4 -0
- package/docs/400-reference/600-client/030-native-web-view.md +166 -0
- package/docs/400-reference/600-client/300-tech-stack-recommendations.md +1 -0
- package/docs/400-reference/700-cli/600-docs.md +1 -1
- package/package.json +3 -3
- package/src/internal/client/background-notifications.ts +3 -1
- package/src/internal/client/client-definition.ts +9 -13
- package/src/internal/client/host/desktop-host.ts +28 -2
- package/src/internal/client/host/hosted-pages/browser-notification-settings.tsx +8 -3
- package/src/internal/client/native-web-view.tsx +158 -0
- package/src/internal/client/push-assets/badge.png +0 -0
- package/src/internal/client/push-assets/icon.png +0 -0
- package/src/internal/client/push-service-worker.ts +118 -0
- package/src/internal/client/register-push-worker.ts +63 -0
- package/src/internal/client/shortcuts.tsx +17 -31
- package/src/internal/server/http/routes.ts +47 -2
- package/src/internal/shared/routes.ts +2 -0
- package/src/public/client.ts +5 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { notificationLinkSchema } from "@overmux/shared";
|
|
2
|
+
import { notificationEventSchema } from "../shared/protocol";
|
|
3
|
+
import {
|
|
4
|
+
pushNotificationBadgePath,
|
|
5
|
+
pushNotificationIconPath,
|
|
6
|
+
} from "../shared/routes";
|
|
7
|
+
|
|
8
|
+
// Only the worker APIs this push-only entry uses. No fetch handler or offline shell.
|
|
9
|
+
type WorkerClient = {
|
|
10
|
+
url: string;
|
|
11
|
+
focus: () => Promise<WorkerClient>;
|
|
12
|
+
navigate: (url: string) => Promise<WorkerClient | null>;
|
|
13
|
+
};
|
|
14
|
+
type LifetimeEvent = { waitUntil: (work: Promise<unknown>) => void };
|
|
15
|
+
type WorkerEvents = {
|
|
16
|
+
install: LifetimeEvent;
|
|
17
|
+
push: LifetimeEvent & { data?: { json: () => unknown } };
|
|
18
|
+
notificationclick: LifetimeEvent & {
|
|
19
|
+
notification: { close: () => void; data?: { link?: unknown } };
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
const worker = globalThis as unknown as {
|
|
23
|
+
addEventListener: <Type extends keyof WorkerEvents>(
|
|
24
|
+
type: Type,
|
|
25
|
+
listener: (event: WorkerEvents[Type]) => void,
|
|
26
|
+
) => void;
|
|
27
|
+
clients: {
|
|
28
|
+
matchAll: (options: {
|
|
29
|
+
includeUncontrolled: boolean;
|
|
30
|
+
type: "window";
|
|
31
|
+
}) => Promise<WorkerClient[]>;
|
|
32
|
+
openWindow: (url: string) => Promise<WorkerClient | null>;
|
|
33
|
+
};
|
|
34
|
+
location: Location;
|
|
35
|
+
registration: {
|
|
36
|
+
showNotification: (
|
|
37
|
+
title: string,
|
|
38
|
+
options: NotificationOptions,
|
|
39
|
+
) => Promise<void>;
|
|
40
|
+
};
|
|
41
|
+
skipWaiting: () => Promise<void>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const notificationDestination = (link: unknown) => {
|
|
45
|
+
const parsed = notificationLinkSchema.safeParse(link);
|
|
46
|
+
if (!parsed.success) {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
const destination = new URL(parsed.data, worker.location.origin);
|
|
50
|
+
// Notification links support relative paths and HTTP URLs, but background clicks
|
|
51
|
+
// must never send an authenticated PWA window to another origin.
|
|
52
|
+
return destination.origin === worker.location.origin
|
|
53
|
+
? destination.href
|
|
54
|
+
: undefined;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const focusOrOpen = async (link: unknown) => {
|
|
58
|
+
const destination = notificationDestination(link);
|
|
59
|
+
const clients = await worker.clients.matchAll({
|
|
60
|
+
includeUncontrolled: true,
|
|
61
|
+
type: "window",
|
|
62
|
+
});
|
|
63
|
+
const client =
|
|
64
|
+
clients.find((candidate) => candidate.url === destination) ??
|
|
65
|
+
clients.find(
|
|
66
|
+
(candidate) => new URL(candidate.url).origin === worker.location.origin,
|
|
67
|
+
);
|
|
68
|
+
if (!client) {
|
|
69
|
+
await worker.clients.openWindow(
|
|
70
|
+
destination ?? new URL("/", worker.location.origin).href,
|
|
71
|
+
);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
// Without a safe link, preserve the user's current route. A disappearing client
|
|
75
|
+
// can return null from navigate; open a fresh window rather than lose the click.
|
|
76
|
+
const navigated =
|
|
77
|
+
destination && client.url !== destination
|
|
78
|
+
? await client.navigate(destination)
|
|
79
|
+
: client;
|
|
80
|
+
if (navigated) {
|
|
81
|
+
await navigated.focus();
|
|
82
|
+
} else {
|
|
83
|
+
await worker.clients.openWindow(destination!);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
worker.addEventListener("install", (event) => {
|
|
88
|
+
// This worker owns no caches or page traffic, so replacing it need not wait for tabs.
|
|
89
|
+
event.waitUntil(worker.skipWaiting());
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
worker.addEventListener("push", (event) => {
|
|
93
|
+
let input: unknown;
|
|
94
|
+
try {
|
|
95
|
+
input = event.data?.json();
|
|
96
|
+
} catch {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const payload = notificationEventSchema.safeParse(input);
|
|
100
|
+
if (!payload.success) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const { notification } = payload.data;
|
|
104
|
+
event.waitUntil(
|
|
105
|
+
worker.registration.showNotification(notification.title, {
|
|
106
|
+
body: notification.body,
|
|
107
|
+
// Bypass cached transparent icons after adding the light background.
|
|
108
|
+
icon: `${pushNotificationIconPath}?v=2`,
|
|
109
|
+
badge: pushNotificationBadgePath,
|
|
110
|
+
data: { link: notification.open?.link },
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
worker.addEventListener("notificationclick", (event) => {
|
|
116
|
+
event.notification.close();
|
|
117
|
+
event.waitUntil(focusOrOpen(event.notification.data?.link));
|
|
118
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Bound registration as well as activation: serviceWorker.ready can wait forever
|
|
2
|
+
// when no worker exists, and an installing worker can fail or stall indefinitely.
|
|
3
|
+
export const registerPushWorker = () =>
|
|
4
|
+
new Promise<ServiceWorkerRegistration>((resolve, reject) => {
|
|
5
|
+
let watched: ServiceWorker | null = null;
|
|
6
|
+
let settled = false;
|
|
7
|
+
const finish = (
|
|
8
|
+
error?: Error,
|
|
9
|
+
registration?: ServiceWorkerRegistration,
|
|
10
|
+
) => {
|
|
11
|
+
if (settled) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
settled = true;
|
|
15
|
+
clearTimeout(timeout);
|
|
16
|
+
watched?.removeEventListener("statechange", onStateChange);
|
|
17
|
+
if (error) {
|
|
18
|
+
reject(error);
|
|
19
|
+
} else {
|
|
20
|
+
resolve(registration!);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
let registered: ServiceWorkerRegistration | undefined;
|
|
24
|
+
const onStateChange = () => {
|
|
25
|
+
if (watched?.state === "activated") {
|
|
26
|
+
finish(undefined, registered);
|
|
27
|
+
} else if (watched?.state === "redundant") {
|
|
28
|
+
finish(
|
|
29
|
+
new Error("The background notification worker failed to activate"),
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const timeout = setTimeout(() => {
|
|
34
|
+
finish(
|
|
35
|
+
new Error("Timed out starting the background notification worker"),
|
|
36
|
+
);
|
|
37
|
+
}, 10_000);
|
|
38
|
+
void navigator.serviceWorker
|
|
39
|
+
.register("/sw.js", { scope: "/", updateViaCache: "none" })
|
|
40
|
+
.then(
|
|
41
|
+
(registration) => {
|
|
42
|
+
// The browser registration itself cannot be cancelled; ignore late results.
|
|
43
|
+
if (settled) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
registered = registration;
|
|
47
|
+
watched =
|
|
48
|
+
registration.active ??
|
|
49
|
+
registration.installing ??
|
|
50
|
+
registration.waiting;
|
|
51
|
+
if (!watched) {
|
|
52
|
+
finish(
|
|
53
|
+
new Error("No background notification worker was installed"),
|
|
54
|
+
);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
watched.addEventListener("statechange", onStateChange);
|
|
58
|
+
onStateChange();
|
|
59
|
+
},
|
|
60
|
+
(error: unknown) =>
|
|
61
|
+
finish(error instanceof Error ? error : new Error(String(error))),
|
|
62
|
+
);
|
|
63
|
+
});
|
|
@@ -8,9 +8,12 @@ import {
|
|
|
8
8
|
type RefObject,
|
|
9
9
|
} from "react";
|
|
10
10
|
import {
|
|
11
|
+
keyBindingSequence,
|
|
11
12
|
matchesKeyBinding,
|
|
12
|
-
|
|
13
|
+
matchesShortcutBindingPrefix,
|
|
14
|
+
normalizedShortcutBinding,
|
|
13
15
|
type KeyBinding,
|
|
16
|
+
type ShortcutBinding,
|
|
14
17
|
} from "@overmux/keybindings";
|
|
15
18
|
|
|
16
19
|
import {
|
|
@@ -19,7 +22,6 @@ import {
|
|
|
19
22
|
shortcutMediaQueries,
|
|
20
23
|
type ClientAppDefinition,
|
|
21
24
|
type CommandHandle,
|
|
22
|
-
type ShortcutBinding,
|
|
23
25
|
} from "./client-definition";
|
|
24
26
|
import {
|
|
25
27
|
CommandsContext,
|
|
@@ -29,25 +31,6 @@ import {
|
|
|
29
31
|
type RegisteredCommand,
|
|
30
32
|
} from "./commands";
|
|
31
33
|
|
|
32
|
-
const bindingSequence = (binding: ShortcutBinding): KeyBinding[] =>
|
|
33
|
-
typeof binding === "string" ? [binding] : [...binding];
|
|
34
|
-
|
|
35
|
-
const normalizedBinding = (binding: ShortcutBinding): string[] =>
|
|
36
|
-
bindingSequence(binding).map((key) => platformBinding(key));
|
|
37
|
-
|
|
38
|
-
const sequenceMatches = (
|
|
39
|
-
binding: ShortcutBinding,
|
|
40
|
-
pending: string[],
|
|
41
|
-
event: KeyboardEvent,
|
|
42
|
-
) => {
|
|
43
|
-
const sequence = normalizedBinding(binding);
|
|
44
|
-
return (
|
|
45
|
-
sequence.length > pending.length &&
|
|
46
|
-
pending.every((key, index) => sequence[index] === key) &&
|
|
47
|
-
matchesKeyBinding(sequence[pending.length] as KeyBinding, event)
|
|
48
|
-
);
|
|
49
|
-
};
|
|
50
|
-
|
|
51
34
|
const isTextInput = (element: Element | null) =>
|
|
52
35
|
element instanceof HTMLInputElement ||
|
|
53
36
|
element instanceof HTMLSelectElement ||
|
|
@@ -67,7 +50,7 @@ const acceptsTextInput = (bindings: readonly ShortcutBinding[]) => {
|
|
|
67
50
|
return true;
|
|
68
51
|
}
|
|
69
52
|
return bindings.some((binding) => {
|
|
70
|
-
const sequence =
|
|
53
|
+
const sequence = keyBindingSequence(binding);
|
|
71
54
|
return (
|
|
72
55
|
sequence.length > 1 ||
|
|
73
56
|
sequence[0]?.startsWith("F") ||
|
|
@@ -260,12 +243,12 @@ export const ShortcutHost = ({
|
|
|
260
243
|
window.addEventListener("focusin", refreshFocus);
|
|
261
244
|
return () => window.removeEventListener("focusin", refreshFocus);
|
|
262
245
|
}, []);
|
|
246
|
+
const shortcuts = flattenShortcuts(
|
|
247
|
+
definition,
|
|
248
|
+
(media) => window.matchMedia(media).matches,
|
|
249
|
+
);
|
|
263
250
|
useEffect(() => {
|
|
264
|
-
|
|
265
|
-
definition,
|
|
266
|
-
(media) => window.matchMedia(media).matches,
|
|
267
|
-
);
|
|
268
|
-
let pending: string[] = [];
|
|
251
|
+
let pending: KeyBinding[] = [];
|
|
269
252
|
let pendingEvents: KeyboardEvent[] = [];
|
|
270
253
|
let pendingTarget: ShortcutInputTarget | undefined;
|
|
271
254
|
let timeout: number | undefined;
|
|
@@ -302,8 +285,11 @@ export const ShortcutHost = ({
|
|
|
302
285
|
shortcut.bindings
|
|
303
286
|
.filter(
|
|
304
287
|
(binding) =>
|
|
305
|
-
|
|
306
|
-
|
|
288
|
+
matchesShortcutBindingPrefix({
|
|
289
|
+
binding,
|
|
290
|
+
input: event,
|
|
291
|
+
pending,
|
|
292
|
+
}) && canRunShortcut(shortcut, event, registrations),
|
|
307
293
|
)
|
|
308
294
|
.map((binding) => ({ binding, shortcut })),
|
|
309
295
|
);
|
|
@@ -317,7 +303,7 @@ export const ShortcutHost = ({
|
|
|
317
303
|
}
|
|
318
304
|
const complete = matches.find(
|
|
319
305
|
({ binding }) =>
|
|
320
|
-
|
|
306
|
+
normalizedShortcutBinding(binding).length === pending.length + 1,
|
|
321
307
|
);
|
|
322
308
|
if (complete) {
|
|
323
309
|
clearPending();
|
|
@@ -336,7 +322,7 @@ export const ShortcutHost = ({
|
|
|
336
322
|
}
|
|
337
323
|
pending = [
|
|
338
324
|
...pending,
|
|
339
|
-
|
|
325
|
+
normalizedShortcutBinding(matches[0]!.binding)[pending.length]!,
|
|
340
326
|
];
|
|
341
327
|
pendingEvents = [...pendingEvents, event];
|
|
342
328
|
event.preventDefault();
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
2
2
|
import type { Handler, Hono, MiddlewareHandler } from "hono";
|
|
3
|
-
import {
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { basename, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
4
6
|
|
|
5
7
|
import {
|
|
6
8
|
createHttpAuthenticationHandlers,
|
|
@@ -21,7 +23,11 @@ import { createOperationHandler } from "./operation-handler";
|
|
|
21
23
|
import { createRequestLoggingMiddleware } from "./request-logging";
|
|
22
24
|
import { createRuntimeManifestHandler } from "./runtime-manifest-handler";
|
|
23
25
|
import { overmuxLogoutPath, overmuxSettingsPath } from "@overmux/shared";
|
|
24
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
pushNotificationBadgePath,
|
|
28
|
+
pushNotificationIconPath,
|
|
29
|
+
viteInfrastructurePath,
|
|
30
|
+
} from "../../shared/routes";
|
|
25
31
|
|
|
26
32
|
// Registration order is the public/protected boundary. Authentication state,
|
|
27
33
|
// login, and health are registered before the session gate; runtime APIs and
|
|
@@ -105,6 +111,28 @@ export const registerHttpRoutes = ({
|
|
|
105
111
|
handler: createRequestLoggingMiddleware({ serverLogger }),
|
|
106
112
|
},
|
|
107
113
|
// BEGIN: UNAUTHENTICATED
|
|
114
|
+
// Public branding must remain fetchable when a push wakes the worker after
|
|
115
|
+
// session expiry. Serve only these packaged PNGs, never userland assets.
|
|
116
|
+
...[pushNotificationIconPath, pushNotificationBadgePath].map(
|
|
117
|
+
(path): RouteDescriptor => ({
|
|
118
|
+
method: "GET",
|
|
119
|
+
path,
|
|
120
|
+
handler: async (context) => {
|
|
121
|
+
const asset = await readFile(
|
|
122
|
+
fileURLToPath(
|
|
123
|
+
new URL(
|
|
124
|
+
`../push/${basename(path)}`,
|
|
125
|
+
import.meta.resolve("overmux"),
|
|
126
|
+
),
|
|
127
|
+
),
|
|
128
|
+
);
|
|
129
|
+
context.header("Cache-Control", "public, max-age=86400");
|
|
130
|
+
context.header("Content-Type", "image/png");
|
|
131
|
+
context.header("X-Content-Type-Options", "nosniff");
|
|
132
|
+
return context.body(new Uint8Array(asset));
|
|
133
|
+
},
|
|
134
|
+
}),
|
|
135
|
+
),
|
|
108
136
|
{
|
|
109
137
|
method: "GET",
|
|
110
138
|
path: "/_overmux/auth-shell/*",
|
|
@@ -138,6 +166,23 @@ export const registerHttpRoutes = ({
|
|
|
138
166
|
handler: authentication.authenticate,
|
|
139
167
|
},
|
|
140
168
|
// BEGIN: AUTHENTICATED
|
|
169
|
+
// Runtime-owned in both Vite development and production; never the SPA fallback.
|
|
170
|
+
{
|
|
171
|
+
method: "GET",
|
|
172
|
+
path: "/sw.js",
|
|
173
|
+
handler: async (context) => {
|
|
174
|
+
const script = await readFile(
|
|
175
|
+
fileURLToPath(
|
|
176
|
+
new URL("../push/sw.js", import.meta.resolve("overmux")),
|
|
177
|
+
),
|
|
178
|
+
"utf8",
|
|
179
|
+
);
|
|
180
|
+
context.header("Cache-Control", "no-cache");
|
|
181
|
+
context.header("Content-Type", "text/javascript; charset=UTF-8");
|
|
182
|
+
context.header("X-Content-Type-Options", "nosniff");
|
|
183
|
+
return context.body(script);
|
|
184
|
+
},
|
|
185
|
+
},
|
|
141
186
|
{
|
|
142
187
|
method: "POST",
|
|
143
188
|
path: "/api/auth/logout",
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export const pushNotificationIconPath = "/_overmux/push/icon.png";
|
|
2
|
+
export const pushNotificationBadgePath = "/_overmux/push/badge.png";
|
|
1
3
|
export const runtimeWebSocketPath = "/api/socket";
|
|
2
4
|
export const viteHmrPath = "/_overmux/vite/hmr";
|
|
3
5
|
export const viteInfrastructurePath = "/_overmux/vite/*";
|
package/src/public/client.ts
CHANGED
|
@@ -14,6 +14,11 @@ export {
|
|
|
14
14
|
type StreamResult,
|
|
15
15
|
} from "../internal/client/overmux-react";
|
|
16
16
|
export { OvermuxHost } from "../internal/client/host/overmux-host";
|
|
17
|
+
export { NativeWebView } from "../internal/client/native-web-view";
|
|
18
|
+
export type {
|
|
19
|
+
NativeWebViewProps,
|
|
20
|
+
NativeWebViewLoadError,
|
|
21
|
+
} from "../internal/client/native-web-view";
|
|
17
22
|
export {
|
|
18
23
|
OvermuxPortal,
|
|
19
24
|
OvermuxThemeScope,
|