dsh-enhancement 1.1.1 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -94,6 +94,7 @@ Clicking a desktop notification opens the matching session. On Linux and Windows
94
94
  ## Mobile push
95
95
 
96
96
  - Standard Web Push with one subscription per device.
97
+ - **PWA must be enabled first**: the Service Worker (`/sw.js`) is served by the PWA feature, so push cannot be subscribed while PWA is off.
97
98
  - Click “Subscribe this device” in settings; the browser asks for notification permission, which must be triggered by your click.
98
99
  - iOS / iPadOS 16.4+ is required, and DSH must be added to the Home Screen; regular Safari tabs cannot receive push.
99
100
  - Unsubscribe at any time, or send a test push to verify the setup.
package/README.md CHANGED
@@ -95,6 +95,7 @@ DSH 只在直连官方 DeepSeek API 时发送 `x-deepseek-harness-session-id`;
95
95
  ## 手机推送
96
96
 
97
97
  - 使用标准 Web Push,每台设备单独订阅。
98
+ - **需要先启用 PWA**:Service Worker(`/sw.js`)由 PWA 提供,PWA 关闭时无法订阅。
98
99
  - 在设置里点“订阅本设备”,浏览器会请求通知权限;权限必须由你主动点击触发。
99
100
  - iOS / iPadOS 需要 16.4 或更高版本,并且必须先把 DSH 添加到主屏幕;Safari 普通标签页收不到推送。
100
101
  - 可以随时取消订阅,或发送一条测试推送确认链路正常。
