overmux 0.0.2 → 0.0.4

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 (72) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/auth/.vite/manifest.json +2 -2
  3. package/dist/auth/assets/{index-Bx8JhwVi.css → index-BM9gETvx.css} +1 -1
  4. package/dist/auth/assets/{index-CHIE7qYj.js → index-DabAfcs1.js} +1 -1
  5. package/dist/auth/index.html +2 -2
  6. package/dist/bin.js +430 -18
  7. package/dist/bin.js.map +1 -1
  8. package/dist/docs/200-getting-started/100-install-and-run-overmux.md +1 -2
  9. package/dist/docs/300-fundamentals/100-project-structure.md +20 -0
  10. package/dist/docs/300-fundamentals/300-theming.md +51 -0
  11. package/dist/docs/400-reference/100-configuration.md +20 -0
  12. package/dist/docs/400-reference/200-server-api.md +18 -0
  13. package/dist/docs/400-reference/300-client-api.md +36 -0
  14. package/dist/docs/400-reference/400-cli/050-init.md +17 -0
  15. package/dist/docs/400-reference/400-cli/350-instance.md +24 -0
  16. package/dist/exports/client.d.ts +33 -3
  17. package/dist/exports/client.d.ts.map +1 -1
  18. package/dist/exports/client.js +123 -14
  19. package/dist/exports/client.js.map +1 -1
  20. package/dist/exports/{index-D3_Dpy_T.d.ts → index-DS70rKzo.d.ts} +14 -2
  21. package/dist/exports/index-DS70rKzo.d.ts.map +1 -0
  22. package/dist/exports/index.d.ts +2 -2
  23. package/dist/exports/index.js +1 -1
  24. package/dist/exports/index.js.map +1 -1
  25. package/dist/exports/{notifications-kDp16bU_.js → notifications-av0FK0yZ.js} +7 -2
  26. package/dist/exports/{notifications-kDp16bU_.js.map → notifications-av0FK0yZ.js.map} +1 -1
  27. package/dist/exports/server.d.ts.map +1 -1
  28. package/dist/exports/server.js +52 -10
  29. package/dist/exports/server.js.map +1 -1
  30. package/dist/internal/server/coordinator/server-child.js +110 -46
  31. package/dist/internal/server/coordinator/server-child.js.map +1 -1
  32. package/docs/200-getting-started/100-install-and-run-overmux.md +1 -2
  33. package/docs/300-fundamentals/100-project-structure.md +20 -0
  34. package/docs/300-fundamentals/300-theming.md +51 -0
  35. package/docs/400-reference/100-configuration.md +20 -0
  36. package/docs/400-reference/200-server-api.md +18 -0
  37. package/docs/400-reference/300-client-api.md +36 -0
  38. package/docs/400-reference/400-cli/050-init.md +17 -0
  39. package/docs/400-reference/400-cli/350-instance.md +24 -0
  40. package/package.json +2 -2
  41. package/src/internal/cli/app.ts +7 -1
  42. package/src/internal/cli/commands/init-template.ts +146 -0
  43. package/src/internal/cli/commands/init.ts +267 -0
  44. package/src/internal/cli/commands/instance.ts +58 -0
  45. package/src/internal/cli/commands/integration.ts +15 -0
  46. package/src/internal/cli/commands/zellij-install.ts +96 -0
  47. package/src/internal/cli/parse-port.ts +2 -1
  48. package/src/internal/client/auth/auth-shell.css +17 -1
  49. package/src/internal/client/auth/auth-shell.tsx +2 -11
  50. package/src/internal/client/auth/login-code-input.tsx +140 -0
  51. package/src/internal/client/browser-api.ts +17 -3
  52. package/src/internal/client/clipboard.ts +22 -0
  53. package/src/internal/client/host/desktop-host.ts +15 -0
  54. package/src/internal/client/host/environment.ts +67 -0
  55. package/src/internal/client/host/instance-forwarding.ts +21 -0
  56. package/src/internal/client/host/notification-forwarding.ts +1 -10
  57. package/src/internal/client/host/overmux-host.tsx +8 -0
  58. package/src/internal/client/overmux-react.ts +24 -2
  59. package/src/internal/client/websocket.ts +48 -20
  60. package/src/internal/server/auth/instance-control.ts +24 -3
  61. package/src/internal/server/runtime/create-runtime.ts +12 -1
  62. package/src/internal/server/runtime/runtime-instance.ts +46 -0
  63. package/src/internal/server/runtime/runtime-resources.ts +3 -0
  64. package/src/internal/server/server-startup-options.ts +9 -4
  65. package/src/internal/server/start-application-server.ts +20 -12
  66. package/src/internal/server/websocket-server.ts +7 -0
  67. package/src/internal/shared/index.ts +2 -0
  68. package/src/internal/shared/protocol.ts +10 -1
  69. package/src/public/client.ts +5 -0
  70. package/src/public/config.ts +7 -0
  71. package/src/public/index.ts +2 -0
  72. package/dist/exports/index-D3_Dpy_T.d.ts.map +0 -1
