dsh-inner-browser-use 0.34.28 → 0.34.96
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/README.md +53 -18
- package/README.zh.md +26 -14
- package/client.js +223 -23
- package/docs/ARCHITECTURE.md +15 -12
- package/docs/ARTIFACTS.md +1 -1
- package/docs/BENCH.md +209 -34
- package/docs/FLASH.md +84 -0
- package/docs/ROADMAP.md +21 -15
- package/docs/VERIFY.md +20 -10
- package/index.js +186 -24
- package/package.json +17 -3
- package/read.js +633 -29
- package/runtime/flash-app/main.js +232 -0
- package/runtime/flash-app/package.json +1 -0
- package/runtime/flash-setup.mjs +151 -0
- package/runtime/swf.mjs +233 -0
- package/scripts/check-flash-local.mjs +104 -0
- package/scripts/check-flash-mirror.mjs +73 -0
- package/scripts/check-flash-tools.mjs +74 -0
- package/scripts/check-flash-window.mjs +58 -0
- package/scripts/doctor.mjs +3 -1
- package/sidecar.js +1098 -96
- package/tools.js +1068 -144
package/README.md
CHANGED
|
@@ -10,17 +10,22 @@ live picture of that browser, and your own cursor keeps working on the picture.
|
|
|
10
10
|
|
|
11
11
|
| Tool | What it does |
|
|
12
12
|
| --- | --- |
|
|
13
|
-
| `
|
|
14
|
-
| `
|
|
15
|
-
| `
|
|
16
|
-
| `
|
|
17
|
-
| `
|
|
18
|
-
| `
|
|
19
|
-
| `
|
|
20
|
-
| `
|
|
21
|
-
| `
|
|
22
|
-
|
|
23
|
-
`
|
|
13
|
+
| `browser_open` | Starts the browser (or reuses the running one) and opens an address. `headless: false` shows a real window on the desktop instead. |
|
|
14
|
+
| `browser_view` | Returns the address, the title, every control as `1 … 120`, and the visible text. |
|
|
15
|
+
| `browser_read` | Reads the page the way a person does: one Markdown block of headings, paragraphs, lists and links, no controls. Far fewer tokens than a snapshot on long pages, and the links stay usable. Give it a `query` and only the passages that answer it come back, ranked (BM25 over Readability-style blocks, link-heavy menus down-weighted). |
|
|
16
|
+
| `browser_find` | Finds words (or a regex) anywhere on the page: matching controls come back with a clickable number, matching text with its point, each saying whether it is on screen. |
|
|
17
|
+
| `browser_extract` | Returns the items a CSS selector matches, one compact line each with its link, instead of the whole page. |
|
|
18
|
+
| `browser_click` | Clicks a numbered control, or an `x`/`y` point. `double: true` double-clicks. A cookie banner or modal over the control is cleared first, then the click happens. |
|
|
19
|
+
| `browser_type` | Types text into the focused field, or clicks a numbered control first. Chinese goes in as one string. `submit: true` presses Enter. |
|
|
20
|
+
| `browser_key` | Presses one key: Enter, Tab, Escape, Backspace, Delete, Insert, arrows, Home, End, PageUp, PageDown, F1-F12, the numeric keypad, letters, digits, punctuation and every shifted symbol, plus ContextMenu, CapsLock, PrintScreen and media keys. `Ctrl`/`Alt`/`Shift`/`Meta`/`Mod` modifiers, chords with `+`, and `Ctrl+C/X/V/A/Z/Y` run the real editing commands. |
|
|
21
|
+
| `browser_scroll` | Scrolls the wheel at the centre of the page, or at `x`/`y`. `dy: 600` is about a screen down. |
|
|
22
|
+
| `browser_shot` | Saves a full-size PNG into `~/.dsh-inner-browser-use/screenshots` and returns the path. |
|
|
23
|
+
| `browser_state` | Reports whether the browser runs, which profile it uses, its address and title, and how many columns are showing it. |
|
|
24
|
+
|
|
25
|
+
`browser_read` on the same page is the words only, in Markdown (measured: 4,520
|
|
26
|
+
characters of an about page against 2,987 characters of snapshot — and the
|
|
27
|
+
snapshot spends most of its budget on controls; on a long article the gap grows
|
|
28
|
+
with the number of controls skipped). `browser_view` answers with one page of text:
|
|
24
29
|
|
|
25
30
|
```
|
|
26
31
|
https://example.com
|
|
@@ -34,8 +39,9 @@ Numbers stay valid until the next read. Anything that changes the page returns a
|
|
|
34
39
|
|
|
35
40
|
## The column
|
|
36
41
|
|
|
37
|
-
The pane is a picture of the browser, sent as JPEG over the session's own stream:
|
|
38
|
-
|
|
42
|
+
The pane is a picture of the browser, sent as JPEG over the session's own stream: quality 72,
|
|
43
|
+
every frame, about 82 KB and 25 per second on a page at rest. The page is laid out at the size
|
|
44
|
+
of the column, so the picture fills the frame instead of sitting letterboxed inside it.
|
|
39
45
|
|
|
40
46
|
Two pointers live on that picture:
|
|
41
47
|
|
|
@@ -82,7 +88,9 @@ Screenshots are the only thing written outside the profile:
|
|
|
82
88
|
| Read, dense page | 444 tokens (1510 chars, 90 controls) |
|
|
83
89
|
| Read round trip | 1–4 ms |
|
|
84
90
|
| Click round trip | 2 ms |
|
|
85
|
-
|
|
|
91
|
+
| Key round trip | 7 ms |
|
|
92
|
+
| Key to next frame | 15–18 ms |
|
|
93
|
+
| Screenshot | 75 ms, 115 KB |
|
|
86
94
|
|
|
87
95
|
## Install
|
|
88
96
|
|
|
@@ -102,6 +110,22 @@ dsh plugin install ./dsh-inner-browser-use-0.16.0.tgz
|
|
|
102
110
|
The plugin appears under **Plugins** as *Background browser*. It needs Google Chrome or
|
|
103
111
|
Chromium on the machine; see [docs/COMPATIBILITY.md](docs/COMPATIBILITY.md).
|
|
104
112
|
|
|
113
|
+
## When it opens
|
|
114
|
+
|
|
115
|
+
Only when the person asks for it by name: "in the browser", "on the web page",
|
|
116
|
+
"open this site", "click it for me". A link on its own is not a request.
|
|
117
|
+
|
|
118
|
+
- A public article, one link's text, docs, a GitHub repo or PR: read it with
|
|
119
|
+
`web_fetch` or Envoy. No browser, no Chrome process.
|
|
120
|
+
- If the page needs a login, is an empty shell to `web_fetch`, or needs a click,
|
|
121
|
+
say why and ask once instead of starting a browser on your own.
|
|
122
|
+
- Asked for, it starts without a question and never opens a second, external
|
|
123
|
+
Chrome. The picture stays in this chat's right column and stays open when the
|
|
124
|
+
work is done.
|
|
125
|
+
|
|
126
|
+
One browser is shared by every chat (see [Sharing](#sharing)), so opening one is
|
|
127
|
+
a tab, not a second Chrome.
|
|
128
|
+
|
|
105
129
|
## Use
|
|
106
130
|
|
|
107
131
|
1. Open the chat you want the agent to browse for.
|
|
@@ -110,24 +134,35 @@ Chromium on the machine; see [docs/COMPATIBILITY.md](docs/COMPATIBILITY.md).
|
|
|
110
134
|
The first call starts the browser and puts its mirror in this chat's right column. Nothing
|
|
111
135
|
opens before a call: a chat that never asked for a page keeps its column as it was.
|
|
112
136
|
|
|
137
|
+
## Sharing
|
|
138
|
+
|
|
139
|
+
Every chat uses the same background Chrome: one process, one tab per chat, and
|
|
140
|
+
each tab's address saved so it can be closed and reopened cheaply. Past six
|
|
141
|
+
chats the oldest tab nobody is watching is closed (a watched tab — the one on
|
|
142
|
+
someone's screen — never is). Tabs an earlier plugin load left behind are closed
|
|
143
|
+
on first use: one profile used for weeks otherwise keeps a renderer per chat that
|
|
144
|
+
ever opened it. Nothing is launched per chat, and the browser itself is closed 15
|
|
145
|
+
minutes after the last mirror is detached.
|
|
146
|
+
|
|
113
147
|
## Frames, downloads, network
|
|
114
148
|
|
|
115
149
|
- **Frames are read, including cross-site ones.** Controls inside an iframe are numbered like
|
|
116
150
|
the page's own, whether Chrome shares the page's process or runs the frame apart; a click in
|
|
117
151
|
either lands where the frame actually sits.
|
|
118
152
|
- **A download lands on disk.** A click that saves a file writes it into the browser's own
|
|
119
|
-
profile folder, and `
|
|
120
|
-
- **The network is visible.** `
|
|
153
|
+
profile folder, and `browser_downloads` names it, its size and when it arrived.
|
|
154
|
+
- **The network is visible.** `browser_network` lists what the page asked for, newest first,
|
|
121
155
|
so a page that fills itself in from an address can be understood without a screenshot.
|
|
122
156
|
- **An overlay does not eat the action.** A cookie banner over the button is cleared the way a
|
|
123
157
|
person would clear it, then the click lands — in the same call, and in every step of a
|
|
124
|
-
`
|
|
158
|
+
`browser_do` batch. Anything offering to delete, pay or sign out is reported instead.
|
|
125
159
|
|
|
126
160
|
## What it does not do
|
|
127
161
|
|
|
128
162
|
- **It does not touch your Chrome.** The browser runs from a separate profile with no
|
|
129
163
|
connection to the window you are using.
|
|
130
|
-
- **The viewport is
|
|
164
|
+
- **The layout viewport is the column, nothing more.** Desktop layout, no mobile emulation, no
|
|
165
|
+
touch emulation; a very narrow column is laid out narrow rather than zoomed out.
|
|
131
166
|
- **One page at a time.** A link that opens a tab is followed — the mirror lands on the new
|
|
132
167
|
page and the popup closes — so there is never a second page to choose between.
|
|
133
168
|
|
package/README.zh.md
CHANGED
|
@@ -9,17 +9,21 @@ profile,不是产品自带的那个浏览页签:右栏放的是它的画面
|
|
|
9
9
|
|
|
10
10
|
| 工具 | 作用 |
|
|
11
11
|
| --- | --- |
|
|
12
|
-
| `
|
|
13
|
-
| `
|
|
14
|
-
| `
|
|
15
|
-
| `
|
|
16
|
-
| `
|
|
17
|
-
| `
|
|
18
|
-
| `
|
|
19
|
-
| `
|
|
20
|
-
| `
|
|
21
|
-
|
|
22
|
-
`
|
|
12
|
+
| `browser_open` | 启动浏览器(已在跑就复用)并打开一个地址。传 `headless: false` 会改为在桌面上开真实窗口。 |
|
|
13
|
+
| `browser_view` | 返回地址、标题、编号成 `1 … 120` 的控件,以及可见文字。 |
|
|
14
|
+
| `browser_read` | 按人读文章的方式给 Markdown(标题、段落、列表、链接)。带 `query` 只回能回答的段落;问不到时自动改为整页,不再空手回来。页面和上次读的没变就只回一行,不重发正文(`force: true` 强制重发)。 |
|
|
15
|
+
| `browser_extract` | 按 CSS 选择器把列表项读成一行一条、带链接,比整页读省得多。 |
|
|
16
|
+
| `browser_view` 的 `map` | 大页面先看地标:各类控件各多少个、去重后的控件和编号、正文多少字,但不带回正文。 |
|
|
17
|
+
| `browser_click` | 点编号控件,或点 `x`/`y` 坐标。`double: true` 双击。控件被横幅或弹层挡着时,先关掉它再点。 |
|
|
18
|
+
| `browser_type` | 往焦点输入框输入文字,也可以先点一个编号控件。中文整段送进去。`submit: true` 回车提交。 |
|
|
19
|
+
| `browser_key` | 按一个键:Enter、Tab、Escape、Backspace、Delete、上下左右、Home、End、PageUp、PageDown。 |
|
|
20
|
+
| `browser_scroll` | 在页面中心滚轮,也可以指定 `x`/`y`。`dy: 600` 约一屏。 |
|
|
21
|
+
| `browser_shot` | 存一张原尺寸 PNG 到 `~/.dsh-inner-browser-use/screenshots`,返回路径。 |
|
|
22
|
+
| `browser_state` | 报浏览器在不在跑、用的哪个 profile、地址、标题,以及有几个右栏在看它。 |
|
|
23
|
+
|
|
24
|
+
点、输入、按键之后的那一行会多一句结果:`· navigated to …`(换页了)、`· content changed`(页内有变化)、`· no visible change`(点了没动)、Flash 页则是 `· screen updated` / `· no frame change`;`read: true` 时不再多说。
|
|
25
|
+
|
|
26
|
+
`browser_view` 一次给一页文本:
|
|
23
27
|
|
|
24
28
|
```
|
|
25
29
|
https://example.com
|
|
@@ -72,6 +76,7 @@ profile 之外只写一样东西:`~/.dsh-inner-browser-use/screenshots/` 里
|
|
|
72
76
|
| 单帧 | 1440×900 JPEG,约 82 KB |
|
|
73
77
|
| 画面 | 约 25 帧每秒 |
|
|
74
78
|
| 指针移动到箭头跟上 | 0.8–7 ms |
|
|
79
|
+
| 右栏里手动点击到画面更新 | 中位 13 ms,最坏 17 ms(点击派发 2 ms、悬停 8 ms) |
|
|
75
80
|
|
|
76
81
|
## 安装
|
|
77
82
|
|
|
@@ -98,12 +103,19 @@ dsh plugin install ./dsh-inner-browser-use-0.12.1.tgz
|
|
|
98
103
|
第一次调用会启动浏览器,并把镜像放进这个会话的右栏。没有调用就不会有东西自己打开:没要过
|
|
99
104
|
页面的会话,右栏保持原样。
|
|
100
105
|
|
|
106
|
+
## Flash 页面
|
|
107
|
+
|
|
108
|
+
`browser_open` 带 `flash: true` 时,这个地址改用插件自带的 Flash 运行时(Electron 87 + Pepper Flash)在**离屏窗口**里播放:窗口一次都不显示,Dock 与菜单栏都不出现,画面只在发起会话的右栏里。切换回来再调一次 `flash: false`,选择会记住。运行时首次使用时下载到 `~/.dsh/inner-browser-use/flash/`,不需要单独安装播放器。
|
|
109
|
+
|
|
110
|
+
`npm run flash-hidden` 会真起一次运行时并断言「窗口未显示 / 帧持续产出 / 两帧不同」;`npm run flash-mirror` 量镜像帧率与按下/抬起/按键耗时;`npm run flash-tools` 量工具层成本(奥拉星登录页实测:点击 28 ms、输入 14 ms、`browser_view` 31 ms;同一页后续回话只说标题,点击 22 字符、输入 13 字符);`npm run flash-local` 用仓库自带的 SWF 在本机起页,不依赖网络,量首帧与输入直达。
|
|
111
|
+
|
|
101
112
|
## 框架、下载、网络
|
|
102
113
|
|
|
103
114
|
- **iframe 能读,跨站也能。** 不管是和页面共用进程,还是 Chrome 单独给一个进程,里面的控件都和页面自己的控件一样有编号;点击按框架在页面上的实际位置落点。
|
|
104
|
-
- **下载会落到磁盘。** 点一下就下载的文件写在这个浏览器自己的 profile 目录里,`
|
|
105
|
-
-
|
|
106
|
-
-
|
|
115
|
+
- **下载会落到磁盘。** 点一下就下载的文件写在这个浏览器自己的 profile 目录里,`browser_downloads` 报出文件名、大小和到达时间。
|
|
116
|
+
- **PDF 有去处。** Chrome 无头进程画不出 PDF,右栏只会是白图;读到 PDF 时把文件取回归档到该浏览器 profile 的 `downloads/`,结果里直接给绝对路径,交给 `envoy_read` 或本地阅读器。
|
|
117
|
+
- **能看到网络。** `browser_network` 倒序列出页面请求了什么,自己用接口填内容、屏幕上又看不出原因的页面,不用截图去猜。
|
|
118
|
+
- **遮罩不吞动作。** 盖在按钮上的 cookie 横幅会按人的做法先关掉再点下去,同一次调用里完成;`browser_do` 的每一步都一样。写着删除、支付、退出的遮罩只报告,不代按。
|
|
107
119
|
|
|
108
120
|
## 不做什么
|
|
109
121
|
|
package/client.js
CHANGED
|
@@ -32,7 +32,7 @@ window.__ModuleLoader__.load({
|
|
|
32
32
|
const DICT = {
|
|
33
33
|
zh: {
|
|
34
34
|
'side.label': '后台浏览器',
|
|
35
|
-
'side.empty': '
|
|
35
|
+
'side.empty': '后台的独立 Chrome 还没有页面,在上方地址栏输入网址',
|
|
36
36
|
'side.restore': '恢复上次的页面',
|
|
37
37
|
'side.connecting': '正在连接…',
|
|
38
38
|
'side.failed': '画面没有连上',
|
|
@@ -45,7 +45,7 @@ window.__ModuleLoader__.load({
|
|
|
45
45
|
},
|
|
46
46
|
en: {
|
|
47
47
|
'side.label': 'Background browser',
|
|
48
|
-
'side.empty': '
|
|
48
|
+
'side.empty': 'The background Chrome has no page yet; type an address above',
|
|
49
49
|
'side.restore': 'Reopen the last page',
|
|
50
50
|
'side.connecting': 'Connecting…',
|
|
51
51
|
'side.failed': 'The picture did not connect',
|
|
@@ -68,6 +68,10 @@ window.__ModuleLoader__.load({
|
|
|
68
68
|
'.dshib-side-key:hover{background:var(--dsw-alias-bg-subtle);color:var(--dsw-alias-label-primary)}',
|
|
69
69
|
'.dshib-side-url{flex:1 1 auto;min-width:0;border:1px solid transparent;border-radius:6px;background:var(--dsw-alias-bg-subtle);padding:4px 8px;font:inherit;font-size:12px;color:var(--dsw-alias-label-secondary);outline:none}',
|
|
70
70
|
'.dshib-side-url:focus{border-color:var(--dsw-alias-border-strong);color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base)}',
|
|
71
|
+
'.dshib-side-wins{flex:none;display:flex;gap:4px;padding:4px 6px;overflow-x:auto;border-bottom:1px solid var(--dsw-alias-separator)}',
|
|
72
|
+
'.dshib-side-win{flex:0 1 auto;min-width:0;max-width:180px;display:inline-flex;align-items:center;gap:4px;border:0;border-radius:6px;padding:2px 8px;background:transparent;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;cursor:pointer}',
|
|
73
|
+
'.dshib-side-win[data-on="1"]{background:var(--dsw-alias-bg-subtle);color:var(--dsw-alias-label-primary)}',
|
|
74
|
+
'.dshib-side-win > span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
71
75
|
'.dshib-side-stage{flex:1 1 auto;position:relative;min-width:0;min-height:0;display:flex;align-items:center;justify-content:center;overflow:hidden;background:var(--dsw-alias-bg-base);outline:none;cursor:default}',
|
|
72
76
|
'.dshib-side-frame{display:block;user-select:none;-webkit-user-drag:none;cursor:default}',
|
|
73
77
|
'.dshib-side-ime{position:absolute;width:1px;height:1px;padding:0;margin:0;border:0;resize:none;overflow:hidden;opacity:0;pointer-events:none}',
|
|
@@ -115,7 +119,7 @@ window.__ModuleLoader__.load({
|
|
|
115
119
|
return ''
|
|
116
120
|
}
|
|
117
121
|
}
|
|
118
|
-
const closeHere = (sessionId) => {
|
|
122
|
+
const closeHere = (sessionId, byPerson = false) => {
|
|
119
123
|
/* A tab the person closed is not a tab they want back on return. */
|
|
120
124
|
try {
|
|
121
125
|
void fetch(`${ROUTE}/sidecar/forget?session=${encodeURIComponent(sessionId)}`, { method: 'POST' }) // ui-copy-ignore
|
|
@@ -132,6 +136,11 @@ window.__ModuleLoader__.load({
|
|
|
132
136
|
const kind = String(record?.kind ?? record?.record?.kind ?? '')
|
|
133
137
|
const id = String(record?.id ?? record?.record?.id ?? '')
|
|
134
138
|
if (kind !== SIDE_KIND && id !== SIDE_ID) continue
|
|
139
|
+
/* The person's close arrives from the tab strip: the host is
|
|
140
|
+
already taking that tab down, and closing it again here races
|
|
141
|
+
the host and can leave the tab half closed. Forgetting the ask
|
|
142
|
+
is what this path owes. */
|
|
143
|
+
if (byPerson) continue
|
|
135
144
|
try {
|
|
136
145
|
if (typeof record?.close === 'function') record.close()
|
|
137
146
|
else if (typeof surface?.closeIn === 'function') surface.closeIn(sessionId, id)
|
|
@@ -167,6 +176,12 @@ window.__ModuleLoader__.load({
|
|
|
167
176
|
single: true,
|
|
168
177
|
priority: 'extension',
|
|
169
178
|
title: () => L('side.label'),
|
|
179
|
+
/* The tab strip's close control is not the pane's own button: a
|
|
180
|
+
tab closed there never told the host, the ask stayed alive, and
|
|
181
|
+
the keepalive put the mirror back a few seconds later — a tab
|
|
182
|
+
the person closed that would not stay closed. Every close path
|
|
183
|
+
ends here. */
|
|
184
|
+
onClose: (_tab, where) => closeHere(String(where?.sessionId ?? mountedOf()), true),
|
|
170
185
|
})))
|
|
171
186
|
disposers.push(scope.slots.inject(SLOT, () => scope.slots.register({
|
|
172
187
|
name: SLOT, // ui-copy-ignore
|
|
@@ -227,7 +242,10 @@ window.__ModuleLoader__.load({
|
|
|
227
242
|
const watchAsk = () => {
|
|
228
243
|
try {
|
|
229
244
|
if (typeof surface?.mounted?.subscribe === 'function') {
|
|
230
|
-
const off = surface.mounted.subscribe(
|
|
245
|
+
const off = surface.mounted.subscribe(() => {
|
|
246
|
+
syncAsk()
|
|
247
|
+
restore()
|
|
248
|
+
})
|
|
231
249
|
if (typeof off === 'function') return off
|
|
232
250
|
}
|
|
233
251
|
} catch {
|
|
@@ -236,10 +254,41 @@ window.__ModuleLoader__.load({
|
|
|
236
254
|
return null
|
|
237
255
|
}
|
|
238
256
|
syncAsk()
|
|
257
|
+
/* An app restart leaves the browser alive but no ask on the wire, so
|
|
258
|
+
the column would come back empty in a chat that never closed it.
|
|
259
|
+
This chat asking for the state of the browser it still owns is what
|
|
260
|
+
puts the mirror back without anyone pressing anything. */
|
|
261
|
+
const restoreTimers = []
|
|
262
|
+
const restore = (tries = 0) => {
|
|
263
|
+
const who = mountedOf()
|
|
264
|
+
if (who === '') return
|
|
265
|
+
const again = () => {
|
|
266
|
+
if (tries >= 2) return
|
|
267
|
+
restoreTimers.push(setTimeout(() => restore(tries + 1), 1500))
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
fetch(`${ROUTE}/sidecar/state?session=${encodeURIComponent(who)}`) // ui-copy-ignore
|
|
271
|
+
.then((said) => (said.ok === true ? said.json() : null))
|
|
272
|
+
.then((state) => {
|
|
273
|
+
if (state?.owner !== who) return
|
|
274
|
+
const live = state?.alive === true || state?.chat?.alive === true
|
|
275
|
+
if (live !== true) {
|
|
276
|
+
again()
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
ask(who)
|
|
280
|
+
})
|
|
281
|
+
.catch(again)
|
|
282
|
+
} catch {
|
|
283
|
+
again()
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
restore()
|
|
239
287
|
const offAsk = watchAsk()
|
|
240
288
|
const pollAsk = setInterval(syncAsk, 1000)
|
|
241
289
|
const stopAsk = () => {
|
|
242
290
|
clearInterval(pollAsk)
|
|
291
|
+
for (const timer of restoreTimers) clearTimeout(timer)
|
|
243
292
|
if (typeof offAsk === 'function') {
|
|
244
293
|
try {
|
|
245
294
|
offAsk()
|
|
@@ -274,6 +323,32 @@ window.__ModuleLoader__.load({
|
|
|
274
323
|
function SidecarBody(props) {
|
|
275
324
|
const [frame, setFrame] = React.useState(null)
|
|
276
325
|
const [ptr, setPtr] = React.useState(null)
|
|
326
|
+
/* The page is laid out at the size of this column, so the picture it
|
|
327
|
+
sends already has the right shape; these are its pixels. */
|
|
328
|
+
const [view, setView] = React.useState({ w: SIDE_VIEW.width, h: SIDE_VIEW.height })
|
|
329
|
+
/* The person at the mirror drives with their own hand: while they are,
|
|
330
|
+
the agent's arrow stays out of the way, and it comes back by itself
|
|
331
|
+
once their hold runs out. */
|
|
332
|
+
const [person, setPerson] = React.useState(false)
|
|
333
|
+
const personTimer = React.useRef(0)
|
|
334
|
+
/* The person's own hand hides the arrow the moment it lands, without
|
|
335
|
+
waiting for the browser to report a hold: the arrow is a claim about
|
|
336
|
+
what the agent did, and a claim that outlives the agent's turn is a lie
|
|
337
|
+
the person has to look past. It returns when their hand stops. */
|
|
338
|
+
const [hand, setHand] = React.useState(false)
|
|
339
|
+
const handTimer = React.useRef(0)
|
|
340
|
+
const touched = () => {
|
|
341
|
+
setHand(true)
|
|
342
|
+
clearTimeout(handTimer.current)
|
|
343
|
+
handTimer.current = setTimeout(() => setHand(false), 1500)
|
|
344
|
+
}
|
|
345
|
+
const hearHold = (message) => {
|
|
346
|
+
const hold = Number(message?.hold ?? 0)
|
|
347
|
+
if (!Number.isFinite(hold) || hold <= 0) return
|
|
348
|
+
setPerson(true)
|
|
349
|
+
clearTimeout(personTimer.current)
|
|
350
|
+
personTimer.current = setTimeout(() => setPerson(false), hold + 120)
|
|
351
|
+
}
|
|
277
352
|
const [phase, setPhase] = React.useState('connecting')
|
|
278
353
|
const [note, setNote] = React.useState('')
|
|
279
354
|
/* The address this chat had before its tab went away: the pane reopens
|
|
@@ -283,6 +358,9 @@ window.__ModuleLoader__.load({
|
|
|
283
358
|
const [draft, setDraft] = React.useState('')
|
|
284
359
|
const [editing, setEditing] = React.useState(false)
|
|
285
360
|
const [size, setSize] = React.useState({ w: 0, h: 0 })
|
|
361
|
+
/* The browser's own windows, this chat's only: a second window a site
|
|
362
|
+
opened is a row here, not a second tab in the column. */
|
|
363
|
+
const [wins, setWins] = React.useState([])
|
|
286
364
|
const stage = React.useRef(null)
|
|
287
365
|
/* Which chat owns the mirror, as the sidecar host records it. // ui-copy-ignore
|
|
288
366
|
Empty until
|
|
@@ -384,11 +462,15 @@ window.__ModuleLoader__.load({
|
|
|
384
462
|
pending = null
|
|
385
463
|
setFrame(message)
|
|
386
464
|
setPtr(message.pointer ?? null)
|
|
465
|
+
if (Number.isFinite(Number(message.w)) && Number.isFinite(Number(message.h))) {
|
|
466
|
+
setView({ w: Number(message.w), h: Number(message.h) })
|
|
467
|
+
}
|
|
387
468
|
setPhase('live')
|
|
388
469
|
drawnAt.current = Date.now()
|
|
389
470
|
if (typeof message.url === 'string' && message.url !== '') setUrl(message.url)
|
|
390
471
|
}
|
|
391
472
|
const draw = (message) => {
|
|
473
|
+
hearHold(message)
|
|
392
474
|
pending = message
|
|
393
475
|
if (raf !== 0) return
|
|
394
476
|
if (typeof requestAnimationFrame === 'function') raf = requestAnimationFrame(paint)
|
|
@@ -417,6 +499,7 @@ window.__ModuleLoader__.load({
|
|
|
417
499
|
} else if (message.type === 'pointer') {
|
|
418
500
|
/* The arrow is drawn the moment the agent moves, not when the page
|
|
419
501
|
next repaints: it stays honest even on a page that is idle. */
|
|
502
|
+
hearHold(message)
|
|
420
503
|
setPtr(message.pointer ?? null)
|
|
421
504
|
} else if (typeof message.jpeg === 'string') {
|
|
422
505
|
draw(message)
|
|
@@ -425,6 +508,8 @@ window.__ModuleLoader__.load({
|
|
|
425
508
|
source.onerror = () => setPhase('lost')
|
|
426
509
|
return () => {
|
|
427
510
|
clearInterval(stuck)
|
|
511
|
+
clearTimeout(personTimer.current)
|
|
512
|
+
clearTimeout(handTimer.current)
|
|
428
513
|
if (raf !== 0 && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(raf)
|
|
429
514
|
source.close()
|
|
430
515
|
}
|
|
@@ -448,9 +533,87 @@ window.__ModuleLoader__.load({
|
|
|
448
533
|
return () => observer.disconnect()
|
|
449
534
|
}, [])
|
|
450
535
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
const
|
|
536
|
+
/* The picture carries the page, not the window list: ask for the list on
|
|
537
|
+
a slow clock, and after a switch. */
|
|
538
|
+
const [winsTick, setWinsTick] = React.useState(0)
|
|
539
|
+
React.useEffect(() => {
|
|
540
|
+
if (mine !== true) return undefined
|
|
541
|
+
let alive = true
|
|
542
|
+
const chat = String(props.sessionId ?? '')
|
|
543
|
+
const load = () => {
|
|
544
|
+
fetch(`${ROUTE}/sidecar/tabs?session=${encodeURIComponent(chat)}`) // ui-copy-ignore
|
|
545
|
+
.then((reply) => (reply.ok === true ? reply.json() : null))
|
|
546
|
+
.then((info) => {
|
|
547
|
+
if (alive === false || info?.ok !== true || Array.isArray(info.tabs) !== true) return
|
|
548
|
+
setWins(info.tabs)
|
|
549
|
+
})
|
|
550
|
+
.catch(() => {})
|
|
551
|
+
}
|
|
552
|
+
load()
|
|
553
|
+
const clock = setInterval(load, 2500)
|
|
554
|
+
return () => {
|
|
555
|
+
alive = false
|
|
556
|
+
clearInterval(clock)
|
|
557
|
+
}
|
|
558
|
+
}, [mine, props.sessionId, winsTick])
|
|
559
|
+
|
|
560
|
+
const switchWin = (at) => {
|
|
561
|
+
fetch(`${ROUTE}/sidecar/tabs`, { // ui-copy-ignore
|
|
562
|
+
method: 'POST',
|
|
563
|
+
headers: { 'content-type': 'application/json' },
|
|
564
|
+
body: JSON.stringify({ session: String(props.sessionId ?? ''), op: 'use', index: at }), // ui-copy-ignore
|
|
565
|
+
})
|
|
566
|
+
.then((reply) => (reply.ok === true ? reply.json() : null))
|
|
567
|
+
.then((info) => {
|
|
568
|
+
if (info?.ok === true && Array.isArray(info.tabs) === true) setWins(info.tabs)
|
|
569
|
+
setWinsTick((n) => n + 1)
|
|
570
|
+
})
|
|
571
|
+
.catch(() => {})
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const scale = size.w > 0 && size.h > 0 ? Math.min(size.w / view.w, size.h / view.h) : 0
|
|
575
|
+
const padX = (size.w - view.w * scale) / 2
|
|
576
|
+
const padY = (size.h - view.h * scale) / 2
|
|
577
|
+
|
|
578
|
+
/* The column tells the page how big it is, a beat after the resize stops:
|
|
579
|
+
a page that relayouts on every pointer step would never settle. */
|
|
580
|
+
React.useEffect(() => {
|
|
581
|
+
if (mine !== true || size.w < 200 || size.h < 200) return undefined
|
|
582
|
+
/* The page already has that shape: a resize that lands between two
|
|
583
|
+
viewport sizes would only cost a relayout and another frame. */
|
|
584
|
+
if (Math.abs(Number(view?.w ?? 0) - size.w) < 1 && Math.abs(Number(view?.h ?? 0) - size.h) < 1) return undefined
|
|
585
|
+
/* The page is told the column's size a beat after the resize stops, and
|
|
586
|
+
again until the browser says it took it: a request that arrives while
|
|
587
|
+
the page is still starting is refused, and a pane that gives up then
|
|
588
|
+
keeps a letterboxed picture for as long as it stays open. */
|
|
589
|
+
let live = true
|
|
590
|
+
let tries = 0
|
|
591
|
+
let timer = 0
|
|
592
|
+
const send = () => {
|
|
593
|
+
if (!live) return
|
|
594
|
+
fetch(`${ROUTE}/sidecar/viewport`, {
|
|
595
|
+
method: 'POST',
|
|
596
|
+
headers: { 'content-type': 'application/json' },
|
|
597
|
+
body: JSON.stringify({ session: String(props.sessionId ?? ''), w: Math.round(size.w), h: Math.round(size.h) }),
|
|
598
|
+
})
|
|
599
|
+
.then((answer) => (answer.ok === true ? answer.json() : { ok: false }))
|
|
600
|
+
.then((said) => {
|
|
601
|
+
if (!live || said?.ok === true || tries >= 5) return
|
|
602
|
+
tries += 1
|
|
603
|
+
timer = setTimeout(send, 400)
|
|
604
|
+
})
|
|
605
|
+
.catch(() => {
|
|
606
|
+
if (!live || tries >= 5) return
|
|
607
|
+
tries += 1
|
|
608
|
+
timer = setTimeout(send, 600)
|
|
609
|
+
})
|
|
610
|
+
}
|
|
611
|
+
timer = setTimeout(send, 250)
|
|
612
|
+
return () => {
|
|
613
|
+
live = false
|
|
614
|
+
clearTimeout(timer)
|
|
615
|
+
}
|
|
616
|
+
}, [mine, size.w, size.h, view?.w, view?.h])
|
|
454
617
|
|
|
455
618
|
const who = String(props.sessionId ?? '')
|
|
456
619
|
const post = (path, body) => {
|
|
@@ -487,7 +650,7 @@ window.__ModuleLoader__.load({
|
|
|
487
650
|
const box = el.getBoundingClientRect()
|
|
488
651
|
const x = (event.clientX - box.left - padX) / scale
|
|
489
652
|
const y = (event.clientY - box.top - padY) / scale
|
|
490
|
-
if (x < 0 || y < 0 || x >
|
|
653
|
+
if (x < 0 || y < 0 || x > view.w || y > view.h) return null
|
|
491
654
|
return { x: Math.round(x), y: Math.round(y) }
|
|
492
655
|
}
|
|
493
656
|
const ime = React.useRef(null)
|
|
@@ -503,6 +666,7 @@ window.__ModuleLoader__.load({
|
|
|
503
666
|
}
|
|
504
667
|
const stageRef = stage
|
|
505
668
|
const onPointerDown = (event) => {
|
|
669
|
+
touched()
|
|
506
670
|
const point = toView(event)
|
|
507
671
|
if (point === null) return
|
|
508
672
|
const field = ime.current
|
|
@@ -515,6 +679,7 @@ window.__ModuleLoader__.load({
|
|
|
515
679
|
}
|
|
516
680
|
const movedAt = React.useRef(0)
|
|
517
681
|
const onPointerMove = (event) => {
|
|
682
|
+
touched()
|
|
518
683
|
const point = toView(event)
|
|
519
684
|
if (point === null) return
|
|
520
685
|
/* Hover is part of a page: menus open under the hand, so the movement
|
|
@@ -526,24 +691,46 @@ window.__ModuleLoader__.load({
|
|
|
526
691
|
hover(point)
|
|
527
692
|
}
|
|
528
693
|
const onWheel = (event) => {
|
|
694
|
+
touched()
|
|
529
695
|
event.preventDefault()
|
|
530
|
-
const point = toView(event) ?? { x: Math.round(
|
|
696
|
+
const point = toView(event) ?? { x: Math.round(view.w / 2), y: Math.round(view.h / 2) }
|
|
531
697
|
post('/sidecar/input', { kind: 'wheel', x: point.x, y: point.y, dx: event.deltaX, dy: event.deltaY })
|
|
532
698
|
}
|
|
699
|
+
/* The page's keys are the person's keys: shift makes a capital, a
|
|
700
|
+
shortcut stays a shortcut so Ctrl+V pastes and Ctrl+A selects, and the
|
|
701
|
+
key that goes down also goes up so a game can hold it. */
|
|
702
|
+
const modsOf = (event) => (event.altKey === true ? 1 : 0)
|
|
703
|
+
| (event.ctrlKey === true ? 2 : 0)
|
|
704
|
+
| (event.metaKey === true ? 4 : 0)
|
|
705
|
+
| (event.shiftKey === true ? 8 : 0)
|
|
706
|
+
const MODIFIER_KEYS = ['Alt', 'Control', 'Meta', 'Shift', 'AltGraph', 'OS', 'Super', 'Hyper']
|
|
707
|
+
const keyBody = (event, down) => {
|
|
708
|
+
const modifiers = modsOf(event)
|
|
709
|
+
const shortcut = (modifiers & 7) !== 0
|
|
710
|
+
const typing = down === true && event.key.length === 1 && (modifiers & 7) === 0 && event.repeat !== true
|
|
711
|
+
return {
|
|
712
|
+
kind: 'key',
|
|
713
|
+
key: event.key,
|
|
714
|
+
code: event.code || event.key,
|
|
715
|
+
keyCode: event.keyCode || 0,
|
|
716
|
+
modifiers,
|
|
717
|
+
...(typing ? { text: event.key } : {}),
|
|
718
|
+
...(down === false ? { down: false } : {}),
|
|
719
|
+
}
|
|
720
|
+
}
|
|
533
721
|
const onKeyDown = (event) => {
|
|
722
|
+
touched()
|
|
534
723
|
/* A key still being composed belongs to the IME, not to the page. */
|
|
535
724
|
if (event.isComposing === true) return
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
if (event.
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
post('/sidecar/input', { kind: 'text', text: event.key })
|
|
546
|
-
}
|
|
725
|
+
if (event.key == null || MODIFIER_KEYS.includes(event.key) === true) return
|
|
726
|
+
event.preventDefault()
|
|
727
|
+
post('/sidecar/input', event.repeat === true ? { ...keyBody(event, true), repeat: true } : keyBody(event, true))
|
|
728
|
+
}
|
|
729
|
+
const onKeyUp = (event) => {
|
|
730
|
+
if (event.isComposing === true) return
|
|
731
|
+
if (event.key == null || MODIFIER_KEYS.includes(event.key) === true) return
|
|
732
|
+
event.preventDefault()
|
|
733
|
+
post('/sidecar/input', keyBody(event, false))
|
|
547
734
|
}
|
|
548
735
|
const goto = () => {
|
|
549
736
|
const address = String(draft ?? '').trim()
|
|
@@ -557,7 +744,7 @@ window.__ModuleLoader__.load({
|
|
|
557
744
|
return h('div', { className: 'dshib-side' },
|
|
558
745
|
h('div', { className: 'dshib-side-note' }, L('side.foreign')))
|
|
559
746
|
}
|
|
560
|
-
const pointer = ptr
|
|
747
|
+
const pointer = person === true || hand === true ? null : ptr
|
|
561
748
|
const noteText = phase === 'failed' ? note || L('side.failed') : phase === 'live' ? L('side.empty') : L('side.connecting')
|
|
562
749
|
return h('div', { className: 'dshib-side' },
|
|
563
750
|
h('div', { className: 'dshib-side-bar' },
|
|
@@ -579,6 +766,17 @@ window.__ModuleLoader__.load({
|
|
|
579
766
|
goto()
|
|
580
767
|
},
|
|
581
768
|
})),
|
|
769
|
+
...(wins.length > 1
|
|
770
|
+
? [h('div', { className: 'dshib-side-wins' },
|
|
771
|
+
...wins.map((win) => h('button', {
|
|
772
|
+
key: `${win.index}:${win.url}`,
|
|
773
|
+
className: 'dshib-side-win',
|
|
774
|
+
type: 'button', // ui-copy-ignore
|
|
775
|
+
'data-on': win.current === true ? '1' : '0',
|
|
776
|
+
title: win.url,
|
|
777
|
+
onClick: () => switchWin(win.index),
|
|
778
|
+
}, h('span', null, String(win.title ?? '') || String(win.url ?? '')))))]
|
|
779
|
+
: []),
|
|
582
780
|
h('div', {
|
|
583
781
|
className: 'dshib-side-stage',
|
|
584
782
|
ref: stage,
|
|
@@ -587,6 +785,7 @@ window.__ModuleLoader__.load({
|
|
|
587
785
|
onPointerMove,
|
|
588
786
|
onWheel,
|
|
589
787
|
onKeyDown,
|
|
788
|
+
onKeyUp,
|
|
590
789
|
},
|
|
591
790
|
frame === null && back !== ''
|
|
592
791
|
? h('button', {
|
|
@@ -627,12 +826,13 @@ window.__ModuleLoader__.load({
|
|
|
627
826
|
clearIme()
|
|
628
827
|
},
|
|
629
828
|
onKeyDown,
|
|
829
|
+
onKeyUp,
|
|
630
830
|
}),
|
|
631
831
|
frame === null ? null : h('img', {
|
|
632
832
|
className: 'dshib-side-frame',
|
|
633
833
|
src: `data:image/jpeg;base64,${frame.jpeg}`,
|
|
634
|
-
width: Math.max(1, Math.round(
|
|
635
|
-
height: Math.max(1, Math.round(
|
|
834
|
+
width: Math.max(1, Math.round(view.w * scale)),
|
|
835
|
+
height: Math.max(1, Math.round(view.h * scale)),
|
|
636
836
|
alt: '', // ui-copy-ignore
|
|
637
837
|
draggable: false,
|
|
638
838
|
}),
|