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.
@@ -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.10",
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 navigator.serviceWorker.ready;
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 ? (
@@ -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 { join } from "node:path";
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 { viteInfrastructurePath } from "../../shared/routes";
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/*";