@@ -0,0 +1,58 @@
1
+ import { buildCommand } from "@stricli/core";
2
+
3
+ import {
4
+ discoverInstance,
5
+ sendControlRequest,
6
+ } from "../../server/auth/instance-control";
7
+ import { createInstanceIdentity } from "../../shared/index";
8
+ import { parsePort } from "../parse-port";
9
+
10
+ type InstanceFlags = { json?: boolean; port?: string };
11
+
12
+ // Read the already-resolved identity through the private same-user control socket.
13
+ // Config may have changed since startup, and re-evaluating it can repeat side effects.
14
+ // The running server resolved its identity once using the actual bound port.
15
+ const runInstance = async (
16
+ flags: InstanceFlags,
17
+ output: Pick<NodeJS.WriteStream, "write">,
18
+ ) => {
19
+ const port = flags.port === undefined ? undefined : parsePort(flags.port);
20
+ if (port instanceof Error) {
21
+ throw port;
22
+ }
23
+ const instance = await discoverInstance(port);
24
+ const response = await sendControlRequest(instance, {
25
+ type: "get-instance",
26
+ });
27
+ const identity = createInstanceIdentity(response.instanceId);
28
+ output.write(
29
+ flags.json
30
+ ? `${JSON.stringify(identity)}\n`
31
+ : `Instance ID: ${identity.instanceId}\nDeep-link prefix: ${identity.deepLinkPrefix}\n`,
32
+ );
33
+ };
34
+
35
+ export const createInstanceCommand = (
36
+ output: Pick<NodeJS.WriteStream, "write">,
37
+ ) =>
38
+ buildCommand({
39
+ func: (flags: InstanceFlags) => runInstance(flags, output),
40
+ parameters: {
41
+ aliases: { p: "port" },
42
+ flags: {
43
+ json: {
44
+ brief: "Print machine-readable JSON",
45
+ kind: "boolean",
46
+ optional: true,
47
+ },
48
+ port: {
49
+ brief: "Running local server port",
50
+ kind: "parsed",
51
+ optional: true,
52
+ parse: String,
53
+ },
54
+ },
55
+ positional: { kind: "tuple", parameters: [] },
56
+ },
57
+ docs: { brief: "Print the identity of a running Overmux server" },
58
+ });
@@ -0,0 +1,15 @@
1
+ // This namespace groups deliberate integration lifecycle commands by verb.
2
+ // Only install zellij exists now; the parser shape leaves later lifecycle verbs explicit.
3
+ import { buildRouteMap } from "@stricli/core";
4
+
5
+ import { zellijInstallCommand } from "./zellij-install";
6
+
7
+ const installIntegrationCommand = buildRouteMap({
8
+ docs: { brief: "Install an Overmux integration" },
9
+ routes: { zellij: zellijInstallCommand },
10
+ });
11
+
12
+ export const integrationCommand = buildRouteMap({
13
+ docs: { brief: "Manage Overmux integrations" },
14
+ routes: { install: installIntegrationCommand },
15
+ });
@@ -0,0 +1,96 @@
1
+ // This hard-coded CLI seam delegates installation to the invoking project's integration.
2
+ // It intentionally resolves one known subpath rather than creating a plugin registry.
3
+ import { readFileSync } from "node:fs";
4
+ import { findPackageJSON } from "node:module";
5
+ import { dirname, resolve } from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+
8
+ import { buildCommand } from "@stricli/core";
9
+
10
+ type ZellijInstallModule = {
11
+ installZellij: (options: { session?: string }) => Promise<{
12
+ artifactPath: string;
13
+ pluginVersion: string;
14
+ protocolVersion: number;
15
+ session: string;
16
+ sha256: string;
17
+ }>;
18
+ };
19
+
20
+ type ZellijInstallDependencies = {
21
+ cwd: string;
22
+ importModule: (specifier: string) => Promise<ZellijInstallModule>;
23
+ resolveInstall: () => string;
24
+ };
25
+
26
+ const resolveZellijInstall = (cwd: string) => {
27
+ const projectUrl = pathToFileURL(resolve(cwd, "package.json"));
28
+ const packagePath = findPackageJSON("@overmux/zellij/install", projectUrl);
29
+ if (packagePath === undefined) {
30
+ throw new Error("package not found");
31
+ }
32
+ const packageJson = JSON.parse(readFileSync(packagePath, "utf8")) as {
33
+ exports?: { "./install"?: { import?: unknown } };
34
+ };
35
+ const installExport = packageJson.exports?.["./install"]?.import;
36
+ if (typeof installExport !== "string") {
37
+ throw new Error("package has no importable ./install export");
38
+ }
39
+ return resolve(dirname(packagePath), installExport);
40
+ };
41
+
42
+ const defaultDependencies = (): ZellijInstallDependencies => {
43
+ const cwd = process.cwd();
44
+ return {
45
+ cwd,
46
+ importModule: (specifier) =>
47
+ import(specifier) as Promise<ZellijInstallModule>,
48
+ resolveInstall: () => resolveZellijInstall(cwd),
49
+ };
50
+ };
51
+
52
+ export const executeZellijInstall = async (
53
+ { session }: { session?: string },
54
+ dependencies = defaultDependencies(),
55
+ ) => {
56
+ let modulePath: string;
57
+ try {
58
+ modulePath = dependencies.resolveInstall();
59
+ } catch {
60
+ throw new Error(
61
+ `Could not resolve @overmux/zellij/install from ${dependencies.cwd}. Add @overmux/zellij to this project, then run the command again.`,
62
+ );
63
+ }
64
+ const integration = await dependencies.importModule(
65
+ pathToFileURL(modulePath).href,
66
+ );
67
+ if (typeof integration.installZellij !== "function") {
68
+ throw new Error(
69
+ "@overmux/zellij/install does not export installZellij. Update @overmux/zellij and run the command again.",
70
+ );
71
+ }
72
+ return integration.installZellij({ session });
73
+ };
74
+
75
+ const runZellijInstall = async ({ session }: { session?: string }) => {
76
+ const result = await executeZellijInstall({ session });
77
+ console.log(
78
+ `Installed Zellij plugin ${result.pluginVersion} (protocol ${result.protocolVersion})\nSession: ${result.session}\nArtifact: ${result.artifactPath}\nSHA-256: ${result.sha256}`,
79
+ );
80
+ };
81
+
82
+ export const zellijInstallCommand = buildCommand({
83
+ docs: { brief: "Install and approve the @overmux/zellij plugin" },
84
+ func: runZellijInstall,
85
+ parameters: {
86
+ flags: {
87
+ session: {
88
+ brief: "Target an active Zellij session",
89
+ kind: "parsed",
90
+ optional: true,
91
+ parse: String,
92
+ placeholder: "name",
93
+ },
94
+ },
95
+ },
96
+ });
@@ -2,7 +2,8 @@ import { serverConfigRuntimeSchema } from "@overmux/shared/node";
2
2
 
