overmux 0.0.3 → 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.
- package/CHANGELOG.md +10 -0
- package/dist/auth/.vite/manifest.json +2 -2
- package/dist/auth/assets/{index-Bx8JhwVi.css → index-BM9gETvx.css} +1 -1
- package/dist/auth/assets/{index-CHIE7qYj.js → index-DabAfcs1.js} +1 -1
- package/dist/auth/index.html +2 -2
- package/dist/bin.js +91 -11
- package/dist/bin.js.map +1 -1
- package/dist/docs/300-fundamentals/300-theming.md +51 -0
- package/dist/docs/400-reference/100-configuration.md +20 -0
- package/dist/docs/400-reference/200-server-api.md +18 -0
- package/dist/docs/400-reference/300-client-api.md +19 -0
- package/dist/docs/400-reference/400-cli/350-instance.md +24 -0
- package/dist/exports/client.d.ts +16 -3
- package/dist/exports/client.d.ts.map +1 -1
- package/dist/exports/client.js +100 -13
- package/dist/exports/client.js.map +1 -1
- package/dist/exports/{index-D3_Dpy_T.d.ts → index-DS70rKzo.d.ts} +14 -2
- package/dist/exports/index-DS70rKzo.d.ts.map +1 -0
- package/dist/exports/index.d.ts +2 -2
- package/dist/exports/index.js +1 -1
- package/dist/exports/index.js.map +1 -1
- package/dist/exports/{notifications-kDp16bU_.js → notifications-av0FK0yZ.js} +7 -2
- package/dist/exports/{notifications-kDp16bU_.js.map → notifications-av0FK0yZ.js.map} +1 -1
- package/dist/exports/server.d.ts.map +1 -1
- package/dist/exports/server.js +52 -10
- package/dist/exports/server.js.map +1 -1
- package/dist/internal/server/coordinator/server-child.js +110 -46
- package/dist/internal/server/coordinator/server-child.js.map +1 -1
- package/docs/300-fundamentals/300-theming.md +51 -0
- package/docs/400-reference/100-configuration.md +20 -0
- package/docs/400-reference/200-server-api.md +18 -0
- package/docs/400-reference/300-client-api.md +19 -0
- package/docs/400-reference/400-cli/350-instance.md +24 -0
- package/package.json +1 -1
- package/src/internal/cli/app.ts +2 -0
- package/src/internal/cli/commands/instance.ts +58 -0
- package/src/internal/cli/parse-port.ts +2 -1
- package/src/internal/client/auth/auth-shell.css +17 -1
- package/src/internal/client/auth/auth-shell.tsx +2 -11
- package/src/internal/client/auth/login-code-input.tsx +140 -0
- package/src/internal/client/browser-api.ts +17 -3
- package/src/internal/client/host/desktop-host.ts +3 -0
- package/src/internal/client/host/environment.ts +67 -0
- package/src/internal/client/host/instance-forwarding.ts +21 -0
- package/src/internal/client/host/overmux-host.tsx +8 -0
- package/src/internal/client/overmux-react.ts +24 -2
- package/src/internal/client/websocket.ts +32 -12
- package/src/internal/server/auth/instance-control.ts +24 -3
- package/src/internal/server/runtime/create-runtime.ts +12 -1
- package/src/internal/server/runtime/runtime-instance.ts +46 -0
- package/src/internal/server/runtime/runtime-resources.ts +3 -0
- package/src/internal/server/server-startup-options.ts +9 -4
- package/src/internal/server/start-application-server.ts +20 -12
- package/src/internal/server/websocket-server.ts +7 -0
- package/src/internal/shared/index.ts +2 -0
- package/src/internal/shared/protocol.ts +10 -1
- package/src/public/client.ts +1 -0
- package/src/public/config.ts +7 -0
- package/src/public/index.ts +2 -0
- package/dist/exports/index-D3_Dpy_T.d.ts.map +0 -1
|
@@ -1,3 +1,54 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Theming
|
|
3
3
|
---
|
|
4
|
+
|
|
5
|
+
## Host and platform selectors
|
|
6
|
+
|
|
7
|
+
Overmux sets two independent attributes on the hosted application's `<html>` element before React mounts:
|
|
8
|
+
|
|
9
|
+
```html
|
|
10
|
+
<html data-om-host="desktop" data-om-platform="macos">
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Use these public CSS selectors to adapt your application to its environment:
|
|
14
|
+
|
|
15
|
+
```css
|
|
16
|
+
/* Hide browser-only installation guidance in native and installed apps. */
|
|
17
|
+
html:not([data-om-host="browser"]) .install-prompt {
|
|
18
|
+
display: none;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/* OS-specific styling also works when using Overmux in a browser. */
|
|
22
|
+
html[data-om-platform="macos"] .shortcut-hint {
|
|
23
|
+
font-family: system-ui;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/* Combine the independent host and platform selectors. */
|
|
27
|
+
html[data-om-host="desktop"][data-om-platform="windows"] .app-toolbar {
|
|
28
|
+
padding-inline: 1rem;
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Host values
|
|
33
|
+
|
|
34
|
+
| `data-om-host` | Meaning |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| `desktop` | Running inside the native Overmux desktop host, regardless of window size. |
|
|
37
|
+
| `browser` | Running in a regular browser tab or window. |
|
|
38
|
+
| `pwa` | Running as an installed web app in `standalone`, `minimal-ui`, or `window-controls-overlay` display mode, including Safari's installed standalone mode. |
|
|
39
|
+
|
|
40
|
+
A progressive web app (PWA) is a website that can run as an installed application. Visiting an installable website does not make the host `pwa`. Ordinary browser fullscreen does not qualify either. Native desktop detection takes precedence over display mode. Browser/PWA selectors update when the supported display modes change.
|
|
41
|
+
|
|
42
|
+
`desktop` describes the host, not a desktop-sized layout. Use CSS media or container queries for responsive layout. The native host's implementation technology is not a CSS value.
|
|
43
|
+
|
|
44
|
+
### Platform values
|
|
45
|
+
|
|
46
|
+
`data-om-platform` is always one of `macos`, `windows`, `linux`, `android`, `ios`, or `unknown`.
|
|
47
|
+
|
|
48
|
+
The native desktop host supplies its OS identity through versioned preload metadata. Browsers use best-effort detection from browser-provided platform and user-agent information, including touch-capable iPads presenting a Mac identity. Missing, obscured, or unrecognized information can yield `unknown`; do not use these selectors for security decisions or feature detection.
|
|
49
|
+
|
|
50
|
+
### Scope
|
|
51
|
+
|
|
52
|
+
These attributes belong to the hosted application document, independently of `OvermuxThemeScope`. They do not change theme tokens, color scheme, contrast, nested theme scopes, or portal inheritance. Selectors anchored at `html` also match portal content in the same document, including caller-owned external portal containers.
|
|
53
|
+
|
|
54
|
+
This API does not theme native window controls or the desktop connection screen. It requires no public JavaScript API; use the attributes directly in your application's CSS.
|
|
@@ -1,3 +1,23 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Configuration
|
|
3
3
|
---
|
|
4
|
+
|
|
5
|
+
## Instance identity
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { defineOvermuxConfig } from "overmux";
|
|
9
|
+
import { server } from "./server";
|
|
10
|
+
|
|
11
|
+
export default defineOvermuxConfig({
|
|
12
|
+
server,
|
|
13
|
+
instanceId: ({ port }) => `rich-work-${port}`,
|
|
14
|
+
});
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`instanceId` accepts a fixed string (for example `instanceId: "rich-work"`) or a function of `{ port }`. It is resolved once at server startup using the actual listening port. Every hostname serving this running instance reports the same ID. Changing the port intentionally changes a port-dependent ID.
|
|
18
|
+
|
|
19
|
+
`port` in `overmux.config.ts` must be an integer from `1` through `65535`. Internal listeners and tests may use `0` to ask the OS for a free port; this is not a user configuration value.
|
|
20
|
+
|
|
21
|
+
The default is the machine hostname followed by `-<port>`. IDs are canonical lowercase ASCII: 1-253 characters, start and end with a letter or digit, and may contain dots or hyphens internally. Explicit IDs are validated, never silently normalized. Set an explicit ID if the machine hostname does not meet these rules.
|
|
22
|
+
|
|
23
|
+
Distinct instances require distinct IDs. IDs are names, not credentials; authentication is still required. Deep links use `overmux://<instance-id>/<application-route>`. Their authority is only a lookup key, never a network address. Use the live runtime helpers or `overmux instance --json` to produce a prefix rather than guessing it from a browser URL.
|
|
@@ -1,3 +1,21 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Server API
|
|
3
3
|
---
|
|
4
|
+
|
|
5
|
+
## Instance identity in handlers
|
|
6
|
+
|
|
7
|
+
Operation, resource, and stream handler contexts expose the running instance:
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { defineOperation } from "overmux";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
|
|
13
|
+
export const paneLink = defineOperation({
|
|
14
|
+
input: z.object({ paneId: z.string() }),
|
|
15
|
+
output: z.string(),
|
|
16
|
+
handle: ({ paneId }, context) =>
|
|
17
|
+
`${context.instance.getDeepLinkPrefix()}/panes/${encodeURIComponent(paneId)}`,
|
|
18
|
+
});
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`context.instance.getInstanceId(): string` and `context.instance.getDeepLinkPrefix(): string` are synchronous and ready before handlers execute. They belong to this server runtime, not process-global state. The prefix is exactly `overmux://<instance-id>` without a trailing slash. Encode route segments once, not the whole assembled URL.
|
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
title: Client API
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
+
## Instance identity
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
import { createOvermuxHooks } from "overmux/client";
|
|
9
|
+
import type { serverConfig } from "./server";
|
|
10
|
+
|
|
11
|
+
const { useInstance } = createOvermuxHooks<typeof serverConfig>();
|
|
12
|
+
|
|
13
|
+
const PaneLink = ({ paneId }: { paneId: string }) => {
|
|
14
|
+
const instance = useInstance();
|
|
15
|
+
if (!instance) return null;
|
|
16
|
+
return <a href={`${instance.deepLinkPrefix}/panes/${encodeURIComponent(paneId)}`}>Open pane in desktop</a>;
|
|
17
|
+
};
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Use these hooks under the existing Overmux provider. `useInstance()` returns `{ instanceId, deepLinkPrefix } | undefined` and rerenders when authenticated discovery arrives or changes after reconnecting.
|
|
21
|
+
|
|
22
|
+
The same scoped `overmuxServerApi` object passed to command handlers for operations also provides `overmuxServerApi.getInstanceId()` and `overmuxServerApi.getDeepLinkPrefix()`. Both return `undefined` until the authenticated server announces its identity. The prefix is exactly `overmux://<instance-id>`, without a trailing slash. Browser addresses are not instance identities. Desktop hosts receive discovery through a narrow identity bridge automatically; application code does not need to forward it.
|
|
23
|
+
|
|
5
24
|
## Clipboard
|
|
6
25
|
|
|
7
26
|
```ts
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "overmux instance"
|
|
3
|
+
description: "Read the identity of a running Overmux server"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# `overmux instance`
|
|
7
|
+
|
|
8
|
+
Print the live server's `instanceId` and `deepLinkPrefix`. Like `overmux call`, this discovers a local running server through its private same-user control channel. It queries the identity resolved by that running server, never reloads local configuration, and does not verify HTTP or WebSocket reachability.
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
overmux instance
|
|
12
|
+
overmux instance --port 4242 --json
|
|
13
|
+
# {"instanceId":"rich-work-4242","deepLinkPrefix":"overmux://rich-work-4242"}
|
|
14
|
+
|
|
15
|
+
prefix=$(overmux instance --json | jq -r .deepLinkPrefix)
|
|
16
|
+
printf '%s/tmux/$71/@647/%%25647\n' "$prefix"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
| Flag | Description |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| `--port <value>, -p <value>` | Select a running local server when several are available |
|
|
22
|
+
| `--json` | Print only a JSON object with `instanceId` and `deepLinkPrefix` |
|
|
23
|
+
|
|
24
|
+
The prefix has no trailing slash. Append your application route, encoding each path segment once: pane ID `%647` becomes `%25647`. An instance ID is a lookup key, not a hostname, network address, or credential. The desktop associates it with URLs learned from authenticated server connections.
|
package/package.json
CHANGED
package/src/internal/cli/app.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { desktopRoute } from "./commands/desktop";
|
|
|
14
14
|
import { createDocsCommand, resolveInstalledDocsRoot } from "./commands/docs";
|
|
15
15
|
import { createInitCommand } from "./commands/init";
|
|
16
16
|
import { integrationCommand } from "./commands/integration";
|
|
17
|
+
import { createInstanceCommand } from "./commands/instance";
|
|
17
18
|
import { serveCommand } from "./commands/serve";
|
|
18
19
|
import { detectCliEnvironment, type CliCommandContext } from "./environment";
|
|
19
20
|
|
|
@@ -33,6 +34,7 @@ export const createCliApp = (process = globalThis.process) =>
|
|
|
33
34
|
}),
|
|
34
35
|
init: createInitCommand(process),
|
|
35
36
|
integration: integrationCommand,
|
|
37
|
+
instance: createInstanceCommand(process.stdout),
|
|
36
38
|
serve: serveCommand,
|
|
37
39
|
},
|
|
38
40
|
}),
|
|
@@ -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
|
+
});
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
<
|
|
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 {
|
|
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
|
-
|
|
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}`);
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
type DesktopHost = {
|
|
2
|
+
version: 1;
|
|
3
|
+
platform: string;
|
|
4
|
+
instance?: { report: (identity: { instanceId: string }) => void; version: 1 };
|
|
2
5
|
clipboard?: { writeText: (text: string) => void; version: 1 };
|
|
3
6
|
notifications?: { show: (notification: unknown) => void; version: 1 };
|
|
4
7
|
};
|
|
@@ -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
|
+
};
|
|
@@ -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 {
|
|
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 {
|
|
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,
|