overmux 0.0.1 → 0.0.2

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.
Files changed (193) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +7 -0
  3. package/dist/auth/.vite/manifest.json +11 -0
  4. package/dist/auth/assets/index-Bx8JhwVi.css +1 -0
  5. package/dist/auth/assets/index-CHIE7qYj.js +72 -0
  6. package/dist/auth/index.html +14 -0
  7. package/dist/bin.js +4504 -7870
  8. package/dist/bin.js.map +1 -0
  9. package/dist/docs/000-index.md +21 -0
  10. package/dist/docs/100-introduction/100-what-is-overmux.md +7 -0
  11. package/dist/docs/100-introduction/200-how-overmux-works.md +7 -0
  12. package/dist/docs/100-introduction/300-why-overmux.md +22 -0
  13. package/dist/docs/200-getting-started/100-install-and-run-overmux.md +51 -0
  14. package/dist/docs/200-getting-started/200-install-overmux-desktop.md +41 -0
  15. package/dist/docs/200-getting-started/300-install-overmux-pwa.mdx +92 -0
  16. package/dist/docs/200-getting-started/400-secure-with-https/100-choose-an-https-setup.md +14 -0
  17. package/dist/docs/200-getting-started/400-secure-with-https/200-tailscale-serve.md +44 -0
  18. package/dist/docs/200-getting-started/400-secure-with-https/300-cloudflare-tunnel.md +59 -0
  19. package/dist/docs/200-getting-started/400-secure-with-https/400-self-hosted-reverse-proxy.md +32 -0
  20. package/dist/docs/300-fundamentals/100-project-structure.md +3 -0
  21. package/dist/docs/300-fundamentals/200-configuration.md +3 -0
  22. package/dist/docs/300-fundamentals/300-theming.md +3 -0
  23. package/dist/docs/300-fundamentals/400-server.md +3 -0
  24. package/dist/docs/300-fundamentals/500-client.md +3 -0
  25. package/dist/docs/300-fundamentals/600-operations.md +3 -0
  26. package/dist/docs/300-fundamentals/700-resources.md +3 -0
  27. package/dist/docs/300-fundamentals/800-streams.md +3 -0
  28. package/dist/docs/300-fundamentals/900-authentication-and-security.md +3 -0
  29. package/dist/docs/400-reference/100-configuration.md +3 -0
  30. package/dist/docs/400-reference/200-server-api.md +3 -0
  31. package/dist/docs/400-reference/300-client-api.md +3 -0
  32. package/dist/docs/400-reference/400-cli/100-serve.md +33 -0
  33. package/dist/docs/400-reference/400-cli/200-auth.md +52 -0
  34. package/dist/docs/400-reference/400-cli/300-call.md +28 -0
  35. package/dist/docs/400-reference/400-cli/400-check.md +20 -0
  36. package/dist/docs/400-reference/400-cli/500-ai-context.md +102 -0
  37. package/dist/docs/400-reference/400-cli/600-docs.md +23 -0
  38. package/dist/docs/400-reference/400-cli/700-desktop.md +35 -0
  39. package/dist/docs/500-hosted-pages.md +31 -0
  40. package/dist/exports/client.d.ts +237 -0
  41. package/dist/exports/client.d.ts.map +1 -0
  42. package/dist/exports/client.js +1643 -0
  43. package/dist/exports/client.js.map +1 -0
  44. package/dist/exports/index-D3_Dpy_T.d.ts +155 -0
  45. package/dist/exports/index-D3_Dpy_T.d.ts.map +1 -0
  46. package/dist/exports/index.d.ts +2 -0
  47. package/dist/exports/index.js +49 -0
  48. package/dist/exports/index.js.map +1 -0
  49. package/dist/exports/notifications-kDp16bU_.js +56 -0
  50. package/dist/exports/notifications-kDp16bU_.js.map +1 -0
  51. package/dist/exports/server.d.ts +63 -0
  52. package/dist/exports/server.d.ts.map +1 -0
  53. package/dist/exports/server.js +1120 -0
  54. package/dist/exports/server.js.map +1 -0
  55. package/dist/exports/style.css +368 -0
  56. package/dist/internal/server/coordinator/server-child.d.ts +1 -0
  57. package/dist/internal/server/coordinator/server-child.js +3299 -0
  58. package/dist/internal/server/coordinator/server-child.js.map +1 -0
  59. package/docs/000-index.md +21 -0
  60. package/docs/100-introduction/100-what-is-overmux.md +7 -0
  61. package/docs/100-introduction/200-how-overmux-works.md +7 -0
  62. package/docs/100-introduction/300-why-overmux.md +22 -0
  63. package/docs/200-getting-started/100-install-and-run-overmux.md +51 -0
  64. package/docs/200-getting-started/200-install-overmux-desktop.md +41 -0
  65. package/docs/200-getting-started/300-install-overmux-pwa.mdx +92 -0
  66. package/docs/200-getting-started/400-secure-with-https/100-choose-an-https-setup.md +14 -0
  67. package/docs/200-getting-started/400-secure-with-https/200-tailscale-serve.md +44 -0
  68. package/docs/200-getting-started/400-secure-with-https/300-cloudflare-tunnel.md +59 -0
  69. package/docs/200-getting-started/400-secure-with-https/400-self-hosted-reverse-proxy.md +32 -0
  70. package/docs/300-fundamentals/100-project-structure.md +3 -0
  71. package/docs/300-fundamentals/200-configuration.md +3 -0
  72. package/docs/300-fundamentals/300-theming.md +3 -0
  73. package/docs/300-fundamentals/400-server.md +3 -0
  74. package/docs/300-fundamentals/500-client.md +3 -0
  75. package/docs/300-fundamentals/600-operations.md +3 -0
  76. package/docs/300-fundamentals/700-resources.md +3 -0
  77. package/docs/300-fundamentals/800-streams.md +3 -0
  78. package/docs/300-fundamentals/900-authentication-and-security.md +3 -0
  79. package/docs/400-reference/100-configuration.md +3 -0
  80. package/docs/400-reference/200-server-api.md +3 -0
  81. package/docs/400-reference/300-client-api.md +3 -0
  82. package/docs/400-reference/400-cli/100-serve.md +33 -0
  83. package/docs/400-reference/400-cli/200-auth.md +52 -0
  84. package/docs/400-reference/400-cli/300-call.md +28 -0
  85. package/docs/400-reference/400-cli/400-check.md +20 -0
  86. package/docs/400-reference/400-cli/500-ai-context.md +102 -0
  87. package/docs/400-reference/400-cli/600-docs.md +23 -0
  88. package/docs/400-reference/400-cli/700-desktop.md +35 -0
  89. package/docs/500-hosted-pages.md +31 -0
  90. package/package.json +59 -18
  91. package/src/internal/cli/app.ts +72 -0
  92. package/src/internal/cli/bin.ts +3 -0
  93. package/src/internal/cli/commands/ai.ts +44 -0
  94. package/src/internal/cli/commands/auth.ts +195 -0
  95. package/src/internal/cli/commands/call.ts +128 -0
  96. package/src/internal/cli/commands/check.ts +48 -0
  97. package/src/internal/cli/commands/desktop-installer.ts +446 -0
  98. package/src/internal/cli/commands/desktop-release.ts +190 -0
  99. package/src/internal/cli/commands/desktop.ts +47 -0
  100. package/src/internal/cli/commands/docs.ts +34 -0
  101. package/src/internal/cli/commands/serve.ts +322 -0
  102. package/src/internal/cli/environment.ts +54 -0
  103. package/src/internal/cli/login.ts +46 -0
  104. package/src/internal/cli/managed-vite.ts +267 -0
  105. package/src/internal/cli/parse-port.ts +8 -0
  106. package/src/internal/client/auth/auth-api.ts +53 -0
  107. package/src/internal/client/auth/auth-shell.css +74 -0
  108. package/src/internal/client/auth/auth-shell.tsx +167 -0
  109. package/src/internal/client/auth/entry.tsx +26 -0
  110. package/src/internal/client/auth/index.html +13 -0
  111. package/src/internal/client/background-notifications.ts +194 -0
  112. package/src/internal/client/browser-api.ts +97 -0
  113. package/src/internal/client/client-definition.ts +184 -0
  114. package/src/internal/client/commands.tsx +260 -0
  115. package/src/internal/client/host/browser-log-forwarding.ts +149 -0
  116. package/src/internal/client/host/hosted-pages/browser-notification-settings.tsx +118 -0
  117. package/src/internal/client/host/hosted-pages/hosted-page.css +87 -0
  118. package/src/internal/client/host/hosted-pages/hosted-page.tsx +9 -0
  119. package/src/internal/client/host/hosted-pages/logout-page.tsx +75 -0
  120. package/src/internal/client/host/hosted-pages/settings-page.tsx +45 -0
  121. package/src/internal/client/host/notification-forwarding.ts +22 -0
  122. package/src/internal/client/host/overmux-host.tsx +200 -0
  123. package/src/internal/client/host/recovery-screen.css +75 -0
  124. package/src/internal/client/host/recovery-screen.tsx +17 -0
  125. package/src/internal/client/host/update-popover.css +82 -0
  126. package/src/internal/client/host/update-popover.tsx +166 -0
  127. package/src/internal/client/index.ts +45 -0
  128. package/src/internal/client/overmux-react.ts +283 -0
  129. package/src/internal/client/shortcuts.tsx +373 -0
  130. package/src/internal/client/theme-scope.css +114 -0
  131. package/src/internal/client/theme-scope.tsx +107 -0
  132. package/src/internal/client/transport.ts +69 -0
  133. package/src/internal/client/websocket.ts +441 -0
  134. package/src/internal/server/auth/auth-http.ts +414 -0
  135. package/src/internal/server/auth/auth-service.ts +627 -0
  136. package/src/internal/server/auth/auth-shell-http.ts +90 -0
  137. package/src/internal/server/auth/instance-control.ts +381 -0
  138. package/src/internal/server/check-overmux.ts +364 -0
  139. package/src/internal/server/coordinator/ipc-protocol.ts +44 -0
  140. package/src/internal/server/coordinator/server-child.ts +108 -0
  141. package/src/internal/server/coordinator/server-coordinator.ts +262 -0
  142. package/src/internal/server/coordinator/update-watcher.ts +54 -0
  143. package/src/internal/server/http/authenticated-http-app-test-helper.ts +45 -0
  144. package/src/internal/server/http/background-notification-handlers.ts +83 -0
  145. package/src/internal/server/http/control-handlers.ts +44 -0
  146. package/src/internal/server/http/create-http-app.ts +53 -0
  147. package/src/internal/server/http/development-web-backend.ts +83 -0
  148. package/src/internal/server/http/operation-handler.ts +93 -0
  149. package/src/internal/server/http/request-logging.ts +34 -0
  150. package/src/internal/server/http/routes.ts +241 -0
  151. package/src/internal/server/http/runtime-manifest-handler.ts +12 -0
  152. package/src/internal/server/index.ts +16 -0
  153. package/src/internal/server/notifications/background-notification-payload.ts +74 -0
  154. package/src/internal/server/notifications/background-notification-service.ts +139 -0
  155. package/src/internal/server/notifications/background-notification-store.ts +162 -0
  156. package/src/internal/server/notifications/notification-service.ts +70 -0
  157. package/src/internal/server/notifications/private-json-file.ts +84 -0
  158. package/src/internal/server/paths.ts +13 -0
  159. package/src/internal/server/rate-limiter.ts +58 -0
  160. package/src/internal/server/runtime/create-runtime.ts +87 -0
  161. package/src/internal/server/runtime/resource-dependency-graph.ts +128 -0
  162. package/src/internal/server/runtime/runtime-lifecycle.ts +95 -0
  163. package/src/internal/server/runtime/runtime-operations.ts +78 -0
  164. package/src/internal/server/runtime/runtime-resources.ts +201 -0
  165. package/src/internal/server/runtime/runtime-streams.ts +130 -0
  166. package/src/internal/server/server-logger.ts +110 -0
  167. package/src/internal/server/server-startup-options.ts +51 -0
  168. package/src/internal/server/start-application-server.ts +412 -0
  169. package/src/internal/server/start-overmux-server.ts +26 -0
  170. package/src/internal/server/test-port.ts +23 -0
  171. package/src/internal/server/vite-websocket-proxy.ts +201 -0
  172. package/src/internal/server/websocket-connection.ts +586 -0
  173. package/src/internal/server/websocket-limits.ts +2 -0
  174. package/src/internal/server/websocket-server.ts +311 -0
  175. package/src/internal/shared/background-notifications.ts +37 -0
  176. package/src/internal/shared/index.ts +2 -0
  177. package/src/internal/shared/protocol.ts +317 -0
  178. package/src/internal/shared/routes.ts +3 -0
  179. package/src/public/ai-context.ts +44 -0
  180. package/src/public/client.ts +53 -0
  181. package/src/public/config.ts +287 -0
  182. package/src/public/contracts.ts +55 -0
  183. package/src/public/index.ts +57 -0
  184. package/src/public/notifications.ts +10 -0
  185. package/src/public/server.ts +16 -0
  186. package/dist/public/core.ts +0 -119
  187. package/dist/public/protocol.ts +0 -51
  188. package/dist/public/terminal.ts +0 -23
  189. package/dist/web/assets/index-DC___IUw.css +0 -2
  190. package/dist/web/assets/index-DF9Cptse.js +0 -74
  191. package/dist/web/assets/terminal-component-BrP-ENHg.css +0 -1
  192. package/dist/web/assets/terminal-component-DyLEbMrx.js +0 -36
  193. package/dist/web/index.html +0 -14
