dsh-openai-subscription 0.1.2 → 0.1.4

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/dist/client.js CHANGED
@@ -53,13 +53,58 @@ const ZH = {
53
53
  'error.poll': '授权状态暂时中断,正在自动重试。',
54
54
  'error.start': '无法启动授权,请稍后重试。',
55
55
  'error.cancel': '无法取消授权,请重试。',
56
- 'error.sync': '模型同步失败,现有模型设置未被覆盖。',
56
+ 'error.sync': '模型同步未完成,请重新读取状态并检查模型设置后重试。',
57
57
  'error.disconnect': '无法断开连接,请重试。',
58
58
  'error.clipboard': '无法复制,请手动选择代码。',
59
59
  'dialog.sync.title': '合并并同步模型?',
60
60
  'dialog.sync.detail': '账号可见模型和 DSH 内置模型将合并到现有列表;你的新增模型、字段编辑和删除选择会被保留。',
61
61
  'dialog.disconnect.title': '断开 ChatGPT 连接?',
62
62
  'dialog.disconnect.detail': '这只会删除本机授权和插件管理的未修改模型项,不会注销或删除你的 ChatGPT 账号。',
63
+ 'status.expired': '授权已过期',
64
+ 'status.expired.detail': '已保存的授权已过期,请刷新授权或重新连接后再使用。',
65
+ 'status.unknown': '已保存连接',
66
+ 'status.unknown.detail': '尚无法确认授权是否有效。若请求失败,请刷新授权或重新连接。',
67
+ 'status.failed': '无法读取连接状态',
68
+ 'status.reloading': '正在重新读取状态…',
69
+ 'status.stale': '显示的是上次读取的状态。',
70
+ 'action.reload': '重新读取状态',
71
+ 'action.retryPoll': '重试检查授权',
72
+ 'action.select': '选择代码',
73
+ 'device.manual': '代码已选中,请按 Ctrl+C 或 ⌘C 复制;也可长按代码复制。',
74
+ 'device.code': '一次性设备代码',
75
+ 'device.newTab': '在新标签页打开',
76
+ 'error.unsafe-url': '验证地址不符合安全要求,已阻止打开。请取消后重试或更新 DSH。',
77
+ 'error.poll-paused': '已暂停自动检查,设备授权可能仍在继续。请重试检查或取消授权。',
78
+ 'error.poll-idle': '未找到本次授权记录,无法确认是否完成。请重新读取状态或取消后重新连接。',
79
+ 'error.poll-unconfirmed': '无法确认保存的授权结果是否属于本次尝试。请重新读取状态,或取消后重新连接。',
80
+ 'error.long-action': '请求超时,但本机操作可能仍在进行。请先重新读取状态,确认后再重试。',
81
+ 'error.credentials-unavailable': '凭据服务不可用,请重启或更新 DSH。',
82
+ 'error.shell-unavailable': '本机进程服务不可用,请重启或更新 DSH。',
83
+ 'error.timer-unavailable': '计时服务不可用,请重启或更新 DSH。',
84
+ 'error.component-unavailable': '登录组件不可用,请更新或重启 DSH。',
85
+ 'error.runtime-unsupported': '当前运行环境不支持此登录方式,请更新 DSH。',
86
+ 'error.busy': '另一项操作正在进行,请稍后重新读取状态。',
87
+ 'error.invalid-method': '登录方式不受支持,请更新 DSH 后重新连接。',
88
+ 'error.not-connected': '未找到本机授权,请连接 ChatGPT。',
89
+ 'error.not-refreshable': '授权无法刷新,请重新连接 ChatGPT。',
90
+ 'error.device-auth-disabled': '账号未启用设备授权,请在 ChatGPT 安全设置中启用后重试。',
91
+ 'error.access-denied': '授权被拒绝,请检查账号权限后重试。',
92
+ 'error.authorization-expired': '设备授权已过期,请重新连接以获取新代码。',
93
+ 'error.rate-limited': '请求过于频繁,请稍后重试。',
94
+ 'error.network': '无法连接服务,请检查网络后重试。',
95
+ 'error.invalid-response': '服务返回了无效响应,请重试或更新 DSH。',
96
+ 'error.process-exited': '登录进程提前退出,请重新连接或更新 DSH。',
97
+ 'error.credential-write-failed': '无法保存本机授权,请检查本机权限后重试;也可断开以清理残留数据。',
98
+ 'error.credential-changed': '本机授权已被其他操作更改,请重新读取状态后重试。',
99
+ 'error.settings-unavailable': '设置服务不可用,请重启 DSH 后重试。',
100
+ 'error.models-unavailable': '暂时无法获取模型目录,请稍后重试同步。',
101
+ 'error.models-empty': '未获取到可用模型,请检查账号权限后重试。',
102
+ 'error.models-confirmation-required': '现有模型列表需要确认合并,请确认后再同步。',
103
+ 'error.settings-conflict': '模型设置已被其他操作更改,请重新读取状态后重试。',
104
+ 'error.settings-write-failed': '无法保存模型设置,请检查本机权限后重试。',
105
+ 'error.ownership-save-failed': '模型可能已更新,但清理记录未能保存。请重试同步并检查模型设置。',
106
+ 'error.cancelled': '操作已取消。',
107
+ 'error.unknown': '操作未完成,请重新读取状态后重试。',
63
108
  };