package/lib/client.js CHANGED
@@ -100,7 +100,7 @@ window.__ModuleLoader__.load({
100
100
  muted: { color: 'var(--dsw-alias-label-secondary, #667085)', fontSize: 12, lineHeight: 1.6 },
101
101
  card: { borderTop: '1px solid var(--dsw-alias-border-l4, rgba(0,0,0,.08))', padding: '16px 0 8px', display: 'flex', flexDirection: 'column', gap: 6, background: 'transparent' },
102
102
  blockHead: { display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 },
103
- dirty: { flex: 'none', fontSize: 11, lineHeight: '18px', padding: '1px 8px', borderRadius: 999, color: 'var(--dsw-alias-state-warning-primary, #b54708)', background: 'var(--dsw-alias-state-warning-bg, rgba(247,144,9,.12))', border: '.5px solid var(--dsw-alias-state-warning-primary-a30, rgba(181,71,8,.3))' },
103
+ dirty: { flex: 'none', fontSize: 11, lineHeight: '18px', padding: '1px 8px', borderRadius: 999, color: 'var(--dsw-alias-state-warn-primary, #b54708)', background: 'color-mix(in srgb, var(--dsw-alias-state-warn-primary, #b54708) 12%, transparent)', border: '.5px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary, #b54708) 30%, transparent)' },
104
104
  subhead: { margin: '14px 0 2px', fontSize: 13, fontWeight: 600, color: 'var(--dsw-alias-label-primary, inherit)' },
105
105
  row: { display: 'flex', flexDirection: 'column', gap: 6, padding: '10px 0' },
106
106
  label: { color: 'var(--dsw-alias-label-primary, inherit)', fontSize: 13, fontWeight: 500 },
@@ -111,12 +111,12 @@ window.__ModuleLoader__.load({
111
111
  actions: { display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8, paddingTop: 4 },
112
112
  button: { height: 32, padding: '0 14px', borderRadius: 'var(--dsw-radius-md, 12px)', border: '.5px solid var(--dsw-alias-border-l4, rgba(0,0,0,.16))', background: 'transparent', color: 'var(--dsw-alias-label-primary, inherit)', cursor: 'pointer', fontSize: 13 },
113
113
  primary: { background: 'var(--dsw-alias-state-business-primary, #4d8dff)', borderColor: 'transparent', color: '#fff', fontWeight: 600 },
114
- danger: { color: 'var(--dsw-alias-state-danger-primary, #d92d20)' },
114
+ danger: { color: 'var(--dsw-alias-state-error-primary, #d92d20)' },
115
115
  pill: { display: 'inline-flex', alignItems: 'center', gap: 6, padding: '3px 9px', borderRadius: 999, border: '.5px solid var(--dsw-alias-border-l4, rgba(0,0,0,.16))', fontSize: 11, color: 'var(--dsw-alias-label-secondary, #667085)' },
116
116
  dot: { width: 7, height: 7, borderRadius: 999, background: '#54d18a', display: 'inline-block' },
117
117
  dotOff: { width: 7, height: 7, borderRadius: 999, background: '#98a2b3', display: 'inline-block' },
118
118
  code: { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 11, wordBreak: 'break-all', background: 'var(--dsw-alias-bg-layer-3, rgba(0,0,0,.04))', color: 'var(--dsw-alias-label-secondary, #475467)', padding: '6px 8px', borderRadius: 8 },
119
- error: { color: 'var(--dsw-alias-state-danger-primary, #d92d20)', fontSize: 12, lineHeight: 1.5 },
119
+ error: { color: 'var(--dsw-alias-state-error-primary, #d92d20)', fontSize: 12, lineHeight: 1.5 },
120
120
  success: { color: 'var(--dsw-alias-state-success-primary, #079455)', fontSize: 12, lineHeight: 1.5 },
121
121
  divider: { height: 1, background: 'var(--dsw-alias-border-l4, rgba(0,0,0,.08))', margin: '6px 0' },
122
122
  };
@@ -125,7 +125,7 @@ window.__ModuleLoader__.load({
125
125
  }
126
126
  const CARD_CSS = `
127
127
  .enh-root input:not([type="checkbox"]):not([type="file"]), .enh-root select { transition:border-color .15s, box-shadow .15s; }
128
- .enh-root input:not([type="checkbox"]):not([type="file"]):focus, .enh-root input:not([type="checkbox"]):not([type="file"]):focus-visible, .enh-root select:focus, .enh-root select:focus-visible { outline:none !important; border-color:var(--dsw-alias-state-business-primary, #4d8dff) !important; box-shadow:0 0 0 3px var(--dsw-alias-state-business-primary-a10, rgba(77,141,255,.18)) !important; }
128
+ .enh-root input:not([type="checkbox"]):not([type="file"]):focus, .enh-root input:not([type="checkbox"]):not([type="file"]):focus-visible, .enh-root select:focus, .enh-root select:focus-visible { outline:none !important; border-color:var(--dsw-alias-state-business-primary, #4d8dff) !important; box-shadow:0 0 0 3px color-mix(in srgb, var(--dsw-alias-state-business-primary, #4d8dff) 18%, transparent) !important; }
129
129
  .enh-root select { appearance:none; background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M3 4.5 6 7.5 9 4.5' fill='none' stroke='%23667085' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); background-repeat:no-repeat; background-position:right 12px center; padding-right:32px !important; }
130
130
  .enh-root input[type="file"] { font-size:12px; color:var(--dsw-alias-label-secondary, #475467); max-width:100%; }
131
131
  .enh-root input[type="file"]::file-selector-button { appearance:none; height:32px; margin-right:10px; padding:0 14px; border-radius:var(--dsw-radius-md, 12px); border:.5px solid var(--dsw-alias-border-l4, rgba(0,0,0,.16)); background:var(--dsw-alias-bg-layer-3, #fff); color:var(--dsw-alias-label-primary, inherit); font:inherit; font-size:13px; cursor:pointer; }
@@ -139,7 +139,7 @@ window.__ModuleLoader__.load({
139
139
  .enh-root input[type="checkbox"]:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary, #4d8dff); outline-offset:2px; }
140
140
 
141
141
  .enh-root .enh-block-head { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
142
- .enh-root .enh-dirty { flex:none; font-size:11px; line-height:18px; padding:1px 8px; border-radius:999px; color:var(--dsw-alias-state-warning-primary, #b54708); background:var(--dsw-alias-state-warning-bg, rgba(247,144,9,.12)); border:.5px solid var(--dsw-alias-state-warning-primary-a30, rgba(181,71,8,.3)); }
142
+ .enh-root .enh-dirty { flex:none; font-size:11px; line-height:18px; padding:1px 8px; border-radius:999px; color:var(--dsw-alias-state-warn-primary, #b54708); background:color-mix(in srgb, var(--dsw-alias-state-warn-primary, #b54708) 12%, transparent); border:.5px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary, #b54708) 30%, transparent); }
143
143
  .enh-root .enh-subhead { margin:14px 0 2px; font-size:13px; font-weight:600; color:var(--dsw-alias-label-primary, inherit); }
144
144
  .enh-root .enh-grid2 { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px 16px; }
145
145
  .enh-root .enh-grid3 { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:10px 16px; }
@@ -464,6 +464,10 @@ window.__ModuleLoader__.load({
464
464
  throw new Error('Web Push 需要 HTTPS(或 localhost);请先启用 HTTPS');
465
465
  if (!('serviceWorker' in navigator) || !('PushManager' in window))
466
466
  throw new Error('当前浏览器不支持 Web Push');
467
+ // /sw.js 仅在启用 PWA 时注册;PWA 关闭时 Service Worker 不可用,Web Push 无从订阅。
468
+ const swProbe = await fetch('/sw.js', { method: 'HEAD', cache: 'no-store' }).catch(() => undefined);
469
+ if (!swProbe || !swProbe.ok)
470
+ throw new Error('Web Push 需要先启用 PWA(PWA 关闭时 Service Worker /sw.js 不可用)');
467
471
  if (!pushPublicKey)
468
472
  throw new Error('VAPID 公钥尚未生成,请刷新状态后重试');
469
473
  let permission = Notification.permission;
@@ -698,21 +702,31 @@ window.__ModuleLoader__.load({
698
702
  /* ignore */
699
703
  }
700
704
  };
705
+ const onMessage = (event) => {
706
+ const data = event.data;
707
+ if (data?.type === 'dsh-enhancement-open-session' && data.sessionId && SESSION_ID_PATTERN.test(data.sessionId))
708
+ openSession(ctx, data.sessionId);
709
+ };
701
710
  read();
702
711
  window.addEventListener('popstate', read);
703
712
  try {
704
- navigator.serviceWorker?.addEventListener('message', (event) => {
705
- const data = event.data;
706
- if (data?.type === 'dsh-enhancement-open-session' && data.sessionId && SESSION_ID_PATTERN.test(data.sessionId))
707
- openSession(ctx, data.sessionId);
708
- });
713
+ navigator.serviceWorker?.addEventListener('message', onMessage);
709
714
  }
710
715
  catch {
711
716
  /* service worker unavailable on HTTP */
712
717
  }
718
+ return () => {
719
+ window.removeEventListener('popstate', read);
720
+ try {
721
+ navigator.serviceWorker?.removeEventListener('message', onMessage);
722
+ }
723
+ catch {
724
+ /* ignore */
725
+ }
726
+ };
713
727
  }
714
728
  function apply(ctx) {
715
- openSessionFromQuery(ctx);
729
+ ctx.effect(() => openSessionFromQuery(ctx), 'dsh-enhancement: open session from query');
716
730
  ctx.effect(() => connectFocusEvents(ctx), 'dsh-enhancement: focus existing page');
717
731
  ctx.effect(() => {
718
732
  return ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({
package/lib/gateway.js CHANGED
@@ -12,6 +12,7 @@ import { createServer, request as httpRequest } from 'node:http';
12
12
  import { createServer as createHttpsServer } from 'node:https';
13
13
  import { connect as netConnect } from 'node:net';
14
14
  import { URL } from 'node:url';
15
+ import { dirname } from 'node:path';
15
16
  import { SESSION_COOKIE, clearSessionCookie, formatHost, mintDshCookie, readDshSecret, requestAuthority, sessionCookie, signSession, verifySession, } from './state.js';
16
17
  import { caCertificatePem, prepareTls, tlsStatus } from './tls.js';
17
18
  const LOGIN_WINDOW_MS = 10 * 60 * 1000;
@@ -53,7 +54,9 @@ function parseCookies(header) {
53
54
  function safeNext(raw) {
54
55
  if (typeof raw !== 'string' || !raw.startsWith('/') || raw.startsWith('//'))
55
56
  return '/';
56
- if (/[\r\n]/.test(raw))
57
+ // 反斜杠在 URL 解析中会被归一成斜杠(`/\evil.com` → `//evil.com`),构成开放重定向;
58
+ // 编码形式 %5c 同理,一并拒绝。CRLF 用于防响应拆分。
59
+ if (/[\r\n\\]/.test(raw) || /%5c|%2f%2f/i.test(raw))
57
60
  return '/';
58
61
  return raw;
59
62
  }
@@ -189,8 +192,7 @@ export class Gateway {
189
192
  }
190
193
  this.dshSecret = await readDshSecret(this.ctx);
191
194
  if (!this.dshSecret) {
192
- this.log('warn', '无法读取 DSH 浏览器会话签名密钥;会在后台重试(网关自身仍可登录)');
193
- this.scheduleDshSecretRetry();
195
+ this.log('warn', '无法读取 DSH 浏览器会话签名密钥;启动后会在后台重试(网关自身仍可登录)');
194
196
  }
195
197
  const handler = (req, res) => {
196
198
  void this.handleRequest(req, res).catch((err) => {
@@ -232,6 +234,10 @@ export class Gateway {
232
234
  server.listen(config.port, config.bindHost, () => {
233
235
  server.off('error', onError);
234
236
  this.started = true;
237
+ // 监听成功(started=true)后才安排密钥重试:此前的 scheduleDshSecretRetry
238
+ // 会因守卫 !this.started 直接返回,导致重试从不发生。
239
+ if (!this.dshSecret)
240
+ this.scheduleDshSecretRetry();
235
241
  this.log('info', `gateway listening on ${this.secure ? 'https' : 'http'}://${formatHost(config.bindHost)}:${config.port}`);
236
242
  resolve();
237
243
  });
@@ -299,7 +305,7 @@ export class Gateway {
299
305
  dshOrigin: origin,
300
306
  dshAccessUrl,
301
307
  interfaces,
302
- stateDir: this.store.statePath.replace(/\/state\.json$/, ''),
308
+ stateDir: dirname(this.store.statePath),
303
309
  statePath: this.store.statePath,
304
310
  error: this.error,
305
311
  https: tlsStatus(config, this.ctx.webServer.host),
package/lib/state.js CHANGED
@@ -194,7 +194,16 @@ export class StateStore {
194
194
  };
195
195
  }
196
196
  catch {
197
- /* fall through to a fresh state; keep a warning via caller */
197
+ // 解析失败不能静默清空:先把损坏文件改名备份,再回落默认状态,避免用户
198
+ // 的密码哈希 / VAPID / 推送订阅被无提示地覆盖丢失。
199
+ try {
200
+ const backup = `${this.statePath}.corrupt-${Date.now()}`;
201
+ renameSync(this.statePath, backup);
202
+ console.warn(`[dsh-enhancement] state.json 无法解析,已备份到 ${backup} 并以默认配置继续`);
203
+ }
204
+ catch {
205
+ /* 备份失败也不阻塞启动 */
206
+ }
198
207
  }
199
208
  }
200
209
  const config = defaultConfig(this.webServerHost);
@@ -301,7 +310,7 @@ export function verifySession(secret, value) {
301
310
  return null;
302
311
  try {
303
312
  const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
304
- if (payload.v !== 1 || typeof payload.exp !== 'number' || typeof payload.exp !== 'number')
313
+ if (payload.v !== 1 || typeof payload.sid !== 'string' || typeof payload.iat !== 'number' || typeof payload.exp !== 'number')
305
314
  return null;
306
315
  if (payload.exp <= Date.now())
307
316
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-enhancement",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
4
4
  "description": "DSH 增强套件:远程安全访问网关(密码/HTTPS)、PWA 安装与图标、桌面通知与 Web Push、会话请求头、设置页。",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -31,7 +31,8 @@
31
31
  "build": "tsc -p tsconfig.json && tsc -p tsconfig.client.json && node scripts/build-client.mjs",
32
32
  "verify": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.client.json --noEmit",
33
33
  "prepare": "npm run build",
34
- "test": "node --test test/session-header.test.mjs"
34
+ "pretest": "npm run build",
35
+ "test": "node --test test/session-header.test.mjs test/gateway-auth.test.mjs"
35
36
  },
36
37
  "dsh": {
37
38
  "bundle": {
@@ -40,7 +41,6 @@
40
41
  "client": {
41
42
  "platform": "web",
42
43
  "inject": [
43
- "@deepseek-ai/dsh-client-ui-slots",
44
44
  "@deepseek-ai/dsh-client-ui-workspace"
45
45
  ],
46
46
  "immediately": true
@@ -159,7 +159,7 @@ const styles: Record<string, React.CSSProperties> = {
159
159
  muted: { color: 'var(--dsw-alias-label-secondary, #667085)', fontSize: 12, lineHeight: 1.6 },
160
160
  card: { borderTop: '1px solid var(--dsw-alias-border-l4, rgba(0,0,0,.08))', padding: '16px 0 8px', display: 'flex', flexDirection: 'column', gap: 6, background: 'transparent' },
161
161
  blockHead: { display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 },
162
- dirty: { flex: 'none', fontSize: 11, lineHeight: '18px', padding: '1px 8px', borderRadius: 999, color: 'var(--dsw-alias-state-warning-primary, #b54708)', background: 'var(--dsw-alias-state-warning-bg, rgba(247,144,9,.12))', border: '.5px solid var(--dsw-alias-state-warning-primary-a30, rgba(181,71,8,.3))' },
162
+ dirty: { flex: 'none', fontSize: 11, lineHeight: '18px', padding: '1px 8px', borderRadius: 999, color: 'var(--dsw-alias-state-warn-primary, #b54708)', background: 'color-mix(in srgb, var(--dsw-alias-state-warn-primary, #b54708) 12%, transparent)', border: '.5px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary, #b54708) 30%, transparent)' },
163
163
  subhead: { margin: '14px 0 2px', fontSize: 13, fontWeight: 600, color: 'var(--dsw-alias-label-primary, inherit)' },
164
164
  row: { display: 'flex', flexDirection: 'column', gap: 6, padding: '10px 0' },
165
165
  label: { color: 'var(--dsw-alias-label-primary, inherit)', fontSize: 13, fontWeight: 500 },
@@ -170,12 +170,12 @@ const styles: Record<string, React.CSSProperties> = {
170
170
  actions: { display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8, paddingTop: 4 },
171
171
  button: { height: 32, padding: '0 14px', borderRadius: 'var(--dsw-radius-md, 12px)', border: '.5px solid var(--dsw-alias-border-l4, rgba(0,0,0,.16))', background: 'transparent', color: 'var(--dsw-alias-label-primary, inherit)', cursor: 'pointer', fontSize: 13 },
172
172
  primary: { background: 'var(--dsw-alias-state-business-primary, #4d8dff)', borderColor: 'transparent', color: '#fff', fontWeight: 600 },
173
- danger: { color: 'var(--dsw-alias-state-danger-primary, #d92d20)' },
173
+ danger: { color: 'var(--dsw-alias-state-error-primary, #d92d20)' },
174
174
  pill: { display: 'inline-flex', alignItems: 'center', gap: 6, padding: '3px 9px', borderRadius: 999, border: '.5px solid var(--dsw-alias-border-l4, rgba(0,0,0,.16))', fontSize: 11, color: 'var(--dsw-alias-label-secondary, #667085)' },
175
175
  dot: { width: 7, height: 7, borderRadius: 999, background: '#54d18a', display: 'inline-block' },
176
176
  dotOff: { width: 7, height: 7, borderRadius: 999, background: '#98a2b3', display: 'inline-block' },
177
177
  code: { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 11, wordBreak: 'break-all', background: 'var(--dsw-alias-bg-layer-3, rgba(0,0,0,.04))', color: 'var(--dsw-alias-label-secondary, #475467)', padding: '6px 8px', borderRadius: 8 },
178
- error: { color: 'var(--dsw-alias-state-danger-primary, #d92d20)', fontSize: 12, lineHeight: 1.5 },
178
+ error: { color: 'var(--dsw-alias-state-error-primary, #d92d20)', fontSize: 12, lineHeight: 1.5 },
179
179
  success: { color: 'var(--dsw-alias-state-success-primary, #079455)', fontSize: 12, lineHeight: 1.5 },
180
180
  divider: { height: 1, background: 'var(--dsw-alias-border-l4, rgba(0,0,0,.08))', margin: '6px 0' },
181
181
  }
@@ -192,7 +192,7 @@ function Row(props: { label: string; children: React.ReactNode }): React.ReactEl
192
192
 
193
193
  const CARD_CSS = `
194
194
  .enh-root input:not([type="checkbox"]):not([type="file"]), .enh-root select { transition:border-color .15s, box-shadow .15s; }
195
- .enh-root input:not([type="checkbox"]):not([type="file"]):focus, .enh-root input:not([type="checkbox"]):not([type="file"]):focus-visible, .enh-root select:focus, .enh-root select:focus-visible { outline:none !important; border-color:var(--dsw-alias-state-business-primary, #4d8dff) !important; box-shadow:0 0 0 3px var(--dsw-alias-state-business-primary-a10, rgba(77,141,255,.18)) !important; }
195
+ .enh-root input:not([type="checkbox"]):not([type="file"]):focus, .enh-root input:not([type="checkbox"]):not([type="file"]):focus-visible, .enh-root select:focus, .enh-root select:focus-visible { outline:none !important; border-color:var(--dsw-alias-state-business-primary, #4d8dff) !important; box-shadow:0 0 0 3px color-mix(in srgb, var(--dsw-alias-state-business-primary, #4d8dff) 18%, transparent) !important; }
196
196
  .enh-root select { appearance:none; background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M3 4.5 6 7.5 9 4.5' fill='none' stroke='%23667085' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); background-repeat:no-repeat; background-position:right 12px center; padding-right:32px !important; }
197
197
  .enh-root input[type="file"] { font-size:12px; color:var(--dsw-alias-label-secondary, #475467); max-width:100%; }
198
198
  .enh-root input[type="file"]::file-selector-button { appearance:none; height:32px; margin-right:10px; padding:0 14px; border-radius:var(--dsw-radius-md, 12px); border:.5px solid var(--dsw-alias-border-l4, rgba(0,0,0,.16)); background:var(--dsw-alias-bg-layer-3, #fff); color:var(--dsw-alias-label-primary, inherit); font:inherit; font-size:13px; cursor:pointer; }
@@ -206,7 +206,7 @@ const CARD_CSS = `
206
206
  .enh-root input[type="checkbox"]:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary, #4d8dff); outline-offset:2px; }
207
207
 
208
208
  .enh-root .enh-block-head { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
209
- .enh-root .enh-dirty { flex:none; font-size:11px; line-height:18px; padding:1px 8px; border-radius:999px; color:var(--dsw-alias-state-warning-primary, #b54708); background:var(--dsw-alias-state-warning-bg, rgba(247,144,9,.12)); border:.5px solid var(--dsw-alias-state-warning-primary-a30, rgba(181,71,8,.3)); }
209
+ .enh-root .enh-dirty { flex:none; font-size:11px; line-height:18px; padding:1px 8px; border-radius:999px; color:var(--dsw-alias-state-warn-primary, #b54708); background:color-mix(in srgb, var(--dsw-alias-state-warn-primary, #b54708) 12%, transparent); border:.5px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary, #b54708) 30%, transparent); }
210
210
  .enh-root .enh-subhead { margin:14px 0 2px; font-size:13px; font-weight:600; color:var(--dsw-alias-label-primary, inherit); }
211
211
  .enh-root .enh-grid2 { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px 16px; }
212
212
  .enh-root .enh-grid3 { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:10px 16px; }
@@ -625,6 +625,9 @@ function Card(): React.ReactElement {
625
625
  const enablePush = () => run(async () => {
626
626
  if (!window.isSecureContext) throw new Error('Web Push 需要 HTTPS(或 localhost);请先启用 HTTPS')
627
627
  if (!('serviceWorker' in navigator) || !('PushManager' in window)) throw new Error('当前浏览器不支持 Web Push')
628
+ // /sw.js 仅在启用 PWA 时注册;PWA 关闭时 Service Worker 不可用,Web Push 无从订阅。
629
+ const swProbe = await fetch('/sw.js', { method: 'HEAD', cache: 'no-store' }).catch(() => undefined)
630
+ if (!swProbe || !swProbe.ok) throw new Error('Web Push 需要先启用 PWA(PWA 关闭时 Service Worker /sw.js 不可用)')
628
631
  if (!pushPublicKey) throw new Error('VAPID 公钥尚未生成,请刷新状态后重试')
629
632
  let permission = Notification.permission
630
633
  if (permission === 'default') permission = await Notification.requestPermission()
@@ -1210,7 +1213,7 @@ function connectFocusEvents(ctx: ClientContext): (() => void) | undefined {
1210
1213
  }
1211
1214
  }
1212
1215
 
1213
- function openSessionFromQuery(ctx: ClientContext): void {
1216
+ function openSessionFromQuery(ctx: ClientContext): () => void {
1214
1217
  const read = () => {
1215
1218
  try {
1216
1219
  const url = new URL(location.href)
@@ -1223,20 +1226,29 @@ function openSessionFromQuery(ctx: ClientContext): void {
1223
1226
  /* ignore */
1224
1227
  }
1225
1228
  }
1229
+ const onMessage = (event: MessageEvent) => {
1230
+ const data = event.data as { type?: string; sessionId?: string } | undefined
1231
+ if (data?.type === 'dsh-enhancement-open-session' && data.sessionId && SESSION_ID_PATTERN.test(data.sessionId)) openSession(ctx, data.sessionId)
1232
+ }
1226
1233
  read()
1227
1234
  window.addEventListener('popstate', read)
1228
1235
  try {
1229
- navigator.serviceWorker?.addEventListener('message', (event) => {
1230
- const data = event.data as { type?: string; sessionId?: string } | undefined
1231
- if (data?.type === 'dsh-enhancement-open-session' && data.sessionId && SESSION_ID_PATTERN.test(data.sessionId)) openSession(ctx, data.sessionId)
1232
- })
1236
+ navigator.serviceWorker?.addEventListener('message', onMessage)
1233
1237
  } catch {
1234
1238
  /* service worker unavailable on HTTP */
1235
1239
  }
1240
+ return () => {
1241
+ window.removeEventListener('popstate', read)
1242
+ try {
1243
+ navigator.serviceWorker?.removeEventListener('message', onMessage)
1244
+ } catch {
1245
+ /* ignore */
1246
+ }
1247
+ }
1236
1248
  }
1237
1249
 
1238
1250
  export function apply(ctx: ClientContext): void {
1239
- openSessionFromQuery(ctx)
1251
+ ctx.effect(() => openSessionFromQuery(ctx), 'dsh-enhancement: open session from query')
1240
1252
  ctx.effect(() => connectFocusEvents(ctx), 'dsh-enhancement: focus existing page')
1241
1253
  ctx.effect(() => {
1242
1254
  return ctx.slots.inject('plugins.bundle.config', () =>
package/src/gateway.ts CHANGED
@@ -12,6 +12,7 @@ import { createServer, request as httpRequest, type IncomingMessage, type Server
12
12
  import { createServer as createHttpsServer } from 'node:https'
13
13
  import { connect as netConnect, type Socket } from 'node:net'
14
14
  import { URL } from 'node:url'
15
+ import { dirname } from 'node:path'
15
16
  import {
16
17
  SESSION_COOKIE,
17
18
  StateStore,
@@ -78,7 +79,9 @@ function parseCookies(header: string | undefined): Array<[string, string]> {
78
79
 
79
80
  function safeNext(raw: unknown): string {
80
81
  if (typeof raw !== 'string' || !raw.startsWith('/') || raw.startsWith('//')) return '/'
81
- if (/[\r\n]/.test(raw)) return '/'
82
+ // 反斜杠在 URL 解析中会被归一成斜杠(`/\evil.com` → `//evil.com`),构成开放重定向;
83
+ // 编码形式 %5c 同理,一并拒绝。CRLF 用于防响应拆分。
84
+ if (/[\r\n\\]/.test(raw) || /%5c|%2f%2f/i.test(raw)) return '/'
82
85
  return raw
83
86
  }
84
87
 
@@ -213,8 +216,7 @@ export class Gateway {
213
216
  }
214
217
  this.dshSecret = await readDshSecret(this.ctx)
215
218
  if (!this.dshSecret) {
216
- this.log('warn', '无法读取 DSH 浏览器会话签名密钥;会在后台重试(网关自身仍可登录)')
217
- this.scheduleDshSecretRetry()
219
+ this.log('warn', '无法读取 DSH 浏览器会话签名密钥;启动后会在后台重试(网关自身仍可登录)')
218
220
  }
219
221
  const handler = (req: IncomingMessage, res: ServerResponse): void => {
220
222
  void this.handleRequest(req, res).catch((err: unknown) => {
@@ -256,6 +258,9 @@ export class Gateway {
256
258
  server.listen(config.port, config.bindHost, () => {
257
259
  server.off('error', onError)
258
260
  this.started = true
261
+ // 监听成功(started=true)后才安排密钥重试:此前的 scheduleDshSecretRetry
262
+ // 会因守卫 !this.started 直接返回,导致重试从不发生。
263
+ if (!this.dshSecret) this.scheduleDshSecretRetry()
259
264
  this.log('info', `gateway listening on ${this.secure ? 'https' : 'http'}://${formatHost(config.bindHost)}:${config.port}`)
260
265
  resolve()
261
266
  })
@@ -322,7 +327,7 @@ export class Gateway {
322
327
  dshOrigin: origin,
323
328
  dshAccessUrl,
324
329
  interfaces,
325
- stateDir: this.store.statePath.replace(/\/state\.json$/, ''),
330
+ stateDir: dirname(this.store.statePath),
326
331
  statePath: this.store.statePath,
327
332
  error: this.error,
328
333
  https: tlsStatus(config, this.ctx.webServer.host),
package/src/state.ts CHANGED
@@ -278,7 +278,15 @@ export class StateStore {
278
278
  updatedAt: nowIso(),
279
279
  }
280
280
  } catch {
281
- /* fall through to a fresh state; keep a warning via caller */
281
+ // 解析失败不能静默清空:先把损坏文件改名备份,再回落默认状态,避免用户
282
+ // 的密码哈希 / VAPID / 推送订阅被无提示地覆盖丢失。
283
+ try {
284
+ const backup = `${this.statePath}.corrupt-${Date.now()}`
285
+ renameSync(this.statePath, backup)
286
+ console.warn(`[dsh-enhancement] state.json 无法解析,已备份到 ${backup} 并以默认配置继续`)
287
+ } catch {
288
+ /* 备份失败也不阻塞启动 */
289
+ }
282
290
  }
283
291
  }
284
292
  const config = defaultConfig(this.webServerHost)
@@ -404,7 +412,7 @@ export function verifySession(secret: string, value: string | undefined): Sessio
404
412
  if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) return null
405
413
  try {
406
414
  const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as SessionPayload
407
- if (payload.v !== 1 || typeof payload.exp !== 'number' || typeof payload.exp !== 'number') return null
415
+ if (payload.v !== 1 || typeof payload.sid !== 'string' || typeof payload.iat !== 'number' || typeof payload.exp !== 'number') return null
408
416
  if (payload.exp <= Date.now()) return null
409
417
  return payload
410
418
  } catch {