@@ -0,0 +1,260 @@
1
+ import type { RuntimeManifest } from "../shared/index";
2
+ import {
3
+ createContext,
4
+ type RefObject,
5
+ useContext,
6
+ useEffect,
7
+ useRef,
8
+ } from "react";
9
+ import { z } from "zod";
10
+
11
+ import type {
12
+ ClientAppDefinition,
13
+ CommandHandle,
14
+ ShortcutBinding,
15
+ } from "./client-definition";
16
+ import {
17
+ activeCommandBindings,
18
+ configuredCommandBindings,
19
+ } from "./client-definition";
20
+ import type { RuntimeOvermuxServerApi } from "./browser-api";
21
+
22
+ type CommandRunResult = Promise<unknown> | unknown;
23
+ type LocalCommandRun = () => CommandRunResult;
24
+ type ParamsSchema<TCommand> = TCommand extends {
25
+ params: infer TParams extends z.ZodType;
26
+ }
27
+ ? TParams
28
+ : undefined;
29
+ type RegistrationParams<TCommand> =
30
+ ParamsSchema<TCommand> extends z.ZodType
31
+ ? { params: z.input<ParamsSchema<TCommand>> }
32
+ : { params?: never };
33
+ type RegistrationRun<TCommand> = TCommand extends {
34
+ run: (...args: never[]) => unknown;
35
+ }
36
+ ? { run?: LocalCommandRun }
37
+ : { run: LocalCommandRun };
38
+ type CommandRegistration<TCommand> = RegistrationParams<TCommand> &
39
+ RegistrationRun<TCommand> & {
40
+ element?: RefObject<HTMLElement | null>;
41
+ enabled?: boolean;
42
+ };
43
+ type UseCommandRegistration<TCommand> =
44
+ ParamsSchema<TCommand> extends z.ZodType
45
+ ? CommandRegistration<TCommand> | SkipToken
46
+ : CommandRegistration<TCommand>;
47
+
48
+ export const skipToken = Symbol("overmux command registration");
49
+ export type SkipToken = typeof skipToken;
50
+
51
+ export type RegisteredCommand = {
52
+ command: CommandHandle;
53
+ element: () => HTMLElement | null | undefined;
54
+ enabled: () => boolean;
55
+ execute: () => Promise<void>;
56
+ };
57
+
58
+ type RuntimeContextValue = {
59
+ manifest: RuntimeManifest;
60
+ overmuxServerApi: RuntimeOvermuxServerApi;
61
+ refreshCommands: () => void;
62
+ registerCommand: (registration: RegisteredCommand) => () => void;
63
+ };
64
+
65
+ export const RuntimeContext = createContext<RuntimeContextValue | undefined>(
66
+ undefined,
67
+ );
68
+
69
+ export const useRuntime = () => {
70
+ const runtime = useContext(RuntimeContext);
71
+ if (!runtime) {
72
+ throw new Error(
73
+ "Overmux hooks must be used inside an active Overmux client runtime",
74
+ );
75
+ }
76
+ return runtime;
77
+ };
78
+
79
+ type RuntimeCommandRegistration = {
80
+ element?: RefObject<HTMLElement | null>;
81
+ enabled?: boolean;
82
+ params?: unknown;
83
+ run?: LocalCommandRun;
84
+ };
85
+
86
+ const executeRegistration = async (
87
+ command: CommandHandle,
88
+ registration: RuntimeCommandRegistration,
89
+ overmuxServerApi: RuntimeOvermuxServerApi,
90
+ ) => {
91
+ const params = command.params
92
+ ? command.params.parse(registration.params)
93
+ : undefined;
94
+ if (registration.run) {
95
+ await registration.run();
96
+ return;
97
+ }
98
+ if (typeof command.run !== "function") {
99
+ throw new Error(`Command has no implementation: ${command.id}`);
100
+ }
101
+ await command.run({ overmuxServerApi, params });
102
+ };
103
+
104
+ export const createRegisteredCommand = ({
105
+ command,
106
+ getRegistration,
107
+ overmuxServerApi,
108
+ }: {
109
+ command: CommandHandle;
110
+ getRegistration: () => RuntimeCommandRegistration | SkipToken;
111
+ overmuxServerApi: RuntimeOvermuxServerApi;
112
+ }): RegisteredCommand => ({
113
+ command,
114
+ element: () => {
115
+ const registration = getRegistration();
116
+ return registration === skipToken
117
+ ? undefined
118
+ : registration.element?.current;
119
+ },
120
+ enabled: () => {
121
+ const registration = getRegistration();
122
+ return registration === skipToken ? false : (registration.enabled ?? true);
123
+ },
124
+ execute: () => {
125
+ const registration = getRegistration();
126
+ return registration === skipToken
127
+ ? Promise.resolve()
128
+ : executeRegistration(command, registration, overmuxServerApi);
129
+ },
130
+ });
131
+
132
+ export const useCommand = <TCommand extends CommandHandle>(
133
+ command: TCommand,
134
+ registration: UseCommandRegistration<TCommand>,
135
+ ) => {
136
+ const { overmuxServerApi, refreshCommands, registerCommand } = useRuntime();
137
+ const registrationRef = useRef(registration);
138
+ registrationRef.current = registration;
139
+ const skipped = registration === skipToken;
140
+ useEffect(() => {
141
+ if (skipped) {
142
+ return;
143
+ }
144
+ return registerCommand(
145
+ createRegisteredCommand({
146
+ command,
147
+ getRegistration: () =>
148
+ registrationRef.current as RuntimeCommandRegistration | SkipToken,
149
+ overmuxServerApi,
150
+ }),
151
+ );
152
+ }, [command, overmuxServerApi, registerCommand, skipped]);
153
+ const enabled = skipped ? false : (registration.enabled ?? true);
154
+ const element = skipped ? undefined : registration.element;
155
+ useEffect(refreshCommands, [element, enabled, refreshCommands, skipped]);
156
+ };
157
+
158
+ const elementDepth = (element: HTMLElement) => {
159
+ let depth = 0;
160
+ for (
161
+ let current: HTMLElement | null = element;
162
+ current;
163
+ current = current.parentElement
164
+ ) {
165
+ depth += 1;
166
+ }
167
+ return depth;
168
+ };
169
+
170
+ export const selectCommandRegistration = (
171
+ command: CommandHandle,
172
+ registrations: ReadonlySet<RegisteredCommand>,
173
+ activeElement: Element | null,
174
+ ) => {
175
+ const candidates = [...registrations].filter(
176
+ (registration) => registration.command === command,
177
+ );
178
+ const focused = candidates.filter((registration) => {
179
+ const element = registration.element();
180
+ return Boolean(element && activeElement && element.contains(activeElement));
181
+ });
182
+ return (
183
+ focused.reduce<RegisteredCommand | undefined>((selected, candidate) => {
184
+ const selectedElement = selected?.element();
185
+ const candidateElement = candidate.element();
186
+ if (!selectedElement || !candidateElement) {
187
+ return candidate;
188
+ }
189
+ return elementDepth(candidateElement) >= elementDepth(selectedElement)
190
+ ? candidate
191
+ : selected;
192
+ }, undefined) ?? candidates.at(-1)
193
+ );
194
+ };
195
+
196
+ const currentActiveElement = () => document.activeElement;
197
+
198
+ const selectedRegistration = (
199
+ command: CommandHandle,
200
+ registrations: ReadonlySet<RegisteredCommand>,
201
+ getActiveElement: () => Element | null = currentActiveElement,
202
+ ) => selectCommandRegistration(command, registrations, getActiveElement());
203
+
204
+ export const executeCommand = async (
205
+ command: CommandHandle,
206
+ registrations: ReadonlySet<RegisteredCommand>,
207
+ getActiveElement: () => Element | null = currentActiveElement,
208
+ ) => {
209
+ const selected = selectedRegistration(
210
+ command,
211
+ registrations,
212
+ getActiveElement,
213
+ );
214
+ if (selected?.enabled()) {
215
+ await selected.execute();
216
+ }
217
+ };
218
+
219
+ export type CommandEntry = {
220
+ bindings: readonly ShortcutBinding[];
221
+ disabledReason?: string;
222
+ enabled: boolean;
223
+ execute: () => Promise<void>;
224
+ id: string;
225
+ title: string;
226
+ };
227
+
228
+ export const createCommandEntries = (
229
+ definition: ClientAppDefinition,
230
+ registrations: ReadonlySet<RegisteredCommand>,
231
+ getActiveElement: () => Element | null = currentActiveElement,
232
+ matchesMedia: (media: string) => boolean = (media) =>
233
+ typeof window !== "undefined" && window.matchMedia(media).matches,
234
+ ): CommandEntry[] =>
235
+ Object.values(definition.commands).flatMap((command) => {
236
+ const selected = selectedRegistration(
237
+ command,
238
+ registrations,
239
+ getActiveElement,
240
+ );
241
+ return selected
242
+ ? [
243
+ {
244
+ bindings: activeCommandBindings(
245
+ configuredCommandBindings(definition, command),
246
+ matchesMedia,
247
+ ),
248
+ enabled: selected.enabled(),
249
+ execute: () =>
250
+ executeCommand(command, registrations, getActiveElement),
251
+ id: command.id,
252
+ title: command.title,
253
+ },
254
+ ]
255
+ : [];
256
+ });
257
+
258
+ export const CommandsContext = createContext<readonly CommandEntry[]>([]);
259
+
260
+ export const useCommands = () => useContext(CommandsContext);
@@ -0,0 +1,149 @@
1
+ import type { ClientDiagnosticEvent } from "../../shared/index";
2
+
3
+ const maxStringLength = 2_000;
4
+ const truncate = (value: string, length = maxStringLength) =>
5
+ value.length <= length ? value : `${value.slice(0, length)}…[truncated]`;
6
+
7
+ const serializeValue = (
8
+ value: unknown,
9
+ seen: WeakSet<object>,
10
+ depth = 0,
11
+ ): unknown => {
12
+ if (typeof value === "string") {
13
+ return truncate(value);
14
+ }
15
+ if (
16
+ value === null ||
17
+ typeof value === "boolean" ||
18
+ typeof value === "number"
19
+ ) {
20
+ return value;
21
+ }
22
+ if (typeof value === "bigint") {
23
+ return `${value}n`;
24
+ }
25
+ if (typeof value === "undefined" || typeof value === "symbol") {
26
+ return String(value);
27
+ }
28
+ if (typeof value === "function") {
29
+ return `[Function ${value.name || "anonymous"}]`;
30
+ }
31
+ if (depth >= 4) {
32
+ return "[Max depth]";
33
+ }
34
+ if (typeof value !== "object") {
35
+ return truncate(String(value));
36
+ }
37
+ if (seen.has(value)) {
38
+ return "[Circular]";
39
+ }
40
+ seen.add(value);
41
+ if (value instanceof Error) {
42
+ return {
43
+ message: truncate(value.message),
44
+ name: value.name,
45
+ ...(value.stack ? { stack: truncate(value.stack, 8_000) } : {}),
46
+ };
47
+ }
48
+ if (Array.isArray(value)) {
49
+ return value
50
+ .slice(0, 25)
51
+ .map((item) => serializeValue(item, seen, depth + 1));
52
+ }
53
+ try {
54
+ return Object.fromEntries(
55
+ Object.entries(value)
56
+ .slice(0, 25)
57
+ .map(([key, item]) => [key, serializeValue(item, seen, depth + 1)]),
58
+ );
59
+ } catch {
60
+ return `[Unserializable ${Object.prototype.toString.call(value)}]`;
61
+ }
62
+ };
63
+
64
+ export const serializeBrowserLogArguments = (values: unknown[]) => {
65
+ const seen = new WeakSet<object>();
66
+ const serialized = values.map((value) => serializeValue(value, seen));
67
+ const json = JSON.stringify(serialized);
68
+ return truncate(json, 16_000);
69
+ };
70
+
71
+ const diagnosticUrl = () => {
72
+ try {
73
+ return `${location.origin}${location.pathname}`;
74
+ } catch {
75
+ return undefined;
76
+ }
77
+ };
78
+
79
+ const stackOf = (values: unknown[]) => {
80
+ const error = values.find((value) => value instanceof Error);
81
+ return error instanceof Error && error.stack
82
+ ? truncate(error.stack, 8_000)
83
+ : undefined;
84
+ };
85
+
86
+ export const installBrowserLogForwarding = (
87
+ report: (event: ClientDiagnosticEvent) => void,
88
+ ) => {
89
+ const methods = ["log", "info", "warn", "error", "debug"] as const;
90
+ const originals = Object.fromEntries(
91
+ methods.map((level) => [level, console[level].bind(console)]),
92
+ ) as Record<(typeof methods)[number], (...values: unknown[]) => void>;
93
+ let reporting = false;
94
+ const emit = (
95
+ level: ClientDiagnosticEvent["level"],
96
+ values: unknown[],
97
+ stack?: string,
98
+ ) => {
99
+ if (reporting) {
100
+ return;
101
+ }
102
+ reporting = true;
103
+ try {
104
+ const serializedArguments = serializeBrowserLogArguments(values);
105
+ report({
106
+ arguments: serializedArguments,
107
+ level,
108
+ message: truncate(
109
+ values
110
+ .map((value) =>
111
+ typeof value === "string" ? value : serializedArguments,
112
+ )
113
+ .join(" "),
114
+ 4_000,
115
+ ),
116
+ ...(stack ? { stack: truncate(stack, 8_000) } : {}),
117
+ timestamp: new Date().toISOString(),
118
+ type: "client-diagnostic",
119
+ ...(diagnosticUrl() ? { url: diagnosticUrl() } : {}),
120
+ });
121
+ } finally {
122
+ reporting = false;
123
+ }
124
+ };
125
+ methods.forEach((level) => {
126
+ console[level] = (...values: unknown[]) => {
127
+ originals[level](...values);
128
+ emit(level, values, stackOf(values));
129
+ };
130
+ });
131
+ const onError = (event: ErrorEvent) =>
132
+ emit("error", [event.message], event.error?.stack);
133
+ const onRejection = (event: PromiseRejectionEvent) =>
134
+ emit(
135
+ "error",
136
+ ["Unhandled rejection", event.reason],
137
+ stackOf([event.reason]),
138
+ );
139
+ window.addEventListener("error", onError);
140
+ window.addEventListener("unhandledrejection", onRejection);
141
+
142
+ return () => {
143
+ methods.forEach((level) => {
144
+ console[level] = originals[level];
145
+ });
146
+ window.removeEventListener("error", onError);
147
+ window.removeEventListener("unhandledrejection", onRejection);
148
+ };
149
+ };
@@ -0,0 +1,118 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+
3
+ import {
4
+ disableBackgroundNotifications,
5
+ enableBackgroundNotifications,
6
+ reconciledStatus,
7
+ type BackgroundNotificationStatus,
8
+ } from "../../background-notifications";
9
+
10
+ const statusMessage = (status: BackgroundNotificationStatus) => {
11
+ const messages: Record<BackgroundNotificationStatus, string> = {
12
+ checking: "Checking background notification status…",
13
+ denied:
14
+ "Background notifications are blocked in this browser's site settings.",
15
+ disabled: "Background notifications are disabled for this browser.",
16
+ enabled: "Background notifications are enabled for this browser.",
17
+ error: "Background notification settings could not be updated. Try again.",
18
+ "ios-home-screen":
19
+ "On iPhone or iPad, add Overmux to the Home Screen before enabling background notifications.",
20
+ unsupported: "Background notifications are not supported in this browser.",
21
+ };
22
+ return messages[status];
23
+ };
24
+
25
+ export const BrowserNotificationSettings = () => {
26
+ const [status, setStatus] =
27
+ useState<BackgroundNotificationStatus>("checking");
28
+
29
+ useEffect(() => {
30
+ let active = true;
31
+ const reconcile = () => {
32
+ void reconciledStatus()
33
+ .then((next) => {
34
+ if (active) {
35
+ setStatus(next);
36
+ }
37
+ })
38
+ .catch(() => {
39
+ if (active) {
40
+ setStatus("error");
41
+ }
42
+ });
43
+ };
44
+ const onVisibilityChange = () => {
45
+ if (document.visibilityState === "visible") {
46
+ reconcile();
47
+ }
48
+ };
49
+ reconcile();
50
+ document.addEventListener("visibilitychange", onVisibilityChange);
51
+ navigator.serviceWorker?.addEventListener("controllerchange", reconcile);
52
+ return () => {
53
+ active = false;
54
+ document.removeEventListener("visibilitychange", onVisibilityChange);
55
+ navigator.serviceWorker?.removeEventListener(
56
+ "controllerchange",
57
+ reconcile,
58
+ );
59
+ };
60
+ }, []);
61
+
62
+ const enable = useCallback(async () => {
63
+ try {
64
+ setStatus("checking");
65
+ const enabled = await enableBackgroundNotifications();
66
+ setStatus(
67
+ enabled
68
+ ? "enabled"
69
+ : Notification.permission === "denied"
70
+ ? "denied"
71
+ : "disabled",
72
+ );
73
+ } catch {
74
+ setStatus("error");
75
+ }
76
+ }, []);
77
+
78
+ const disable = useCallback(async () => {
79
+ try {
80
+ setStatus("checking");
81
+ await disableBackgroundNotifications();
82
+ setStatus("disabled");
83
+ } catch {
84
+ setStatus("error");
85
+ }
86
+ }, []);
87
+
88
+ const canEnable = status === "disabled" || status === "error";
89
+ const canDisable = status === "enabled" || status === "error";
90
+ return (
91
+ <div data-om-browser-notifications="">
92
+ <h3>Browser notifications</h3>
93
+ <p aria-live="polite" data-om-background-notifications-status="">
94
+ {statusMessage(status)}
95
+ </p>
96
+ <div data-om-hosted-actions="">
97
+ {canEnable ? (
98
+ <button
99
+ data-om-background-notifications-enable=""
100
+ onClick={enable}
101
+ type="button"
102
+ >
103
+ Enable
104
+ </button>
105
+ ) : null}
106
+ {canDisable ? (
107
+ <button
108
+ data-om-background-notifications-disable=""
109
+ onClick={disable}
110
+ type="button"
111
+ >
112
+ Disable
113
+ </button>
114
+ ) : null}
115
+ </div>
116
+ </div>
117
+ );
118
+ };
@@ -0,0 +1,87 @@
1
+ @layer om.components {
2
+ @scope ([data-om-hosted-page]) {
3
+ :where(:scope) {
4
+ position: fixed;
5
+ inset: 0;
6
+ box-sizing: border-box;
7
+ overflow: auto;
8
+ padding: clamp(1rem, 5vw, 4rem);
9
+ color: var(--om-color-fg, var(--_om-color-fg));
10
+ background: var(--om-color-canvas, var(--_om-color-canvas));
11
+ }
12
+ :where([data-om-hosted-panel]) {
13
+ inline-size: min(100%, 44rem);
14
+ box-sizing: border-box;
15
+ margin-inline: auto;
16
+ padding: clamp(1.25rem, 4vw, 2.5rem);
17
+ border: 1px solid var(--om-color-border, var(--_om-color-border));
18
+ border-radius: var(--om-radius, 0.5rem);
19
+ background: var(--om-color-surface, var(--_om-color-surface));
20
+ box-shadow: var(--om-elevation, 0 1rem 2rem rgb(0 0 0 / 20%));
21
+ }
22
+ :where(h1, h2, h3, p) {
23
+ margin-block-start: 0;
24
+ }
25
+ :where(h1) {
26
+ font-size: clamp(1.5rem, 4vw, 2rem);
27
+ }
28
+ :where(h2) {
29
+ font-size: 1.125rem;
30
+ }
31
+ :where(h3) {
32
+ font-size: 1rem;
33
+ }
34
+ :where(p) {
35
+ color: var(--om-color-muted, var(--_om-color-muted));
36
+ line-height: 1.55;
37
+ }
38
+ :where([data-om-hosted-header]) {
39
+ display: flex;
40
+ align-items: baseline;
41
+ justify-content: space-between;
42
+ gap: 1rem;
43
+ }
44
+ :where([data-om-hosted-section]) {
45
+ margin-block-start: 1.5rem;
46
+ padding-block-start: 1.5rem;
47
+ border-block-start: 1px solid
48
+ var(--om-color-border, var(--_om-color-border));
49
+ }
50
+ :where([data-om-hosted-actions]) {
51
+ display: flex;
52
+ gap: 0.5rem;
53
+ }
54
+ :where(
55
+ [data-om-background-notifications-enable],
56
+ [data-om-background-notifications-disable],
57
+ [data-om-hosted-retry],
58
+ [data-om-hosted-button]
59
+ ) {
60
+ display: inline-flex;
61
+ min-block-size: 2.75rem;
62
+ box-sizing: border-box;
63
+ align-items: center;
64
+ padding-inline: 0.85rem;
65
+ border: 1px solid var(--om-color-border, var(--_om-color-border));
66
+ border-radius: var(--om-radius, 0.5rem);
67
+ color: inherit;
68
+ background: var(--om-color-panel, var(--_om-color-panel));
69
+ font: inherit;
70
+ text-decoration: none;
71
+ cursor: pointer;
72
+ }
73
+ :where(a) {
74
+ color: var(--om-color-accent, var(--_om-color-accent));
75
+ }
76
+ :where(a, button):focus-visible {
77
+ outline: 2px solid var(--om-color-accent, var(--_om-color-accent));
78
+ outline-offset: 2px;
79
+ }
80
+ @media (max-width: 30rem) {
81
+ :where([data-om-hosted-header]) {
82
+ align-items: flex-start;
83
+ flex-direction: column;
84
+ }
85
+ }
86
+ }
87
+ }
@@ -0,0 +1,9 @@
1
+ import "./hosted-page.css";
2
+
3
+ import type { ReactNode } from "react";
4
+
5
+ export const HostedPage = ({ children }: { children: ReactNode }) => (
6
+ <main data-om-hosted-page="">
7
+ <article data-om-hosted-panel="">{children}</article>
8
+ </main>
9
+ );
@@ -0,0 +1,75 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ import { HostedPage } from "./hosted-page";
4
+
5
+ const logoutRequests = new WeakMap<Document, Promise<void>>();
6
+ const minimumLogoutDuration = 3_000;
7
+
8
+ const requestLogout = () => {
9
+ const existing = logoutRequests.get(document);
10
+ if (existing) {
11
+ return existing;
12
+ }
13
+ const request = fetch("/api/auth/logout", { method: "POST" }).then(
14
+ (response) => {
15
+ if (!response.ok) {
16
+ throw new Error(`Logout failed: ${response.status}`);
17
+ }
18
+ },
19
+ );
20
+ logoutRequests.set(document, request);
21
+ return request;
22
+ };
23
+
24
+ const minimumDelay = () =>
25
+ new Promise<void>((resolve) => setTimeout(resolve, minimumLogoutDuration));
26
+
27
+ export const HostedLogoutPage = () => {
28
+ const [attempt, setAttempt] = useState(0);
29
+ const [error, setError] = useState(false);
30
+
31
+ useEffect(() => {
32
+ let active = true;
33
+ setError(false);
34
+ void Promise.allSettled([requestLogout(), minimumDelay()]).then(
35
+ ([logout]) => {
36
+ if (!active) {
37
+ return;
38
+ }
39
+ if (logout?.status === "fulfilled") {
40
+ location.replace("/login");
41
+ return;
42
+ }
43
+ setError(true);
44
+ },
45
+ );
46
+ return () => {
47
+ active = false;
48
+ };
49
+ }, [attempt]);
50
+
51
+ return (
52
+ <HostedPage>
53
+ <h1>{error ? "Log out failed" : "Logging out…"}</h1>
54
+ {error ? (
55
+ <>
56
+ <p role="alert">
57
+ Overmux could not log you out. Check your connection and try again.
58
+ </p>
59
+ <button
60
+ data-om-hosted-retry=""
61
+ onClick={() => {
62
+ logoutRequests.delete(document);
63
+ setAttempt((current) => current + 1);
64
+ }}
65
+ type="button"
66
+ >
67
+ Retry
68
+ </button>
69
+ </>
70
+ ) : (
71
+ <p>Please wait while Overmux ends your session.</p>
72
+ )}
73
+ </HostedPage>
74
+ );
75
+ };