3
3
  export const parsePort = (value: string): Error | number => {
4
4
  const port = Number(value);
5
- return serverConfigRuntimeSchema.shape.port.safeParse(port).success
5
+ return port > 0 &&
6
+ serverConfigRuntimeSchema.shape.port.safeParse(port).success
6
7
  ? port
7
8
  : new Error(`Invalid port: ${value}`);
8
9
  };
@@ -46,10 +46,26 @@ body {
46
46
  border: 1px solid #666;
47
47
  background: #111;
48
48
  color: inherit;
49
- letter-spacing: 0.08em;
49
+ font-family: ui-monospace, monospace;
50
+ font-size: 1.25rem;
51
+ letter-spacing: 0.12em;
50
52
  text-transform: uppercase;
51
53
  }
52
54
 
55
+ [data-om-auth-shell] input[aria-invalid="true"] {
56
+ border-color: #ff9b9b;
57
+ }
58
+
59
+ #auth-code-error {
60
+ margin: 0;
61
+ color: #ff9b9b;
62
+ font-size: 0.875rem;
63
+ }
64
+
65
+ #auth-code-error:not(:empty) {
66
+ margin-top: 0.5rem;
67
+ }
68
+
53
69
  [data-om-auth-shell] button {
54
70
  margin-top: 1rem;
55
71
  border: 0;
@@ -15,6 +15,7 @@ import {
15
15
  // React Query is intentionally excluded to keep the public pre-authentication shell lightweight.
16
16
  // Its mutations may still run concurrently, so it would not replace latest-request cancellation.
17
17
  import { getAuthState, login, type LoginInput } from "./auth-api";
18
+ import { LoginCodeInput } from "./login-code-input";
18
19
  import "./auth-shell.css";
19
20
 
20
21
  type AuthStatus =
@@ -147,17 +148,7 @@ export const AuthShell = ({ ticket }: AuthShellProps) => {
147
148
  </p>
148
149
  <form aria-busy={pending} onSubmit={submitCode}>
149
150
  <label htmlFor="auth-code">Login code</label>
150
- <input
151
- aria-describedby="auth-status"
152
- autoCapitalize="characters"
153
- autoComplete="one-time-code"
154
- disabled={pending}
155
- id="auth-code"
156
- name="code"
157
- pattern="[A-Za-z0-9]{4}-[A-Za-z0-9]{4}"
158
- required
159
- spellCheck={false}
160
- />
151
+ <LoginCodeInput disabled={pending} />
161
152
  <button disabled={pending} type="submit">
162
153
  Unlock Overmux
163
154
  </button>
@@ -0,0 +1,140 @@
1
+ import { useState, type ClipboardEvent, type KeyboardEvent } from "react";
2
+
3
+ type LoginCodeInputProps = {
4
+ disabled: boolean;
5
+ };
6
+
7
+ const maxCodeLength = 8;
8
+ const codeError = "Enter 8 letters or numbers, with no spaces inside the code.";
9
+
10
+ const formatCode = (value: string) => {
11
+ const trimmed = value.trim();
12
+ const ungrouped = trimmed.replace("-", "");
13
+ if (!/^[A-Za-z0-9]*$/.test(ungrouped)) {
14
+ return trimmed;
15
+ }
16
+ const characters = ungrouped.slice(0, maxCodeLength).toUpperCase();
17
+ return characters.length < 4
18
+ ? characters
19
+ : `${characters.slice(0, 4)}-${characters.slice(4)}`;
20
+ };
21
+
22
+ const formatInput = (input: HTMLInputElement) => {
23
+ const { value, selectionStart, selectionEnd, selectionDirection } = input;
24
+ const formatted = formatCode(value);
25
+ if (formatted === value) {
26
+ return;
27
+ }
28
+ // Formatting the prefix maps the native selection past an inserted dash without
29
+ // moving edits in the middle of the code to the end of the field.
30
+ const start = formatCode(
31
+ value.slice(0, selectionStart ?? value.length),
32
+ ).length;
33
+ const end = formatCode(value.slice(0, selectionEnd ?? value.length)).length;
34
+ input.value = formatted;
35
+ input.setSelectionRange(start, end, selectionDirection ?? undefined);
36
+ };
37
+
38
+ const pasteCode = (event: ClipboardEvent<HTMLInputElement>) => {
39
+ event.preventDefault();
40
+ const input = event.currentTarget;
41
+ // Text inputs silently remove line breaks; preserve internal ones as invalid
42
+ // spaces rather than accidentally accepting a code with embedded whitespace.
43
+ const pasted = event.clipboardData
44
+ .getData("text/plain")
45
+ .trim()
46
+ .replace(/[\r\n]/g, " ");
47
+ // A whole code replaces both groups regardless of the caret. Reject oversized
48
+ // pastes before changing the field, including partial pastes that would overflow.
49
+ const replaceWholeCode = pasted.length >= maxCodeLength;
50
+ const start = replaceWholeCode ? 0 : (input.selectionStart ?? 0);
51
+ const end = replaceWholeCode ? input.value.length : (input.selectionEnd ?? 0);
52
+ const nextValue =
53
+ input.value.slice(0, start) + pasted + input.value.slice(end);
54
+ if (nextValue.trim().replace("-", "").length > maxCodeLength) {
55
+ input.setCustomValidity(codeError);
56
+ return false;
57
+ }
58
+ input.setCustomValidity("");
59
+ input.setRangeText(pasted, start, end, "end");
60
+ formatInput(input);
61
+ return true;
62
+ };
63
+
64
+ const editSeparator = (event: KeyboardEvent<HTMLInputElement>) => {
65
+ const input = event.currentTarget;
66
+ const caret = input.selectionStart;
67
+ if (
68
+ event.ctrlKey ||
69
+ event.metaKey ||
70
+ event.altKey ||
71
+ caret !== input.selectionEnd ||
72
+ input.value[4] !== "-"
73
+ ) {
74
+ return;
75
+ }
76
+ if (event.key === "-" && (caret === 4 || caret === 5)) {
77
+ event.preventDefault();
78
+ input.setSelectionRange(5, 5);
79
+ }
80
+ // Treat the separator as formatting: deletion at its boundary removes the
81
+ // adjacent character rather than restoring the dash without making progress.
82
+ if (event.key === "Backspace" && caret === 5) {
83
+ event.preventDefault();
84
+ input.setRangeText("", 3, 5, "end");
85
+ formatInput(input);
86
+ }
87
+ if (event.key === "Delete" && caret === 4) {
88
+ event.preventDefault();
89
+ input.setRangeText("", 4, Math.min(6, input.value.length), "end");
90
+ formatInput(input);
91
+ }
92
+ };
93
+
94
+ export const LoginCodeInput = ({ disabled }: LoginCodeInputProps) => {
95
+ const [invalid, setInvalid] = useState(false);
96
+
97
+ return (
98
+ <>
99
+ <input
100
+ aria-describedby="auth-code-error auth-status"
101
+ aria-invalid={invalid || undefined}
102
+ autoCapitalize="characters"
103
+ autoComplete="one-time-code"
104
+ disabled={disabled}
105
+ id="auth-code"
106
+ maxLength={maxCodeLength + 1}
107
+ name="code"
108
+ onBlur={(event) => formatInput(event.currentTarget)}
109
+ onInput={(event) => {
110
+ event.currentTarget.setCustomValidity("");
111
+ // Do not discard a typed space before learning whether it is internal.
112
+ if (!/\s$/.test(event.currentTarget.value)) {
113
+ formatInput(event.currentTarget);
114
+ }
115
+ setInvalid(false);
116
+ }}
117
+ onInvalid={() => setInvalid(true)}
118
+ onKeyDown={(event) => {
119
+ if (event.key === "Enter") {
120
+ formatInput(event.currentTarget);
121
+ }
122
+ editSeparator(event);
123
+ if (event.defaultPrevented) {
124
+ event.currentTarget.setCustomValidity("");
125
+ setInvalid(false);
126
+ }
127
+ }}
128
+ onPaste={(event) => setInvalid(!pasteCode(event))}
129
+ pattern="[A-Za-z0-9]{4}-[A-Za-z0-9]{4}"
130
+ placeholder="XXXX-XXXX"
131
+ required
132
+ spellCheck={false}
133
+ type="text"
134
+ />
135
+ <p aria-live="polite" id="auth-code-error">
136
+ {invalid ? codeError : undefined}
137
+ </p>
138
+ </>
139
+ );
140
+ };
@@ -1,5 +1,8 @@
1
1
  import type { RuntimeManifest } from "../shared/index";
2
- import type { ContractInputArguments } from "../../public/index";
2
+ import type {
3
+ ContractInputArguments,
4
+ InstanceIdentity,
5
+ } from "../../public/index";
3
6
  import type { z } from "zod";
4
7
 
5
8
  import type { ClientTransport } from "./transport";
@@ -25,14 +28,21 @@ type OperationArguments<TInput extends z.ZodType> = [
25
28
  signal?: AbortSignal,
26
29
  ];
27
30
 
28
- export type OvermuxServerApi<TServerConfig> = {
31
+ type InstanceGetters = {
32
+ getInstanceId: () => string | undefined;
33
+ getDeepLinkPrefix: () => string | undefined;
34
+ };
35
+
36
+ export type OvermuxServerApi<TServerConfig> = InstanceGetters & {
29
37
  executeOperation: <TId extends OperationId<TServerConfig>>(
30
38
  id: TId,
31
39
  ...args: OperationArguments<OperationInput<TServerConfig, TId>>
32
40
  ) => Promise<OperationOutput<TServerConfig, TId>>;
33
41
  };
34
42
 
35
- export type RuntimeOvermuxServerApi = {
43
+ export type RuntimeOvermuxServerApi = InstanceGetters & {
44
+ getInstance: () => InstanceIdentity | undefined;
45
+ subscribeInstance: (listener: () => void) => () => void;
36
46
  executeOperation: (
37
47
  id: string,
38
48
  input?: unknown,
@@ -73,6 +83,10 @@ export const createOvermuxServerApi = ({
73
83
  manifest: RuntimeManifest;
74
84
  transport: ClientTransport;
75
85
  }): RuntimeOvermuxServerApi => ({
86
+ getInstance: transport.getInstance,
87
+ subscribeInstance: transport.subscribeInstance,
88
+ getInstanceId: () => transport.getInstance()?.instanceId,
89
+ getDeepLinkPrefix: () => transport.getInstance()?.deepLinkPrefix,
76
90
  executeOperation: (id, input, signal) => {
77
91
  if (!manifest.operations.includes(id)) {
78
92
  throw new Error(`Operation is not available: ${id}`);
@@ -0,0 +1,22 @@
1
+ import "./host/desktop-host";
2
+
3
+ // Reads the system clipboard through the browser, never through the desktop host.
4
+ export const readClipboardText = async (): Promise<string> => {
5
+ if (!globalThis.navigator?.clipboard?.readText) {
6
+ throw new Error("Clipboard reading is unavailable in this browser context");
7
+ }
8
+ return navigator.clipboard.readText();
9
+ };
10
+
11
+ // Desktop writes are fire-and-forget: resolution confirms dispatch, not completion.
12
+ export const writeClipboardText = async (text: string): Promise<void> => {
13
+ const desktopClipboard = globalThis.window?.overmuxHost?.clipboard;
14
+ if (desktopClipboard?.version === 1) {
15
+ desktopClipboard.writeText(text);
16
+ return;
17
+ }
18
+ if (!globalThis.navigator?.clipboard?.writeText) {
19
+ throw new Error("Clipboard writing is unavailable in this browser context");
20
+ }
21
+ await navigator.clipboard.writeText(text);
22
+ };
@@ -0,0 +1,15 @@
1
+ type DesktopHost = {
2
+ version: 1;
3
+ platform: string;
4
+ instance?: { report: (identity: { instanceId: string }) => void; version: 1 };
5
+ clipboard?: { writeText: (text: string) => void; version: 1 };
6
+ notifications?: { show: (notification: unknown) => void; version: 1 };
7
+ };
8
+
9
+ declare global {
10
+ interface Window {
11
+ overmuxHost?: DesktopHost;
12
+ }
13
+ }
14
+
15
+ export {};
@@ -0,0 +1,67 @@
1
+ import "./desktop-host";
2
+
3
+ type Platform = "macos" | "windows" | "linux" | "android" | "ios" | "unknown";
4
+ type BrowserNavigator = Navigator & {
5
+ standalone?: boolean;
6
+ userAgentData?: { platform: string };
7
+ };
8
+
9
+ const browserPlatform = (navigator: BrowserNavigator): Platform => {
10
+ const { userAgent, maxTouchPoints } = navigator;
11
+ const platform = navigator.userAgentData?.platform ?? navigator.platform;
12
+ const identity = `${platform} ${userAgent}`;
13
+ // iPadOS can advertise itself as a Mac when requesting desktop websites.
14
+ if (
15
+ /iPad|iPhone|iPod/i.test(identity) ||
16
+ (/Mac/i.test(identity) && maxTouchPoints > 1)
17
+ ) {
18
+ return "ios";
19
+ }
20
+ if (/Android/i.test(identity)) {
21
+ return "android";
22
+ }
23
+ if (/Win/i.test(identity)) {
24
+ return "windows";
25
+ }
26
+ if (/Mac/i.test(identity)) {
27
+ return "macos";
28
+ }
29
+ return /Linux/i.test(identity) ? "linux" : "unknown";
30
+ };
31
+
32
+ const desktopPlatform = (platform: string): Platform => {
33
+ if (platform === "darwin") {
34
+ return "macos";
35
+ }
36
+ if (platform === "win32") {
37
+ return "windows";
38
+ }
39
+ return platform === "linux" ? "linux" : "unknown";
40
+ };
41
+
42
+ export const installEnvironmentSelectors = () => {
43
+ const html = document.documentElement;
44
+ const host = window.overmuxHost;
45
+ const desktop = host?.version === 1;
46
+ const navigator: BrowserNavigator = window.navigator;
47
+ html.dataset.omPlatform = desktop
48
+ ? desktopPlatform(host.platform)
49
+ : browserPlatform(navigator);
50
+ if (desktop) {
51
+ html.dataset.omHost = "desktop";
52
+ return () => {};
53
+ }
54
+
55
+ // Fullscreen alone is not evidence of an installed app.
56
+ const displayMode = window.matchMedia(
57
+ "(display-mode: standalone), (display-mode: minimal-ui), (display-mode: window-controls-overlay)",
58
+ );
59
+ const updateHost = () => {
60
+ html.dataset.omHost =
61
+ displayMode.matches || navigator.standalone === true ? "pwa" : "browser";
62
+ };
63
+ updateHost();
64
+ displayMode.addEventListener("change", updateHost);
65
+ // The document owns these attributes; disposal only releases the live listener.
66
+ return () => displayMode.removeEventListener("change", updateHost);
67
+ };
@@ -0,0 +1,21 @@
1
+ import type { ClientTransport } from "../transport";
2
+ import type {} from "./desktop-host";
3
+
4
+ export const installInstanceForwarding = (transport: ClientTransport) => {
5
+ const report = () => {
6
+ const identity = transport.getInstance();
7
+ const bridge = window.overmuxHost?.instance;
8
+ if (!identity || bridge?.version !== 1) {
9
+ return;
10
+ }
11
+ try {
12
+ bridge.report({ instanceId: identity.instanceId });
13
+ } catch (cause) {
14
+ // A host integration failure must not interrupt browser discovery or traffic.
15
+ console.error("Overmux host instance reporting failed", cause);
16
+ }
17
+ };
18
+ const unsubscribe = transport.subscribeInstance(report);
19
+ report();
20
+ return unsubscribe;
21
+ };
@@ -1,18 +1,9 @@
1
+ import "./desktop-host";
1
2
  import { notificationEventSchema } from "../../shared/index";
2
3
 
3
4
  import type { WebSocketTransport } from "../transport";
4
5
 
5
6
  // Forwards server notifications to the optional desktop host injected at window.overmuxHost.
6
- type NotificationHost = {
7
- notifications?: { show: (notification: unknown) => void; version: 1 };
8
- };
9
-
10
- declare global {
11
- interface Window {
12
- overmuxHost?: NotificationHost;
13
- }
14
- }
15
-
16
7
  export const installNotificationForwarding = (transport: WebSocketTransport) =>
17
8
  transport.subscribeNotification((event) => {
18
9
  const notification = notificationEventSchema.safeParse(event);
@@ -21,6 +21,7 @@ import { OvermuxThemeScope } from "../theme-scope";
21
21
  import { createOvermuxServerApi } from "../browser-api";
22
22
  import { installBrowserLogForwarding } from "./browser-log-forwarding";
23
23
  import { installNotificationForwarding } from "./notification-forwarding";
24
+ import { installInstanceForwarding } from "./instance-forwarding";
24
25
  import type { ClientAppDefinition } from "../client-definition";
25
26
  import { type RegisteredCommand, RuntimeContext } from "../commands";
26
27
  import { ShortcutHost } from "../shortcuts";
@@ -29,6 +30,12 @@ import { HostedLogoutPage } from "./hosted-pages/logout-page";
29
30
  import { HostedSettingsPage } from "./hosted-pages/settings-page";
30
31
  import { RecoveryScreen } from "./recovery-screen";
31
32
  import { UpdatePopover } from "./update-popover";
33
+ import { installEnvironmentSelectors } from "./environment";
34
+
35
+ // Userland owns createRoot. Initialize on import, before any React tree mounts.
36
+ const disposeEnvironment =
37
+ typeof window === "undefined" ? undefined : installEnvironmentSelectors();
38
+ import.meta.hot?.dispose(() => disposeEnvironment?.());
32
39
 
33
40
  type ErrorBoundaryProps = {
34
41
  children: ReactNode;
@@ -129,6 +136,7 @@ const OvermuxRuntime = ({
129
136
  [transport],
130
137
  );
131
138
  useEffect(() => installNotificationForwarding(transport), [transport]);
139
+ useEffect(() => installInstanceForwarding(transport), [transport]);
132
140
  const refreshCommands = useCallback(
133
141
  () => setRegistrationRevision((revision) => revision + 1),
134
142
  [],
@@ -1,11 +1,21 @@
1
- import type { ContractInputArguments } from "../../public/index";
1
+ import type {
2
+ ContractInputArguments,
3
+ InstanceIdentity,
4
+ } from "../../public/index";
2
5
  import {
3
6
  useMutation,
4
7
  useQuery,
5
8
  useQueryClient,
6
9
  type UseMutationResult,
7
10
  } from "@tanstack/react-query";
8
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
11
+ import {
12
+ useCallback,
13
+ useEffect,
14
+ useMemo,
15
+ useRef,
16
+ useState,
17
+ useSyncExternalStore,
18
+ } from "react";
9
19
  import type { z } from "zod";
10
20
 
11
21
  import { skipToken, type SkipToken, useRuntime } from "./commands";
@@ -254,7 +264,19 @@ const useStreamById = <TClientMessage, TServerMessage>(
254
264
  };
255
265
  };
256
266
 
267
+ const undiscoveredInstance = () => undefined;
268
+
269
+ const useInstance = (): InstanceIdentity | undefined => {
270
+ const { overmuxServerApi } = useRuntime();
271
+ return useSyncExternalStore(
272
+ overmuxServerApi.subscribeInstance,
273
+ overmuxServerApi.getInstance,
274
+ undiscoveredInstance,
275
+ );
276
+ };
277
+
257
278
  export const createOvermuxHooks = <TServerConfig>() => ({
279
+ useInstance,
258
280
  useOperation: <TId extends Extract<keyof Operations<TServerConfig>, string>>({
259
281
  id,
260
282
  signal,