dsh-zen-remote 2.3.5 → 2.3.7
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 -5
- package/CHANGELOG.md +71 -0
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/docs/interface.md +12 -6
- package/docs/plugin-bridge.md +2 -2
- package/docs/remote-access.en.md +9 -3
- package/docs/remote-access.md +9 -3
- package/lan-gate.mjs +7 -0
- package/lib/adapters/dsh-better-sidebar/index.js +31 -2
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
- package/lib/client-pairing.js +10 -0
- package/lib/client-pairing.js.map +1 -1
- package/lib/client-routes.js +183 -94
- package/lib/client-routes.js.map +1 -1
- package/lib/client.js +589 -97
- package/lib/debug-redact.js +253 -0
- package/lib/debug-redact.js.map +1 -0
- package/lib/debug-report.js +60 -0
- package/lib/debug-report.js.map +1 -0
- package/lib/fetch-route-intercept.js +23 -0
- package/lib/fetch-route-intercept.js.map +1 -1
- package/lib/index.js +306 -45
- package/lib/index.js.map +1 -1
- package/lib/intercept.js +43 -1
- package/lib/intercept.js.map +1 -1
- package/lib/log-ring.js +76 -0
- package/lib/log-ring.js.map +1 -0
- package/lib/plugin-adapter-core.js +30 -1
- package/lib/plugin-adapter-core.js.map +1 -1
- package/lib/plugin-route-intercept.js +506 -11
- package/lib/plugin-route-intercept.js.map +1 -1
- package/lib/plugin-tunnel.js +210 -18
- package/lib/plugin-tunnel.js.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +5 -2
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
- package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
- package/lib/types/client/effects/remote-drop.d.ts +20 -8
- package/lib/types/client/effects/remote-drop.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +10 -1
- 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/content.css.d.ts +1 -1
- package/lib/types/client/styles/content.css.d.ts.map +1 -1
- package/lib/types/client/styles/layout.css.d.ts +1 -1
- package/lib/types/client/styles/layout.css.d.ts.map +1 -1
- package/lib/types/client/styles/misc.css.d.ts +1 -1
- package/lib/types/client/styles/misc.css.d.ts.map +1 -1
- package/lib/types/client-data/remote-drop.d.ts +64 -0
- package/lib/types/client-data/remote-drop.d.ts.map +1 -1
- package/lib/types/client-data/settings-form.d.ts +3 -0
- package/lib/types/client-data/settings-form.d.ts.map +1 -1
- package/lib/types/client-pairing.d.ts +8 -0
- package/lib/types/client-pairing.d.ts.map +1 -1
- package/lib/types/client-routes.d.ts +23 -0
- package/lib/types/client-routes.d.ts.map +1 -1
- package/lib/types/debug-redact.d.ts +68 -0
- package/lib/types/debug-redact.d.ts.map +1 -0
- package/lib/types/debug-report.d.ts +58 -0
- package/lib/types/debug-report.d.ts.map +1 -0
- package/lib/types/fetch-route-intercept.d.ts +26 -0
- package/lib/types/fetch-route-intercept.d.ts.map +1 -1
- package/lib/types/index.d.ts +7 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept.d.ts +6 -0
- package/lib/types/intercept.d.ts.map +1 -1
- package/lib/types/log-ring.d.ts +57 -0
- package/lib/types/log-ring.d.ts.map +1 -0
- package/lib/types/plugin-adapter-core.d.ts.map +1 -1
- package/lib/types/plugin-route-intercept.d.ts +8 -0
- package/lib/types/plugin-route-intercept.d.ts.map +1 -1
- package/lib/types/plugin-tunnel.d.ts +37 -2
- package/lib/types/plugin-tunnel.d.ts.map +1 -1
- package/package.json +3 -2
- package/pwa/app.css +13 -2
- package/scripts/check-debug-report.mjs +277 -0
- package/scripts/check-info-sheet-stacking.mjs +85 -0
- package/scripts/check-remote-drop.mjs +130 -0
- package/scripts/check-tablet-safe-area.mjs +49 -0
- package/src/adapters/dsh-better-sidebar/index.ts +31 -2
- package/src/client/MobileSessionInfo.tsx +29 -2
- package/src/client/effects/remote-drop.ts +271 -24
- package/src/client/locales.ts +24 -4
- package/src/client/settings/SettingsSection.tsx +120 -3
- package/src/client/styles/content.css.ts +44 -14
- package/src/client/styles/layout.css.ts +9 -3
- package/src/client/styles/misc.css.ts +38 -0
- package/src/client-data/remote-drop.ts +103 -0
- package/src/client-data/settings-form.ts +3 -0
- package/src/client-pairing.ts +11 -0
- package/src/client-routes.ts +196 -90
- package/src/debug-redact.ts +290 -0
- package/src/debug-report.ts +93 -0
- package/src/fetch-route-intercept.ts +29 -0
- package/src/index.ts +295 -43
- package/src/intercept.ts +46 -0
- package/src/log-ring.ts +101 -0
- package/src/plugin-adapter-core.ts +29 -2
- package/src/plugin-route-intercept.ts +516 -12
- package/src/plugin-tunnel.ts +230 -20
package/pwa/app.css
CHANGED
|
@@ -45,9 +45,20 @@ html:not([data-lan-device="desktop"]) {
|
|
|
45
45
|
* trigger-happy and disorienting: any accidental overscroll reloaded the
|
|
46
46
|
* page and dropped them back to the conversation list mid-chat). This rule
|
|
47
47
|
* stands on its own now — it just stops the browser's native gesture from
|
|
48
|
-
* firing an unwanted reload inside the standalone PWA.
|
|
48
|
+
* firing an unwanted reload inside the standalone PWA.
|
|
49
|
+
*
|
|
50
|
+
* html AND body, both axes (2026-11-02): the viewport's overscroll behavior
|
|
51
|
+
* propagates from html per spec, but engines have historically read body —
|
|
52
|
+
* the same reason the phone shell (styles/home.css.ts) sets it on both. The
|
|
53
|
+
* old body-only form left the VIEWPORT-level rubber band alive at tablet
|
|
54
|
+
* widths (<768px is covered again by home.css with !important; 768px+ and
|
|
55
|
+
* the >=1024px standalone PWA had nothing), so a tablet PWA could be dragged
|
|
56
|
+
* up/down and bounced back. overscroll-behavior (not -y) also absorbs the
|
|
57
|
+
* sideways bounce; inner scrollers are untouched — they keep their own
|
|
58
|
+
* scrolling, only their edge-overscroll no longer chains into the document. */
|
|
59
|
+
html:not([data-lan-device="desktop"]),
|
|
49
60
|
html:not([data-lan-device="desktop"]) body {
|
|
50
|
-
overscroll-behavior
|
|
61
|
+
overscroll-behavior: none;
|
|
51
62
|
}
|
|
52
63
|
|
|
53
64
|
/* Apply the pinch-to-resize font scale. touch-gestures.js sets
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// Behaviour check for the debug report's redactor and assembler
|
|
2
|
+
// (src/debug-redact.ts + src/debug-report.ts, CU-2): the redaction truth
|
|
3
|
+
// table (home fold, absolute paths to their leaf, virtual-id truncation,
|
|
4
|
+
// token runs, URL host classes), the serverUrl shaper, the report's plate
|
|
5
|
+
// structure, and the constructive-exclusion assertion — an input poisoned
|
|
6
|
+
// with paths, hostnames and tokens must come out with NONE of them
|
|
7
|
+
// recoverable from the serialized report.
|
|
8
|
+
//
|
|
9
|
+
// Run: node scripts/check-debug-report.mjs (needs Node >= 23.6 type stripping)
|
|
10
|
+
import assert from 'node:assert/strict'
|
|
11
|
+
import { existsSync } from 'node:fs'
|
|
12
|
+
import { registerHooks } from 'node:module'
|
|
13
|
+
import { fileURLToPath } from 'node:url'
|
|
14
|
+
|
|
15
|
+
// The host half's sources import each other with `.js` specifiers (the tsc
|
|
16
|
+
// emit convention). Under strip-only type stripping those specifiers do not
|
|
17
|
+
// resolve against the .ts sources, so this check registers one synchronous
|
|
18
|
+
// resolve hook: for a relative `.js` specifier that misses, retry the same
|
|
19
|
+
// specifier with `.ts` when that file exists. Nothing else is rewritten.
|
|
20
|
+
// Registered before the dynamic imports below — static imports would be
|
|
21
|
+
// resolved before any module body runs.
|
|
22
|
+
registerHooks({
|
|
23
|
+
resolve(specifier, context, nextResolve) {
|
|
24
|
+
try {
|
|
25
|
+
return nextResolve(specifier, context)
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (
|
|
28
|
+
(specifier.startsWith('./') || specifier.startsWith('../'))
|
|
29
|
+
&& specifier.endsWith('.js')
|
|
30
|
+
&& typeof context.parentURL === 'string'
|
|
31
|
+
) {
|
|
32
|
+
const candidate = new URL(`${specifier.slice(0, -3)}.ts`, context.parentURL)
|
|
33
|
+
if (existsSync(fileURLToPath(candidate))) {
|
|
34
|
+
return { url: candidate.href, format: 'module', shortCircuit: true }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
throw error
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const { bindHostShape, redactText, redactValue, serverUrlShape } = await import('../src/debug-redact.ts')
|
|
43
|
+
const { assembleDebugReport } = await import('../src/debug-report.ts')
|
|
44
|
+
|
|
45
|
+
const HOME_UNIX = '/Users/alice'
|
|
46
|
+
const HOME_WIN = 'C:\\Users\\bob'
|
|
47
|
+
const TOKEN_64 = 'a'.repeat(64)
|
|
48
|
+
const HEX_32 = '0123456789abcdef0123456789abcdef'
|
|
49
|
+
|
|
50
|
+
// ---- 1. redactText: home fold ------------------------------------------------
|
|
51
|
+
assert.equal(redactText('/Users/alice/work/x.txt', { home: HOME_UNIX }), '~/work/x.txt', 'a home path folds to ~ and keeps its relative tail')
|
|
52
|
+
assert.equal(redactText('/Users/alice', { home: HOME_UNIX }), '~', 'the bare home folds to ~')
|
|
53
|
+
assert.equal(redactText('/opt/data/other.txt', { home: HOME_UNIX }), '<路径:other.txt>', 'a non-home absolute path reduces to its leaf')
|
|
54
|
+
assert.equal(redactText('C:\\Users\\bob\\doc.txt', { home: HOME_WIN }), '~/doc.txt', 'a Windows home path folds to ~')
|
|
55
|
+
assert.equal(redactText('D:\\work\\notes.txt', { home: HOME_WIN }), '<路径:notes.txt>', 'a non-home Windows path reduces to its leaf')
|
|
56
|
+
|
|
57
|
+
// ---- 2. redactText: absolute paths ------------------------------------------
|
|
58
|
+
assert.equal(redactText('failed at /opt/data/secret/report.log today', { home: HOME_UNIX }), 'failed at <路径:report.log> today', 'the path keeps only its leaf, inline')
|
|
59
|
+
assert.ok(!redactText('F:\\Users\\name\\x.txt', {}).includes('Users'), 'the Windows path directory chain is gone')
|
|
60
|
+
assert.equal(redactText('a/b is relative', {}), 'a/b is relative', 'a relative word pair stays untouched')
|
|
61
|
+
|
|
62
|
+
// ---- 3. redactText: virtual ids ----------------------------------------------
|
|
63
|
+
assert.equal(
|
|
64
|
+
redactText('closed zr~0123abcd~session-9f8e7d6c5b4a3210 gracefully', {}),
|
|
65
|
+
'closed zr~0123abcd~9f8e7d6c gracefully',
|
|
66
|
+
'a virtual id keeps the server id and the session id head',
|
|
67
|
+
)
|
|
68
|
+
assert.ok(!redactText('zr~0123abcd~session-9f8e7d6c5b4a3210', {}).includes('9f8e7d6c5b4a3210'), 'the full virtual id is not recoverable')
|
|
69
|
+
|
|
70
|
+
// ---- 4. redactText: token runs -----------------------------------------------
|
|
71
|
+
// `=` is in the run class (base64 padding), so a `token=<64 chars>` blob is
|
|
72
|
+
// ONE run and dies whole — the label going with it is fine for redaction.
|
|
73
|
+
assert.equal(redactText(`token=${TOKEN_64}`, {}), '<token>', 'a 64-char secret run becomes <token>, label included')
|
|
74
|
+
assert.equal(redactText(`hex ${HEX_32} embedded`, {}), 'hex <token> embedded', 'a 32-char hex run becomes <token>')
|
|
75
|
+
assert.ok(!redactText(`keep ${'short'} and ${'also-short'}`, {}).includes('<token>'), 'short words never trip the token pass')
|
|
76
|
+
|
|
77
|
+
// ---- 5. redactText: URL hosts -------------------------------------------------
|
|
78
|
+
assert.equal(redactText('probe http://127.0.0.1:3080/api failed', {}), 'probe http://loopback:3080/api failed', 'a loopback host degrades to its class')
|
|
79
|
+
assert.equal(redactText('probe http://192.168.1.10:3080 failed', {}), 'probe http://lan-ip:3080 failed', 'a LAN host degrades to its class')
|
|
80
|
+
assert.equal(redactText('probe https://myserver.example.com/x failed', {}), 'probe https://domain/x failed', 'a domain host degrades to its class')
|
|
81
|
+
assert.equal(redactText('probe https://1.2.3.4:8443/x failed', {}), 'probe https://public-ip:8443/x failed', 'a public IP degrades to its class')
|
|
82
|
+
assert.equal(redactText('probe http://[::1]:3080/x failed', {}), 'probe http://loopback:3080/x failed', 'a bracketed IPv6 loopback degrades to its class')
|
|
83
|
+
|
|
84
|
+
// ---- 6. redactText: a realistic free-text lastError ---------------------------
|
|
85
|
+
// A home-nested path folds to `~` (the one shape kept); a path elsewhere
|
|
86
|
+
// reduces to its leaf; the embedded URL keeps only its address class.
|
|
87
|
+
const lastError = 'relay connect via http://10.0.0.5:8787 failed; dumped body to /var/tmp/secret/x.bin'
|
|
88
|
+
const redactedError = redactText(lastError, { home: '/home/alice' })
|
|
89
|
+
assert.ok(!redactedError.includes('10.0.0.5'), 'the LAN IP is gone from the embedded URL')
|
|
90
|
+
assert.ok(!redactedError.includes('/var/tmp'), 'the injected non-home path is not recoverable')
|
|
91
|
+
assert.ok(redactedError.includes('http://lan-ip:8787'), 'the embedded URL keeps its shape')
|
|
92
|
+
assert.equal(redactedError.includes('<路径:x.bin>'), true, 'the embedded path keeps only its leaf')
|
|
93
|
+
assert.equal(redactText('dumped to /home/alice/secret/x.bin', { home: '/home/alice' }).includes('~/secret/x.bin'), true, 'a home-nested embedded path folds to ~')
|
|
94
|
+
|
|
95
|
+
// ---- 6b. CU-fix: the four review-reproduced breaches (inputs verbatim) -------
|
|
96
|
+
// C1-a: a credentialed URL — neither the password nor the real hostname may
|
|
97
|
+
// survive; the userinfo dies whole.
|
|
98
|
+
const credUrl = redactText('fetch https://alice:s3cretPassw0rd@nas-office.example.com:8443/share/x failed', {})
|
|
99
|
+
assert.equal(credUrl, 'fetch https://<token>@domain:8443/share/x failed', 'userinfo dropped whole, host degraded, port kept')
|
|
100
|
+
for (const leak of ['alice', 's3cretPassw0rd', 'nas-office', 'example.com']) {
|
|
101
|
+
assert.ok(!credUrl.includes(leak), `credentialed URL: "${leak}" is not recoverable`)
|
|
102
|
+
}
|
|
103
|
+
// C1-b: a Windows UNC path — server and share names never survive.
|
|
104
|
+
const unc = redactText('opened \\\\NAS-OFFICE\\data\\users\\kyomio\\secret.docx ok', {})
|
|
105
|
+
assert.equal(unc, 'opened <路径:secret.docx> ok', 'a UNC path keeps only its leaf')
|
|
106
|
+
for (const leak of ['NAS-OFFICE', 'data', 'kyomio']) assert.ok(!unc.includes(leak), `UNC: "${leak}" is not recoverable`)
|
|
107
|
+
assert.equal(redactText('share \\\\NAS-OFFICE\\data gone', {}), 'share <路径> gone', 'a bare UNC root keeps nothing')
|
|
108
|
+
assert.equal(redactText('\\\\NAS\\Shared Docs\\q3\\plan.xlsx failed', {}), '<路径:plan.xlsx> failed', 'inner UNC segments may hold spaces; the leaf may not')
|
|
109
|
+
// C1-c: ws:// / wss:// / ftp:// addresses degrade like http(s).
|
|
110
|
+
assert.equal(redactText('socket ws://192.168.1.10:3088/relay closed', {}), 'socket ws://lan-ip:3088/relay closed', 'ws:// host degrades')
|
|
111
|
+
assert.equal(redactText('socket wss://relay.example.com/v1 closed', {}), 'socket wss://domain/v1 closed', 'wss:// host degrades')
|
|
112
|
+
assert.equal(redactText('mirror ftp://files.example.com/pub', {}), 'mirror ftp://domain/pub', 'ftp:// host degrades')
|
|
113
|
+
// C1-d: a drive-letter leaf no longer swallows the sentence after it.
|
|
114
|
+
assert.equal(redactText('D:\\work\\x\\plan.txt failed', {}), '<路径:plan.txt> failed', 'the leaf stops at whitespace')
|
|
115
|
+
assert.equal(redactText('/opt/x/plan.txt failed', {}), '<路径:plan.txt> failed', 'the Unix leaf stops at whitespace too')
|
|
116
|
+
assert.equal(redactText('C:\\Program Files\\My App\\app.log rotated', {}), '<路径:app.log> rotated', 'inner Windows segments may hold spaces')
|
|
117
|
+
assert.equal(redactText('open https://domain/x', {}), 'open https://domain/x', 'a URL scheme colon is not a drive letter')
|
|
118
|
+
|
|
119
|
+
// ---- 6e. CU-fix2: the re-review's three breaches (N1/N2/N3) ------------------
|
|
120
|
+
// N1: a long URL path no longer merges with the port into one token run —
|
|
121
|
+
// the URL is lifted out before the token pass and restored degraded, port
|
|
122
|
+
// and path kept (the ws relay stream as the re-review reproduced it).
|
|
123
|
+
assert.equal(
|
|
124
|
+
redactText('ws://192.168.1.10:3080/_dsh/zen-remote/relay/v1/stream closed', {}),
|
|
125
|
+
'ws://lan-ip:3080/_dsh/zen-remote/relay/v1/stream closed',
|
|
126
|
+
'N1: the port and the path survive a long URL path',
|
|
127
|
+
)
|
|
128
|
+
// N1 (added by the main session): the lifted URL skips the text-wide token
|
|
129
|
+
// pass, so its query secrets are redacted on restore — the port stays, the
|
|
130
|
+
// device value dies whole, the key stays readable.
|
|
131
|
+
const DEVICE_HEX = 'f'.repeat(64)
|
|
132
|
+
const relayQuery = redactText(`ws://192.168.1.10:3080/_dsh/zen-remote/relay/v1/stream?device=${DEVICE_HEX}&v=2`, {})
|
|
133
|
+
assert.equal(relayQuery, 'ws://lan-ip:3080/_dsh/zen-remote/relay/v1/stream?device=<token>&v=2', 'N1: a long query value becomes <token>, port kept')
|
|
134
|
+
assert.ok(!relayQuery.includes(DEVICE_HEX.slice(0, 16)), 'N1: the query secret is not recoverable')
|
|
135
|
+
assert.equal(redactText(`https://h.example.com/share/${HEX_32}/x`, {}), 'https://domain/share/<token>/x', 'N1: a secret-shaped path segment dies, the rest of the path stays')
|
|
136
|
+
assert.equal(
|
|
137
|
+
redactText('see https://h.example.com/x?sessionId=zr~0123abcd~session-9f8e7d6c5b4a3210, then retry', {}),
|
|
138
|
+
'see https://domain/x?sessionId=zr~0123abcd~9f8e7d6c, then retry',
|
|
139
|
+
'N1: a virtual id in a query value truncates; trailing punctuation stays outside the URL',
|
|
140
|
+
)
|
|
141
|
+
assert.equal(redactText('raw \u0000zr-url:0\u0000 sentinel', {}), 'raw \uFFFDzr-url:0\uFFFD sentinel', 'N1: an input NUL can never forge a sentinel')
|
|
142
|
+
// N2: a password holding `/` is still userinfo — the boundary is the LAST
|
|
143
|
+
// `@` before `?`/`#`, so neither the password nor the host survives.
|
|
144
|
+
const slashPassword = redactText('https://bob:ab/cd@nas.example.com:8443/x', {})
|
|
145
|
+
assert.equal(slashPassword, 'https://<token>@domain:8443/x', 'N2: userinfo with a slash dies whole')
|
|
146
|
+
for (const leak of ['bob', 'ab/cd', 'nas.example.com', 'nas']) {
|
|
147
|
+
assert.ok(!slashPassword.includes(leak), `N2: "${leak}" is not recoverable`)
|
|
148
|
+
}
|
|
149
|
+
// N2's accepted side effect: a path that itself holds `@` loses everything
|
|
150
|
+
// before that `@` to `<token>@` — over-redaction, the safe direction.
|
|
151
|
+
assert.equal(
|
|
152
|
+
redactText('https://social.example.com/users/@alice/profile', {}),
|
|
153
|
+
'https://<token>@domain/profile',
|
|
154
|
+
'N2 side effect: a path `@` over-redacts (accepted)',
|
|
155
|
+
)
|
|
156
|
+
// N3: the home fold runs BEFORE the token pass — a long home path keeps its
|
|
157
|
+
// `~/…` tail instead of dying whole as one run.
|
|
158
|
+
assert.equal(
|
|
159
|
+
redactText('dump at /Volumes/MacData/Users/kyomio/Downloads/relay-dump.bin', { home: '/Volumes/MacData/Users/kyomio' }),
|
|
160
|
+
'dump at ~/Downloads/relay-dump.bin',
|
|
161
|
+
'N3: a long home path folds to ~/… and keeps its tail',
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
// ---- 6c. S1: base64url runs (a JWT) die whole --------------------------------
|
|
165
|
+
const JWT_PAYLOAD = 'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ'
|
|
166
|
+
const JWT_SIG = 'SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'
|
|
167
|
+
const jwt = redactText(`Bearer eyJhbGciOiJIUzI1NiJ9.${JWT_PAYLOAD}.${JWT_SIG}`, {})
|
|
168
|
+
assert.ok(!jwt.includes('adQssw5c'), 'the signature tail past "_" is not left behind')
|
|
169
|
+
assert.ok(!jwt.includes('SflKxwRJ'), 'the signature head is gone')
|
|
170
|
+
assert.ok(!jwt.includes(JWT_PAYLOAD.slice(0, 16)), 'the payload is gone')
|
|
171
|
+
assert.equal(redactText(`k=${'a'.repeat(20)}-${'b'.repeat(20)}`, {}), '<token>', 'a hyphen no longer splits a run below the threshold')
|
|
172
|
+
|
|
173
|
+
// ---- 6d. S3: the bind host's shape -------------------------------------------
|
|
174
|
+
assert.equal(bindHostShape('127.0.0.1'), 'loopback', 'loopback bind')
|
|
175
|
+
assert.equal(bindHostShape('0.0.0.0'), 'wildcard', 'all-interfaces bind')
|
|
176
|
+
assert.equal(bindHostShape('::'), 'wildcard', 'IPv6 all-interfaces bind')
|
|
177
|
+
assert.equal(bindHostShape('192.168.1.10'), 'lan-ip', 'a LAN bind keeps only its class')
|
|
178
|
+
assert.equal(bindHostShape(undefined), 'unset', 'no bind host')
|
|
179
|
+
|
|
180
|
+
// ---- 7. redactValue: deep pass -------------------------------------------------
|
|
181
|
+
const deep = redactValue({ a: { b: [`see ${TOKEN_64}`, 7, null, true] }, keep: 'plain' }, { home: HOME_UNIX })
|
|
182
|
+
assert.deepEqual(deep, { a: { b: ['see <token>', 7, null, true] }, keep: 'plain' }, 'strings redact at any depth; non-strings pass')
|
|
183
|
+
assert.ok(!('mutated' in ({})), 'sanity')
|
|
184
|
+
|
|
185
|
+
// ---- 8. serverUrlShape ---------------------------------------------------------
|
|
186
|
+
assert.equal(serverUrlShape('http://127.0.0.1:3080'), 'http://loopback:3080', 'loopback class')
|
|
187
|
+
assert.equal(serverUrlShape('http://localhost'), 'http://loopback', 'localhost class, no port')
|
|
188
|
+
assert.equal(serverUrlShape('http://192.168.1.10:3080'), 'http://lan-ip:3080', 'LAN class')
|
|
189
|
+
assert.equal(serverUrlShape('http://10.0.0.5'), 'http://lan-ip', 'LAN class, no port')
|
|
190
|
+
assert.equal(serverUrlShape('https://100.64.0.9'), 'https://lan-ip', 'CGNAT/Tailscale is LAN class')
|
|
191
|
+
assert.equal(serverUrlShape('https://1.2.3.4:8443'), 'https://public-ip:8443', 'public IP class')
|
|
192
|
+
assert.equal(serverUrlShape('https://myserver.example.com'), 'https://domain', 'domain class')
|
|
193
|
+
assert.equal(serverUrlShape('https://myserver.example.com:443'), 'https://domain', 'the scheme default port is omitted')
|
|
194
|
+
assert.equal(serverUrlShape('http://nas.local:3080'), 'http://domain:3080', 'an mDNS name is domain class')
|
|
195
|
+
assert.equal(serverUrlShape(''), 'unset', 'unset input')
|
|
196
|
+
assert.equal(serverUrlShape(undefined), 'unset', 'non-string input')
|
|
197
|
+
assert.equal(serverUrlShape('not a url'), 'invalid', 'unparsable input')
|
|
198
|
+
assert.ok(!serverUrlShape('https://my-nas-office.example.com:8443').includes('my-nas-office'), 'the hostname never survives the shaper')
|
|
199
|
+
|
|
200
|
+
// ---- 9. assembleDebugReport: plate structure -----------------------------------
|
|
201
|
+
const report = assembleDebugReport({
|
|
202
|
+
meta: {
|
|
203
|
+
generatedAt: '2026-11-01T12:00:00.000Z',
|
|
204
|
+
pluginVersion: '2.3.6',
|
|
205
|
+
dshVersion: '0.2.1-rc.5',
|
|
206
|
+
platform: 'darwin arm64',
|
|
207
|
+
role: 'client',
|
|
208
|
+
profileName: 'default',
|
|
209
|
+
},
|
|
210
|
+
configSummary: {
|
|
211
|
+
role: 'client',
|
|
212
|
+
host: '192.168.1.10',
|
|
213
|
+
port: 3080,
|
|
214
|
+
idleHours: 12,
|
|
215
|
+
maxUploadBytes: 52428800,
|
|
216
|
+
autoShareNewSessions: true,
|
|
217
|
+
pluginBridgeConsent: { 'dsh-better-sidebar': false },
|
|
218
|
+
// Poisoned extras: a secret-bearing field that construction should have
|
|
219
|
+
// kept out — and if it ever sneaks back in, the deep redactor catches it.
|
|
220
|
+
deviceToken: TOKEN_64,
|
|
221
|
+
relaySecret: HEX_32,
|
|
222
|
+
vapidPrivateKey: 'B'.repeat(64),
|
|
223
|
+
serverUrl: 'https://myserver.example.com',
|
|
224
|
+
},
|
|
225
|
+
statusBody: {
|
|
226
|
+
state: 'unreachable',
|
|
227
|
+
serverUrl: 'https://myserver.example.com',
|
|
228
|
+
lastError,
|
|
229
|
+
},
|
|
230
|
+
gatewaySummary: { reachable: true, port: 3080, deviceCount: 2, shareCount: 1 },
|
|
231
|
+
logs: [
|
|
232
|
+
{ t: '2026-11-01T11:59:59.000Z', level: 'info', msg: 'relay link online (server=0123abcd)' },
|
|
233
|
+
{ t: '2026-11-01T12:00:00.000Z', level: 'warn', msg: `refused: /Users/alice/work/x.txt ${TOKEN_64}` },
|
|
234
|
+
],
|
|
235
|
+
home: HOME_UNIX,
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
// The plates exist, in order, with the host-only plate present.
|
|
239
|
+
assert.deepEqual(Object.keys(report), ['_meta', 'config', 'status', 'gateway', 'logs'], 'all five plates, in order')
|
|
240
|
+
assert.equal(report._meta.role, 'client', '_meta carries the role')
|
|
241
|
+
assert.equal(report._meta.pluginVersion, '2.3.6', '_meta carries the plugin version')
|
|
242
|
+
|
|
243
|
+
// ---- 10. the constructive-exclusion assertion ----------------------------------
|
|
244
|
+
const serialized = JSON.stringify(report)
|
|
245
|
+
// JSON round-trip: the report the user downloads must parse back.
|
|
246
|
+
assert.deepEqual(JSON.parse(serialized), report, 'the report survives a JSON round-trip')
|
|
247
|
+
for (const poison of [TOKEN_64, HEX_32, 'B'.repeat(64), 'myserver.example.com', '/Users/alice', '10.0.0.5', '192.168.1.10', '/var/tmp/secret', '9f8e7d6c5b4a3210']) {
|
|
248
|
+
assert.ok(!serialized.includes(poison), `poisoned input "${poison.slice(0, 24)}…" is not recoverable from the output`)
|
|
249
|
+
}
|
|
250
|
+
assert.equal(report.config.serverUrl, 'https://domain', 'config.serverUrl is a shape, not the address')
|
|
251
|
+
assert.equal(report.config.host, 'lan-ip', 'config.host is a bind shape, not the address (S3)')
|
|
252
|
+
assert.equal(report.status.serverUrl, 'https://domain', 'status.serverUrl degraded through the deep pass')
|
|
253
|
+
assert.ok(String(report.status.lastError).includes('<路径:x.bin>'), 'the status lastError kept only the path leaf')
|
|
254
|
+
assert.ok(String(report.logs[1].msg).includes('~/work/x.txt'), 'log lines keep the home-folded tail')
|
|
255
|
+
assert.ok(String(report.logs[1].msg).includes('<token>'), 'log lines lose token runs')
|
|
256
|
+
assert.equal(report.logs[0].msg, 'relay link online (server=0123abcd)', 'a clean log line passes through unchanged')
|
|
257
|
+
assert.equal(report.gateway.deviceCount, 2, 'the gateway plate rides through')
|
|
258
|
+
assert.ok(serialized.includes('<token>'), 'the redactor placeholder is present')
|
|
259
|
+
|
|
260
|
+
// The no-gateway (client role) shape: no gateway plate at all.
|
|
261
|
+
const clientReport = assembleDebugReport({
|
|
262
|
+
meta: { generatedAt: '2026-11-01T12:00:00.000Z', pluginVersion: '2.3.6', dshVersion: '0.2.1-rc.5', platform: 'win32 x64', role: 'client' },
|
|
263
|
+
configSummary: {},
|
|
264
|
+
statusBody: {},
|
|
265
|
+
logs: [],
|
|
266
|
+
home: HOME_WIN,
|
|
267
|
+
})
|
|
268
|
+
assert.ok(!('gateway' in clientReport), 'no gateway plate without a summary')
|
|
269
|
+
assert.deepEqual(clientReport.config.serverUrl, 'unset', 'an unset serverUrl shapes to unset')
|
|
270
|
+
|
|
271
|
+
// The assembler never mutates its input.
|
|
272
|
+
const input = { configSummary: { deviceToken: TOKEN_64 }, statusBody: { lastError: '/opt/x.bin' }, logs: [{ t: 't', level: 'info', msg: '/opt/x.bin' }] }
|
|
273
|
+
assembleDebugReport({ meta: { generatedAt: 'g', pluginVersion: 'v', dshVersion: 'd', platform: 'p', role: 'host' }, ...input, home: '/' })
|
|
274
|
+
assert.equal(input.configSummary.deviceToken, TOKEN_64, 'input config untouched')
|
|
275
|
+
assert.equal(input.statusBody.lastError, '/opt/x.bin', 'input status untouched')
|
|
276
|
+
|
|
277
|
+
console.log('check-debug-report: all assertions passed')
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
// Run: node scripts/check-info-sheet-stacking.mjs (needs Node >= 23.6 type stripping)
|
|
15
15
|
import assert from 'node:assert/strict'
|
|
16
16
|
import { INFO_CSS } from '../src/client/styles/info.css.ts'
|
|
17
|
+
import { CONTENT_CSS } from '../src/client/styles/content.css.ts'
|
|
18
|
+
import { LAYOUT_CSS } from '../src/client/styles/layout.css.ts'
|
|
19
|
+
import { en, zh } from '../src/client/locales.ts'
|
|
17
20
|
|
|
18
21
|
/** Host z-index values this sheet has to out-rank (dsh-client-ui-conversation). */
|
|
19
22
|
const HOST_COMPOSER_SEAT = 7
|
|
@@ -52,4 +55,86 @@ assert.ok(
|
|
|
52
55
|
'the header promotion escaped the (max-width: 767px) block — it would change the desktop layout',
|
|
53
56
|
)
|
|
54
57
|
|
|
58
|
+
// ---- UI-1 (2026-10-30): turn-tail clock + info-card stat cells -------------
|
|
59
|
+
// Lives here rather than in its own script because package.json's test line
|
|
60
|
+
// is an explicit list, and the info-card half of UI-1 is this sheet's own
|
|
61
|
+
// content. Both halves regress silently (CSS still parses, the card still
|
|
62
|
+
// opens), so they get the same loud-failure treatment as the stacking rule.
|
|
63
|
+
|
|
64
|
+
/** Body of the single rule whose selector line matches `selector` exactly. */
|
|
65
|
+
const ruleBody = (css, selector, label) => {
|
|
66
|
+
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
67
|
+
const match = new RegExp(`(?:^|\\n)\\s*${escaped}\\s*\\{([^}]*)\\}`).exec(css)
|
|
68
|
+
assert.ok(match, `${label}: rule \`${selector}\` is gone`)
|
|
69
|
+
return match[1]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// DSH 0.2.x wraps the usage button and the 24h clock in one non-wrapping
|
|
73
|
+
// inline-flex _endInfo. The 0.1.x-era rule (flex-basis: 100% + white-space:
|
|
74
|
+
// normal on the clock) squeezed "18:06" onto two lines inside it.
|
|
75
|
+
const tailTime = ruleBody(
|
|
76
|
+
CONTENT_CSS,
|
|
77
|
+
'[data-chat-flow-kind="turn-tail"] [class$="_actions"] [class$="_timeEnd"]',
|
|
78
|
+
'content.css.ts',
|
|
79
|
+
)
|
|
80
|
+
assert.ok(
|
|
81
|
+
/white-space:\s*nowrap\s*!important/.test(tailTime),
|
|
82
|
+
'turn-tail _timeEnd must declare white-space: nowrap !important — the clock may never break mid-"18:06"',
|
|
83
|
+
)
|
|
84
|
+
assert.ok(
|
|
85
|
+
!/flex-basis:\s*100%/.test(tailTime),
|
|
86
|
+
'turn-tail _timeEnd still carries flex-basis: 100% — the 0.1.x "own line" clamp that squeezes the clock inside _endInfo',
|
|
87
|
+
)
|
|
88
|
+
assert.ok(
|
|
89
|
+
!/white-space:\s*normal/.test(tailTime),
|
|
90
|
+
'turn-tail _timeEnd still carries white-space: normal — this is what let the clock wrap',
|
|
91
|
+
)
|
|
92
|
+
assert.ok(
|
|
93
|
+
/flex:\s*none\s*!important/.test(tailTime),
|
|
94
|
+
'turn-tail _timeEnd must be flex: none — a shrinkable clock is the squeeze itself',
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
// The other half of the root cause: layout.css.ts used to make the clock a
|
|
98
|
+
// shrinkable flex item (flex: 0 1 auto + min-width: 0) for every message row.
|
|
99
|
+
const rowTime = ruleBody(LAYOUT_CSS, '[data-phase] [class$="_actions"] [class$="_timeEnd"]', 'layout.css.ts')
|
|
100
|
+
assert.ok(/flex:\s*none\s*!important/.test(rowTime), 'layout.css.ts _timeEnd must be flex: none !important')
|
|
101
|
+
assert.ok(
|
|
102
|
+
!/min-width:\s*0/.test(rowTime) && !/flex:\s*0 1 auto/.test(rowTime),
|
|
103
|
+
'layout.css.ts _timeEnd is shrinkable again (min-width: 0 / flex: 0 1 auto) — the clock will be squeezed inside _endInfo',
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
// _endInfo wraps as a unit, and the row may grow instead of clipping.
|
|
107
|
+
const endInfo = ruleBody(
|
|
108
|
+
CONTENT_CSS,
|
|
109
|
+
'[data-chat-flow-kind="turn-tail"] [class$="_actions"] [class$="_endInfo"]',
|
|
110
|
+
'content.css.ts',
|
|
111
|
+
)
|
|
112
|
+
assert.ok(/flex:\s*none/.test(endInfo), 'turn-tail _endInfo must be flex: none so the usage + clock group wraps whole')
|
|
113
|
+
const tailRows = [...CONTENT_CSS.matchAll(/\n\s*\[data-chat-flow-kind="turn-tail"\] \[class\$="_actions"\]\s*\{([^}]*)\}/g)]
|
|
114
|
+
const tailRow = tailRows.map((m) => m[1]).find((body) => /flex-wrap/.test(body))
|
|
115
|
+
assert.ok(tailRow, 'content.css.ts lost the wrapping turn-tail _actions rule')
|
|
116
|
+
assert.ok(
|
|
117
|
+
/height:\s*auto\s*!important/.test(tailRow) && /overflow:\s*visible\s*!important/.test(tailRow),
|
|
118
|
+
'turn-tail _actions must release the host fixed height (height: auto) and layout.css.ts overflow: hidden (overflow: visible), or a wrapped second line is cut off',
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
// Desktop no-op: every turn-tail rule above has to sit inside the phone block.
|
|
122
|
+
const contentPhone = /@media \(max-width: 767px\) \{/.exec(CONTENT_CSS)
|
|
123
|
+
assert.ok(contentPhone, 'content.css.ts lost its phone media query')
|
|
124
|
+
assert.ok(
|
|
125
|
+
CONTENT_CSS.indexOf('[data-chat-flow-kind="turn-tail"]') > contentPhone.index,
|
|
126
|
+
'a turn-tail rule escaped the (max-width: 767px) block — it would change the desktop layout',
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
// Info-card stat cells: turns + steps merged into one cell, output speed in
|
|
130
|
+
// the freed slot. Both dictionaries must carry the keys or t() prints the key.
|
|
131
|
+
for (const [name, dict] of [['zh', zh], ['en', en]]) {
|
|
132
|
+
for (const key of ['infoStatTurnsSteps', 'infoStatSpeed']) {
|
|
133
|
+
assert.ok(
|
|
134
|
+
typeof dict[key] === 'string' && dict[key].length > 0,
|
|
135
|
+
`locales.ts ${name} is missing '${key}' — the info card would render the raw key as a label`,
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
55
140
|
console.log('check-info-sheet-stacking: ok')
|
|
@@ -9,6 +9,7 @@ import assert from 'node:assert/strict'
|
|
|
9
9
|
import { readFileSync } from 'node:fs'
|
|
10
10
|
import {
|
|
11
11
|
decideRemoteDrop,
|
|
12
|
+
decideRemoteIntake,
|
|
12
13
|
isRemoteSessionId,
|
|
13
14
|
rewrapFiles,
|
|
14
15
|
} from '../src/client-data/remote-drop.ts'
|
|
@@ -109,6 +110,135 @@ assert.deepEqual(pair.map((f) => f.name), ['report.pdf', 'a.txt'])
|
|
|
109
110
|
assert.ok(effects.includes('function dismissHostDropOverlay'), 'the guard defines dismissHostDropOverlay')
|
|
110
111
|
const calls = effects.match(/^\s*dismissHostDropOverlay\(\)\s*$/gm)?.length ?? 0
|
|
111
112
|
assert.equal(calls, 1, `the folder-block branch calls dismissHostDropOverlay() as a standalone statement (saw ${calls})`)
|
|
113
|
+
// The picker's structural gate refuses a `webkitdirectory` input on the
|
|
114
|
+
// SAME line as the other four traits (RU-fix adoption 2): a third-party
|
|
115
|
+
// hidden+multiple+no-accept folder picker must not be redirected.
|
|
116
|
+
assert.ok(
|
|
117
|
+
/input\.type !== 'file' \|\| !input\.multiple \|\| !input\.hidden \|\| input\.hasAttribute\('accept'\) \|\| input\.webkitdirectory/.test(effects),
|
|
118
|
+
'the picker gate checks type/multiple/hidden/accept/webkitdirectory in one condition',
|
|
119
|
+
)
|
|
112
120
|
}
|
|
113
121
|
|
|
122
|
+
// ---- 7. decideRemoteIntake (AU-1): the picker / paste entry decision ----------
|
|
123
|
+
// Reusable descriptor builders. `pathFile` = carries a disk path, not an image
|
|
124
|
+
// — the ONE shape that lands on the host's zero-copy `@path` chip and the only
|
|
125
|
+
// one the intake may intercept. `imageFile` = carries a path but is an image
|
|
126
|
+
// (already byte-uploads). `pathlessFile` = no path (already byte-uploads).
|
|
127
|
+
const pathFile = { entry: 'picker', isDirectory: false, hasPath: true, isImage: false }
|
|
128
|
+
const imageFile = { entry: 'picker', isDirectory: false, hasPath: true, isImage: true }
|
|
129
|
+
const pathlessFile = { entry: 'picker', isDirectory: false, hasPath: false, isImage: false }
|
|
130
|
+
const pasteFile = { entry: 'paste', isDirectory: false, hasPath: true, isImage: false }
|
|
131
|
+
const dropFile = { entry: 'drop', isDirectory: false, hasPath: true, isImage: false }
|
|
132
|
+
|
|
133
|
+
const intake = (overrides, items = [pathFile]) =>
|
|
134
|
+
decideRemoteIntake({
|
|
135
|
+
trusted: true,
|
|
136
|
+
hasHostPaths: true,
|
|
137
|
+
currentSessionId: REMOTE_ID,
|
|
138
|
+
items,
|
|
139
|
+
...overrides,
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
// 7a. the three gates hold for EVERY entry — one row per gate per entry
|
|
143
|
+
// (untrusted → no bridge → local session), plus the drop entry as a control.
|
|
144
|
+
{
|
|
145
|
+
const gateRows = [
|
|
146
|
+
[{ entry: 'picker' }, { trusted: false }, 'passthrough', 'untrusted-event'],
|
|
147
|
+
[{ entry: 'picker' }, { hasHostPaths: false }, 'passthrough', 'no-host-paths'],
|
|
148
|
+
[{ entry: 'picker' }, { currentSessionId: LOCAL_ID }, 'passthrough', 'not-remote-session'],
|
|
149
|
+
[{ entry: 'paste' }, { trusted: false }, 'passthrough', 'untrusted-event'],
|
|
150
|
+
[{ entry: 'paste' }, { hasHostPaths: false }, 'passthrough', 'no-host-paths'],
|
|
151
|
+
[{ entry: 'paste' }, { currentSessionId: LOCAL_ID }, 'passthrough', 'not-remote-session'],
|
|
152
|
+
[{ entry: 'drop' }, { trusted: false }, 'passthrough', 'untrusted-event'],
|
|
153
|
+
[{ entry: 'drop' }, { hasHostPaths: false }, 'passthrough', 'no-host-paths'],
|
|
154
|
+
[{ entry: 'drop' }, { currentSessionId: LOCAL_ID }, 'passthrough', 'not-remote-session'],
|
|
155
|
+
]
|
|
156
|
+
for (const [entryOverride, gateOverride, action, reason] of gateRows) {
|
|
157
|
+
const item = { ...pathFile, ...entryOverride }
|
|
158
|
+
assert.deepEqual(
|
|
159
|
+
intake({ ...gateOverride, currentSessionId: gateOverride.currentSessionId ?? REMOTE_ID }, [item]),
|
|
160
|
+
{ action, reason },
|
|
161
|
+
`gate row ${JSON.stringify({ ...entryOverride, ...gateOverride })}`,
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 7b. picker intervention: ONLY the path-carrying non-image file redirects.
|
|
167
|
+
assert.deepEqual(intake({}, [pathFile]), { action: 'redirect', reason: 'remote-path-files' }, 'picker: a path-carrying non-image redirects')
|
|
168
|
+
assert.deepEqual(intake({}, [{ ...pathFile, isImage: true }]), { action: 'passthrough', reason: 'no-path-or-image-only' }, 'picker: a pure-image pick passes through (the image branch already uploads bytes)') // 钉子
|
|
169
|
+
assert.deepEqual(intake({}, [pathlessFile]), { action: 'passthrough', reason: 'no-path-or-image-only' }, 'picker: a pathless file passes through')
|
|
170
|
+
assert.deepEqual(
|
|
171
|
+
intake({}, [{ ...pathFile, hasPath: false }, { ...pathFile, isImage: true }]),
|
|
172
|
+
{ action: 'passthrough', reason: 'no-path-or-image-only' },
|
|
173
|
+
'picker: mixed pathless + image still passes through — no bad-branch file in the batch',
|
|
174
|
+
)
|
|
175
|
+
assert.deepEqual(
|
|
176
|
+
intake({}, [pathlessFile, { ...pathFile, isImage: true }, pathFile]),
|
|
177
|
+
{ action: 'redirect', reason: 'remote-path-files' },
|
|
178
|
+
'picker: ONE bad-branch file in the batch redirects the whole intake',
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
// 7c. paste: only kind:'file' clipboard items reach the descriptors, so a
|
|
182
|
+
// plain-text paste arrives as an EMPTY list (钉子) — and an image-only paste
|
|
183
|
+
// passes through exactly like the picker's.
|
|
184
|
+
assert.deepEqual(intake({}, []), { action: 'passthrough', reason: 'no-files' }, 'paste: an empty file list (plain text) passes through') // 钉子
|
|
185
|
+
assert.deepEqual(intake({}, [{ ...pasteFile }]), { action: 'redirect', reason: 'remote-path-files' }, 'paste: a path-carrying non-image redirects')
|
|
186
|
+
assert.deepEqual(
|
|
187
|
+
intake({}, [{ ...pasteFile, isImage: true }]),
|
|
188
|
+
{ action: 'passthrough', reason: 'no-path-or-image-only' },
|
|
189
|
+
'paste: a pasted screenshot (image with a path) passes through — unchanged behavior',
|
|
190
|
+
)
|
|
191
|
+
assert.deepEqual(
|
|
192
|
+
intake({}, [{ ...pasteFile, hasPath: false }]),
|
|
193
|
+
{ action: 'passthrough', reason: 'no-path-or-image-only' },
|
|
194
|
+
'paste: a pathless file passes through',
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
// 7d. drop entries keep the FROZEN "any file intervenes" semantics: even a
|
|
198
|
+
// pathless / image drop item redirects (decideRemoteDrop never looked at
|
|
199
|
+
// paths), while the same items through picker/paste would pass through.
|
|
200
|
+
assert.deepEqual(
|
|
201
|
+
intake({}, [{ ...dropFile, hasPath: false, isImage: true }]),
|
|
202
|
+
{ action: 'redirect', reason: 'remote-path-files' },
|
|
203
|
+
'drop: any file intervenes, frozen semantics',
|
|
204
|
+
)
|
|
205
|
+
assert.deepEqual(
|
|
206
|
+
intake({}, [{ ...dropFile, hasPath: false, isImage: true }, pathlessFile]),
|
|
207
|
+
{ action: 'redirect', reason: 'remote-path-files' },
|
|
208
|
+
'mixed entries: one drop item keeps the whole intake on the drop semantics',
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
// 7e. directories block ahead of every redirect, in any entry (钉子: the
|
|
212
|
+
// directory rule outranks a bad-branch file in the same batch).
|
|
213
|
+
assert.deepEqual(
|
|
214
|
+
intake({}, [{ ...pasteFile, isDirectory: true }, pasteFile]),
|
|
215
|
+
{ action: 'block-directories', reason: 'contains-directory' },
|
|
216
|
+
'paste: a directory blocks even with a redirect-worthy file present',
|
|
217
|
+
) // 钉子
|
|
218
|
+
assert.deepEqual(
|
|
219
|
+
intake({}, [{ ...pathFile, isDirectory: true }]),
|
|
220
|
+
{ action: 'block-directories', reason: 'contains-directory' },
|
|
221
|
+
'picker: a directory blocks (defensive — the picker has none, the rule is shared)',
|
|
222
|
+
)
|
|
223
|
+
assert.deepEqual(
|
|
224
|
+
intake({}, [{ ...dropFile, isDirectory: true }]),
|
|
225
|
+
{ action: 'block-directories', reason: 'contains-directory' },
|
|
226
|
+
'drop entries block on directories like decideRemoteDrop',
|
|
227
|
+
)
|
|
228
|
+
// Gates outrank the directory rule: a local session never intervenes, even on
|
|
229
|
+
// a folder (folders must keep working locally).
|
|
230
|
+
assert.deepEqual(
|
|
231
|
+
intake({ currentSessionId: LOCAL_ID }, [{ ...pasteFile, isDirectory: true }]),
|
|
232
|
+
{ action: 'passthrough', reason: 'not-remote-session' },
|
|
233
|
+
'a directory in a LOCAL session passes through — the gates come first',
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
// 7f. no inventory at all: deciding blind could eat a working flow — pass
|
|
237
|
+
// through even in a remote session. (Direct call: the helper's default list
|
|
238
|
+
// would mask the undefined.)
|
|
239
|
+
assert.deepEqual(
|
|
240
|
+
decideRemoteIntake({ trusted: true, hasHostPaths: true, currentSessionId: REMOTE_ID, items: undefined }),
|
|
241
|
+
{ action: 'passthrough', reason: 'no-entry-api' },
|
|
242
|
+
)
|
|
243
|
+
|
|
114
244
|
console.log('check-remote-drop: ok')
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Self-check for the >=1024px standalone-PWA safe-area rule in misc.css.ts.
|
|
2
|
+
//
|
|
3
|
+
// That rule is the ONLY thing paying the status-bar inset on a wide tablet's
|
|
4
|
+
// home-screen PWA: the gateway hands viewport-fit=cover to every non-desktop
|
|
5
|
+
// device, but every other compensation rule stops at the 1023px breakpoint.
|
|
6
|
+
// If it is dropped (or a gate is weakened), nothing throws anywhere testable
|
|
7
|
+
// — desktop stays 0px-padding because env() is 0 there — the regression only
|
|
8
|
+
// shows up as "the top buttons are hard to tap" on a real tablet. Hence a
|
|
9
|
+
// check that fails loudly instead.
|
|
10
|
+
// Run: node scripts/check-tablet-safe-area.mjs (needs Node >= 23.6 type stripping)
|
|
11
|
+
import assert from 'node:assert/strict'
|
|
12
|
+
import { MISC_CSS } from '../src/client/styles/misc.css.ts'
|
|
13
|
+
import { BASE_CSS } from '../src/client/styles/base.css.ts'
|
|
14
|
+
|
|
15
|
+
const rule = /@media\s*\(min-width:\s*1024px\)\s*and\s*\(display-mode:\s*standalone\)\s*\{\s*:has\(\s*>\s*\[data-shell-overlay\]\)\s*\{([^}]*)\}\s*\}/.exec(MISC_CSS)
|
|
16
|
+
assert.ok(
|
|
17
|
+
rule,
|
|
18
|
+
'the >=1024px standalone safe-area rule is gone: without it a wide tablet\'s ' +
|
|
19
|
+
'home-screen PWA paints its top controls under the status bar (the gateway ' +
|
|
20
|
+
'serves viewport-fit=cover at every width). See misc.css.ts.',
|
|
21
|
+
)
|
|
22
|
+
const body = rule[1]
|
|
23
|
+
assert.match(body, /box-sizing:\s*border-box\s*!important/, 'border-box is load-bearing: the frame is height:100% content-box (S2.1)')
|
|
24
|
+
assert.match(body, /padding-top:\s*var\(--mnav-sat\)\s*!important/, 'top inset must read the --mnav-sat variable (debug-param controllable)')
|
|
25
|
+
assert.match(body, /padding-bottom:\s*var\(--mnav-sab\)\s*!important/, 'bottom inset must read the --mnav-sab variable (Android tablet nav bar)')
|
|
26
|
+
|
|
27
|
+
// Both media gates are what make the desktop no-op structural. Dropping
|
|
28
|
+
// display-mode in particular would re-enable the rule in every >=1024px
|
|
29
|
+
// desktop BROWSER — still 0px there thanks to env(), but the gate keeps the
|
|
30
|
+
// no-op from depending on that coincidence.
|
|
31
|
+
assert.ok(
|
|
32
|
+
/min-width:\s*1024px/.test(rule[0]) && /display-mode:\s*standalone/.test(rule[0]),
|
|
33
|
+
'both no-op gates (min-width 1024px AND display-mode standalone) must stay on the media query',
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
// The variables the rule reads must stay defined on :root in base.css.ts —
|
|
37
|
+
// NOT inside any width media query, or the rule degrades to invalid-at
|
|
38
|
+
// computed-value time (padding falls back to unset) above 1023px.
|
|
39
|
+
const rootVars = /:root\s*\{[^}]*--mnav-sat:\s*env\(safe-area-inset-top,\s*0px\)[^}]*--mnav-sab:\s*env\(safe-area-inset-bottom,\s*0px\)[^}]*\}/.exec(BASE_CSS)
|
|
40
|
+
assert.ok(
|
|
41
|
+
rootVars,
|
|
42
|
+
'--mnav-sat/--mnav-sab must stay defined on :root in base.css.ts (ungated) for the >=1024px rule to resolve',
|
|
43
|
+
)
|
|
44
|
+
assert.ok(
|
|
45
|
+
!/@media[^{]*\{[^@]*:root\s*\{[^}]*--mnav-sat/.test(BASE_CSS),
|
|
46
|
+
'--mnav-sat must not move inside a media query in base.css.ts',
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
console.log('check-tablet-safe-area: ok')
|