@yokira404/dsh-git-guard 0.0.0-stage → 1.0.1
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/LICENSE +21 -0
- package/README.md +106 -2
- package/README.zh.md +91 -0
- package/client.js +645 -0
- package/cordis.patch.yml +11 -0
- package/docs/position.svg +79 -0
- package/icon.svg +25 -0
- package/index.js +597 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +54 -4
package/client.js
ADDED
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client half of @yokira404/dsh-git-guard.
|
|
3
|
+
*
|
|
4
|
+
* One contribution: a pair of icon switches in the Session header's right-aligned
|
|
5
|
+
* utilities list (`conversation.session.header.utilities`), at the far right of that
|
|
6
|
+
* row — beside the window controls, separated from whatever else the row holds by a
|
|
7
|
+
* hairline rule.
|
|
8
|
+
*
|
|
9
|
+
* Which seat, and why: the header is a two-column grid whose second column is the
|
|
10
|
+
* title row, and inside it the actions list sits beside the title while the utilities
|
|
11
|
+
* list is pushed right (`margin-left:auto` on the header's own corner, `:empty` making
|
|
12
|
+
* both vanish when unused). Membership of the utilities list is therefore what ties a
|
|
13
|
+
* control to the window controls rather than to the title, and it is what makes the
|
|
14
|
+
* group move when other entries appear or go: the list is sorted by `order` and laid
|
|
15
|
+
* out in one flex row, so nothing has to be positioned by hand.
|
|
16
|
+
*
|
|
17
|
+
* The pair is a picture of one rule, not the rule itself. Enforcement lives in the
|
|
18
|
+
* Host half, so a click here is a *request*: the new pair is normalised, drawn
|
|
19
|
+
* immediately, and written to the Host's own route; the Host's answer is the
|
|
20
|
+
* truth, and a request that fails puts the pair back where it was rather than
|
|
21
|
+
* leaving two switches that claim to be protecting something they are not.
|
|
22
|
+
*
|
|
23
|
+
* The two switches are named for what they stop:
|
|
24
|
+
*
|
|
25
|
+
* left (local) blocks `git add` / `git commit` — the local repository;
|
|
26
|
+
* right (remote) blocks `git push` / `git remote` / `gh` — the upload.
|
|
27
|
+
*
|
|
28
|
+
* They are drawn as the host's own chrome buttons are — same box, same radius, same
|
|
29
|
+
* tokens, no fill of their own — and the state is carried by the *icon*: an unguarded
|
|
30
|
+
* repository is the repository alone, and switching a guard on adds the ring and the
|
|
31
|
+
* stroke the reference uses for "forbidden".
|
|
32
|
+
*
|
|
33
|
+
* Shape of the pair: both start on (the protective default), all four states stay
|
|
34
|
+
* reachable, nothing is ever locked, and the pair's behaviour is exactly two clicks,
|
|
35
|
+
* carrying each other in opposite directions:
|
|
36
|
+
*
|
|
37
|
+
* clicking the LEFT switch toggles it; landing ON turns the RIGHT one on too,
|
|
38
|
+
* landing OFF leaves the RIGHT one alone;
|
|
39
|
+
* clicking the RIGHT switch toggles it; landing OFF turns the LEFT one off too,
|
|
40
|
+
* landing ON leaves the LEFT one alone.
|
|
41
|
+
*
|
|
42
|
+
* So a guard is never *asked for* at the cost of the other one — only released that
|
|
43
|
+
* way. `local off · remote on`, the pairing the reader asked to be able to reach from
|
|
44
|
+
* both-off with a right-hand click, is reached by that click or by handling the local
|
|
45
|
+
* guard back on its own; it is never reached by a right-hand click that was meant to
|
|
46
|
+
* do something else.
|
|
47
|
+
*
|
|
48
|
+
* Both buttons are therefore always clickable, and the pair never needs a disabled
|
|
49
|
+
* look to explain itself.
|
|
50
|
+
*/
|
|
51
|
+
window.__ModuleLoader__.load({
|
|
52
|
+
id: '@yokira404/dsh-git-guard',
|
|
53
|
+
factory(require) {
|
|
54
|
+
const React = require('react')
|
|
55
|
+
const { createElement: h, useCallback, useEffect, useRef, useState } = React
|
|
56
|
+
|
|
57
|
+
/* ───────────────────────────── constants ───────────────────────────── */
|
|
58
|
+
|
|
59
|
+
const NS = 'dsh-git-guard'
|
|
60
|
+
const LOCALE_NS = 'gitGuard'
|
|
61
|
+
const VERSION = '1.0.1'
|
|
62
|
+
/** This package's own two routes on the Host's Web carrier, same origin as the GUI. */
|
|
63
|
+
const ROUTE = '/dsh-git-guard/state'
|
|
64
|
+
/** A switch is asked for, never waited on forever: the draw must never hang. */
|
|
65
|
+
const REQUEST_TIMEOUT_MS = 4000
|
|
66
|
+
/** Slow reconciliation, so a second window's choice reaches this one eventually. */
|
|
67
|
+
const RECONCILE_MS = 20000
|
|
68
|
+
/**
|
|
69
|
+
* The Client's own head start, byte-identical in meaning to the Host's default.
|
|
70
|
+
* Both on: the reader asked for a guard that is protecting the moment it loads.
|
|
71
|
+
*/
|
|
72
|
+
const DEFAULT_STATE = { local: true, remote: true }
|
|
73
|
+
|
|
74
|
+
/* ──────────────────────────── dictionaries ─────────────────────────── */
|
|
75
|
+
|
|
76
|
+
const ZH = {
|
|
77
|
+
'local.on': '本地仓库保护:开(阻止 git add / git commit 等本地写入;打开时若远端是关的,会一并打开)',
|
|
78
|
+
'local.off': '本地仓库保护:关(允许本地仓库写入)',
|
|
79
|
+
'local.label': '本地仓库保护',
|
|
80
|
+
'remote.on': '远端仓库保护:开(阻止 git push / git remote / gh 等上传)',
|
|
81
|
+
'remote.off': '远端仓库保护:关(允许上传远端仓库;关闭时本地保护会一并关闭)',
|
|
82
|
+
'remote.label': '远端仓库保护',
|
|
83
|
+
'failed': '未能同步到 DSH 主机,开关已还原',
|
|
84
|
+
}
|
|
85
|
+
const EN = {
|
|
86
|
+
'local.on': 'Local repository guard: on (blocks git add / git commit and other local writes; switching it on also arms the remote guard when that one is off)',
|
|
87
|
+
'local.off': 'Local repository guard: off (local repository writes allowed)',
|
|
88
|
+
'local.label': 'Local repository guard',
|
|
89
|
+
'remote.on': 'Remote repository guard: on (blocks git push / git remote / gh and other uploads)',
|
|
90
|
+
'remote.off': 'Remote repository guard: off (uploads allowed; switching it off also releases the local guard)',
|
|
91
|
+
'remote.label': 'Remote repository guard',
|
|
92
|
+
'failed': 'Could not reach the DSH Host; the switches were put back',
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* ────────────────────────────── icons ─────────────────────────────── */
|
|
96
|
+
|
|
97
|
+
/*
|
|
98
|
+
* Geometry for the two states of one icon.
|
|
99
|
+
*
|
|
100
|
+
* The reference marks the "forbidden" reading with a ring and a stroke, and the
|
|
101
|
+
* pair's own state is what the ring is *for*: an unguarded repository is drawn as
|
|
102
|
+
* the repository alone, and switching the guard on adds the ring and the stroke.
|
|
103
|
+
* The two states therefore differ by a whole mark rather than by a colour, which
|
|
104
|
+
* is what lets these buttons drop the black/white fill and sit beside the window
|
|
105
|
+
* controls looking like the host drew them.
|
|
106
|
+
*
|
|
107
|
+
* The art is drawn on a 32-unit grid rather than a 24 one, and that is what makes
|
|
108
|
+
* it big enough to read: the whole picture is a ring around a repository, and a
|
|
109
|
+
* ring around something needs more room than a bare glyph does. Everything below
|
|
110
|
+
* is in those units, and the button is the host's 28px box.
|
|
111
|
+
*
|
|
112
|
+
* The ring encloses the art rather than sitting on top of it, which fixes both
|
|
113
|
+
* sizes: at r 10.6 with a 1.5 stroke its inner edge is at 9.85, so everything
|
|
114
|
+
* inside clears 9.85 while the ring's outer edge (11.35) still leaves room in the
|
|
115
|
+
* 32 grid for the round cap of the slash. The drawing therefore keeps a margin
|
|
116
|
+
* *inside its own grid*, which is what the host's header buttons do — the icon is
|
|
117
|
+
* a glyph in the row, not a tile that fills its box.
|
|
118
|
+
*/
|
|
119
|
+
const RING = { cx: 16, cy: 16, r: 10.6 }
|
|
120
|
+
/**
|
|
121
|
+
* The cylinder: a lid, a body, and three bands seen from just above the equator.
|
|
122
|
+
*
|
|
123
|
+
* Its *optical* centre has to be y 16 — the ring's centre and the GitHub mark's
|
|
124
|
+
* centre — and the optical centre of a cylinder is the middle of its body, not the
|
|
125
|
+
* middle of its bounding box: the lid above reads as a face and the base below as
|
|
126
|
+
* an edge, so the eye balances them around the body. Taking the bounding box as
|
|
127
|
+
* the shape put the whole glyph 1.8 units high, which is what "the left icon sits
|
|
128
|
+
* a bit high" looked like. The body therefore runs y 10.55 to 21.45 (centre 16),
|
|
129
|
+
* with the lid's ellipse above it and the base's below.
|
|
130
|
+
*
|
|
131
|
+
* Half-width 6.1 at a 1.4 stroke puts its edge at 6.8, clear of the ring's inner
|
|
132
|
+
* edge at 9.85 — the gap between the cylinder and the ring is what makes the two
|
|
133
|
+
* read as subject and mark rather than as one cluster.
|
|
134
|
+
*
|
|
135
|
+
* The bottom of the body is a cubic segment rather than an elliptical arc, and
|
|
136
|
+
* deliberately so: an `a rx ry …` sweep across a half-ellipse needs the radius to
|
|
137
|
+
* be *half the chord*, and the chord here is 2*sqrt(6.1² + 2.55²) = 6.61, not 6.1.
|
|
138
|
+
* Writing the horizontal half-width as the radius scales the arc down past its
|
|
139
|
+
* endpoints and the renderer clamps it into a spike — which is exactly what one
|
|
140
|
+
* revision of this drawing looked like.
|
|
141
|
+
*/
|
|
142
|
+
const CYLINDER_LID = { cx: 16, cy: 10.55, rx: 6.1, ry: 2.55 }
|
|
143
|
+
const CYLINDER_OUTLINE = 'M9.9 10.55v10.9c0 1.41 2.73 2.55 6.1 2.55s6.1-1.14 6.1-2.55V10.55'
|
|
144
|
+
const CYLINDER_BANDS = [
|
|
145
|
+
'M9.9 13.2c0 1.41 2.73 2.55 6.1 2.55s6.1-1.14 6.1-2.55',
|
|
146
|
+
'M9.9 16c0 1.41 2.73 2.55 6.1 2.55s6.1-1.14 6.1-2.55',
|
|
147
|
+
'M9.9 18.8c0 1.41 2.73 2.55 6.1 2.55s6.1-1.14 6.1-2.55',
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The GitHub mark. The official path is drawn for a 98 box and every unit square
|
|
152
|
+
* of that box carries ink, which is what lets one transform place it without
|
|
153
|
+
* measuring the outline. It is the larger of the two drawings on purpose: a solid
|
|
154
|
+
* silhouette has to be bigger than a line drawing to carry the same weight, so the
|
|
155
|
+
* mark's tile (18.4) exceeds the cylinder's width (12.2 plus its stroke) and the
|
|
156
|
+
* ring crosses its corners the way the reference's ring crosses its subject.
|
|
157
|
+
*/
|
|
158
|
+
const MARK_SOURCE = 98
|
|
159
|
+
const MARK_TILE = 18.4
|
|
160
|
+
const MARK_OFFSET = (32 - MARK_TILE) / 2
|
|
161
|
+
const MARK_SCALE = MARK_TILE / MARK_SOURCE
|
|
162
|
+
const MARK_PATH =
|
|
163
|
+
'M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z'
|
|
164
|
+
|
|
165
|
+
/*
|
|
166
|
+
* The line weight. The reference draws its bands distinctly — each band separate,
|
|
167
|
+
* with air between them — and a 2.2 stroke on this cylinder is heavy enough to
|
|
168
|
+
* close those gaps into one dark mass at 26px, which is what "the lines are too
|
|
169
|
+
* thick" looks like. 1.4 matches the ring's own weight, so the guarded state reads
|
|
170
|
+
* as one drawing rather than a heavy subject inside a light ring, and it leaves
|
|
171
|
+
* 1.4 units of clear space between bands (about 1.1px at the button's real size,
|
|
172
|
+
* where the previous weight left under 0.9px and the bands merged under
|
|
173
|
+
* anti-aliasing).
|
|
174
|
+
*/
|
|
175
|
+
const BODY_STROKE = { fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeLinejoin: 'round' }
|
|
176
|
+
const RING_STROKE = { fill: 'none', stroke: 'currentColor', strokeWidth: 1.5 }
|
|
177
|
+
const SLASH_STROKE = { fill: 'none', stroke: 'currentColor', strokeWidth: 1.9, strokeLinecap: 'round' }
|
|
178
|
+
/**
|
|
179
|
+
* One diagonal across the ring, end to end, sized so both round caps land just
|
|
180
|
+
* inside it rather than poking through.
|
|
181
|
+
*/
|
|
182
|
+
const SLASH_PATH = 'M8.6 23.4 23.4 8.6'
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The two marks a guarded icon adds. Both are drawn only while the guard is on —
|
|
186
|
+
* that is the entire difference between the states, so it is the only thing these
|
|
187
|
+
* two components have to decide.
|
|
188
|
+
*/
|
|
189
|
+
function GuardMarks() {
|
|
190
|
+
return [
|
|
191
|
+
h('circle', { key: 'ring', cx: RING.cx, cy: RING.cy, r: RING.r, ...RING_STROKE }),
|
|
192
|
+
h('path', { key: 'slash', d: SLASH_PATH, ...SLASH_STROKE }),
|
|
193
|
+
]
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function IconBase(children) {
|
|
197
|
+
return h(
|
|
198
|
+
'svg',
|
|
199
|
+
{
|
|
200
|
+
viewBox: '0 0 32 32',
|
|
201
|
+
width: 32,
|
|
202
|
+
height: 32,
|
|
203
|
+
'aria-hidden': 'true',
|
|
204
|
+
focusable: 'false',
|
|
205
|
+
style: { display: 'block', width: '100%', height: '100%' },
|
|
206
|
+
},
|
|
207
|
+
children,
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The local repository: the database cylinder, ringed and struck while guarded. */
|
|
212
|
+
function LocalIcon(props) {
|
|
213
|
+
return IconBase([
|
|
214
|
+
h('path', { key: 'body', d: CYLINDER_OUTLINE, ...BODY_STROKE }),
|
|
215
|
+
h('ellipse', { key: 'lid', cx: CYLINDER_LID.cx, cy: CYLINDER_LID.cy, rx: CYLINDER_LID.rx, ry: CYLINDER_LID.ry, ...BODY_STROKE }),
|
|
216
|
+
...CYLINDER_BANDS.map((d, index) => h('path', { key: 'band' + index, d, ...BODY_STROKE })),
|
|
217
|
+
...(props !== null && props !== undefined && props.on === true ? GuardMarks() : []),
|
|
218
|
+
])
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The remote repository: the GitHub mark, ringed and struck while guarded. */
|
|
222
|
+
function RemoteIcon(props) {
|
|
223
|
+
return IconBase([
|
|
224
|
+
h('g', { key: 'mark', transform: 'translate(' + MARK_OFFSET + ' ' + MARK_OFFSET + ') scale(' + MARK_SCALE + ')' }, [
|
|
225
|
+
h('path', { key: 'path', d: MARK_PATH, fill: 'currentColor' }),
|
|
226
|
+
]),
|
|
227
|
+
...(props !== null && props !== undefined && props.on === true ? GuardMarks() : []),
|
|
228
|
+
])
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/* ───────────────────────────── stylesheet ──────────────────────────── */
|
|
232
|
+
|
|
233
|
+
/*
|
|
234
|
+
* The ground carries the state, and the ground is the host's own control wash.
|
|
235
|
+
*
|
|
236
|
+
* A switch that is *off* has no ground at all: an icon sitting in the header, the
|
|
237
|
+
* way the chrome's bare glyph buttons sit there. A switch that is *on* wears
|
|
238
|
+
* `--dsw-alias-interactive-bg-hover` — the fill DSH's segmented tracks use, which
|
|
239
|
+
* the host's own stylesheet describes as "the same translucent fill the hover
|
|
240
|
+
* state uses, so it reads as a place rather than a second button". So "on" reads
|
|
241
|
+
* as a pressed, filled chip and "off" reads as plain chrome, which is the reading
|
|
242
|
+
* a pair of guards should have.
|
|
243
|
+
*
|
|
244
|
+
* Hover steps one notch up the host's ladder to `--dsw-alias-interactive-bg-active`
|
|
245
|
+
* — the same family, one shade stronger — so the pointer gets an answer whatever
|
|
246
|
+
* the state, without a second colour being invented and without the heavy
|
|
247
|
+
* `--dsw-alias-button-tool-bar-hover` (a 60% grey that is right for a toolbar at
|
|
248
|
+
* the edge of a panel and far too strong for a header).
|
|
249
|
+
*
|
|
250
|
+
* Every token here is the host's; `evidence/selftest.mjs` asserts both that this
|
|
251
|
+
* stylesheet still names them and that the theme really defines each one — an
|
|
252
|
+
* alias that resolves to nothing is exactly how a button ends up with the wrong
|
|
253
|
+
* colour, which is how this rule arrived at its present shape.
|
|
254
|
+
*
|
|
255
|
+
* The icon keeps the host's own icon grey in **both** states —
|
|
256
|
+
* `--dsw-alias-label-secondary`, the `#545557` the chrome's panel toggles are drawn
|
|
257
|
+
* in. An earlier revision stepped the guarded icon up to
|
|
258
|
+
* `--dsw-alias-label-primary`, which the light theme resolves to `#0f1115`: a
|
|
259
|
+
* near-black that made the switch look like a foreign control sitting among the
|
|
260
|
+
* window buttons. The state is not told by weight at all — the ring and the stroke
|
|
261
|
+
* say it, and the ground says it a second time.
|
|
262
|
+
*/
|
|
263
|
+
const CSS = `
|
|
264
|
+
.dsh-gg-root{display:inline-flex;align-items:center;gap:2px}
|
|
265
|
+
/* The group's own left edge. It sits in the header's right-aligned utilities list,
|
|
266
|
+
immediately left of the window controls, so the rule separates the guards from
|
|
267
|
+
whatever else the utilities hold — and moves with them when they change. The token
|
|
268
|
+
is the same hairline the header itself draws its bottom border with. */
|
|
269
|
+
.dsh-gg-rule{flex:none;width:1px;height:16px;margin:0 6px;background:var(--dsw-alias-border-l3)}
|
|
270
|
+
.dsh-gg-btn{
|
|
271
|
+
position:relative;
|
|
272
|
+
box-sizing:border-box;
|
|
273
|
+
display:inline-flex;align-items:center;justify-content:center;
|
|
274
|
+
flex:none;
|
|
275
|
+
width:28px;height:28px;
|
|
276
|
+
padding:0;
|
|
277
|
+
border:none;
|
|
278
|
+
border-radius:var(--dsw-radius-sm);
|
|
279
|
+
background:0 0;
|
|
280
|
+
color:var(--dsw-alias-label-secondary);
|
|
281
|
+
cursor:pointer;
|
|
282
|
+
-webkit-appearance:none;appearance:none;
|
|
283
|
+
transition:background-color .15s var(--ds-ease-in-out, ease), color .15s var(--ds-ease-in-out, ease);
|
|
284
|
+
}
|
|
285
|
+
/* Off is bare chrome; on adds the host's control wash. The icon stays the host's icon
|
|
286
|
+
grey either way — the ring and the stroke are what say "guarded". */
|
|
287
|
+
.dsh-gg-btn[data-on="1"]{background:var(--dsw-alias-interactive-bg-hover)}
|
|
288
|
+
.dsh-gg-btn:hover{background:var(--dsw-alias-interactive-bg-active)}
|
|
289
|
+
.dsh-gg-btn:active{background:var(--dsw-alias-interactive-bg-active)}
|
|
290
|
+
.dsh-gg-btn:focus-visible{
|
|
291
|
+
outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));
|
|
292
|
+
outline-offset:-2px;
|
|
293
|
+
}
|
|
294
|
+
.dsh-gg-btn:disabled{cursor:default;opacity:.45}
|
|
295
|
+
/* 26px of the host's 28px box: the icon is the button's whole content, so it takes
|
|
296
|
+
the box in full and leaves only the 1px hairline that keeps the ring off the
|
|
297
|
+
rounded edge. The box itself, its radius and its hover ground are the host's, and
|
|
298
|
+
the drawing keeps the rest of its margin inside its own 32-unit grid. */
|
|
299
|
+
.dsh-gg-icon{display:block;width:26px;height:26px}
|
|
300
|
+
.dsh-gg-note{
|
|
301
|
+
position:absolute;top:calc(100% + 6px);right:0;
|
|
302
|
+
z-index:40;
|
|
303
|
+
max-width:220px;
|
|
304
|
+
padding:5px 8px;
|
|
305
|
+
border-radius:var(--dsw-radius-sm);
|
|
306
|
+
background:var(--dsw-alias-bg-overlay);
|
|
307
|
+
color:var(--dsw-alias-label-primary);
|
|
308
|
+
font-size:12px;line-height:1.35;white-space:nowrap;
|
|
309
|
+
box-shadow:0 6px 18px rgba(0,0,0,.28);
|
|
310
|
+
pointer-events:none;
|
|
311
|
+
}
|
|
312
|
+
`
|
|
313
|
+
|
|
314
|
+
function insertStyles(css) {
|
|
315
|
+
try {
|
|
316
|
+
const builtin = typeof window === 'undefined' ? undefined : window.styles
|
|
317
|
+
if (builtin !== undefined && typeof builtin.insert === 'function') return builtin.insert(css)
|
|
318
|
+
} catch (error) {
|
|
319
|
+
console.error('[' + NS + '] styles builtin failed', error)
|
|
320
|
+
}
|
|
321
|
+
const tag = document.createElement('style')
|
|
322
|
+
tag.setAttribute('data-dsh-gg', 'style')
|
|
323
|
+
tag.textContent = css
|
|
324
|
+
document.head.appendChild(tag)
|
|
325
|
+
return () => {
|
|
326
|
+
tag.remove()
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/* ─────────────────────────────── state ─────────────────────────────── */
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Coerce a value into a drawable pair. This is type repair only: the pair's two
|
|
334
|
+
* behavioural rules are properties of the *gestures* that produce them and live
|
|
335
|
+
* in `nextState`, never here. Putting such a rule here made it an invariant of
|
|
336
|
+
* every state instead, and an invariant of that shape locks a switch on for as
|
|
337
|
+
* long as its partner is off — the local switch could not be turned off again.
|
|
338
|
+
*/
|
|
339
|
+
function normalize(value) {
|
|
340
|
+
const source = value !== null && typeof value === 'object' ? value : {}
|
|
341
|
+
return { local: source.local !== false, remote: source.remote !== false }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The pair's behaviour is exactly two clicks, and this is both of them. The Host
|
|
346
|
+
* half carries a byte-identical copy — the two halves are served to different
|
|
347
|
+
* runtimes and cannot import each other — and `evidence/selftest.mjs` asserts the
|
|
348
|
+
* two spellings have not drifted apart.
|
|
349
|
+
*
|
|
350
|
+
* @param {{ local: boolean, remote: boolean }} state - the pair as it stands.
|
|
351
|
+
* @param {'local' | 'remote'} clicked - which switch the reader pressed.
|
|
352
|
+
* @returns {{ local: boolean, remote: boolean }} the pair after that click.
|
|
353
|
+
*/
|
|
354
|
+
function nextState(state, clicked) {
|
|
355
|
+
if (clicked === 'local') {
|
|
356
|
+
const local = state.local !== true
|
|
357
|
+
/* Landing ON turns the remote guard on too; landing OFF leaves it exactly as it was. */
|
|
358
|
+
return { local, remote: local === true ? true : state.remote === true }
|
|
359
|
+
}
|
|
360
|
+
const remote = state.remote !== true
|
|
361
|
+
/* Releasing the upload guard releases the local one with it; asking for it moves nothing. */
|
|
362
|
+
return { local: remote === false ? false : state.local === true, remote }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function same(a, b) {
|
|
366
|
+
return a.local === b.local && a.remote === b.remote
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function stateUrl(sessionId) {
|
|
370
|
+
return ROUTE + '?session=' + encodeURIComponent(sessionId === undefined || sessionId === null ? '' : String(sessionId))
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** One request that always settles: the draw must never wait on the Host. */
|
|
374
|
+
async function request(url, init) {
|
|
375
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null
|
|
376
|
+
const timer = controller === null ? null : window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
|
|
377
|
+
try {
|
|
378
|
+
const response = await window.fetch(url, {
|
|
379
|
+
...init,
|
|
380
|
+
...(controller === null ? {} : { signal: controller.signal }),
|
|
381
|
+
credentials: 'same-origin',
|
|
382
|
+
})
|
|
383
|
+
if (response.ok !== true) throw new Error('HTTP ' + response.status)
|
|
384
|
+
return await response.json()
|
|
385
|
+
} finally {
|
|
386
|
+
if (timer !== null) window.clearTimeout(timer)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function readState(sessionId) {
|
|
391
|
+
const payload = await request(stateUrl(sessionId), { method: 'GET' })
|
|
392
|
+
if (payload === null || payload.ok !== true) throw new Error('unexpected payload')
|
|
393
|
+
return normalize(payload.state)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function writeState(sessionId, state) {
|
|
397
|
+
const payload = await request(stateUrl(sessionId), {
|
|
398
|
+
method: 'POST',
|
|
399
|
+
headers: { 'content-type': 'application/json' },
|
|
400
|
+
body: JSON.stringify({ state }),
|
|
401
|
+
})
|
|
402
|
+
if (payload === null || payload.ok !== true) throw new Error('unexpected payload')
|
|
403
|
+
return normalize(payload.state)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/* ───────────────────────────── component ───────────────────────────── */
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* One square switch. `aria-pressed` carries the state for a screen reader and
|
|
410
|
+
* `data-on` carries it for the stylesheet, so neither has to be inferred from
|
|
411
|
+
* the colours — which is what lets the same two colours mean two states.
|
|
412
|
+
*
|
|
413
|
+
* There is no disabled arm: every state this pair can be in is reachable by a
|
|
414
|
+
* click, so a button that refuses clicks would only ever be a lie.
|
|
415
|
+
*/
|
|
416
|
+
function Switch(props) {
|
|
417
|
+
const { on, title, onClick, children } = props
|
|
418
|
+
return h(
|
|
419
|
+
'button',
|
|
420
|
+
{
|
|
421
|
+
type: 'button',
|
|
422
|
+
className: 'dsh-gg-btn',
|
|
423
|
+
'data-on': on === true ? '1' : '0',
|
|
424
|
+
'data-dsh-gg': props.mark,
|
|
425
|
+
title,
|
|
426
|
+
'aria-label': props.ariaLabel ?? title,
|
|
427
|
+
'aria-pressed': on === true ? 'true' : 'false',
|
|
428
|
+
onClick,
|
|
429
|
+
},
|
|
430
|
+
h('span', { className: 'dsh-gg-icon' }, children),
|
|
431
|
+
)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function HookBadge(props) {
|
|
435
|
+
const { sessionId, t } = props
|
|
436
|
+
const [state, setState] = useState(DEFAULT_STATE)
|
|
437
|
+
const [failed, setFailed] = useState(false)
|
|
438
|
+
const live = useRef(true)
|
|
439
|
+
|
|
440
|
+
useEffect(() => {
|
|
441
|
+
live.current = true
|
|
442
|
+
return () => {
|
|
443
|
+
live.current = false
|
|
444
|
+
}
|
|
445
|
+
}, [])
|
|
446
|
+
|
|
447
|
+
/*
|
|
448
|
+
* Read once per Session, and keep reading slowly: a second window, or this
|
|
449
|
+
* one reloaded while the Host kept its own copy, has to converge on the Host
|
|
450
|
+
* rather than on whichever tab drew last.
|
|
451
|
+
*/
|
|
452
|
+
useEffect(() => {
|
|
453
|
+
let cancelled = false
|
|
454
|
+
const pull = async () => {
|
|
455
|
+
try {
|
|
456
|
+
const next = await readState(sessionId)
|
|
457
|
+
if (cancelled === true || live.current === false) return
|
|
458
|
+
setFailed(false)
|
|
459
|
+
setState((current) => (same(current, next) === true ? current : next))
|
|
460
|
+
} catch {
|
|
461
|
+
/* A Host that is not answering is not a reason to draw the wrong state. */
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
void pull()
|
|
465
|
+
const timer = window.setInterval(() => void pull(), RECONCILE_MS)
|
|
466
|
+
return () => {
|
|
467
|
+
cancelled = true
|
|
468
|
+
window.clearInterval(timer)
|
|
469
|
+
}
|
|
470
|
+
}, [sessionId])
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Draw the request first, then ask: a switch that waits for a round trip
|
|
474
|
+
* before moving feels broken, and the Host's answer is the only thing that
|
|
475
|
+
* can turn the draw back.
|
|
476
|
+
*/
|
|
477
|
+
const apply = useCallback(
|
|
478
|
+
(next) => {
|
|
479
|
+
const wanted = normalize(next)
|
|
480
|
+
setState(wanted)
|
|
481
|
+
void (async () => {
|
|
482
|
+
try {
|
|
483
|
+
const confirmed = await writeState(sessionId, wanted)
|
|
484
|
+
if (live.current === false) return
|
|
485
|
+
setFailed(false)
|
|
486
|
+
setState(confirmed)
|
|
487
|
+
} catch {
|
|
488
|
+
if (live.current === false) return
|
|
489
|
+
setFailed(true)
|
|
490
|
+
/* Put the pair back: an unconfirmed switch must not claim to guard. */
|
|
491
|
+
try {
|
|
492
|
+
const current = await readState(sessionId)
|
|
493
|
+
if (live.current === true) setState(current)
|
|
494
|
+
} catch {
|
|
495
|
+
if (live.current === true) setState(DEFAULT_STATE)
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
})()
|
|
499
|
+
},
|
|
500
|
+
[sessionId],
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
/*
|
|
504
|
+
* The pair's whole behaviour is `nextState` above — two clicks, no invariants.
|
|
505
|
+
* Both switches stay clickable in every state, so the reader can always reach
|
|
506
|
+
* the combination they want.
|
|
507
|
+
*/
|
|
508
|
+
const toggleLocal = useCallback(() => {
|
|
509
|
+
apply(nextState(state, 'local'))
|
|
510
|
+
}, [apply, state])
|
|
511
|
+
|
|
512
|
+
const toggleRemote = useCallback(() => {
|
|
513
|
+
apply(nextState(state, 'remote'))
|
|
514
|
+
}, [apply, state])
|
|
515
|
+
|
|
516
|
+
const localTitle = state.local === true ? t('local.on') : t('local.off')
|
|
517
|
+
const remoteTitle = state.remote === true ? t('remote.on') : t('remote.off')
|
|
518
|
+
|
|
519
|
+
return h(
|
|
520
|
+
'div',
|
|
521
|
+
{ className: 'dsh-gg-root', 'data-dsh-gg': 'root', 'data-session': sessionId ?? '', 'data-version': VERSION },
|
|
522
|
+
/* The rule comes first: the group's left edge, and the only thing in this
|
|
523
|
+
entry that is not a switch. */
|
|
524
|
+
h('span', { className: 'dsh-gg-rule', 'aria-hidden': 'true' }),
|
|
525
|
+
h(
|
|
526
|
+
Switch,
|
|
527
|
+
{
|
|
528
|
+
mark: 'local',
|
|
529
|
+
on: state.local === true,
|
|
530
|
+
title: localTitle,
|
|
531
|
+
ariaLabel: t('local.label'),
|
|
532
|
+
onClick: toggleLocal,
|
|
533
|
+
},
|
|
534
|
+
/*
|
|
535
|
+
* The state travels into the icon: the ring and the stroke ARE the switch's
|
|
536
|
+
* on state, so `on` is not decoration here, it is the whole reading.
|
|
537
|
+
*/
|
|
538
|
+
h(LocalIcon, { on: state.local === true }),
|
|
539
|
+
),
|
|
540
|
+
h(
|
|
541
|
+
Switch,
|
|
542
|
+
{
|
|
543
|
+
mark: 'remote',
|
|
544
|
+
on: state.remote === true,
|
|
545
|
+
title: remoteTitle,
|
|
546
|
+
ariaLabel: t('remote.label'),
|
|
547
|
+
onClick: toggleRemote,
|
|
548
|
+
},
|
|
549
|
+
h(RemoteIcon, { on: state.remote === true }),
|
|
550
|
+
),
|
|
551
|
+
failed === true ? h('div', { className: 'dsh-gg-note', role: 'status' }, t('failed')) : null,
|
|
552
|
+
)
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/* ─────────────────────────────── runtime ───────────────────────────── */
|
|
556
|
+
|
|
557
|
+
const INJECT = ['slots', 'locale']
|
|
558
|
+
|
|
559
|
+
/** Register or re-register the header action; the registry owns the disposal. */
|
|
560
|
+
let disposeEntry = null
|
|
561
|
+
let entryLive = false
|
|
562
|
+
|
|
563
|
+
function openEntry(ctx) {
|
|
564
|
+
disposeEntry = ctx.slots.register(
|
|
565
|
+
{
|
|
566
|
+
name: 'conversation.session.header.utilities',
|
|
567
|
+
id: NS,
|
|
568
|
+
/*
|
|
569
|
+
* The utilities list is right-aligned inside the header's title row, so
|
|
570
|
+
* membership of it — not a position inside the title — is what puts the
|
|
571
|
+
* pair beside the window controls. A high order keeps it at that end as
|
|
572
|
+
* other entries are added: the shipped open-in-app and session-log
|
|
573
|
+
* controls register at -10 and 0, the sidebar toggle at 10, and the list
|
|
574
|
+
* is sorted ascending, so 100 is past them and stays past them.
|
|
575
|
+
*/
|
|
576
|
+
order: 100,
|
|
577
|
+
locale: LOCALE_NS,
|
|
578
|
+
inject: () => ({}),
|
|
579
|
+
},
|
|
580
|
+
HookBadge,
|
|
581
|
+
)
|
|
582
|
+
entryLive = true
|
|
583
|
+
return disposeEntry
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return {
|
|
587
|
+
inject: INJECT,
|
|
588
|
+
apply(ctx) {
|
|
589
|
+
ctx.effect(() => insertStyles(CSS), NS + ':styles')
|
|
590
|
+
try {
|
|
591
|
+
ctx.locale.register(LOCALE_NS, { zh: ZH, en: EN })
|
|
592
|
+
} catch (error) {
|
|
593
|
+
/*
|
|
594
|
+
* A dictionary is presentation: if the locale service refuses it the pair
|
|
595
|
+
* still works, it just falls back to the names below. This must never be
|
|
596
|
+
* the reason the plugin fails to load.
|
|
597
|
+
*/
|
|
598
|
+
console.error('[' + NS + '] locale registration failed', error)
|
|
599
|
+
}
|
|
600
|
+
/*
|
|
601
|
+
* The shell declares this Slot while the conversation panel boots, so the
|
|
602
|
+
* injection is retried a few times: a first attempt that lands too early
|
|
603
|
+
* must not cost the reader the pair for the whole session.
|
|
604
|
+
*/
|
|
605
|
+
let attempts = 0
|
|
606
|
+
const timers = new Set()
|
|
607
|
+
const injectEntry = () => {
|
|
608
|
+
try {
|
|
609
|
+
ctx.slots.inject('conversation.session.header.utilities', () => {
|
|
610
|
+
try {
|
|
611
|
+
openEntry(ctx)
|
|
612
|
+
return () => {
|
|
613
|
+
entryLive = false
|
|
614
|
+
if (disposeEntry !== null) disposeEntry()
|
|
615
|
+
disposeEntry = null
|
|
616
|
+
}
|
|
617
|
+
} catch (error) {
|
|
618
|
+
console.error('[' + NS + '] register failed', error)
|
|
619
|
+
throw error
|
|
620
|
+
}
|
|
621
|
+
})
|
|
622
|
+
} catch (error) {
|
|
623
|
+
attempts += 1
|
|
624
|
+
console.error('[' + NS + '] inject failed (attempt ' + attempts + ')', error)
|
|
625
|
+
if (attempts >= 3) return
|
|
626
|
+
const timer = window.setTimeout(() => {
|
|
627
|
+
timers.delete(timer)
|
|
628
|
+
injectEntry()
|
|
629
|
+
}, 400 * attempts)
|
|
630
|
+
timers.add(timer)
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
injectEntry()
|
|
634
|
+
ctx.effect(
|
|
635
|
+
() => () => {
|
|
636
|
+
for (const timer of timers) window.clearTimeout(timer)
|
|
637
|
+
timers.clear()
|
|
638
|
+
if (entryLive === true && disposeEntry !== null) disposeEntry()
|
|
639
|
+
},
|
|
640
|
+
NS + ':entry',
|
|
641
|
+
)
|
|
642
|
+
},
|
|
643
|
+
}
|
|
644
|
+
},
|
|
645
|
+
})
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Bundle layer for @yokira404/dsh-git-guard.
|
|
2
|
+
#
|
|
3
|
+
# Listing this package in the profile's dsh.profile.bundles makes DSH read this
|
|
4
|
+
# patch and insert the row below, which loads the Host half — the half that owns
|
|
5
|
+
# the `tools/pre-execute` rule and the two HTTP routes the switches write to. The
|
|
6
|
+
# Host half also makes `dsh.client` visible to the client-modules scan, which
|
|
7
|
+
# serves ./client.js to the browser, where the pair is drawn.
|
|
8
|
+
- insert:
|
|
9
|
+
- id: git-guard
|
|
10
|
+
name: '@yokira404/dsh-git-guard'
|
|
11
|
+
config: {}
|