dsh-zen-remote 2.0.3 → 2.2.0
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/AGENTS.md +15 -1
- package/README.md +11 -5
- package/README.zh-CN.md +11 -5
- package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +15 -0
- package/docs/adr/0002-relay-emulates-websocket.md +8 -0
- package/docs/interface.md +3 -1
- package/docs/plugin-bridge.md +140 -0
- package/docs/remote-access.en.md +57 -0
- package/docs/remote-access.md +57 -0
- package/lib/adapters/dsh-better-sidebar/index.js +144 -0
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -0
- package/lib/adapters/dsh-better-sidebar/methods.js +198 -0
- package/lib/adapters/dsh-better-sidebar/methods.js.map +1 -0
- package/lib/adapters/index.js +17 -0
- package/lib/adapters/index.js.map +1 -0
- package/lib/adapters/types.js +17 -0
- package/lib/adapters/types.js.map +1 -0
- package/lib/admin-routes.js +51 -0
- package/lib/admin-routes.js.map +1 -1
- package/lib/client-routes.js +18 -1
- package/lib/client-routes.js.map +1 -1
- package/lib/client.js +449 -45
- package/lib/config.js +27 -0
- package/lib/config.js.map +1 -1
- package/lib/http.js +10 -0
- package/lib/http.js.map +1 -1
- package/lib/index.js +246 -0
- package/lib/index.js.map +1 -1
- package/lib/plugin-adapter-core.js +385 -0
- package/lib/plugin-adapter-core.js.map +1 -0
- package/lib/plugin-bridge.js +318 -0
- package/lib/plugin-bridge.js.map +1 -0
- package/lib/plugin-loopback.js +207 -0
- package/lib/plugin-loopback.js.map +1 -0
- package/lib/plugin-route-intercept.js +1103 -0
- package/lib/plugin-route-intercept.js.map +1 -0
- package/lib/plugin-tunnel.js +416 -0
- package/lib/plugin-tunnel.js.map +1 -0
- package/lib/relay-client.js +319 -2
- package/lib/relay-client.js.map +1 -1
- package/lib/relay-server.js +633 -0
- package/lib/relay-server.js.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +27 -0
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -0
- package/lib/types/adapters/dsh-better-sidebar/methods.d.ts +51 -0
- package/lib/types/adapters/dsh-better-sidebar/methods.d.ts.map +1 -0
- package/lib/types/adapters/index.d.ts +17 -0
- package/lib/types/adapters/index.d.ts.map +1 -0
- package/lib/types/adapters/types.d.ts +42 -0
- package/lib/types/adapters/types.d.ts.map +1 -0
- package/lib/types/admin-routes.d.ts +16 -0
- package/lib/types/admin-routes.d.ts.map +1 -1
- package/lib/types/client/effects/remote-session.d.ts +0 -22
- package/lib/types/client/effects/remote-session.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +16 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
- package/lib/types/client-data/plugin-bridge.d.ts +79 -0
- package/lib/types/client-data/plugin-bridge.d.ts.map +1 -0
- package/lib/types/client-data/remote-status.d.ts +20 -0
- package/lib/types/client-data/remote-status.d.ts.map +1 -1
- package/lib/types/client-data/settings-form.d.ts +37 -1
- package/lib/types/client-data/settings-form.d.ts.map +1 -1
- package/lib/types/client-routes.d.ts +13 -0
- package/lib/types/client-routes.d.ts.map +1 -1
- package/lib/types/config.d.ts +8 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/http.d.ts +7 -0
- package/lib/types/http.d.ts.map +1 -1
- package/lib/types/index.d.ts +4 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/plugin-adapter-core.d.ts +263 -0
- package/lib/types/plugin-adapter-core.d.ts.map +1 -0
- package/lib/types/plugin-bridge.d.ts +187 -0
- package/lib/types/plugin-bridge.d.ts.map +1 -0
- package/lib/types/plugin-loopback.d.ts +104 -0
- package/lib/types/plugin-loopback.d.ts.map +1 -0
- package/lib/types/plugin-route-intercept.d.ts +114 -0
- package/lib/types/plugin-route-intercept.d.ts.map +1 -0
- package/lib/types/plugin-tunnel.d.ts +96 -0
- package/lib/types/plugin-tunnel.d.ts.map +1 -0
- package/lib/types/relay-client.d.ts +76 -0
- package/lib/types/relay-client.d.ts.map +1 -1
- package/lib/types/relay-server.d.ts +32 -1
- package/lib/types/relay-server.d.ts.map +1 -1
- package/llms.txt +1 -0
- package/package.json +2 -2
- package/scripts/check-plugin-bridge-ui.mjs +168 -0
- package/scripts/check-settings-form.mjs +3 -1
- package/src/adapters/dsh-better-sidebar/index.ts +144 -0
- package/src/adapters/dsh-better-sidebar/methods.ts +215 -0
- package/src/adapters/index.ts +19 -0
- package/src/adapters/types.ts +44 -0
- package/src/admin-routes.ts +64 -0
- package/src/client/effects/remote-session.ts +82 -10
- package/src/client/locales.ts +36 -0
- package/src/client/settings/SettingsSection.tsx +107 -3
- package/src/client/styles/remote-session.css.ts +70 -31
- package/src/client-data/plugin-bridge.ts +150 -0
- package/src/client-data/remote-status.ts +30 -0
- package/src/client-data/settings-form.ts +78 -0
- package/src/client-routes.ts +29 -1
- package/src/config.ts +32 -0
- package/src/http.ts +12 -0
- package/src/index.ts +252 -0
- package/src/plugin-adapter-core.ts +530 -0
- package/src/plugin-bridge.ts +435 -0
- package/src/plugin-loopback.ts +299 -0
- package/src/plugin-route-intercept.ts +1193 -0
- package/src/plugin-tunnel.ts +470 -0
- package/src/relay-client.ts +394 -2
- package/src/relay-server.ts +656 -1
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dsh-better-sidebar adapter (PB-12), written against EXACTLY
|
|
3
|
+
* version 0.25.0. Everything it knows about the plugin came off
|
|
4
|
+
* `/tmp/bs-versions/0.25.0/lib/index.js` (the installed copy at
|
|
5
|
+
* `~/.dsh/profiles/desktop/node_modules/dsh-better-sidebar` is the same
|
|
6
|
+
* build); the method table lives in `methods.ts` beside this file, and the
|
|
7
|
+
* test suite re-extracts the plugin's real method-name set to catch a
|
|
8
|
+
* silently drifted version.
|
|
9
|
+
*
|
|
10
|
+
* Product decisions (2026-10-10): reads AND writes both forward — a
|
|
11
|
+
* bridged better-sidebar is whole-server disk access, git writes, and
|
|
12
|
+
* model spend (commit-message generation), documented as such; NO path
|
|
13
|
+
* fencing; `open.external` refuses for a remote session; `settings.*` and
|
|
14
|
+
* `/sidebar/bundle` stay local; the adapter registers as whole-machine
|
|
15
|
+
* data (consent per plugin, default off).
|
|
16
|
+
*
|
|
17
|
+
* The `cwd` handling is the one subtle part: better-sidebar resolves a
|
|
18
|
+
* request's working directory as the session's own `header.cwd` first and
|
|
19
|
+
* only falls back to a `cwd` field the CLIENT supplied (lib/index.js:6295,
|
|
20
|
+
* 6075-6099). A remote panel must not steer the server by path, so the
|
|
21
|
+
* server hook strips `cwd` — from the JSON body (`/sidebar/api`) and from
|
|
22
|
+
* the query (`/sidebar/file`, `/sidebar/upload`) — and lets the backend
|
|
23
|
+
* resolve from the (validated, real) session id.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { BridgeAdapter } from '../types.js'
|
|
27
|
+
import { isVirtual } from '../../virtual-id.js'
|
|
28
|
+
import { SIDEBAR_METHODS, sidebarMethodNameOf } from './methods.js'
|
|
29
|
+
|
|
30
|
+
/** The relay's upload budget (`MAX_UPLOAD_BYTES` in lib/relay-server.js).
|
|
31
|
+
* Inlined rather than imported — an adapter may only lean on pure utility
|
|
32
|
+
* modules — and pinned to the original by a test in
|
|
33
|
+
* test/adapter-better-sidebar.test.cjs, which fails the pair apart when
|
|
34
|
+
* either side moves. */
|
|
35
|
+
const RELAY_UPLOAD_CAP_BYTES = 100 * 1024 * 1024
|
|
36
|
+
|
|
37
|
+
/** better-sidebar's own JSON body bound (lib/index.js:863
|
|
38
|
+
* `MAX_BODY_BYTES = 1 << 20`) — the adapter forwards at most what the
|
|
39
|
+
* backend would accept anyway. */
|
|
40
|
+
const SIDEBAR_MAX_BODY_BYTES = 1024 * 1024
|
|
41
|
+
|
|
42
|
+
/** The method name out of a `/sidebar/api/*` pathname, or `''`. */
|
|
43
|
+
const apiMethodOf = (pathname: string): string => sidebarMethodNameOf(pathname) ?? ''
|
|
44
|
+
|
|
45
|
+
export const dshBetterSidebarAdapter: BridgeAdapter = {
|
|
46
|
+
plugin: 'dsh-better-sidebar',
|
|
47
|
+
versions: ['0.25.0'],
|
|
48
|
+
routes: [
|
|
49
|
+
{
|
|
50
|
+
// The JSON API: the method name rides the path tail, the session
|
|
51
|
+
// fields ride the body top level (sessionId / rootSessionId /
|
|
52
|
+
// childId — the sidechat calls).
|
|
53
|
+
path: '/sidebar/api',
|
|
54
|
+
match: 'prefix',
|
|
55
|
+
methods: ['POST'],
|
|
56
|
+
maxBody: SIDEBAR_MAX_BODY_BYTES,
|
|
57
|
+
locator: { in: 'json-body', fields: ['sessionId', 'rootSessionId', 'childId'] },
|
|
58
|
+
deny: ({ pathname, currentSessionId }) => {
|
|
59
|
+
const name = apiMethodOf(pathname)
|
|
60
|
+
if (name === '') return undefined
|
|
61
|
+
// Opening apps/URLs on the HOST is a remote-panel no: refused when
|
|
62
|
+
// the browser is currently looking at a REMOTE session (the
|
|
63
|
+
// request body carries no session field of its own — client.js
|
|
64
|
+
// 9383-9392 — so the classify step could never tell). A LOCAL
|
|
65
|
+
// session's open.external never reaches here as a refusal and
|
|
66
|
+
// stays on the local machine, exactly as without zen-remote.
|
|
67
|
+
// (The sidechat methods left this hook in PB-13: their child
|
|
68
|
+
// sessions inherit the parent's share through `meta.parentSession`,
|
|
69
|
+
// so they forward like everything else.)
|
|
70
|
+
if (name === 'open.external' && currentSessionId !== undefined && isVirtual(currentSessionId)) {
|
|
71
|
+
return 'remote-open-unsupported'
|
|
72
|
+
}
|
|
73
|
+
return undefined
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
// The media route: better-sidebar registers a PREFIX, the adapter only
|
|
77
|
+
// claims the EXACT documented path — a sub-path (if a future version
|
|
78
|
+
// grows one) stays local rather than being guessed at.
|
|
79
|
+
{ path: '/sidebar/file', match: 'exact', methods: ['GET'], locator: { in: 'query', name: 'sessionId' } },
|
|
80
|
+
// The archive download (lib/index.js:6949-6985).
|
|
81
|
+
{ path: '/sidebar/archive', match: 'exact', methods: ['GET'], locator: { in: 'query', name: 'sessionId' } },
|
|
82
|
+
// The raw upload (lib/index.js:6905-6948): binary bytes, capped by the
|
|
83
|
+
// relay's own upload budget.
|
|
84
|
+
{ path: '/sidebar/upload', match: 'exact', methods: ['POST'], maxBody: RELAY_UPLOAD_CAP_BYTES, locator: { in: 'query', name: 'sessionId' } },
|
|
85
|
+
// The HTML preview (lib/index.js:7026+): the session id is the FIRST
|
|
86
|
+
// path segment after the prefix; the rest of the segments are the
|
|
87
|
+
// preview file's relative path. Same-prefix relative resources ride
|
|
88
|
+
// along (they hit the same prefix route with the same segment layout).
|
|
89
|
+
{ path: '/sidebar/html', match: 'prefix', methods: ['GET'], locator: { in: 'path-segment', index: 0 } },
|
|
90
|
+
// Lazy chunks: client code, session-independent, LOCAL forever.
|
|
91
|
+
{ path: '/sidebar/bundle', match: 'prefix', methods: ['GET'], locator: { in: 'query', name: 'sessionId' }, local: true },
|
|
92
|
+
// The two WebSockets (PB-13, both `registerUpgrade` EXACT registrations
|
|
93
|
+
// in lib/index.js:7066-7091): the agent-opens push feed and the
|
|
94
|
+
// file-tree watch. Both carry the session id in the query; both ride
|
|
95
|
+
// PB-10's byte tunnel untouched — neither side parses frames, and the
|
|
96
|
+
// messages need no rewriting (agent-opens pushes carry the server-real
|
|
97
|
+
// sessionId but the browser never reads it — client.js:23618-23640
|
|
98
|
+
// uses its own connect-time id for the scope; fs-watch frames carry
|
|
99
|
+
// file paths, not session ids).
|
|
100
|
+
{ path: '/sidebar/ws/agent-opens', match: 'exact', methods: ['GET'], upgrade: true, locator: { in: 'query', name: 'sessionId' } },
|
|
101
|
+
{ path: '/sidebar/ws/fs-watch', match: 'exact', methods: ['GET'], upgrade: true, locator: { in: 'query', name: 'sessionId' } },
|
|
102
|
+
],
|
|
103
|
+
client: {
|
|
104
|
+
request: (ctx) => {
|
|
105
|
+
const name = apiMethodOf(ctx.pathname)
|
|
106
|
+
// Belt over the deny hook's suspenders: a refused method refuses
|
|
107
|
+
// even if a future plugin version starts carrying session fields
|
|
108
|
+
// on it (which would flip it into the remote branch).
|
|
109
|
+
if (name === 'open.external') return { allow: false, reason: 'remote-open-unsupported' }
|
|
110
|
+
// Unknown method: refuse — never guess what a drifted version means.
|
|
111
|
+
if (name !== '' && SIDEBAR_METHODS[name] === undefined) return { allow: false, reason: 'adapter-method-unknown' }
|
|
112
|
+
return { allow: true }
|
|
113
|
+
},
|
|
114
|
+
response: (ctx) => {
|
|
115
|
+
const name = apiMethodOf(ctx.pathname)
|
|
116
|
+
if (name === '') return ctx.body
|
|
117
|
+
const spec = SIDEBAR_METHODS[name]
|
|
118
|
+
if (spec === undefined || spec.rewrite === undefined) return ctx.body
|
|
119
|
+
return spec.rewrite(ctx.body, ctx)
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
server: {
|
|
123
|
+
request: (ctx) => {
|
|
124
|
+
// Strip the client-supplied cwd: the backend resolves the working
|
|
125
|
+
// directory from the (already validated) session id's own header,
|
|
126
|
+
// falling back to the persisted record — a remote panel never
|
|
127
|
+
// steers the server by path.
|
|
128
|
+
if (ctx.pathname === '/sidebar/file' || ctx.pathname === '/sidebar/upload') {
|
|
129
|
+
if (ctx.query.get('cwd') !== null) {
|
|
130
|
+
const stripped = new URLSearchParams(ctx.query)
|
|
131
|
+
stripped.delete('cwd')
|
|
132
|
+
return { allow: true, query: stripped }
|
|
133
|
+
}
|
|
134
|
+
return { allow: true }
|
|
135
|
+
}
|
|
136
|
+
if (ctx.route.path === '/sidebar/api' && ctx.body !== null && typeof ctx.body === 'object' && !Array.isArray(ctx.body) && 'cwd' in (ctx.body as Record<string, unknown>)) {
|
|
137
|
+
const stripped = { ...(ctx.body as Record<string, unknown>) }
|
|
138
|
+
delete stripped.cwd
|
|
139
|
+
return { allow: true, body: stripped }
|
|
140
|
+
}
|
|
141
|
+
return { allow: true }
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* better-sidebar 0.25.0 的 `/sidebar/api` 方法表(PB-12)——适配器唯一
|
|
3
|
+
* 认识的接口清单,逐条对着 `/tmp/bs-versions/0.25.0/lib/index.js` 复核
|
|
4
|
+
* (行号见各条注释,以 0.25.0 的构建产物为准)。表里没有的方法在远程
|
|
5
|
+
* 会话里一律拒绝(reason `adapter-method-unknown`):宁可少转发,不可
|
|
6
|
+
* 误转发——`test/adapter-better-sidebar.test.cjs` 会把源码里的实际方法
|
|
7
|
+
* 名集合提取出来与这张表逐一比对,防止插件升级后漏记。
|
|
8
|
+
*
|
|
9
|
+
* 会话字段:绝大多数方法在 JSON 体顶层带 `sessionId`(client.js 的
|
|
10
|
+
* scopePayload,lib/client.js:3866);子代理与工作流面板带
|
|
11
|
+
* `rootSessionId`;sidechat 的后续调用带 `childId`(本单一律拒绝)。
|
|
12
|
+
*
|
|
13
|
+
* 响应里带会话 id 的位置(核实于 0.25.0 源码,全部只有三处):
|
|
14
|
+
* - `session.cwd` 把请求的 `sessionId` 原样回显(lib/index.js:6327);
|
|
15
|
+
* - `subagents.live` 的响应以会话 id 为键(lib/index.js:4513
|
|
16
|
+
* `live[sessionId] = view`,根会话与全部子会话);
|
|
17
|
+
* - `workflows.list` 的每行 `originSessionId`(lib/index.js:4666)与
|
|
18
|
+
* `phases[].members[].childId`(lib/index.js:4686)。
|
|
19
|
+
* `teams.taskCreate`/`taskUpdate` 的响应是 `{ok, value:<任务记录>}`
|
|
20
|
+
* (lib/index.js:4607-4612、4650-4655),任务记录不带会话 id;
|
|
21
|
+
* `changes.ops` 返回 `{events, lastSeq}`,事件属于该会话自身的流
|
|
22
|
+
* (tool/call、tool/result),对象上没有会话 id 字段;
|
|
23
|
+
* `archive.build` 返回 `{id, entries}`(lib/index.js:3470-3473)、
|
|
24
|
+
* `archive.status` 返回 `{state, done, total, bytes, error?}`
|
|
25
|
+
* (lib/index.js:3479-3485)——都不带会话 id。这四类不改写。
|
|
26
|
+
* `sidechat.start` 返回 `{childId}`(lib/index.js:5050)——PB-13 起
|
|
27
|
+
* 虚拟化(子客户端拿它发后续调用)。
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** 响应改写器的上下文(框架的 AdapterResponseContext 的结构性子集)。 */
|
|
31
|
+
export interface SidebarRewriteContext {
|
|
32
|
+
virtualize(id: string): string
|
|
33
|
+
isVirtual(value: unknown): boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 一个 `/sidebar/api` 方法的处置。 */
|
|
37
|
+
export interface SidebarMethodSpec {
|
|
38
|
+
/** 该方法在 JSON 体顶层的会话字段名(settings.* 无会话字段,缺省)。 */
|
|
39
|
+
sessionField?: 'sessionId' | 'rootSessionId' | 'childId'
|
|
40
|
+
/** 读还是写(写方法在远程会话里执行的是主服务端的磁盘与 git)。 */
|
|
41
|
+
kind: 'read' | 'write'
|
|
42
|
+
/** 无会话数据的方法(settings.*):定位结果为空,天然留本机。 */
|
|
43
|
+
local?: true
|
|
44
|
+
/** 响应改写器:把响应里的真实会话 id 换回虚拟 id。 */
|
|
45
|
+
rewrite?: (body: unknown, ctx: SidebarRewriteContext) => unknown
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 无改写的普通转发方法的简写工厂。 */
|
|
49
|
+
const forward = (kind: 'read' | 'write', sessionField: 'sessionId' | 'rootSessionId' | 'childId' = 'sessionId'): SidebarMethodSpec => ({ kind, sessionField })
|
|
50
|
+
|
|
51
|
+
/** 0.25.0 的全部方法。 */
|
|
52
|
+
export const SIDEBAR_METHODS: Readonly<Record<string, SidebarMethodSpec>> = {
|
|
53
|
+
// ---- 会话(lib/index.js:6319-6333)----
|
|
54
|
+
'session.phase': forward('read'),
|
|
55
|
+
'session.cwd': {
|
|
56
|
+
kind: 'read',
|
|
57
|
+
sessionField: 'sessionId',
|
|
58
|
+
// 请求的 sessionId 原样回显(lib/index.js:6327)。
|
|
59
|
+
rewrite: (body, ctx) => rewriteTopLevel(body, 'sessionId', ctx),
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
// ---- 文件(lib/index.js:6334-6442)----
|
|
63
|
+
'fs.tree': forward('read'),
|
|
64
|
+
'fs.trees': forward('read'),
|
|
65
|
+
'fs.search': forward('read'),
|
|
66
|
+
'fs.read': forward('read'),
|
|
67
|
+
'fs.write': forward('write'),
|
|
68
|
+
'fs.rename': forward('write'),
|
|
69
|
+
'fs.mkdir': forward('write'),
|
|
70
|
+
'fs.createFile': forward('write'),
|
|
71
|
+
'fs.remove': forward('write'),
|
|
72
|
+
|
|
73
|
+
// ---- git(lib/index.js:6443-6613)----
|
|
74
|
+
'git.worktrees': forward('read'),
|
|
75
|
+
'git.status': forward('read'),
|
|
76
|
+
'git.diff': forward('read'),
|
|
77
|
+
'git.diff-head': forward('read'),
|
|
78
|
+
'git.branch': forward('read'),
|
|
79
|
+
'git.log': forward('read'),
|
|
80
|
+
'git.commit-diff': forward('read'),
|
|
81
|
+
'git.show': forward('read'),
|
|
82
|
+
'git.blame': forward('read'),
|
|
83
|
+
'git.stage': forward('write'),
|
|
84
|
+
'git.unstage': forward('write'),
|
|
85
|
+
'git.commit': forward('write'),
|
|
86
|
+
'git.push': forward('write'),
|
|
87
|
+
'git.pull': forward('write'),
|
|
88
|
+
'git.checkout': forward('write'),
|
|
89
|
+
'git.discard': forward('write'),
|
|
90
|
+
'git.revert': forward('write'),
|
|
91
|
+
'git.cherry-pick': forward('write'),
|
|
92
|
+
// 提交信息生成:消耗主服务端的模型额度(产品决定:跟随会话,接)。
|
|
93
|
+
'git.suggest-message': forward('write'),
|
|
94
|
+
// 模型目录:sessionId 可选(lib/index.js:6573-6602、6614-6626)。
|
|
95
|
+
'git.commit-model': forward('read'),
|
|
96
|
+
'git.models': forward('read'),
|
|
97
|
+
|
|
98
|
+
// ---- 变更流(lib/index.js:6627-6649)----
|
|
99
|
+
// 事件对象属于该会话自身的流,不带会话 id —— 不改写。
|
|
100
|
+
'changes.ops': forward('read'),
|
|
101
|
+
|
|
102
|
+
// ---- 子代理 / 工作流 / 团队(lib/index.js:4482-4662、4770-4789)----
|
|
103
|
+
'subagents.live': {
|
|
104
|
+
kind: 'read',
|
|
105
|
+
sessionField: 'rootSessionId',
|
|
106
|
+
// 响应的 live 映射以会话 id 为键(lib/index.js:4513)。
|
|
107
|
+
rewrite: (body, ctx) => {
|
|
108
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body)) return body
|
|
109
|
+
const live = (body as Record<string, unknown>).live
|
|
110
|
+
if (live === null || typeof live !== 'object' || Array.isArray(live)) return body
|
|
111
|
+
const rewritten: Record<string, unknown> = {}
|
|
112
|
+
for (const [id, view] of Object.entries(live as Record<string, unknown>)) {
|
|
113
|
+
rewritten[ctx.isVirtual(id) ? id : ctx.virtualize(id)] = view
|
|
114
|
+
}
|
|
115
|
+
return { ...(body as Record<string, unknown>), live: rewritten }
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
'workflows.list': {
|
|
119
|
+
kind: 'read',
|
|
120
|
+
sessionField: 'rootSessionId',
|
|
121
|
+
// 每行的 originSessionId(4666)与 members[].childId(4686)。
|
|
122
|
+
rewrite: (body, ctx) => {
|
|
123
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body)) return body
|
|
124
|
+
const runs = (body as Record<string, unknown>).runs
|
|
125
|
+
if (!Array.isArray(runs)) return body
|
|
126
|
+
return {
|
|
127
|
+
...(body as Record<string, unknown>),
|
|
128
|
+
runs: runs.map((run) => rewriteWorkflowRun(run, ctx)),
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
// 响应是 {ok, value:<任务记录>},不带会话 id —— 不改写。
|
|
133
|
+
'teams.taskCreate': forward('write', 'rootSessionId'),
|
|
134
|
+
'teams.taskUpdate': forward('write', 'rootSessionId'),
|
|
135
|
+
|
|
136
|
+
// ---- 归档(lib/index.js:6663-6686;响应不带会话 id)----
|
|
137
|
+
'archive.build': forward('write'),
|
|
138
|
+
'archive.status': forward('read'),
|
|
139
|
+
|
|
140
|
+
// ---- 设置(lib/index.js:6666-6682)----
|
|
141
|
+
// 无会话字段:定位结果为空,远程判定天然放行本机 —— 表里登记只为
|
|
142
|
+
// 文档完整与「未知方法」比对(真实存在,只是不转发)。
|
|
143
|
+
'settings.get': { kind: 'read', local: true },
|
|
144
|
+
'settings.update': { kind: 'write', local: true },
|
|
145
|
+
|
|
146
|
+
// ---- 外部打开(lib/index.js:6683-6687)----
|
|
147
|
+
// 在宿主机起进程:远程会话拒绝(适配器路由的 deny 钩子按「浏览器当前
|
|
148
|
+
// 会话是虚拟 id」拒绝;这里登记是让方法比对认识它)。请求体不带会话
|
|
149
|
+
// 字段(client.js:9383-9392 只发 action/path/url)。
|
|
150
|
+
'open.external': { kind: 'write', sessionField: 'sessionId' },
|
|
151
|
+
|
|
152
|
+
// ---- 侧聊(lib/index.js:4971-5140,buildSidechatApi)----
|
|
153
|
+
// PB-13 起转发:sidechat 的子会话带 `meta.parentSession = 调用会话 id`
|
|
154
|
+
// (lib/index.js:5012),zen-remote 的共享按祖先链判定,所以侧聊线程
|
|
155
|
+
// 自动继承父会话的共享——闭环是 start 的响应 childId 换成虚拟 id,
|
|
156
|
+
// 浏览器拿它发后续调用时经体定位回本适配器,服务端按继承校验放行。
|
|
157
|
+
'sidechat.start': {
|
|
158
|
+
kind: 'write',
|
|
159
|
+
sessionField: 'sessionId',
|
|
160
|
+
// 响应 `{ childId }`(lib/index.js:5050)——服务端真实 id 换虚拟。
|
|
161
|
+
rewrite: (body, ctx) => rewriteTopLevel(body, 'childId', ctx),
|
|
162
|
+
},
|
|
163
|
+
'sidechat.prompt': forward('write', 'childId'),
|
|
164
|
+
'sidechat.cancel': forward('write', 'childId'),
|
|
165
|
+
'sidechat.dispose': forward('write', 'childId'),
|
|
166
|
+
'sidechat.info': forward('read', 'childId'),
|
|
167
|
+
'sidechat.events': forward('read', 'childId'),
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** `/sidebar/api` 的路径前缀(方法名跟在后面,lib/index.js:6888)。 */
|
|
171
|
+
export const SIDEBAR_API_PREFIX = '/sidebar/api/'
|
|
172
|
+
|
|
173
|
+
/** 从请求 pathname 取出 API 方法名(`/sidebar/api/fs.tree` → `fs.tree`)。 */
|
|
174
|
+
export function sidebarMethodNameOf(pathname: string): string | undefined {
|
|
175
|
+
if (!pathname.startsWith(SIDEBAR_API_PREFIX)) return undefined
|
|
176
|
+
const name = pathname.slice(SIDEBAR_API_PREFIX.length)
|
|
177
|
+
if (name === '' || name.includes('/')) return undefined
|
|
178
|
+
return name
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** 顶层字段的虚拟化改写(session.cwd 的形状)。 */
|
|
182
|
+
function rewriteTopLevel(body: unknown, field: string, ctx: SidebarRewriteContext): unknown {
|
|
183
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body)) return body
|
|
184
|
+
const record = body as Record<string, unknown>
|
|
185
|
+
const value = record[field]
|
|
186
|
+
if (typeof value !== 'string' || value === '' || ctx.isVirtual(value)) return body
|
|
187
|
+
return { ...record, [field]: ctx.virtualize(value) }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** 一行工作流运行的改写(originSessionId + phases[].members[].childId)。 */
|
|
191
|
+
function rewriteWorkflowRun(run: unknown, ctx: SidebarRewriteContext): unknown {
|
|
192
|
+
if (run === null || typeof run !== 'object' || Array.isArray(run)) return run
|
|
193
|
+
const record = run as Record<string, unknown>
|
|
194
|
+
const rewritten: Record<string, unknown> = { ...record }
|
|
195
|
+
const origin = record.originSessionId
|
|
196
|
+
if (typeof origin === 'string' && origin !== '' && !ctx.isVirtual(origin)) rewritten.originSessionId = ctx.virtualize(origin)
|
|
197
|
+
if (Array.isArray(record.phases)) {
|
|
198
|
+
rewritten.phases = record.phases.map((phase) => {
|
|
199
|
+
if (phase === null || typeof phase !== 'object' || Array.isArray(phase)) return phase
|
|
200
|
+
const phaseRecord = phase as Record<string, unknown>
|
|
201
|
+
if (!Array.isArray(phaseRecord.members)) return phase
|
|
202
|
+
return {
|
|
203
|
+
...phaseRecord,
|
|
204
|
+
members: phaseRecord.members.map((member) => {
|
|
205
|
+
if (member === null || typeof member !== 'object' || Array.isArray(member)) return member
|
|
206
|
+
const memberRecord = member as Record<string, unknown>
|
|
207
|
+
const childId = memberRecord.childId
|
|
208
|
+
if (typeof childId !== 'string' || childId === '' || ctx.isVirtual(childId)) return member
|
|
209
|
+
return { ...memberRecord, childId: ctx.virtualize(childId) }
|
|
210
|
+
}),
|
|
211
|
+
}
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
return rewritten
|
|
215
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The adapter registry — the ONE door between `src/adapters/` and the rest
|
|
3
|
+
* of the plugin. Every adapter implementation lives in its own
|
|
4
|
+
* subdirectory (`src/adapters/<plugin-name>/`), is imported here, and adds
|
|
5
|
+
* exactly one element to the array. Nothing outside `src/index.ts` (the
|
|
6
|
+
* wiring) may import from this directory, and no file anywhere may import
|
|
7
|
+
* an adapter subdirectory — both pinned by test/adapters-pluggable.test.cjs.
|
|
8
|
+
* Deleting this whole directory leaves the plugin fully working: the wiring
|
|
9
|
+
* parameter defaults to `[]`.
|
|
10
|
+
*
|
|
11
|
+
* PB-11 ships the FRAMEWORK only: the array stays EMPTY, the first real
|
|
12
|
+
* adapter (better-sidebar) arrives with PB-12.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { BridgeAdapter } from './types.js'
|
|
16
|
+
import { dshBetterSidebarAdapter } from './dsh-better-sidebar/index.js'
|
|
17
|
+
|
|
18
|
+
/** Every adapter zen-remote ships, in registration order. */
|
|
19
|
+
export const ADAPTERS: readonly BridgeAdapter[] = [dshBetterSidebarAdapter]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The adapter-author face of the plugin bridge's adapter framework
|
|
3
|
+
* (PB-11). An adapter is zen-remote-internal code for ONE third-party
|
|
4
|
+
* plugin at its EXACT version: it declares which of that plugin's routes
|
|
5
|
+
* carry a session id and WHERE, and — optionally — hooks the two ends to
|
|
6
|
+
* refuse, strip or rewrite what the pure id relocation cannot express.
|
|
7
|
+
* Everything here sits on the framework shapes from
|
|
8
|
+
* `../plugin-adapter-core.js`; the framework itself never imports this
|
|
9
|
+
* file (only `src/index.ts` imports the ADAPTERS array from
|
|
10
|
+
* `./adapters/index.js`), which is what makes the whole directory
|
|
11
|
+
* removable without touching any other feature.
|
|
12
|
+
*
|
|
13
|
+
* An adapter implementation may import `types.ts` and pure utilities such
|
|
14
|
+
* as `../virtual-id.js` — nothing else.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { AdapterRoute, AdapterRequestContext, AdapterResponseContext, AdapterVerdict } from '../plugin-adapter-core.js'
|
|
18
|
+
|
|
19
|
+
export type { AdapterRoute, AdapterRequestContext, AdapterResponseContext, AdapterVerdict }
|
|
20
|
+
|
|
21
|
+
/** One pluggable adapter. `versions` lists the EXACT plugin versions this
|
|
22
|
+
* adapter was written against — the installed version must be among them
|
|
23
|
+
* or the adapter does not register at all (a best-guess rewrite against a
|
|
24
|
+
* moving schema is how ids get corrupted). A plugin that registers its OWN
|
|
25
|
+
* cooperative declaration always wins; the adapter yields. */
|
|
26
|
+
export interface BridgeAdapter {
|
|
27
|
+
/** The target plugin's npm package name. */
|
|
28
|
+
plugin: string
|
|
29
|
+
/** The checked exact versions; an installed version outside this list
|
|
30
|
+
* means "not adapted". */
|
|
31
|
+
versions: readonly string[]
|
|
32
|
+
routes: readonly AdapterRoute[]
|
|
33
|
+
/** The sub-client half: rewrite or refuse on the way OUT to the relay,
|
|
34
|
+
* and map the answer's real session ids back to virtual form. */
|
|
35
|
+
client?: {
|
|
36
|
+
request?(ctx: AdapterRequestContext): AdapterVerdict
|
|
37
|
+
response?(ctx: AdapterResponseContext): unknown
|
|
38
|
+
}
|
|
39
|
+
/** The main-server half: refuse or strip (e.g. a `cwd` field) before
|
|
40
|
+
* the loopback reaches the plugin. */
|
|
41
|
+
server?: {
|
|
42
|
+
request?(ctx: AdapterRequestContext): AdapterVerdict
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/admin-routes.ts
CHANGED
|
@@ -41,6 +41,7 @@ import { request as httpRequest } from 'node:http'
|
|
|
41
41
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
42
42
|
import type { ConfigSource, ZenRemoteConfig } from './config.js'
|
|
43
43
|
import { responseJson, sameOriginPost } from './http.js'
|
|
44
|
+
import type { BridgeRegistry } from './plugin-bridge.js'
|
|
44
45
|
import type { RelayGateway } from './relay-server.js'
|
|
45
46
|
import { restoreBusy } from './share-ops.js'
|
|
46
47
|
import type { AgentStatusLike } from './share-ops.js'
|
|
@@ -66,6 +67,10 @@ export const ADMIN_PUSH_TEST_ROUTE = `${ADMIN_ROUTE_PREFIX}/push-test`
|
|
|
66
67
|
* POST `{action, sessionId?}`: share / unshare / unshare-all (T33a). */
|
|
67
68
|
export const ADMIN_SHARES_ROUTE = `${ADMIN_ROUTE_PREFIX}/shares`
|
|
68
69
|
|
|
70
|
+
/** GET: the plugin bridge's registered plugins with their whole-machine-data
|
|
71
|
+
* consent, for the settings page's 插件桥 section (PB-5). */
|
|
72
|
+
export const ADMIN_PLUGIN_BRIDGE_ROUTE = `${ADMIN_ROUTE_PREFIX}/plugin-bridge`
|
|
73
|
+
|
|
69
74
|
/** Longest wait for one gateway round-trip. */
|
|
70
75
|
const GATEWAY_TIMEOUT_MS = 5000
|
|
71
76
|
|
|
@@ -161,6 +166,18 @@ export interface AdminHandlerOptions {
|
|
|
161
166
|
* durable header through it to judge subagent sessions; absent (or a
|
|
162
167
|
* failing read) falls back to the projection identity. */
|
|
163
168
|
sessionQuery?: () => SessionQueryLike | undefined
|
|
169
|
+
/** The plugin bridge's server half (PB-5): the declaration registry and
|
|
170
|
+
* the LIVE consent reader backing the plugin-bridge listing. Absent — a
|
|
171
|
+
* composition that never built the registry — and the route answers 404
|
|
172
|
+
* like any unknown admin path. */
|
|
173
|
+
pluginBridge?: {
|
|
174
|
+
registry: BridgeRegistry
|
|
175
|
+
getConsent: () => Record<string, boolean>
|
|
176
|
+
/** The plugins an ADAPTER currently stands in for (PB-11), read live
|
|
177
|
+
* per listing — a plugin that registered its own declaration stops
|
|
178
|
+
* being marked. Absent on a wiring that predates adapters. */
|
|
179
|
+
adaptedPlugins?: () => ReadonlySet<string>
|
|
180
|
+
}
|
|
164
181
|
}
|
|
165
182
|
|
|
166
183
|
/** The slice of the sessionQuery service the share action reads (RT
|
|
@@ -574,6 +591,53 @@ export function createAdminHandler(options: AdminHandlerOptions): AdminHandler {
|
|
|
574
591
|
responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use GET or POST' } })
|
|
575
592
|
return
|
|
576
593
|
}
|
|
594
|
+
if (route === ADMIN_PLUGIN_BRIDGE_ROUTE) {
|
|
595
|
+
// No registry, no route: a composition that never built the plugin
|
|
596
|
+
// bridge answers like any unknown admin path.
|
|
597
|
+
if (options.pluginBridge === undefined) {
|
|
598
|
+
responseJson(res, 404, { ok: false, error: { code: 'not-found', message: 'Unknown admin route' } })
|
|
599
|
+
return
|
|
600
|
+
}
|
|
601
|
+
if (method !== 'GET') {
|
|
602
|
+
res.setHeader('Allow', 'GET')
|
|
603
|
+
responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use GET' } })
|
|
604
|
+
return
|
|
605
|
+
}
|
|
606
|
+
// Wall 2, the pair/action rule: this listing feeds a MANAGEMENT
|
|
607
|
+
// surface (the consent toggles below it), so a request that arrived
|
|
608
|
+
// through the gateway is refused — consent is granted on the server
|
|
609
|
+
// machine, by its owner, period.
|
|
610
|
+
if (req.headers['x-zen-remote-via'] !== undefined) {
|
|
611
|
+
responseJson(res, 403, {
|
|
612
|
+
ok: false,
|
|
613
|
+
error: { code: 'via-gateway', message: '插件桥设置只能在服务端本机操作' },
|
|
614
|
+
})
|
|
615
|
+
return
|
|
616
|
+
}
|
|
617
|
+
// The consent source is a live row read; a broken one reads as the
|
|
618
|
+
// empty map (nothing consented) instead of failing the listing.
|
|
619
|
+
let consent: Record<string, boolean> = {}
|
|
620
|
+
try {
|
|
621
|
+
consent = options.pluginBridge.getConsent()
|
|
622
|
+
} catch {
|
|
623
|
+
consent = {}
|
|
624
|
+
}
|
|
625
|
+
// registry.list() arrives plugin-sorted; the map keeps that order.
|
|
626
|
+
// PB-11: `adapted` marks the entries an ADAPTER stands in for —
|
|
627
|
+
// zen-remote-internal forwarding for a plugin that never registered
|
|
628
|
+
// anything — so the settings page can say「由 zen-remote 适配」.
|
|
629
|
+
// Absent (a wiring that predates adapters) reads as none.
|
|
630
|
+
const adapted = options.pluginBridge.adaptedPlugins?.() ?? new Set<string>()
|
|
631
|
+
const plugins = options.pluginBridge.registry.list().map((declaration) => ({
|
|
632
|
+
plugin: declaration.plugin,
|
|
633
|
+
version: declaration.version,
|
|
634
|
+
hasServerScope: declaration.routes.some((route) => route.scope === 'server'),
|
|
635
|
+
consent: consent[declaration.plugin] === true,
|
|
636
|
+
adapted: adapted.has(declaration.plugin),
|
|
637
|
+
}))
|
|
638
|
+
responseJson(res, 200, { ok: true, value: { plugins } })
|
|
639
|
+
return
|
|
640
|
+
}
|
|
577
641
|
if (route === ADMIN_PAIR_ROUTE || route === ADMIN_ACTION_ROUTE || route === ADMIN_PUSH_TEST_ROUTE) {
|
|
578
642
|
if (method !== 'POST') {
|
|
579
643
|
res.setHeader('Allow', 'POST')
|
|
@@ -2,6 +2,8 @@ import type { ClientContext, MobileSessionRow } from '../compat/types.ts'
|
|
|
2
2
|
import { mainSessionIdOf } from '../compat/types.ts'
|
|
3
3
|
import { isVirtual } from '../../virtual-id.ts'
|
|
4
4
|
import { REMOTE_SESSION_CSS } from '../styles/remote-session.css.ts'
|
|
5
|
+
import { BRIDGE_STAMP_ATTRIBUTE, BRIDGE_STAMP_PLUGIN, getRemoteStatusStore, shouldStampBridgeAttribute, subscribeIfVirtual } from '../../client-data/remote-status.ts'
|
|
6
|
+
import { en, zh } from '../locales.ts'
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* The remote-session marker (T41b): the CURRENT main-view session id, read
|
|
@@ -25,6 +27,29 @@ import { REMOTE_SESSION_CSS } from '../styles/remote-session.css.ts'
|
|
|
25
27
|
* attribute must track the current session regardless of which panel is
|
|
26
28
|
* mounted, and an attribute + stylesheet survives every re-render untouched.
|
|
27
29
|
*/
|
|
30
|
+
|
|
31
|
+
/** The CURRENT main-view session id, the publishMain way (see the module
|
|
32
|
+
* comment). Shared by the session marker and the bridge stamp — both key on
|
|
33
|
+
* the same "which session is on screen" answer. */
|
|
34
|
+
function currentMainSessionIdOf(ctx: ClientContext): string | undefined {
|
|
35
|
+
const { byId } = ctx.sessions.list.getSnapshot()
|
|
36
|
+
const uiSession = ctx.get('uiSession') as { current?: { value?: { key?: unknown } } } | undefined
|
|
37
|
+
const current = uiSession?.current?.value?.key
|
|
38
|
+
const rows = byId as Readonly<Record<string, MobileSessionRow>>
|
|
39
|
+
return typeof current === 'string' && (rows[current]?.retainedBy?.mainView ?? 0) > 0
|
|
40
|
+
? current
|
|
41
|
+
: mainSessionIdOf(byId)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The unbridged note copy in the BROWSER's language (the plugin's own
|
|
45
|
+
* namespace carries both dictionaries; the framework `t` seat is not
|
|
46
|
+
* reachable from a non-React effect, so the navigator's language picks —
|
|
47
|
+
* `zh*` reads Chinese, everything else English). */
|
|
48
|
+
function bridgeNoteOf(language: string | undefined): string {
|
|
49
|
+
const key = 'remoteBridgeUnbridgedNote' as const
|
|
50
|
+
return typeof language === 'string' && language.startsWith('zh') ? zh[key] : en[key]
|
|
51
|
+
}
|
|
52
|
+
|
|
28
53
|
export function installRemoteSessionGuard(ctx: ClientContext): void {
|
|
29
54
|
ctx.effect(() => {
|
|
30
55
|
const tag = document.createElement('style')
|
|
@@ -39,16 +64,7 @@ export function installRemoteSessionGuard(ctx: ClientContext): void {
|
|
|
39
64
|
|
|
40
65
|
ctx.effect(() => {
|
|
41
66
|
const apply = (): void => {
|
|
42
|
-
const
|
|
43
|
-
// publishMain's tie-break (see the module comment): prefer the still-
|
|
44
|
-
// retained current main binding, else the first mainView row.
|
|
45
|
-
const uiSession = ctx.get('uiSession') as { current?: { value?: { key?: unknown }, subscribe?: (listener: () => void) => () => void } } | undefined
|
|
46
|
-
const current = uiSession?.current?.value?.key
|
|
47
|
-
const rows = byId as Readonly<Record<string, MobileSessionRow>>
|
|
48
|
-
const sessionId =
|
|
49
|
-
typeof current === 'string' && (rows[current]?.retainedBy?.mainView ?? 0) > 0
|
|
50
|
-
? current
|
|
51
|
-
: mainSessionIdOf(byId)
|
|
67
|
+
const sessionId = currentMainSessionIdOf(ctx)
|
|
52
68
|
const root = document.documentElement
|
|
53
69
|
if (sessionId !== undefined && isVirtual(sessionId)) root.setAttribute('data-zr-remote-session', '1')
|
|
54
70
|
else root.removeAttribute('data-zr-remote-session')
|
|
@@ -65,4 +81,60 @@ export function installRemoteSessionGuard(ctx: ClientContext): void {
|
|
|
65
81
|
document.documentElement.removeAttribute('data-zr-remote-session')
|
|
66
82
|
}
|
|
67
83
|
}, 'dsh-zen-remote: remote-session attribute')
|
|
84
|
+
|
|
85
|
+
// The bridge stamp (PB-14): while a REMOTE session is on screen, the
|
|
86
|
+
// remote-status poll's `bridgedPlugins` decides whether better-sidebar is
|
|
87
|
+
// bridged RIGHT NOW — bridged, the T53 hiding rules lift (the panels read
|
|
88
|
+
// the server's data through the adapter) and the html carries
|
|
89
|
+
// `data-zr-bridge-dsh-better-sidebar="1"`; not bridged, the rules stay
|
|
90
|
+
// and the html carries the unbridged note copy as a CSS variable the
|
|
91
|
+
// stylesheet spends on the empty tab body. The subscription rides
|
|
92
|
+
// subscribeIfVirtual — a LOCAL session (or a host page) never polls, and
|
|
93
|
+
// the attribute comes off; better-sidebar not installed or the server not
|
|
94
|
+
// paired simply never appear in `bridgedPlugins`, so the stamp never
|
|
95
|
+
// fires from this side alone.
|
|
96
|
+
ctx.effect(() => {
|
|
97
|
+
const root = document.documentElement
|
|
98
|
+
const store = getRemoteStatusStore()
|
|
99
|
+
let offStore: (() => void) | undefined
|
|
100
|
+
const clear = (): void => {
|
|
101
|
+
root.removeAttribute(BRIDGE_STAMP_ATTRIBUTE)
|
|
102
|
+
root.style.removeProperty('--zr-bridge-note')
|
|
103
|
+
}
|
|
104
|
+
const applyBridge = (): void => {
|
|
105
|
+
const view = store.getSnapshot().view
|
|
106
|
+
if (shouldStampBridgeAttribute(view?.bridgedPlugins, BRIDGE_STAMP_PLUGIN)) {
|
|
107
|
+
root.setAttribute(BRIDGE_STAMP_ATTRIBUTE, '1')
|
|
108
|
+
root.style.removeProperty('--zr-bridge-note')
|
|
109
|
+
} else {
|
|
110
|
+
root.removeAttribute(BRIDGE_STAMP_ATTRIBUTE)
|
|
111
|
+
// JSON.stringify's quotes are a legal CSS string — the stylesheet's
|
|
112
|
+
// ::before spends it as `content: var(--zr-bridge-note)`.
|
|
113
|
+
root.style.setProperty('--zr-bridge-note', JSON.stringify(bridgeNoteOf(navigator.language)))
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const retune = (): void => {
|
|
117
|
+
const sessionId = currentMainSessionIdOf(ctx)
|
|
118
|
+
const virtualNow = sessionId !== undefined && isVirtual(sessionId)
|
|
119
|
+
if (virtualNow && offStore === undefined && typeof sessionId === 'string') {
|
|
120
|
+
offStore = subscribeIfVirtual(store, sessionId, applyBridge)
|
|
121
|
+
applyBridge()
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
if (!virtualNow && offStore !== undefined) {
|
|
125
|
+
offStore()
|
|
126
|
+
offStore = undefined
|
|
127
|
+
clear()
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
retune()
|
|
131
|
+
const offs: Array<() => void> = [ctx.sessions.list.subscribe(retune)]
|
|
132
|
+
const current = (ctx.get('uiSession') as { current?: { subscribe?: (listener: () => void) => () => void } } | undefined)?.current
|
|
133
|
+
if (typeof current?.subscribe === 'function') offs.push(current.subscribe(retune))
|
|
134
|
+
return () => {
|
|
135
|
+
for (const off of offs) off()
|
|
136
|
+
offStore?.()
|
|
137
|
+
clear()
|
|
138
|
+
}
|
|
139
|
+
}, 'dsh-zen-remote: bridge stamp')
|
|
68
140
|
}
|