64
109
  const EN = {
65
110
  nav: 'ChatGPT Subscription',
@@ -111,13 +156,58 @@ const EN = {
111
156
  'error.poll': 'Authorization status was interrupted. Retrying automatically.',
112
157
  'error.start': 'Could not start authorization. Try again shortly.',
113
158
  'error.cancel': 'Could not cancel authorization. Try again.',
114
- 'error.sync': 'Model sync failed. Existing model settings were not overwritten.',
159
+ 'error.sync': 'Model sync did not complete. Reload status and review model settings before retrying.',
115
160
  'error.disconnect': 'Could not disconnect. Try again.',
116
161
  'error.clipboard': 'Could not copy. Select the code manually.',
117
162
  'dialog.sync.title': 'Merge and sync models?',
118
163
  'dialog.sync.detail': 'Account-visible and DSH built-in models will be merged into the current list. Local additions, field edits, and deletion choices are preserved.',
119
164
  'dialog.disconnect.title': 'Disconnect ChatGPT?',
120
165
  'dialog.disconnect.detail': 'This only removes local authorization and unchanged plugin-managed model entries. It does not sign out of or delete your ChatGPT account.',
166
+ 'status.expired': 'Authorization expired',
167
+ 'status.expired.detail': 'Saved authorization has expired. Refresh or reconnect before using this subscription.',
168
+ 'status.unknown': 'Connection saved',
169
+ 'status.unknown.detail': 'Authorization validity is not confirmed. Refresh or reconnect if requests fail.',
170
+ 'status.failed': 'Could not read connection status',
171
+ 'status.reloading': 'Reading status again…',
172
+ 'status.stale': 'Showing the last known status.',
173
+ 'action.reload': 'Reload status',
174
+ 'action.retryPoll': 'Retry authorization check',
175
+ 'action.select': 'Select code',
176
+ 'device.manual': 'Code selected. Press Ctrl+C or ⌘C to copy, or touch and hold the code.',
177
+ 'device.code': 'One-time device code',
178
+ 'device.newTab': 'Opens in a new tab',
179
+ 'error.unsafe-url': 'The verification address did not pass safety checks and was blocked. Cancel and retry, or update DSH.',
180
+ 'error.poll-paused': 'Automatic checks paused. Device authorization may still be running. Retry the check or cancel authorization.',
181
+ 'error.poll-idle': 'No record of this authorization was found, so completion cannot be confirmed. Reload status, or cancel and reconnect.',
182
+ 'error.poll-unconfirmed': 'The saved authorization result could not be linked to this attempt. Reload status, or cancel and reconnect.',
183
+ 'error.long-action': 'The request timed out, but the local operation may still be running. Reload status before deciding whether to retry.',
184
+ 'error.credentials-unavailable': 'The credentials service is unavailable. Restart or update DSH.',
185
+ 'error.shell-unavailable': 'The local process service is unavailable. Restart or update DSH.',
186
+ 'error.timer-unavailable': 'The timer service is unavailable. Restart or update DSH.',
187
+ 'error.component-unavailable': 'The sign-in component is unavailable. Update or restart DSH.',
188
+ 'error.runtime-unsupported': 'This runtime does not support sign-in. Update DSH.',
189
+ 'error.busy': 'Another operation is running. Wait, then reload status.',
190
+ 'error.invalid-method': 'This sign-in method is unsupported. Update DSH and reconnect.',
191
+ 'error.not-connected': 'No local authorization was found. Connect ChatGPT.',
192
+ 'error.not-refreshable': 'Authorization cannot be refreshed. Reconnect ChatGPT.',
193
+ 'error.device-auth-disabled': 'Device authorization is disabled for this account. Enable it in ChatGPT security settings and retry.',
194
+ 'error.access-denied': 'Authorization was denied. Check account access and retry.',
195
+ 'error.authorization-expired': 'Device authorization expired. Reconnect to get a new code.',
196
+ 'error.rate-limited': 'Too many requests. Wait before retrying.',
197
+ 'error.network': 'The service could not be reached. Check the network and retry.',
198
+ 'error.invalid-response': 'The service returned an invalid response. Retry or update DSH.',
199
+ 'error.process-exited': 'The sign-in process exited early. Reconnect or update DSH.',
200
+ 'error.credential-write-failed': 'Local authorization could not be saved. Check local permissions and retry, or disconnect to clean up remaining data.',
201
+ 'error.credential-changed': 'Another operation changed local authorization. Reload status and retry.',
202
+ 'error.settings-unavailable': 'The settings service is unavailable. Restart DSH and retry.',
203
+ 'error.models-unavailable': 'The model catalog is temporarily unavailable. Retry model sync later.',
204
+ 'error.models-empty': 'No available models were returned. Check account access and retry.',
205
+ 'error.models-confirmation-required': 'Merging the existing model list needs confirmation. Confirm before syncing.',
206
+ 'error.settings-conflict': 'Another operation changed model settings. Reload status and retry.',
207
+ 'error.settings-write-failed': 'Model settings could not be saved. Check local permissions and retry.',
208
+ 'error.ownership-save-failed': 'Models may have updated, but cleanup records could not be saved. Retry sync and review model settings.',
209
+ 'error.cancelled': 'The operation was cancelled.',
210
+ 'error.unknown': 'The operation did not complete. Reload status and retry.',
121
211
  };
122
212
  function fallbackTranslate(key, params = {}) {
123
213
  const language = typeof navigator === 'undefined' ? 'zh' : navigator.language.toLowerCase();
@@ -212,6 +302,8 @@ window.__ModuleLoader__.load({
212
302
  font-weight: 500;
213
303
  line-height: 20px;
214
304
  cursor: pointer;
305
+ text-decoration: none;
306
+ text-align: center;
215
307
  transition: background .16s ease, border-color .16s ease, opacity .16s ease, transform .16s ease;
216
308
  }
217
309
  .oasub-button:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, rgba(15, 17, 21, .06)); }
@@ -242,6 +334,9 @@ window.__ModuleLoader__.load({
242
334
  .oasub-device-head { display: flex; flex-direction: column; gap: 3px; }
243
335
  .oasub-code {
244
336
  align-self: flex-start;
337
+ width: min(100%, 24ch);
338
+ min-width: 0;
339
+ border: 1px solid var(--dsw-alias-border-l1, rgba(15, 17, 21, .2));
245
340
  margin: 0;
246
341
  padding: 10px 14px;
247
342
  border-radius: 10px;
@@ -284,14 +379,45 @@ window.__ModuleLoader__.load({
284
379
  }
285
380
  tag.textContent = CSS;
286
381
  }
382
+ // Never translate or render arbitrary host diagnostics. Only these machine codes
383
+ // (or an exact RPC message envelope containing one) cross into the UI.
384
+ const ERROR_CODES = new Set([
385
+ 'credentials-unavailable', 'shell-unavailable', 'timer-unavailable', 'component-unavailable',
386
+ 'runtime-unsupported', 'busy', 'invalid-method', 'not-connected', 'not-refreshable',
387
+ 'device-auth-disabled', 'access-denied', 'authorization-expired', 'rate-limited', 'network',
388
+ 'timeout', 'invalid-response', 'process-exited', 'credential-write-failed', 'credential-changed',
389
+ 'settings-unavailable', 'models-unavailable', 'models-empty', 'models-confirmation-required',
390
+ 'settings-conflict', 'settings-write-failed', 'ownership-save-failed', 'cancelled', 'unknown',
391
+ ]);
392
+ function allowedCode(value) {
393
+ return typeof value === 'string' && ERROR_CODES.has(value) ? value : undefined;
394
+ }
395
+ function recordOf(value) {
396
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
397
+ ? value
398
+ : null;
399
+ }
400
+ function failureCode(error) {
401
+ const raw = recordOf(error);
402
+ const code = allowedCode(raw?.code) ?? allowedCode(raw?.errorCode);
403
+ if (code !== undefined)
404
+ return code;
405
+ const match = typeof raw?.message === 'string'
406
+ ? /^\[openai-subscription:([a-z-]+)\]$/.exec(raw.message)
407
+ : null;
408
+ return allowedCode(match?.[1]);
409
+ }
287
410
  class ClientFailure extends Error {
288
411
  code;
289
412
  constructor(code) {
290
- super(code);
291
- this.code = code;
413
+ super('Request failed');
414
+ this.code = allowedCode(code) ?? 'unknown';
292
415
  }
293
416
  }
294
417
  const RPC_TIMEOUT_MS = 15_000;
418
+ const MUTATION_TIMEOUT_MS = 75_000;
419
+ const MAX_POLL_FAILURES = 4;
420
+ const MAX_POLL_DURATION_MS = 15 * 60_000;
295
421
  function remoteCall(connection, method, args, signal, timeoutMs = RPC_TIMEOUT_MS) {
296
422
  return new Promise((resolve, reject) => {
297
423
  const controller = new AbortController();
@@ -310,8 +436,8 @@ window.__ModuleLoader__.load({
310
436
  callback();
311
437
  };
312
438
  const onAbort = () => {
313
- controller.abort(signal?.reason);
314
439
  finish(() => reject(new ClientFailure('cancelled')));
440
+ controller.abort();
315
441
  };
316
442
  if (signal?.aborted) {
317
443
  onAbort();
@@ -319,64 +445,63 @@ window.__ModuleLoader__.load({
319
445
  }
320
446
  signal?.addEventListener('abort', onAbort, { once: true });
321
447
  timeout = setTimeout(() => {
322
- controller.abort();
323
448
  finish(() => reject(new ClientFailure('timeout')));
449
+ controller.abort();
324
450
  }, timeoutMs);
325
451
  let request;
326
452
  try {
327
453
  request = connection.rpc.call('/api', 'openaiSubscription/' + method, { args }, controller.signal);
328
454
  }
329
- catch {
330
- finish(() => reject(new ClientFailure('request')));
455
+ catch (error) {
456
+ finish(() => reject(new ClientFailure(failureCode(error) ?? 'unknown')));
331
457
  return;
332
458
  }
333
- request.then((result) => {
459
+ Promise.resolve(request).then((result) => {
334
460
  finish(() => {
335
- if (!result || result.ok !== true || result.value === undefined) {
336
- console.warn('[openai-subscription] remote call failed:', method, result?.error?.code ?? 'invalid-result');
337
- reject(new ClientFailure('request'));
338
- return;
461
+ if (!result || result.ok !== true) {
462
+ reject(new ClientFailure(failureCode(result?.error) ?? 'unknown'));
463
+ }
464
+ else if (result.value === undefined) {
465
+ reject(new ClientFailure('invalid-response'));
466
+ }
467
+ else {
468
+ resolve(result.value);
339
469
  }
340
- resolve(result.value);
341
470
  });
342
- }).catch(() => finish(() => reject(new ClientFailure('request'))));
471
+ }).catch((error) => finish(() => reject(new ClientFailure(failureCode(error) ?? 'unknown'))));
343
472
  });
344
473
  }
345
474
  function errorKey(error, fallback) {
346
- return error instanceof ClientFailure && error.code === 'timeout' ? 'error.timeout' : fallback;
347
- }
348
- function recordOf(value) {
349
- return typeof value === 'object' && value !== null && !Array.isArray(value)
350
- ? value
351
- : null;
475
+ const code = failureCode(error);
476
+ return code === undefined || code === 'unknown' ? fallback : 'error.' + code;
352
477
  }
353
478
  function parseStatus(value) {
354
479
  const raw = recordOf(value);
355
480
  if (raw === null || typeof raw.configured !== 'boolean' || typeof raw.ready !== 'boolean')
356
- throw new ClientFailure('request');
357
- if (!raw.configured) {
358
- return { configured: false, ready: raw.ready, refreshable: false, modelsSynced: false, modelCount: 0 };
359
- }
481
+ throw new ClientFailure('invalid-response');
360
482
  return {
361
- configured: true,
483
+ configured: raw.configured,
362
484
  ready: raw.ready,
363
- refreshable: raw.refreshable === true,
364
- modelsSynced: raw.modelsSynced === true,
365
- modelCount: typeof raw.modelCount === 'number' && Number.isSafeInteger(raw.modelCount) && raw.modelCount >= 0
366
- ? raw.modelCount
367
- : 0,
485
+ refreshable: raw.configured && raw.refreshable === true,
486
+ modelsSynced: raw.configured && raw.modelsSynced === true,
487
+ modelCount: raw.configured && typeof raw.modelCount === 'number' && Number.isSafeInteger(raw.modelCount) && raw.modelCount >= 0
488
+ ? raw.modelCount : 0,
489
+ unavailableReason: allowedCode(raw.unavailableReason),
490
+ credentialState: raw.credentialState === 'valid' || raw.credentialState === 'expired' ? raw.credentialState : 'unknown',
491
+ cleanupAvailable: raw.cleanupAvailable === true || raw.configured,
492
+ flowPending: raw.flowPending === true,
368
493
  };
369
494
  }
370
495
  function parseAuthorize(value) {
371
496
  const raw = recordOf(value);
372
497
  if (raw === null || typeof raw.started !== 'boolean')
373
- throw new ClientFailure('request');
374
- return { started: raw.started };
498
+ throw new ClientFailure('invalid-response');
499
+ return { started: raw.started, errorCode: allowedCode(raw.errorCode) };
375
500
  }
376
501
  function parsePoll(value) {
377
502
  const raw = recordOf(value);
378
503
  if (raw === null || (raw.status !== 'idle' && raw.status !== 'pending' && raw.status !== 'done'))
379
- throw new ClientFailure('request');
504
+ throw new ClientFailure('invalid-response');
380
505
  const notices = [];
381
506
  if (Array.isArray(raw.notices)) {
382
507
  for (const candidate of raw.notices) {
@@ -386,10 +511,12 @@ window.__ModuleLoader__.load({
386
511
  const kind = notice.kind;
387
512
  notices.push({
388
513
  kind: kind === 'requesting-code' || kind === 'enter-code' || kind === 'refreshing' || kind === 'models-synced' || kind === 'models-sync-failed'
389
- ? kind
390
- : undefined,
514
+ ? kind : undefined,
391
515
  url: typeof notice.url === 'string' ? notice.url : undefined,
392
- code: typeof notice.code === 'string' ? notice.code : undefined,
516
+ // Device codes are not arbitrary log messages or credentials.
517
+ code: typeof notice.code === 'string' && /^[A-Za-z0-9-]{3,32}$/.test(notice.code)
518
+ ? notice.code : undefined,
519
+ errorCode: allowedCode(notice.errorCode),
393
520
  });
394
521
  }
395
522
  }
@@ -397,21 +524,25 @@ window.__ModuleLoader__.load({
397
524
  status: raw.status,
398
525
  notices,
399
526
  outcome: raw.outcome === 'authorized' || raw.outcome === 'cancelled' || raw.outcome === 'failed' ? raw.outcome : null,
527
+ errorCode: allowedCode(raw.errorCode),
400
528
  };
401
529
  }
402
530
  function parseModelSync(value) {
403
531
  const raw = recordOf(value);
404
532
  if (raw === null || raw.synced !== true || typeof raw.count !== 'number' || !Number.isSafeInteger(raw.count) || raw.count < 0) {
405
- throw new ClientFailure('request');
533
+ throw new ClientFailure('invalid-response');
406
534
  }
407
- return { synced: true, count: raw.count };
535
+ return { synced: true, count: raw.count, warningCode: raw.warningCode == null ? undefined : allowedCode(raw.warningCode) ?? 'unknown' };
408
536
  }
409
537
  function safeVerificationUrl(value) {
410
- if (typeof value !== 'string')
538
+ if (typeof value !== 'string' || value !== value.trim())
411
539
  return null;
412
540
  try {
413
541
  const url = new URL(value);
414
- return url.protocol === 'https:' ? url.href : null;
542
+ return url.origin === 'https://auth.openai.com' && !url.username && !url.password &&
543
+ (url.pathname === '/codex/device' || url.pathname === '/codex/device/') && !url.search && !url.hash &&
544
+ /^https:\/\/auth\.openai\.com(?::443)?\/codex\/device\/?$/i.test(value)
545
+ ? url.href : null;
415
546
  }
416
547
  catch {
417
548
  return null;
@@ -426,26 +557,67 @@ window.__ModuleLoader__.load({
426
557
  const [flowMethod, setFlowMethod] = React.useState('device_code');
427
558
  const [device, setDevice] = React.useState(null);
428
559
  const [notice, setNotice] = React.useState(null);
560
+ const [statusError, setStatusError] = React.useState(null);
561
+ const [statusLoading, setStatusLoading] = React.useState(true);
562
+ const [pollError, setPollError] = React.useState(null);
563
+ const [flowWarning, setFlowWarning] = React.useState(null);
564
+ const [copyNotice, setCopyNotice] = React.useState(null);
565
+ const [cancelPending, setCancelPending] = React.useState(false);
429
566
  const [confirm, setConfirm] = React.useState(null);
430
567
  const [statusRevision, setStatusRevision] = React.useState(0);
431
568
  const actionLock = React.useRef(false);
569
+ const cancelAcknowledged = React.useRef(false);
570
+ const flowObserved = React.useRef(false);
571
+ const reloadRef = React.useRef(null);
572
+ const lifetime = React.useRef(null);
573
+ const phaseRef = React.useRef(phase);
574
+ const dialogRef = React.useRef(null);
575
+ const codeRef = React.useRef(null);
576
+ const returnFocus = React.useRef(null);
577
+ phaseRef.current = phase;
432
578
  const busy = phase !== 'idle';
579
+ const polling = phase === 'authorizing';
433
580
  const configured = info?.configured === true;
434
- const ready = info?.ready !== false;
581
+ const ready = info?.ready === true;
435
582
  const reloadStatus = () => setStatusRevision((revision) => revision + 1);
583
+ const changePhase = (next) => {
584
+ phaseRef.current = next;
585
+ setPhase(next);
586
+ };
587
+ React.useEffect(() => {
588
+ const controller = new AbortController();
589
+ lifetime.current = controller;
590
+ actionLock.current = false;
591
+ cancelAcknowledged.current = false;
592
+ flowObserved.current = false;
593
+ setCancelPending(false);
594
+ setDevice(null);
595
+ setCopyNotice(null);
596
+ changePhase('idle');
597
+ return () => { controller.abort(); };
598
+ }, [connection]);
436
599
  React.useEffect(() => {
437
600
  let alive = true;
438
601
  const controller = new AbortController();
602
+ setStatusLoading(true);
439
603
  remoteCall(connection, 'status', {}, controller.signal).then(parseStatus).then((status) => {
440
604
  if (!alive)
441
605
  return;
442
606
  setInfo(status);
443
- setNotice((current) => current?.tone === 'error' ? null : current);
607
+ // Status owns only its own feedback; action failures survive reloads.
608
+ setStatusError(null);
609
+ if (status.flowPending)
610
+ flowObserved.current = true;
611
+ if (status.flowPending && phaseRef.current === 'idle') {
612
+ setFlowStage(undefined);
613
+ changePhase('authorizing');
614
+ }
444
615
  }).catch((error) => {
445
616
  if (!alive || controller.signal.aborted)
446
617
  return;
447
- setNotice({ tone: 'error', key: errorKey(error, 'error.status') });
448
- });
618
+ setStatusError(errorKey(error, 'error.status'));
619
+ }).finally(() => { if (alive)
620
+ setStatusLoading(false); });
449
621
  return () => {
450
622
  alive = false;
451
623
  controller.abort();
@@ -454,180 +626,344 @@ window.__ModuleLoader__.load({
454
626
  React.useEffect(() => {
455
627
  if (props.subscribeReset === undefined)
456
628
  return;
457
- return props.subscribeReset(() => reloadStatus());
629
+ return props.subscribeReset(reloadStatus);
458
630
  }, [props.subscribeReset]);
459
631
  React.useEffect(() => {
460
- if (confirm === null)
632
+ if (confirm === null || typeof document === 'undefined')
461
633
  return;
462
- const closeOnEscape = (event) => {
463
- if (event.key === 'Escape' && !busy)
634
+ const dialog = dialogRef.current;
635
+ const previous = returnFocus.current ?? document.activeElement;
636
+ const focusable = () => Array.from(dialog?.querySelectorAll('button:not(:disabled), a[href], input:not(:disabled), [tabindex="0"]') ?? []);
637
+ const focusFirst = () => (focusable()[0] ?? dialog)?.focus();
638
+ focusFirst(); // Start on Cancel, never the destructive confirmation.
639
+ const onKey = (event) => {
640
+ if (event.key === 'Escape') {
641
+ event.preventDefault();
642
+ event.stopPropagation();
464
643
  setConfirm(null);
644
+ }
645
+ else if (event.key === 'Tab') {
646
+ const items = focusable();
647
+ const first = items[0];
648
+ const last = items[items.length - 1];
649
+ if (!first || !last) {
650
+ event.preventDefault();
651
+ dialog?.focus();
652
+ return;
653
+ }
654
+ if (!dialog?.contains(document.activeElement) || (event.shiftKey ? document.activeElement === first : document.activeElement === last)) {
655
+ event.preventDefault();
656
+ (event.shiftKey ? last : first).focus();
657
+ }
658
+ }
659
+ };
660
+ const onFocus = (event) => {
661
+ if (dialog && !dialog.contains(event.target))
662
+ focusFirst();
663
+ };
664
+ document.addEventListener('keydown', onKey);
665
+ document.addEventListener('focusin', onFocus);
666
+ return () => {
667
+ document.removeEventListener('keydown', onKey);
668
+ document.removeEventListener('focusin', onFocus);
669
+ if (previous?.isConnected && !previous.matches(':disabled'))
670
+ previous.focus();
671
+ else
672
+ reloadRef.current?.focus();
673
+ returnFocus.current = null;
465
674
  };
466
- document.addEventListener('keydown', closeOnEscape);
467
- return () => document.removeEventListener('keydown', closeOnEscape);
468
- }, [confirm, busy]);
675
+ }, [confirm]);
469
676
  React.useEffect(() => {
470
- if (phase !== 'authorizing' && phase !== 'cancelling')
677
+ if (!polling)
471
678
  return;
472
679
  let alive = true;
680
+ let stopped = false;
473
681
  let inFlight = false;
474
682
  let failures = 0;
475
- let modelSyncPending = false;
683
+ let nextPollAt = 0;
684
+ const startedAt = Date.now();
476
685
  const controller = new AbortController();
477
- const complete = (outcome) => {
478
- if (!alive)
479
- return;
480
- setDevice(null);
481
- setFlowStage(undefined);
482
- setPhase('idle');
483
- if (outcome === 'authorized') {
484
- setNotice(modelSyncPending
485
- ? { tone: 'warning', key: 'toast.sync-pending' }
486
- : { tone: 'success', key: 'toast.connected' });
487
- }
488
- else if (outcome === 'cancelled')
489
- setNotice({ tone: 'neutral', key: 'toast.cancelled' });
490
- else
491
- setNotice({ tone: 'error', key: 'error.start' });
492
- reloadStatus();
493
- };
494
- const applyNotices = (notices) => {
495
- for (const item of notices) {
496
- if (item.kind !== undefined)
497
- setFlowStage(item.kind);
498
- if (item.kind === 'enter-code' && item.code) {
499
- setDevice({ code: item.code, url: safeVerificationUrl(item.url) });
500
- }
501
- else if (item.kind === 'models-synced') {
502
- setNotice({ tone: 'success', key: 'toast.connected' });
503
- }
504
- else if (item.kind === 'models-sync-failed') {
505
- modelSyncPending = true;
506
- setNotice({ tone: 'warning', key: 'toast.sync-pending' });
507
- }
508
- }
686
+ let stopTimer;
687
+ const pause = (key) => {
688
+ stopped = true;
689
+ stopTimer?.();
690
+ setPollError(key);
691
+ changePhase('paused');
509
692
  };
510
693
  const pollOnce = () => {
511
- if (!alive || inFlight)
694
+ if (!alive || stopped)
695
+ return;
696
+ if (Date.now() - startedAt >= MAX_POLL_DURATION_MS) {
697
+ pause('error.poll-paused');
698
+ controller.abort();
699
+ return;
700
+ }
701
+ if (inFlight || Date.now() < nextPollAt)
512
702
  return;
513
703
  inFlight = true;
514
704
  remoteCall(connection, 'poll', {}, controller.signal).then(parsePoll).then((poll) => {
515
- if (!alive)
705
+ if (!alive || stopped)
516
706
  return;
517
707
  failures = 0;
518
- applyNotices(poll.notices);
519
- if (poll.status === 'done')
520
- complete(poll.outcome);
521
- else if (poll.status === 'idle') {
522
- void remoteCall(connection, 'status', {}, controller.signal).then(parseStatus).then((status) => {
523
- if (!alive)
524
- return;
525
- setInfo(status);
526
- complete(status.configured ? 'authorized' : 'failed');
527
- }).catch(() => complete('failed'));
708
+ nextPollAt = 0;
709
+ setPollError(null);
710
+ if (poll.status === 'pending')
711
+ flowObserved.current = true;
712
+ if ((poll.status === 'idle' || !flowObserved.current) && cancelAcknowledged.current) {
713
+ stopped = true;
714
+ stopTimer?.();
715
+ setDevice(null);
716
+ setCopyNotice(null);
717
+ setFlowWarning(null);
718
+ setFlowStage(undefined);
719
+ setNotice({ tone: 'neutral', key: 'toast.cancelled' });
720
+ changePhase('idle');
721
+ reloadStatus();
722
+ return;
723
+ }
724
+ if (poll.status === 'idle' || (poll.status === 'done' && poll.outcome === null)) {
725
+ // Configured may describe an older account. It proves nothing about this attempt.
726
+ pause(poll.status === 'idle' ? 'error.poll-idle' : 'error.invalid-response');
727
+ reloadStatus();
728
+ return;
729
+ }
730
+ if (poll.status === 'done' && !flowObserved.current) {
731
+ // A retained terminal result may predate a timed-out authorize RPC.
732
+ pause('error.poll-unconfirmed');
733
+ reloadStatus();
734
+ return;
735
+ }
736
+ let warning = null;
737
+ for (const item of poll.notices) {
738
+ if (item.kind === 'requesting-code' || item.kind === 'enter-code' || item.kind === 'refreshing')
739
+ setFlowStage(item.kind);
740
+ if (item.kind === 'enter-code') {
741
+ const url = safeVerificationUrl(item.url);
742
+ if (item.code) {
743
+ const code = item.code;
744
+ setDevice((current) => current && current.code === code && current.url === url ? current : { code, url });
745
+ }
746
+ else
747
+ setDevice(null);
748
+ if (!url)
749
+ warning = 'error.unsafe-url';
750
+ else if (!item.code)
751
+ warning = 'error.invalid-response';
752
+ }
753
+ if (item.kind === 'models-sync-failed')
754
+ warning = item.errorCode ? 'error.' + item.errorCode : 'toast.sync-pending';
755
+ else if (item.errorCode)
756
+ warning = 'error.' + item.errorCode;
757
+ }
758
+ setFlowWarning(warning);
759
+ if (poll.status === 'done') {
760
+ stopped = true;
761
+ stopTimer?.();
762
+ setDevice(null);
763
+ setCopyNotice(null);
764
+ setFlowStage(undefined);
765
+ changePhase('idle');
766
+ if (poll.outcome === 'authorized') {
767
+ setNotice({ tone: warning ? 'warning' : 'success', key: warning ?? 'toast.connected' });
768
+ }
769
+ else if (poll.outcome === 'cancelled') {
770
+ setNotice({ tone: 'neutral', key: 'toast.cancelled' });
771
+ }
772
+ else {
773
+ const code = poll.errorCode ?? poll.notices.map((item) => item.errorCode).filter(Boolean).pop();
774
+ setNotice({ tone: 'error', key: code ? 'error.' + code : 'error.start' });
775
+ }
776
+ setFlowWarning(null);
777
+ reloadStatus();
528
778
  }
529
779
  }).catch((error) => {
530
- if (!alive || controller.signal.aborted)
780
+ if (!alive || stopped || controller.signal.aborted)
531
781
  return;
532
782
  failures += 1;
533
- if (failures >= 2)
534
- setNotice({ tone: 'warning', key: errorKey(error, 'error.poll') });
783
+ if (failures >= MAX_POLL_FAILURES)
784
+ pause('error.poll-paused');
785
+ else {
786
+ setPollError(errorKey(error, 'error.poll'));
787
+ nextPollAt = Date.now() + Math.min(8_000, 1_000 * 2 ** (failures - 1));
788
+ }
535
789
  }).finally(() => { inFlight = false; });
536
790
  };
537
791
  pollOnce();
538
- const stop = timer?.interval
539
- ? timer.interval(pollOnce, 1_000)
540
- : (() => {
541
- const id = setInterval(pollOnce, 1_000);
542
- return () => clearInterval(id);
543
- })();
792
+ // Host timers are preferred, but browser timers also support settings-only mounts.
793
+ try {
794
+ stopTimer = timer?.interval(pollOnce, 1_000);
795
+ }
796
+ catch { /* use browser timer below */ }
797
+ if (!stopTimer) {
798
+ const id = setInterval(pollOnce, 1_000);
799
+ stopTimer = () => clearInterval(id);
800
+ }
544
801
  return () => {
545
802
  alive = false;
546
803
  controller.abort();
547
- stop();
804
+ stopTimer?.();
548
805
  };
549
- }, [phase, connection, timer]);
550
- const startAuthorization = (method) => {
806
+ }, [polling, connection, timer]);
807
+ const retryPolling = () => {
808
+ if (phaseRef.current !== 'paused' || actionLock.current)
809
+ return;
810
+ setPollError(null);
811
+ changePhase('authorizing');
812
+ reloadStatus();
813
+ };
814
+ const openConfirm = (action) => {
551
815
  if (busy || actionLock.current)
552
816
  return;
817
+ returnFocus.current = typeof document === 'undefined' ? null : document.activeElement;
818
+ setConfirm(action);
819
+ };
820
+ const startAuthorization = (method) => {
821
+ const signal = lifetime.current?.signal;
822
+ if (phaseRef.current !== 'idle' || actionLock.current || !signal || signal.aborted)
823
+ return;
553
824
  actionLock.current = true;
825
+ cancelAcknowledged.current = false;
826
+ flowObserved.current = false;
554
827
  setNotice(null);
828
+ setPollError(null);
829
+ setFlowWarning(null);
830
+ setCopyNotice(null);
555
831
  setDevice(null);
556
832
  setFlowMethod(method);
557
833
  setFlowStage(method === 'refresh' ? 'refreshing' : 'requesting-code');
558
- setPhase('starting');
559
- remoteCall(connection, 'authorize', { method }).then(parseAuthorize).then((result) => {
834
+ changePhase('starting');
835
+ remoteCall(connection, 'authorize', { method }, signal).then(parseAuthorize).then((result) => {
836
+ if (signal.aborted)
837
+ return;
560
838
  if (!result.started) {
561
- setNotice({ tone: 'error', key: 'error.start' });
562
- setPhase('idle');
839
+ setNotice({ tone: 'error', key: result.errorCode ? 'error.' + result.errorCode : 'error.start' });
840
+ changePhase('idle');
841
+ reloadStatus();
563
842
  return;
564
843
  }
565
- setPhase('authorizing');
844
+ flowObserved.current = true;
845
+ changePhase('authorizing');
566
846
  }).catch((error) => {
567
- void remoteCall(connection, 'cancel', {}).catch(() => { });
847
+ if (signal.aborted)
848
+ return;
849
+ // A timeout does not prove the host failed to start. Never cancel implicitly.
568
850
  setNotice({ tone: 'error', key: errorKey(error, 'error.start') });
569
- setPhase('idle');
570
- }).finally(() => { actionLock.current = false; });
851
+ const code = failureCode(error);
852
+ changePhase(code === 'timeout' || code === 'network' || code === 'unknown' || code === 'invalid-response' ? 'paused' : 'idle');
853
+ reloadStatus();
854
+ }).finally(() => { if (!signal.aborted)
855
+ actionLock.current = false; });
571
856
  };
572
857
  const cancelAuthorization = () => {
573
- if ((phase !== 'authorizing' && phase !== 'starting') || actionLock.current)
858
+ const signal = lifetime.current?.signal;
859
+ if ((phaseRef.current !== 'authorizing' && phaseRef.current !== 'paused') || actionLock.current || !signal || signal.aborted)
574
860
  return;
575
861
  actionLock.current = true;
576
- setPhase('cancelling');
577
- remoteCall(connection, 'cancel', {}).catch((error) => {
578
- setNotice({ tone: 'error', key: errorKey(error, 'error.cancel') });
579
- setPhase('authorizing');
580
- }).finally(() => { actionLock.current = false; });
862
+ setCancelPending(true);
863
+ remoteCall(connection, 'cancel', {}, signal).then(() => {
864
+ if (signal.aborted)
865
+ return;
866
+ // Idle after acknowledged cancellation is safe to dismiss; never infer authorization.
867
+ cancelAcknowledged.current = true;
868
+ if (phaseRef.current === 'paused') {
869
+ setPollError(null);
870
+ changePhase('authorizing');
871
+ }
872
+ }).catch((error) => {
873
+ if (!signal.aborted)
874
+ setNotice({ tone: 'error', key: errorKey(error, 'error.cancel') });
875
+ }).finally(() => {
876
+ if (!signal.aborted) {
877
+ actionLock.current = false;
878
+ setCancelPending(false);
879
+ }
880
+ });
581
881
  };
582
- const synchronizeModels = () => {
583
- if (busy || actionLock.current)
882
+ const synchronizeModels = (confirmed = false) => {
883
+ const signal = lifetime.current?.signal;
884
+ if (phaseRef.current !== 'idle' || actionLock.current || !signal || signal.aborted)
584
885
  return;
585
886
  actionLock.current = true;
586
887
  setConfirm(null);
587
888
  setNotice(null);
588
- setPhase('syncing');
589
- remoteCall(connection, 'syncModels', {}, undefined, 25_000).then(parseModelSync).then((result) => {
590
- setNotice({ tone: 'success', key: 'toast.synced', params: { count: result.count } });
591
- reloadStatus();
889
+ changePhase('syncing');
890
+ let needsConfirmation = false;
891
+ remoteCall(connection, 'syncModels', confirmed ? { confirmed: true } : {}, signal, MUTATION_TIMEOUT_MS).then(parseModelSync).then((result) => {
892
+ if (signal.aborted)
893
+ return;
894
+ setNotice(result.warningCode
895
+ ? { tone: 'warning', key: 'error.' + result.warningCode }
896
+ : { tone: 'success', key: 'toast.synced', params: { count: result.count } });
592
897
  }).catch((error) => {
593
- setNotice({ tone: 'error', key: errorKey(error, 'error.sync') });
898
+ if (signal.aborted)
899
+ return;
900
+ setNotice({ tone: 'error', key: failureCode(error) === 'timeout' ? 'error.long-action' : errorKey(error, 'error.sync') });
901
+ needsConfirmation = failureCode(error) === 'models-confirmation-required';
594
902
  }).finally(() => {
595
- actionLock.current = false;
596
- setPhase('idle');
903
+ if (!signal.aborted) {
904
+ actionLock.current = false;
905
+ changePhase('idle');
906
+ if (needsConfirmation) {
907
+ returnFocus.current = typeof document === 'undefined' ? null : document.activeElement;
908
+ setConfirm('sync');
909
+ }
910
+ reloadStatus();
911
+ }
597
912
  });
598
913
  };
599
914
  const requestModelSync = () => {
600
915
  if (info?.modelsSynced)
601
916
  synchronizeModels();
602
917
  else
603
- setConfirm('sync');
918
+ openConfirm('sync');
604
919
  };
605
920
  const disconnect = () => {
606
- if (busy || actionLock.current)
921
+ const signal = lifetime.current?.signal;
922
+ if (phaseRef.current !== 'idle' || actionLock.current || !signal || signal.aborted)
607
923
  return;
608
924
  actionLock.current = true;
609
925
  setConfirm(null);
610
926
  setNotice(null);
611
- setPhase('disconnecting');
612
- remoteCall(connection, 'logout', {}, undefined, 25_000).then(() => {
613
- setInfo({ configured: false, ready, refreshable: false, modelsSynced: false, modelCount: 0 });
927
+ changePhase('disconnecting');
928
+ remoteCall(connection, 'logout', {}, signal, MUTATION_TIMEOUT_MS).then(() => {
929
+ if (signal.aborted)
930
+ return;
614
931
  setDevice(null);
615
932
  setNotice({ tone: 'success', key: 'toast.disconnected' });
616
933
  }).catch((error) => {
617
- setNotice({ tone: 'error', key: errorKey(error, 'error.disconnect') });
934
+ if (!signal.aborted)
935
+ setNotice({ tone: 'error', key: failureCode(error) === 'timeout' ? 'error.long-action' : errorKey(error, 'error.disconnect') });
618
936
  }).finally(() => {
619
- actionLock.current = false;
620
- setPhase('idle');
937
+ if (!signal.aborted) {
938
+ actionLock.current = false;
939
+ changePhase('idle');
940
+ reloadStatus();
941
+ }
621
942
  });
622
943
  };
944
+ const selectCode = () => {
945
+ codeRef.current?.focus();
946
+ codeRef.current?.select();
947
+ setCopyNotice('device.manual');
948
+ };
623
949
  const copyCode = () => {
624
- if (device === null || typeof navigator.clipboard?.writeText !== 'function') {
625
- setNotice({ tone: 'error', key: 'error.clipboard' });
950
+ if (device === null)
626
951
  return;
952
+ const signal = lifetime.current?.signal;
953
+ try {
954
+ if (typeof navigator === 'undefined' || typeof navigator.clipboard?.writeText !== 'function') {
955
+ selectCode();
956
+ return;
957
+ }
958
+ Promise.resolve(navigator.clipboard.writeText(device.code)).then(() => {
959
+ if (!signal?.aborted)
960
+ setCopyNotice('toast.copied');
961
+ }).catch(() => { if (!signal?.aborted)
962
+ selectCode(); });
963
+ }
964
+ catch {
965
+ selectCode();
627
966
  }
628
- navigator.clipboard.writeText(device.code).then(() => {
629
- setNotice({ tone: 'success', key: 'toast.copied' });
630
- }).catch(() => setNotice({ tone: 'error', key: 'error.clipboard' }));
631
967
  };
632
968
  const progressKey = flowStage === 'enter-code'
633
969
  ? 'progress.enter-code'
@@ -636,9 +972,14 @@ window.__ModuleLoader__.load({
636
972
  : flowStage === 'requesting-code'
637
973
  ? 'progress.requesting-code'
638
974
  : 'progress.authorizing';
975
+ const statusKey = configured
976
+ ? info?.credentialState === 'expired' ? 'status.expired' : info?.credentialState === 'valid' ? 'status.connected' : 'status.unknown'
977
+ : ready ? 'status.disconnected' : 'status.unavailable';
639
978
  const statusCard = info === null
640
- ? el('div', { className: 'oasub-card oasub-skeleton', role: 'status', 'aria-live': 'polite' }, el('div', { className: 'oasub-status-title' }, t('status.loading')))
641
- : el('section', { className: 'oasub-card', 'aria-busy': busy }, el('div', { className: 'oasub-status-line' }, el('div', { className: 'oasub-status-copy' }, el('div', { className: 'oasub-status-title' }, el('span', { className: 'oasub-dot ' + (configured ? 'success' : ready ? '' : 'warning'), 'aria-hidden': true }), t(configured ? 'status.connected' : ready ? 'status.disconnected' : 'status.unavailable')), el('div', { className: 'oasub-status-detail' }, t(configured ? 'status.connected.detail' : ready ? 'status.disconnected.detail' : 'status.unavailable.detail'))), configured && info.modelsSynced
979
+ ? el('div', { className: 'oasub-card' + (statusLoading ? ' oasub-skeleton' : ''), role: 'status', 'aria-live': 'polite' }, el('div', { className: 'oasub-status-title' }, t(statusLoading ? 'status.loading' : 'status.failed')))
980
+ : el('section', { className: 'oasub-card' }, el('div', { className: 'oasub-status-line' }, el('div', { className: 'oasub-status-copy' }, el('div', { className: 'oasub-status-title' }, el('span', { className: 'oasub-dot ' + (statusKey === 'status.connected' ? 'success' : statusKey === 'status.disconnected' ? '' : 'warning'), 'aria-hidden': true }), t(statusKey)), el('div', { className: 'oasub-status-detail' }, t(statusKey + '.detail')), !ready && configured
981
+ ? el('div', { className: 'oasub-status-detail' }, t('status.unavailable')) : null, !ready && info.unavailableReason
982
+ ? el('div', { className: 'oasub-status-detail' }, t('error.' + info.unavailableReason)) : null), configured && info.modelsSynced
642
983
  ? el('span', { className: 'oasub-pill' }, t('model.count', { count: info.modelCount }))
643
984
  : null), configured
644
985
  ? el('div', { className: 'oasub-model' }, el('span', { className: 'oasub-model-icon', 'aria-hidden': true }, info.modelsSynced ? '✓' : '↻'), el('div', { className: 'oasub-model-copy' }, el('div', { className: 'oasub-model-title' }, t(info.modelsSynced ? 'model.synced' : 'model.attention')), el('div', { className: 'oasub-model-detail' }, t(info.modelsSynced ? 'model.synced.detail' : 'model.attention.detail'))))
@@ -663,40 +1004,36 @@ window.__ModuleLoader__.load({
663
1004
  }, phase === 'starting' || phase === 'authorizing'
664
1005
  ? t('action.refreshing')
665
1006
  : t(info.refreshable ? 'action.refresh' : 'action.reconnect'))
666
- : null, configured
1007
+ : null, info.cleanupAvailable
667
1008
  ? el('button', {
668
1009
  type: 'button',
669
1010
  className: 'oasub-button danger quiet',
670
1011
  disabled: busy,
671
- onClick: () => setConfirm('disconnect'),
1012
+ onClick: () => openConfirm('disconnect'),
672
1013
  }, phase === 'disconnecting' ? t('action.disconnecting') : t('action.disconnect'))
673
- : null, phase === 'authorizing' || phase === 'starting' || phase === 'cancelling'
674
- ? el('button', {
675
- type: 'button',
676
- className: 'oasub-button quiet',
677
- disabled: phase === 'cancelling',
678
- onClick: cancelAuthorization,
679
- }, t(phase === 'cancelling' ? 'action.cancelling' : 'action.cancel'))
680
1014
  : null));
681
- return el('div', { className: 'oasub-wrap' }, el('header', { className: 'oasub-header' }, el('div', { className: 'oasub-mark', 'aria-hidden': true }, '✦'), el('div', { className: 'oasub-heading' }, el('h2', { className: 'oasub-title' }, t('title')), el('p', { className: 'oasub-subtitle' }, t('subtitle')))), statusCard, device !== null
682
- ? el('section', { className: 'oasub-card oasub-device', role: 'status', 'aria-live': 'polite' }, el('div', { className: 'oasub-device-head' }, el('div', { className: 'oasub-status-title' }, t('device.title')), el('div', { className: 'oasub-status-detail' }, t('device.detail'))), el('code', { className: 'oasub-code' }, device.code), el('div', { className: 'oasub-actions' }, device.url !== null
683
- ? el('button', { type: 'button', className: 'oasub-button primary', onClick: () => window.open(device.url ?? '', '_blank', 'noopener,noreferrer') }, t('action.open'))
684
- : null, el('button', { type: 'button', className: 'oasub-button', onClick: copyCode }, t('action.copy')), el('button', { type: 'button', className: 'oasub-button quiet', onClick: cancelAuthorization }, t('action.cancel'))))
685
- : phase === 'authorizing' || phase === 'starting' || phase === 'cancelling'
686
- ? el('div', { className: 'oasub-notice', role: 'status', 'aria-live': 'polite' }, el('span', { className: 'oasub-dot warning', 'aria-hidden': true }), t(progressKey))
687
- : null, notice !== null
688
- ? el('div', { className: 'oasub-notice ' + notice.tone, role: notice.tone === 'error' ? 'alert' : 'status', 'aria-live': 'polite' }, el('span', { 'aria-hidden': true }, notice.tone === 'success' ? '✓' : notice.tone === 'error' ? '!' : '•'), t(notice.key, notice.params), notice.key === 'error.status'
689
- ? el('button', { type: 'button', className: 'oasub-button quiet', disabled: busy, onClick: reloadStatus }, t('action.retry'))
690
- : null)
691
- : null, confirm !== null
1015
+ return el('div', { className: 'oasub-wrap' }, el('header', { className: 'oasub-header' }, el('div', { className: 'oasub-mark', 'aria-hidden': true }, '✦'), el('div', { className: 'oasub-heading' }, el('h2', { className: 'oasub-title' }, t('title')), el('p', { className: 'oasub-subtitle' }, t('subtitle')))), statusCard, statusError !== null
1016
+ ? el('div', { className: 'oasub-notice error', role: 'alert' }, t(statusError), info !== null ? ' ' + t('status.stale') : null, el('button', { type: 'button', className: 'oasub-button quiet', disabled: statusLoading, onClick: reloadStatus }, t('action.retry'))) : null, el('div', { className: 'oasub-actions' }, el('button', { ref: reloadRef, type: 'button', className: 'oasub-button', disabled: statusLoading, onClick: reloadStatus }, t(statusLoading ? 'status.reloading' : 'action.reload'))), device !== null
1017
+ ? el('section', { className: 'oasub-card oasub-device', 'aria-label': t('device.title') }, el('div', { className: 'oasub-device-head' }, el('div', { className: 'oasub-status-title' }, t('device.title')), el('div', { className: 'oasub-status-detail' }, t('device.detail'))), el('input', { ref: codeRef, className: 'oasub-code', type: 'text', readOnly: true, value: device.code,
1018
+ 'aria-label': t('device.code'), autoComplete: 'off', spellCheck: false, onFocus: (event) => event.currentTarget.select() }), el('div', { className: 'oasub-actions' }, device.url !== null
1019
+ ? el('a', { className: 'oasub-button primary', href: device.url, target: '_blank', rel: 'noopener noreferrer',
1020
+ 'aria-label': t('action.open') + ' — ' + t('device.newTab') }, t('action.open'))
1021
+ : null, el('button', { type: 'button', className: 'oasub-button', onClick: copyCode }, t('action.copy')), el('button', { type: 'button', className: 'oasub-button', onClick: selectCode }, t('action.select'))), copyNotice ? el('div', { role: 'status', 'aria-live': 'polite', className: 'oasub-status-detail' }, t(copyNotice)) : null) : null, phase === 'authorizing' || phase === 'starting'
1022
+ ? el('div', { className: 'oasub-notice', role: 'status', 'aria-live': 'polite' }, el('span', { className: 'oasub-dot warning', 'aria-hidden': true }), t(cancelPending ? 'action.cancelling' : progressKey)) : null, flowWarning
1023
+ ? el('div', { className: 'oasub-notice warning', role: 'status' }, t(flowWarning)) : null, pollError || phase === 'paused'
1024
+ ? el('div', { className: 'oasub-notice warning', role: 'status', 'aria-live': 'polite' }, t(pollError ?? 'error.poll-paused'), polling && pollError !== 'error.poll' ? ' ' + t('error.poll') : null) : null, phase === 'authorizing' || phase === 'paused'
1025
+ ? el('div', { className: 'oasub-actions' }, phase === 'paused'
1026
+ ? el('button', { type: 'button', className: 'oasub-button primary', disabled: cancelPending, onClick: retryPolling }, t('action.retryPoll')) : null, el('button', { type: 'button', className: 'oasub-button', disabled: cancelPending, onClick: cancelAuthorization }, t(cancelPending ? 'action.cancelling' : 'action.cancel'))) : null, notice !== null
1027
+ ? el('div', { className: 'oasub-notice ' + notice.tone, role: notice.tone === 'error' ? 'alert' : 'status' }, el('span', { 'aria-hidden': true }, notice.tone === 'success' ? '✓' : notice.tone === 'error' ? '!' : '•'), t(notice.key, notice.params)) : null, confirm !== null
692
1028
  ? el('div', { className: 'oasub-dialog-backdrop', role: 'presentation', onMouseDown: (event) => {
693
1029
  if (!busy && event.target === event.currentTarget)
694
1030
  setConfirm(null);
695
- } }, el('div', { className: 'oasub-dialog', role: 'dialog', 'aria-modal': true, 'aria-labelledby': 'oasub-dialog-title' }, el('h3', { id: 'oasub-dialog-title' }, t(confirm === 'sync' ? 'dialog.sync.title' : 'dialog.disconnect.title')), el('p', null, t(confirm === 'sync' ? 'dialog.sync.detail' : 'dialog.disconnect.detail')), el('div', { className: 'oasub-actions' }, el('button', { type: 'button', className: 'oasub-button', disabled: busy, onClick: () => setConfirm(null) }, t('action.cancel')), el('button', {
1031
+ } }, el('div', { ref: dialogRef, tabIndex: -1, className: 'oasub-dialog', role: 'dialog', 'aria-modal': true,
1032
+ 'aria-labelledby': 'oasub-dialog-title', 'aria-describedby': 'oasub-dialog-detail' }, el('h3', { id: 'oasub-dialog-title' }, t(confirm === 'sync' ? 'dialog.sync.title' : 'dialog.disconnect.title')), el('p', { id: 'oasub-dialog-detail' }, t(confirm === 'sync' ? 'dialog.sync.detail' : 'dialog.disconnect.detail')), el('div', { className: 'oasub-actions' }, el('button', { type: 'button', className: 'oasub-button', disabled: busy, onClick: () => setConfirm(null) }, t('action.cancel')), el('button', {
696
1033
  type: 'button',
697
1034
  className: 'oasub-button ' + (confirm === 'sync' ? 'primary' : 'danger'),
698
1035
  disabled: busy,
699
- onClick: confirm === 'sync' ? synchronizeModels : disconnect,
1036
+ onClick: confirm === 'sync' ? () => synchronizeModels(true) : disconnect,
700
1037
  }, t(confirm === 'sync' ? 'action.continue' : 'action.disconnect')))))
701
1038
  : null);
702
1039
  }