overmux 0.0.8 → 0.0.10
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 +21 -0
- package/dist/bin.js +59 -21
- package/dist/bin.js.map +1 -1
- package/dist/docs/100-introduction/300-why-i-built-overmux.md +1 -1
- package/dist/docs/200-getting-started/200-install-overmux-desktop.md +13 -0
- package/dist/docs/400-reference/500-server/100-resources.md +25 -0
- package/dist/docs/400-reference/500-server/500-api.md +4 -2
- package/dist/docs/400-reference/600-client/010-commands.md +0 -29
- package/dist/docs/400-reference/600-client/030-native-web-view.md +166 -0
- package/dist/docs/400-reference/600-client/300-tech-stack-recommendations.md +1 -0
- package/dist/docs/400-reference/700-cli/600-docs.md +1 -1
- package/dist/exports/client.d.ts +44 -8
- package/dist/exports/client.d.ts.map +1 -1
- package/dist/exports/client.js +212 -40
- package/dist/exports/client.js.map +1 -1
- package/dist/exports/{index-Cz3xkCa4.d.ts → index-Dk11nKIv.d.ts} +2 -2
- package/dist/exports/index-Dk11nKIv.d.ts.map +1 -0
- package/dist/exports/index.d.ts +1 -1
- package/dist/exports/index.js +1 -1
- package/dist/exports/index.js.map +1 -1
- package/dist/internal/server/coordinator/server-child.js +75 -22
- package/dist/internal/server/coordinator/server-child.js.map +1 -1
- package/docs/100-introduction/300-why-i-built-overmux.md +1 -1
- package/docs/200-getting-started/200-install-overmux-desktop.md +13 -0
- package/docs/400-reference/500-server/100-resources.md +25 -0
- package/docs/400-reference/500-server/500-api.md +4 -2
- package/docs/400-reference/600-client/010-commands.md +0 -29
- package/docs/400-reference/600-client/030-native-web-view.md +166 -0
- package/docs/400-reference/600-client/300-tech-stack-recommendations.md +1 -0
- package/docs/400-reference/700-cli/600-docs.md +1 -1
- package/package.json +2 -2
- package/src/internal/client/client-definition.ts +9 -13
- package/src/internal/client/host/desktop-host.ts +28 -2
- package/src/internal/client/native-web-view.tsx +158 -0
- package/src/internal/client/overmux-react.ts +121 -27
- package/src/internal/client/shortcuts.tsx +17 -31
- package/src/internal/server/runtime/runtime-resources.ts +99 -20
- package/src/internal/server/websocket-connection.ts +27 -2
- package/src/public/client.ts +5 -0
- package/src/public/config.ts +1 -1
- package/dist/exports/index-Cz3xkCa4.d.ts.map +0 -1
|
@@ -19,4 +19,4 @@ I lived in a customized tmux setup. But kept noticing that that the terminal, fo
|
|
|
19
19
|
|
|
20
20
|
[Herdr](https://github.com/herdrdev/herdr) (a TUI) and [Orca](https://github.com/stablyai/orca) (web based) offer a decent default experience. But they lack the customizability I love with the terminal and neovim. They use their own muxer implementations and dictate workflows.
|
|
21
21
|
|
|
22
|
-
This future
|
|
22
|
+
This future didn't _spark joy_ for me, so I built Overmux to embed my terminal in a Desktop webapp and build my own experience with a mix of tmux terminals + custom web uis.
|
|
@@ -22,6 +22,19 @@ Download the ZIP for your Mac from the [Overmux releases](https://github.com/ric
|
|
|
22
22
|
|
|
23
23
|
Overmux is not currently signed or notarized. Follow [Apple's instructions for opening an app from an unidentified developer](https://support.apple.com/en-us/102445) to allow it to run.
|
|
24
24
|
|
|
25
|
+
<details>
|
|
26
|
+
<summary>macOS says Overmux is damaged or the developer cannot be verified</summary>
|
|
27
|
+
|
|
28
|
+
For quarantine-related blocking, run this **only for a trusted download from [official Overmux releases](https://github.com/richardgill/overmux/releases)**:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
xattr -dr com.apple.quarantine "$HOME/Applications/Overmux.app"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Use `/Applications/Overmux.app` instead if you installed it there. Then reopen Overmux in Finder.
|
|
35
|
+
|
|
36
|
+
</details>
|
|
37
|
+
|
|
25
38
|
## Linux
|
|
26
39
|
|
|
27
40
|
Download the AppImage or Debian package from the [Overmux releases](https://github.com/richardgill/overmux/releases).
|
|
@@ -73,6 +73,31 @@ When the subscription ends, Overmux calls the cleanup function returned by `subs
|
|
|
73
73
|
|
|
74
74
|
The React code stays the same: `useResource({ id: "ls" })`.
|
|
75
75
|
|
|
76
|
+
### Asynchronous setup
|
|
77
|
+
|
|
78
|
+
`subscribe` can be synchronous or asynchronous. Both forms return a cleanup function. For example, replace the `ls` resource's `subscribe` handler to resolve the directory's canonical path before watching it:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import { watch } from "node:fs";
|
|
82
|
+
import { realpath } from "node:fs/promises";
|
|
83
|
+
|
|
84
|
+
// Inside the resource definition:
|
|
85
|
+
subscribe: async (_input, invalidate, context) => {
|
|
86
|
+
const directory = await realpath("/");
|
|
87
|
+
// The subscription may have ended while resolving the path.
|
|
88
|
+
context.signal.throwIfAborted();
|
|
89
|
+
|
|
90
|
+
const watcher = watch(directory, { signal: context.signal }, () => {
|
|
91
|
+
invalidate();
|
|
92
|
+
});
|
|
93
|
+
return () => watcher.close();
|
|
94
|
+
},
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`context.signal` is an `AbortSignal` that Overmux aborts when the client unsubscribes, disconnects, or the server shuts down. Cancellation does not automatically stop an async function: pass the signal to APIs that support it and check it after awaiting setup. Here, `realpath` does not accept a signal, so `throwIfAborted()` prevents starting a watcher if cancellation happened during that work.
|
|
98
|
+
|
|
99
|
+
Overmux calls the returned cleanup function once, even if setup finishes after cancellation. Cleanup may also be asynchronous. In this example, the signal can close the watcher before Overmux calls cleanup; `watcher.close()` is safe to call again. If setup throws before returning cleanup, your handler must release any resources it already acquired.
|
|
100
|
+
|
|
76
101
|
## Derived
|
|
77
102
|
|
|
78
103
|
Derived resources compute a value from other resources.
|
|
@@ -47,7 +47,7 @@ A resource definition has a `contract` created with `defineResourceContract({ in
|
|
|
47
47
|
| Kind | Required handler API |
|
|
48
48
|
| --- | --- |
|
|
49
49
|
| `query` | `read(input, context) => output \| Promise<output>` |
|
|
50
|
-
| `subscription` | `read(input, context)` and `subscribe(input, invalidate, context) =>
|
|
50
|
+
| `subscription` | `read(input, context)` and `subscribe(input, invalidate, context) => (() => void \| Promise<void>) \| Promise<() => void \| Promise<void>>` |
|
|
51
51
|
| `derived` | `dependencies` and `combine(dependencies) => output` |
|
|
52
52
|
|
|
53
53
|
```ts
|
|
@@ -69,7 +69,9 @@ const resources = {
|
|
|
69
69
|
};
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
-
`subscribe`
|
|
72
|
+
`subscribe` returns a cleanup function, directly or asynchronously. Overmux aborts `context.signal` when the subscription ends, including during setup, on client disconnect, or on server shutdown. Overmux calls the returned cleanup function once, even if setup returns it after cancellation. The cleanup function itself may also be asynchronous. If setup throws before returning cleanup, the handler must release any resources it already acquired. See [Asynchronous setup](./100-resources.md#asynchronous-setup) for an example.
|
|
73
|
+
|
|
74
|
+
A derived resource reads the resources named by `dependencies`; invalidating a dependency also invalidates its dependants. See [Resources](./100-resources.md) for contracts, derived dependencies, and invalidation.
|
|
73
75
|
|
|
74
76
|
## Stream handlers
|
|
75
77
|
|
|
@@ -158,32 +158,3 @@ const CommandMenu = () => {
|
|
|
158
158
|
Each entry exposes `id`, `title`, `bindings`, `enabled`, and `execute()`. The list includes disabled commands but excludes commands without a registration.
|
|
159
159
|
|
|
160
160
|
Each command appears once, even if multiple components register it. `execute()` selects the handler using the current focus and runs it only if enabled.
|
|
161
|
-
|
|
162
|
-
## Using commands from packages
|
|
163
|
-
|
|
164
|
-
Packages can export commands to include alongside your own:
|
|
165
|
-
|
|
166
|
-
```tsx
|
|
167
|
-
import { sourceControlCommands } from "@overmux/git/react";
|
|
168
|
-
|
|
169
|
-
export const client = defineOvermuxClient({
|
|
170
|
-
commands: {
|
|
171
|
-
...commands,
|
|
172
|
-
...sourceControlCommands,
|
|
173
|
-
},
|
|
174
|
-
component: App,
|
|
175
|
-
});
|
|
176
|
-
```
|
|
177
|
-
|
|
178
|
-
Adding commands to the registry does not register their handlers. For this package, pass the commands to `SourceControlView`:
|
|
179
|
-
|
|
180
|
-
```tsx
|
|
181
|
-
<SourceControlView
|
|
182
|
-
{...sourceControlProps}
|
|
183
|
-
commandHandles={sourceControlCommands}
|
|
184
|
-
/>
|
|
185
|
-
```
|
|
186
|
-
|
|
187
|
-
The view registers handlers for navigating changed files and scrolling diffs. They then appear in `useCommands()` alongside your own registered commands.
|
|
188
|
-
|
|
189
|
-
Keep command IDs unique when combining registries, and pass the same command objects to the client and the component.
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Native web views
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
`NativeWebView` embeds a website in Overmux Desktop using a chrome native webview.
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
import { NativeWebView } from "overmux/client";
|
|
9
|
+
|
|
10
|
+
export const Website = () => (
|
|
11
|
+
<NativeWebView
|
|
12
|
+
url="https://example.com"
|
|
13
|
+
style={{ width: "100%", height: 600 }}
|
|
14
|
+
fallback={
|
|
15
|
+
<a href="https://example.com" target="_blank" rel="noopener noreferrer">
|
|
16
|
+
Open website in browser
|
|
17
|
+
</a>
|
|
18
|
+
}
|
|
19
|
+
/>
|
|
20
|
+
);
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Props
|
|
24
|
+
|
|
25
|
+
`NativeWebViewProps` and `NativeWebViewLoadError` are exported from `overmux/client`.
|
|
26
|
+
|
|
27
|
+
### `url`
|
|
28
|
+
|
|
29
|
+
**Required.** Type: `string`. The initial website URL.
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
import { NativeWebView } from "overmux/client";
|
|
33
|
+
|
|
34
|
+
<NativeWebView url="https://example.com" style={{ height: 600 }} />;
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Changing this string navigates; rerendering the same URL preserves in-page navigation. A remount starts again at `url`, retaining cookies and site storage.
|
|
38
|
+
|
|
39
|
+
A blocked `url` prop clears the native page, including when an HTTP allowance is revoked. Blocked in-page links leave the current page in place. See [`allowedHttpOrigins`](#allowedhttporigins) for permitted destinations.
|
|
40
|
+
|
|
41
|
+
New-window links open in the system browser, not another embedded surface. The system browser has its own login state. Popup-based authentication flows may therefore not complete inside the embedded view; use in-page sign-in where supported.
|
|
42
|
+
|
|
43
|
+
### `className`
|
|
44
|
+
|
|
45
|
+
**Optional.** Type: `string`. No default. Applies a CSS class to the desktop placeholder `div`, not the embedded website.
|
|
46
|
+
|
|
47
|
+
```tsx
|
|
48
|
+
import { NativeWebView } from "overmux/client";
|
|
49
|
+
|
|
50
|
+
<NativeWebView
|
|
51
|
+
url="https://example.com"
|
|
52
|
+
className="website-pane"
|
|
53
|
+
style={{ height: 600 }}
|
|
54
|
+
/>;
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Define the class in your application's stylesheet. See [Theming and CSS](./theming) for loading styles.
|
|
58
|
+
|
|
59
|
+
### `style`
|
|
60
|
+
|
|
61
|
+
**Optional.** Type: `React.CSSProperties`. No default. Applies inline styles to the desktop placeholder `div`.
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
import { NativeWebView } from "overmux/client";
|
|
65
|
+
|
|
66
|
+
<NativeWebView
|
|
67
|
+
url="https://example.com"
|
|
68
|
+
style={{ width: "100%", height: 600 }}
|
|
69
|
+
/>;
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Give the placeholder a **nonzero width and height**. The component has no default height. Desktop follows its rectangular position and size as the layout moves, scrolls or resizes. Hidden and zero-size placeholders hide the native surface without clearing its site data. See [Layout limitations](#layout-limitations) before using overlapping panes or CSS effects.
|
|
73
|
+
|
|
74
|
+
### `fallback`
|
|
75
|
+
|
|
76
|
+
**Optional.** Type: `React.ReactNode`. Content shown in browsers, PWAs and desktop versions without native web view support.
|
|
77
|
+
|
|
78
|
+
```tsx
|
|
79
|
+
import { NativeWebView } from "overmux/client";
|
|
80
|
+
|
|
81
|
+
<NativeWebView
|
|
82
|
+
url="https://example.com"
|
|
83
|
+
style={{ height: 600 }}
|
|
84
|
+
fallback={
|
|
85
|
+
<a href="https://example.com" target="_blank" rel="noopener noreferrer">
|
|
86
|
+
Open website in browser
|
|
87
|
+
</a>
|
|
88
|
+
}
|
|
89
|
+
/>;
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
### `onLoadError`
|
|
94
|
+
|
|
95
|
+
**Optional.** Type: `(error: NativeWebViewLoadError) => void`. No default handler. Receives `{ url, code, message }` for main-page load failures, blocked destinations, invalid configuration or bridge errors.
|
|
96
|
+
|
|
97
|
+
```tsx
|
|
98
|
+
import { NativeWebView, type NativeWebViewLoadError } from "overmux/client";
|
|
99
|
+
|
|
100
|
+
const reportLoadError = (error: NativeWebViewLoadError) => console.error(error);
|
|
101
|
+
|
|
102
|
+
<NativeWebView
|
|
103
|
+
url="https://example.com"
|
|
104
|
+
style={{ height: 600 }}
|
|
105
|
+
onLoadError={reportLoadError}
|
|
106
|
+
/>;
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Main-page failures exclude subresource failures and aborted navigation superseded by a newer request.
|
|
110
|
+
|
|
111
|
+
| Code | Meaning |
|
|
112
|
+
| --- | --- |
|
|
113
|
+
| Numeric string, such as `"-105"` | Chromium load error |
|
|
114
|
+
| `ERR_URL_BLOCKED` | Destination rejected by navigation policy |
|
|
115
|
+
| `ERR_DESKTOP_BRIDGE` | Bridge or configuration failure |
|
|
116
|
+
| `ERR_OPEN_EXTERNAL` | Failed to open the system browser |
|
|
117
|
+
| `ERR_RENDER_PROCESS_GONE` | Native renderer crashed; the surface is destroyed |
|
|
118
|
+
|
|
119
|
+
Remount to retry after a native renderer crash.
|
|
120
|
+
|
|
121
|
+
### `allowedHttpOrigins`
|
|
122
|
+
|
|
123
|
+
**Optional.** Type: `string[]`. Defaults to `[]`. Additional plain-HTTP origins to permit. An origin is the scheme, hostname and port, without a path.
|
|
124
|
+
|
|
125
|
+
```tsx
|
|
126
|
+
import { NativeWebView } from "overmux/client";
|
|
127
|
+
|
|
128
|
+
<NativeWebView
|
|
129
|
+
url="http://devbox.local:3000"
|
|
130
|
+
style={{ height: 600 }}
|
|
131
|
+
allowedHttpOrigins={["http://devbox.local:3000"]}
|
|
132
|
+
/>;
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
HTTPS is allowed. HTTP is allowed for `localhost`, `127.0.0.1` and `[::1]`, plus exact origins in this list. Entries must be HTTP origins, optionally with a trailing slash. Wildcards, credentials, paths, queries and fragments are rejected. Matching includes the port. Other schemes are denied. Policy is enforced in Electron main for requested URLs, page navigation, redirects and new-window links.
|
|
136
|
+
|
|
137
|
+
### `passthroughBindings`
|
|
138
|
+
|
|
139
|
+
**Optional.** Type: `readonly ShortcutBinding[]`. Defaults to `[]`. Keyboard bindings to forward to the owning Overmux UI while the embedded page has focus. `ShortcutBinding` is exported from `overmux/client`.
|
|
140
|
+
|
|
141
|
+
```tsx
|
|
142
|
+
import { NativeWebView } from "overmux/client";
|
|
143
|
+
|
|
144
|
+
<NativeWebView
|
|
145
|
+
url="https://example.com"
|
|
146
|
+
style={{ height: 600 }}
|
|
147
|
+
passthroughBindings={["Escape", "Control+Shift+X", ["F12", "P", "R"]]}
|
|
148
|
+
/>;
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Uses the same binding and chord syntax as [command shortcuts](./shortcuts), but does not require a matching command registration. A chord is a sequence of key presses, such as F12, then P, then R.
|
|
152
|
+
|
|
153
|
+
Overmux desktop intercepts a completed configured binding before the embedded page receives it and forwards its keyboard input, including modifiers and releases, to the owning Overmux UI. That UI then handles it through its ordinary DOM listeners - for example, a dialog's Escape handler, a local `onKeyDown`, or the normal shortcut host. The embedded page remains sandboxed and receives no Overmux bridge or command capability.
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
## Security and shared sessions
|
|
157
|
+
|
|
158
|
+
Embedded pages have no Overmux bridge, preload script or Node access. Sandbox, context isolation and web security stay enabled. Permissions and downloads are denied.
|
|
159
|
+
|
|
160
|
+
All native views across connected Overmux instances share **one persistent desktop browser session**, `persist:overmux-native-web`. Cookies and login state survive unmounting, switching instances and restarting Desktop. This session is separate from Overmux authentication and from the system browser. Unmounting destroys the surface, not site data. Clearing an Overmux instance does not clear native website logins. There is no per-view profile or partition option.
|
|
161
|
+
|
|
162
|
+
## Layout limitations
|
|
163
|
+
|
|
164
|
+
Native surfaces paint **above the DOM**, including React modals, menus and dropdowns. Use ordinary, non-overlapping rectangular panes. DOM stacking order, rounded corners, CSS transforms, ancestor clipping and arbitrary CSS effects are not faithfully reproduced. Bounds are clipped to the host window, not arbitrary DOM containers. Avoid layouts that depend on those effects; unmount the view when its native surface must not cover other content.
|
|
165
|
+
|
|
166
|
+
There is no `visible` prop, toolbar, zoom or history API, overlap coordination, or pane/session restoration. Host document reloads, crashes, instance changes and window destruction also destroy its native surfaces.
|
|
@@ -8,5 +8,6 @@ We recommend:
|
|
|
8
8
|
- [TanStack Router](https://tanstack.com/router) for routing.
|
|
9
9
|
- [shadcn/ui](https://ui.shadcn.com/) for UI components.
|
|
10
10
|
- [Zod](https://zod.dev/) for schemas and runtime validation.
|
|
11
|
+
- [@pierre/diffs](https://diffs.com/) for building custom Git diff views.
|
|
11
12
|
|
|
12
13
|
If your app already uses a different stack, follow its existing conventions.
|
|
@@ -122,7 +122,7 @@ Inspect these files so guidance matches the versions used by the application.
|
|
|
122
122
|
#### `tech-stack-recommendations`
|
|
123
123
|
|
|
124
124
|
```text
|
|
125
|
-
Prefer pnpm as the package manager, TanStack Router for routing, shadcn/ui for UI components, and Zod for schemas and runtime validation. Follow the application's established stack when it already differs.
|
|
125
|
+
Prefer pnpm as the package manager, TanStack Router for routing, shadcn/ui for UI components, and Zod for schemas and runtime validation. For custom Git diff views, prefer @pierre/diffs (https://diffs.com/). Follow the application's established stack when it already differs.
|
|
126
126
|
```
|
|
127
127
|
|
|
128
128
|
[//]: # (END GENERATED AI CONTEXT)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "overmux",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.10",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"homepage": "https://github.com/richardgill/overmux",
|
|
6
6
|
"repository": {
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"typescript": "5.9.3",
|
|
54
54
|
"web-push": "3.6.7",
|
|
55
55
|
"ws": "8.21.3",
|
|
56
|
-
"@overmux/keybindings": "0.0.
|
|
56
|
+
"@overmux/keybindings": "0.0.7"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@playwright/test": "1.58.0",
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
import type { OvermuxContrast, OvermuxScheme } from "./theme-scope";
|
|
2
2
|
import type { ComponentType } from "react";
|
|
3
3
|
import {
|
|
4
|
-
formatKeyBinding,
|
|
5
4
|
keyBindingSchema,
|
|
6
5
|
matchesKeyBinding,
|
|
6
|
+
shortcutBindingSchema,
|
|
7
7
|
type KeyBinding,
|
|
8
|
+
type KeySequence,
|
|
9
|
+
type ShortcutBinding,
|
|
10
|
+
} from "@overmux/keybindings";
|
|
11
|
+
export { formatShortcutBinding } from "@overmux/keybindings";
|
|
12
|
+
export type {
|
|
13
|
+
KeyBinding,
|
|
14
|
+
KeySequence,
|
|
15
|
+
ShortcutBinding,
|
|
8
16
|
} from "@overmux/keybindings";
|
|
9
|
-
export type { KeyBinding } from "@overmux/keybindings";
|
|
10
17
|
import { z } from "zod";
|
|
11
18
|
|
|
12
19
|
import type { OvermuxServerApi } from "./browser-api";
|
|
13
20
|
|
|
14
|
-
export type KeySequence = readonly [KeyBinding, KeyBinding, ...KeyBinding[]];
|
|
15
|
-
export type ShortcutBinding = KeyBinding | KeySequence;
|
|
16
21
|
export type ConditionalShortcutBinding = {
|
|
17
22
|
binding: ShortcutBinding;
|
|
18
23
|
when: { media: string };
|
|
@@ -23,10 +28,6 @@ export type ChordPrefix = {
|
|
|
23
28
|
unmatched: "replay-to-focused-input";
|
|
24
29
|
};
|
|
25
30
|
|
|
26
|
-
const shortcutBindingSchema = z.union([
|
|
27
|
-
keyBindingSchema,
|
|
28
|
-
z.array(keyBindingSchema).min(2),
|
|
29
|
-
]);
|
|
30
31
|
const commandBindingSchema = z.union([
|
|
31
32
|
shortcutBindingSchema,
|
|
32
33
|
z.object({
|
|
@@ -39,11 +40,6 @@ const chordPrefixSchema = z.object({
|
|
|
39
40
|
unmatched: z.literal("replay-to-focused-input"),
|
|
40
41
|
});
|
|
41
42
|
|
|
42
|
-
export const formatShortcutBinding = (binding: ShortcutBinding) =>
|
|
43
|
-
typeof binding === "string"
|
|
44
|
-
? formatKeyBinding(binding)
|
|
45
|
-
: binding.map(formatKeyBinding).join(" ");
|
|
46
|
-
|
|
47
43
|
type CommandRunResult = Promise<unknown> | unknown;
|
|
48
44
|
type CommandRun<TServerConfig, TParams> = (input: {
|
|
49
45
|
overmuxServerApi: OvermuxServerApi<TServerConfig>;
|
|
@@ -1,6 +1,34 @@
|
|
|
1
|
+
import type { ShortcutBinding } from "@overmux/keybindings";
|
|
2
|
+
|
|
3
|
+
import type { NativeWebViewLoadError } from "../native-web-view";
|
|
4
|
+
|
|
5
|
+
// This capability is independently versioned from the host envelope.
|
|
6
|
+
export type NativeWebViewBridge = {
|
|
7
|
+
version: 1;
|
|
8
|
+
command: (
|
|
9
|
+
input:
|
|
10
|
+
| { type: "create" | "destroy"; id: string }
|
|
11
|
+
| {
|
|
12
|
+
type: "configure";
|
|
13
|
+
id: string;
|
|
14
|
+
url: string;
|
|
15
|
+
allowedHttpOrigins: string[];
|
|
16
|
+
passthroughBindings: readonly ShortcutBinding[];
|
|
17
|
+
},
|
|
18
|
+
) => Promise<void>;
|
|
19
|
+
setBounds: (
|
|
20
|
+
id: string,
|
|
21
|
+
bounds: { x: number; y: number; width: number; height: number },
|
|
22
|
+
) => void;
|
|
23
|
+
onLoadError: (
|
|
24
|
+
callback: (input: { id: string; error: NativeWebViewLoadError }) => void,
|
|
25
|
+
) => () => void;
|
|
26
|
+
};
|
|
27
|
+
|
|
1
28
|
type DesktopHost = {
|
|
2
29
|
version: 1;
|
|
3
30
|
platform: string;
|
|
31
|
+
nativeWebView?: NativeWebViewBridge;
|
|
4
32
|
instance?: { report: (identity: { instanceId: string }) => void; version: 1 };
|
|
5
33
|
clipboard?: { writeText: (text: string) => void; version: 1 };
|
|
6
34
|
notifications?: { show: (notification: unknown) => void; version: 1 };
|
|
@@ -11,5 +39,3 @@ declare global {
|
|
|
11
39
|
overmuxHost?: DesktopHost;
|
|
12
40
|
}
|
|
13
41
|
}
|
|
14
|
-
|
|
15
|
-
export {};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { useEffect, useRef, type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import type { ShortcutBinding } from "@overmux/keybindings";
|
|
4
|
+
|
|
5
|
+
import type { NativeWebViewBridge } from "./host/desktop-host";
|
|
6
|
+
|
|
7
|
+
export type NativeWebViewLoadError = {
|
|
8
|
+
url: string;
|
|
9
|
+
code: string;
|
|
10
|
+
message: string;
|
|
11
|
+
};
|
|
12
|
+
export type NativeWebViewProps = {
|
|
13
|
+
url: string;
|
|
14
|
+
className?: string;
|
|
15
|
+
style?: CSSProperties;
|
|
16
|
+
fallback?: ReactNode;
|
|
17
|
+
onLoadError?: (error: NativeWebViewLoadError) => void;
|
|
18
|
+
allowedHttpOrigins?: string[];
|
|
19
|
+
passthroughBindings?: readonly ShortcutBinding[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type MountedView = {
|
|
23
|
+
id: string;
|
|
24
|
+
active: boolean;
|
|
25
|
+
bridge: NativeWebViewBridge;
|
|
26
|
+
report: (error: NativeWebViewLoadError) => void;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const command = async (
|
|
30
|
+
view: MountedView,
|
|
31
|
+
input: Parameters<NativeWebViewBridge["command"]>[0],
|
|
32
|
+
url: string,
|
|
33
|
+
) => {
|
|
34
|
+
try {
|
|
35
|
+
await view.bridge.command(input);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
view.report({ url, code: "ERR_DESKTOP_BRIDGE", message: String(error) });
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const trackBounds = (element: HTMLDivElement, view: MountedView) => {
|
|
42
|
+
let previous = "";
|
|
43
|
+
let frame = 0;
|
|
44
|
+
const measure = () => {
|
|
45
|
+
const rect = element.getBoundingClientRect();
|
|
46
|
+
const visible =
|
|
47
|
+
!document.hidden &&
|
|
48
|
+
element.checkVisibility({
|
|
49
|
+
opacityProperty: true,
|
|
50
|
+
visibilityProperty: true,
|
|
51
|
+
});
|
|
52
|
+
const bounds = {
|
|
53
|
+
x: Math.round(rect.x),
|
|
54
|
+
y: Math.round(rect.y),
|
|
55
|
+
width: visible ? Math.max(0, Math.round(rect.width)) : 0,
|
|
56
|
+
height: visible ? Math.max(0, Math.round(rect.height)) : 0,
|
|
57
|
+
};
|
|
58
|
+
const key = JSON.stringify(bounds);
|
|
59
|
+
if (key !== previous) {
|
|
60
|
+
previous = key;
|
|
61
|
+
view.bridge.setBounds(view.id, bounds);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const tick = () => {
|
|
65
|
+
measure();
|
|
66
|
+
frame = requestAnimationFrame(tick);
|
|
67
|
+
};
|
|
68
|
+
// ResizeObserver alone misses position-only changes (siblings, scrolling and
|
|
69
|
+
// animation). Sample once per frame, but send IPC only for changed pixel bounds.
|
|
70
|
+
tick();
|
|
71
|
+
document.addEventListener("visibilitychange", measure);
|
|
72
|
+
return () => {
|
|
73
|
+
cancelAnimationFrame(frame);
|
|
74
|
+
document.removeEventListener("visibilitychange", measure);
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const NativeWebView = ({
|
|
79
|
+
url,
|
|
80
|
+
className,
|
|
81
|
+
style,
|
|
82
|
+
fallback,
|
|
83
|
+
onLoadError,
|
|
84
|
+
allowedHttpOrigins = [],
|
|
85
|
+
passthroughBindings = [],
|
|
86
|
+
}: NativeWebViewProps) => {
|
|
87
|
+
const bridge =
|
|
88
|
+
typeof window === "undefined" ||
|
|
89
|
+
window.overmuxHost?.version !== 1 ||
|
|
90
|
+
window.overmuxHost.nativeWebView?.version !== 1
|
|
91
|
+
? undefined
|
|
92
|
+
: window.overmuxHost.nativeWebView;
|
|
93
|
+
const element = useRef<HTMLDivElement>(null);
|
|
94
|
+
const mounted = useRef<MountedView | undefined>(undefined);
|
|
95
|
+
const latest = useRef({ url, onLoadError });
|
|
96
|
+
latest.current = { url, onLoadError };
|
|
97
|
+
// Equal array contents are not a navigation or a policy change.
|
|
98
|
+
const origins = JSON.stringify(allowedHttpOrigins);
|
|
99
|
+
const bindings = JSON.stringify(passthroughBindings);
|
|
100
|
+
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (!bridge || !element.current) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const view: MountedView = {
|
|
106
|
+
id: crypto.randomUUID(),
|
|
107
|
+
active: true,
|
|
108
|
+
bridge,
|
|
109
|
+
report: (error) => {
|
|
110
|
+
// Keep caller exceptions out of IPC promise chains.
|
|
111
|
+
queueMicrotask(() => {
|
|
112
|
+
if (view.active) {
|
|
113
|
+
latest.current.onLoadError?.(error);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
mounted.current = view;
|
|
119
|
+
const unsubscribe = bridge.onLoadError(({ id, error }) => {
|
|
120
|
+
if (id === view.id) {
|
|
121
|
+
view.report(error);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
void command(view, { type: "create", id: view.id }, latest.current.url);
|
|
125
|
+
const stopTracking = trackBounds(element.current, view);
|
|
126
|
+
return () => {
|
|
127
|
+
view.active = false;
|
|
128
|
+
mounted.current = undefined;
|
|
129
|
+
stopTracking();
|
|
130
|
+
unsubscribe();
|
|
131
|
+
// Creation and destruction are ordered commands, not asynchronous load waits.
|
|
132
|
+
void command(view, { type: "destroy", id: view.id }, latest.current.url);
|
|
133
|
+
};
|
|
134
|
+
}, [bridge]);
|
|
135
|
+
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
const view = mounted.current;
|
|
138
|
+
if (view) {
|
|
139
|
+
void command(
|
|
140
|
+
view,
|
|
141
|
+
{
|
|
142
|
+
type: "configure",
|
|
143
|
+
id: view.id,
|
|
144
|
+
url,
|
|
145
|
+
allowedHttpOrigins: JSON.parse(origins) as string[],
|
|
146
|
+
passthroughBindings: JSON.parse(bindings) as ShortcutBinding[],
|
|
147
|
+
},
|
|
148
|
+
url,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}, [bindings, bridge, url, origins]);
|
|
152
|
+
|
|
153
|
+
return bridge ? (
|
|
154
|
+
<div ref={element} className={className} style={style} />
|
|
155
|
+
) : (
|
|
156
|
+
<>{fallback}</>
|
|
157
|
+
);
|
|
158
|
+
};
|