overmux 0.0.10 → 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 +10 -0
- 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/exports/client.d.ts.map +1 -1
- package/dist/exports/client.js +49 -7
- package/dist/exports/client.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/package.json +2 -2
- package/src/internal/client/background-notifications.ts +3 -1
- package/src/internal/client/host/hosted-pages/browser-notification-settings.tsx +8 -3
- 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/server/http/routes.ts +47 -2
- package/src/internal/shared/routes.ts +2 -0
|
@@ -255,3 +255,38 @@ export default defineOvermuxDesktopConfig({
|
|
|
255
255
|
macosTrafficLights: "hidden",
|
|
256
256
|
});
|
|
257
257
|
```
|
|
258
|
+
|
|
259
|
+
### `onBeforeInputEvent`
|
|
260
|
+
|
|
261
|
+
The optional `onBeforeInputEvent` callback exposes Electron's [`before-input-event`](https://www.electronjs.org/docs/latest/api/web-contents#event-before-input-event), receiving the actual Electron `Event`, `Input`, and receiving [`WebContents`](https://www.electronjs.org/docs/latest/api/web-contents).
|
|
262
|
+
|
|
263
|
+
You can use this callback to intercept Electron key presses.
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
import { defineOvermuxDesktopConfig } from "@overmux/desktop";
|
|
268
|
+
|
|
269
|
+
export default defineOvermuxDesktopConfig({
|
|
270
|
+
onBeforeInputEvent: ({ event, input, webContents }) => {
|
|
271
|
+
const key = input.key.toLowerCase();
|
|
272
|
+
if (
|
|
273
|
+
process.platform !== "linux" ||
|
|
274
|
+
input.type !== "keyDown" ||
|
|
275
|
+
!input.meta ||
|
|
276
|
+
input.control ||
|
|
277
|
+
input.alt ||
|
|
278
|
+
input.shift ||
|
|
279
|
+
(key !== "c" && key !== "v")
|
|
280
|
+
) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
event.preventDefault();
|
|
285
|
+
if (key === "c") {
|
|
286
|
+
webContents.copy();
|
|
287
|
+
} else {
|
|
288
|
+
webContents.paste();
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
```
|
|
@@ -30,3 +30,7 @@ export const serverConfig = defineOvermuxServer({
|
|
|
30
30
|
- `open.link`: optional destination to open when the notification is activated. Use an app-relative path starting with `/` or an HTTP(S) URL without credentials.
|
|
31
31
|
|
|
32
32
|
Each text field and link has a maximum length of 4,096 characters. `send()` returns `Promise<void>`.
|
|
33
|
+
|
|
34
|
+
## PWA background notifications
|
|
35
|
+
|
|
36
|
+
Open `/_overmux/settings` on your Overmux server, and enable browser notifications. This requires HTTPS, or localhost for development. See [Hosted pages](../../500-hosted-pages.md) for adding a settings link to your application.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "overmux",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.11",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"homepage": "https://github.com/richardgill/overmux",
|
|
6
6
|
"repository": {
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
"access": "public"
|
|
83
83
|
},
|
|
84
84
|
"scripts": {
|
|
85
|
-
"build": "node ../../../scripts/generate-ai-context-docs.ts && rm -rf dist && vite build && vite build --config vite.auth.config.ts && vp pack && node scripts/clean-declarations.ts && node scripts/add-client-styles.mjs && cp -R docs dist/docs",
|
|
85
|
+
"build": "node ../../../scripts/generate-ai-context-docs.ts && rm -rf dist && vite build && vite build --config vite.auth.config.ts && vite build --config vite.push.config.ts && vp pack && node scripts/clean-declarations.ts && node scripts/add-client-styles.mjs && cp -R docs dist/docs",
|
|
86
86
|
"dev": "pnpm run dev:cli",
|
|
87
87
|
"dev:cli": "node --import jiti/register src/internal/cli/bin.ts serve",
|
|
88
88
|
"serve": "node dist/bin.js serve",
|
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
backgroundNotificationSubscriptionSchema,
|
|
6
6
|
} from "../shared/index";
|
|
7
7
|
|
|
8
|
+
import { registerPushWorker } from "./register-push-worker";
|
|
9
|
+
|
|
8
10
|
const enabledStorageKey = "overmux.backgroundNotificationsEnabled";
|
|
9
11
|
|
|
10
12
|
export type BackgroundNotificationStatus =
|
|
@@ -132,7 +134,7 @@ const removeCurrentSubscription = async () => {
|
|
|
132
134
|
};
|
|
133
135
|
|
|
134
136
|
const reconcileBackgroundNotifications = async () => {
|
|
135
|
-
const registration = await
|
|
137
|
+
const registration = await registerPushWorker();
|
|
136
138
|
const publicKey = await getPublicKey();
|
|
137
139
|
let subscription = await registration.pushManager.getSubscription();
|
|
138
140
|
const subscriptionKey = subscription
|
|
@@ -25,6 +25,7 @@ const statusMessage = (status: BackgroundNotificationStatus) => {
|
|
|
25
25
|
export const BrowserNotificationSettings = () => {
|
|
26
26
|
const [status, setStatus] =
|
|
27
27
|
useState<BackgroundNotificationStatus>("checking");
|
|
28
|
+
const [error, setError] = useState<string>();
|
|
28
29
|
|
|
29
30
|
useEffect(() => {
|
|
30
31
|
let active = true;
|
|
@@ -35,8 +36,9 @@ export const BrowserNotificationSettings = () => {
|
|
|
35
36
|
setStatus(next);
|
|
36
37
|
}
|
|
37
38
|
})
|
|
38
|
-
.catch(() => {
|
|
39
|
+
.catch((cause: unknown) => {
|
|
39
40
|
if (active) {
|
|
41
|
+
setError(cause instanceof Error ? cause.message : undefined);
|
|
40
42
|
setStatus("error");
|
|
41
43
|
}
|
|
42
44
|
});
|
|
@@ -70,7 +72,8 @@ export const BrowserNotificationSettings = () => {
|
|
|
70
72
|
? "denied"
|
|
71
73
|
: "disabled",
|
|
72
74
|
);
|
|
73
|
-
} catch {
|
|
75
|
+
} catch (cause) {
|
|
76
|
+
setError(cause instanceof Error ? cause.message : undefined);
|
|
74
77
|
setStatus("error");
|
|
75
78
|
}
|
|
76
79
|
}, []);
|
|
@@ -80,7 +83,8 @@ export const BrowserNotificationSettings = () => {
|
|
|
80
83
|
setStatus("checking");
|
|
81
84
|
await disableBackgroundNotifications();
|
|
82
85
|
setStatus("disabled");
|
|
83
|
-
} catch {
|
|
86
|
+
} catch (cause) {
|
|
87
|
+
setError(cause instanceof Error ? cause.message : undefined);
|
|
84
88
|
setStatus("error");
|
|
85
89
|
}
|
|
86
90
|
}, []);
|
|
@@ -92,6 +96,7 @@ export const BrowserNotificationSettings = () => {
|
|
|
92
96
|
<h3>Browser notifications</h3>
|
|
93
97
|
<p aria-live="polite" data-om-background-notifications-status="">
|
|
94
98
|
{statusMessage(status)}
|
|
99
|
+
{status === "error" && error ? ` ${error}` : null}
|
|
95
100
|
</p>
|
|
96
101
|
<div data-om-hosted-actions="">
|
|
97
102
|
{canEnable ? (
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
+
});
|
|
@@ -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/*";
|