dsh-live-voice 0.2.1 → 0.2.3

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/lib/server.js CHANGED
@@ -460,7 +460,9 @@ var defaultSettings = Object.freeze({
460
460
  recognitionProcessLocally: true,
461
461
  recognitionAutoInstall: true,
462
462
  voiceDetectionPreset: "natural",
463
+ recognitionMaxUtteranceSeconds: 60,
463
464
  microphoneEnabled: true,
465
+ holdToTalkEnabled: true,
464
466
  announceAssistantMessages: true,
465
467
  interruptSpeechOnUserMessage: false,
466
468
  sendingMode: "manual",
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "dsh-live-voice",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
+ "dshTestedVersion": "0.1.6-alpha.2",
4
5
  "type": "module",
5
6
  "scripts": {
6
7
  "typecheck": "tsc --noEmit",
@@ -13,7 +14,7 @@
13
14
  "setup:hooks": "git config core.hooksPath .githooks",
14
15
  "prepack": "npm run build"
15
16
  },
16
- "description": "Local-first voice conversations for DSH, with local speech recognition and synthesis and optional external providers.",
17
+ "description": "Local-first hands-free AI voice assistant plugin for DeepSeek Harness (DSH), with speech-to-text, text-to-speech, voice commands, and local speech engines.",
17
18
  "keywords": [
18
19
  "dsh",
19
20
  "dsh-plugin",
package/scripts/build.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  // @ts-nocheck
2
2
  import { build, context, type BuildOptions } from 'esbuild';
3
- import { rm, mkdir } from 'node:fs/promises';
3
+ import { rm, mkdir, readFile } from 'node:fs/promises';
4
+
5
+ const packageMetadata = JSON.parse(await readFile('package.json', 'utf8'));
6
+ const versionDefine = {
7
+ __DLV_VERSION__: JSON.stringify(packageMetadata.version),
8
+ __DLV_TESTED_DSH_VERSION__: JSON.stringify(packageMetadata.dshTestedVersion),
9
+ };
4
10
 
5
11
  const client: BuildOptions = {
6
12
  entryPoints: ['src/client/index.ts'],
@@ -10,6 +16,7 @@ const client: BuildOptions = {
10
16
  platform: 'browser',
11
17
  target: ['es2022'],
12
18
  external: ['react', 'react-dom'],
19
+ define: versionDefine,
13
20
  banner: {
14
21
  js: 'window.__ModuleLoader__.load({id:"dsh-live-voice",factory:(require)=>{var module={exports:{}};var exports=module.exports;',
15
22
  },
@@ -35,6 +42,7 @@ const tests: BuildOptions = {
35
42
  platform: 'node',
36
43
  target: ['node22'],
37
44
  packages: 'external',
45
+ define: versionDefine,
38
46
  logLevel: 'silent',
39
47
  };
40
48
  const watch = process.argv.includes('--watch');
@@ -2,6 +2,17 @@
2
2
  import { createWhisperSettings } from './whisper-settings.ts';
3
3
  import { createQwenSettings } from './qwen-settings.ts';
4
4
  import { qwenVoices, usesPluginVoiceDetection, voiceDetectionPresets } from '../core/settings.ts';
5
+ import {
6
+ checkLatestRelease,
7
+ CURRENT_VERSION,
8
+ DSH_BADGE_URL,
9
+ hasNewerRelease,
10
+ LIVE_VOICE_BADGE_URL,
11
+ RELEASES_URL,
12
+ REPOSITORY_URL,
13
+ TESTED_DSH_RELEASE_URL,
14
+ TESTED_DSH_VERSION,
15
+ } from './releases.ts';
5
16
 
6
17
  // UI only: the controller owns capture, recognition, playback and policy.
7
18
  export function createComponents(React) {
@@ -216,25 +227,26 @@ export function createComponents(React) {
216
227
  const remaining = state.autoSendAt
217
228
  ? Math.max(1, Math.ceil((state.autoSendAt - now) / 1000))
218
229
  : null;
219
- const status = state.answeringQuestion && state.recognizing
220
- ? 'Recognizing answer…'
221
- : state.answeringQuestion && state.listening
222
- ? 'Listening for your answer…'
223
- : remaining
224
- ? `Sending in ${remaining}…`
225
- : state.starting
226
- ? 'Starting microphone…'
227
- : state.paused
228
- ? 'Speech paused'
229
- : state.speaking
230
- ? 'Speaking'
231
- : state.recognizing
232
- ? 'Recognizing speech…'
233
- : state.listening
234
- ? 'Listening — waiting for speech'
235
- : state.conversation
236
- ? 'Conversation idle'
237
- : 'Voice ready';
230
+ const status =
231
+ state.answeringQuestion && state.recognizing
232
+ ? 'Recognizing answer…'
233
+ : state.answeringQuestion && state.listening
234
+ ? 'Listening for your answer…'
235
+ : remaining
236
+ ? `Sending in ${remaining}…`
237
+ : state.starting
238
+ ? 'Starting microphone…'
239
+ : state.paused
240
+ ? 'Speech paused'
241
+ : state.speaking
242
+ ? 'Speaking'
243
+ : state.recognizing
244
+ ? 'Recognizing speech…'
245
+ : state.listening
246
+ ? 'Listening — waiting for speech'
247
+ : state.conversation
248
+ ? 'Conversation idle'
249
+ : 'Voice ready';
238
250
  return h(
239
251
  'div',
240
252
  {
@@ -382,6 +394,17 @@ export function createComponents(React) {
382
394
  const settings = state.settings || {};
383
395
  const capabilities = state.capabilities || {};
384
396
  const tabsId = React.useId();
397
+ const [latestRelease, setLatestRelease] = React.useState(null);
398
+ React.useEffect(() => {
399
+ let active = true;
400
+ void checkLatestRelease().then((result) => {
401
+ if (active) setLatestRelease(result.release);
402
+ });
403
+ return () => {
404
+ active = false;
405
+ };
406
+ }, []);
407
+ const updateAvailable = hasNewerRelease(latestRelease);
385
408
  const tabs = [
386
409
  { id: 'conversation', label: 'Conversation' },
387
410
  { id: 'speech', label: 'Speech' },
@@ -482,7 +505,73 @@ export function createComponents(React) {
482
505
  return h(
483
506
  'section',
484
507
  { className: 'dlv-settings', 'aria-label': 'Live Voice settings' },
485
- h('h3', null, 'Live Voice'),
508
+ h(
509
+ 'div',
510
+ { className: 'dlv-settings-heading' },
511
+ h('h3', null, 'Live Voice'),
512
+ h(
513
+ 'div',
514
+ { className: 'dlv-version-badges', 'aria-label': 'Version information' },
515
+ h(
516
+ 'a',
517
+ {
518
+ className: 'dlv-shields-badge',
519
+ href: RELEASES_URL,
520
+ target: '_blank',
521
+ rel: 'noreferrer',
522
+ 'aria-label': `DSH Live Voice v${CURRENT_VERSION}. Open releases`,
523
+ title: `DSH Live Voice v${CURRENT_VERSION}`,
524
+ },
525
+ h('img', { src: LIVE_VOICE_BADGE_URL, alt: '' }),
526
+ h('span', null, `v${CURRENT_VERSION}`),
527
+ ),
528
+ h(
529
+ 'a',
530
+ {
531
+ className: 'dlv-shields-badge',
532
+ href: TESTED_DSH_RELEASE_URL,
533
+ target: '_blank',
534
+ rel: 'noreferrer',
535
+ 'aria-label': `Compatible with DSH v${TESTED_DSH_VERSION}. Open release`,
536
+ title: `Compatible with DSH v${TESTED_DSH_VERSION}`,
537
+ },
538
+ h('img', { src: DSH_BADGE_URL, alt: '' }),
539
+ h('span', null, `v${TESTED_DSH_VERSION}`),
540
+ ),
541
+ ),
542
+ h('span', { className: 'dlv-heading-divider', 'aria-hidden': true }),
543
+ updateAvailable
544
+ ? h(
545
+ 'a',
546
+ {
547
+ className: 'dlv-version-badge dlv-update-badge',
548
+ href: latestRelease.url,
549
+ target: '_blank',
550
+ rel: 'noreferrer',
551
+ 'aria-label': `Update available: ${latestRelease.tag}. Open release`,
552
+ title: `Update available: ${latestRelease.tag}`,
553
+ },
554
+ h('span', { className: 'dlv-update-icon', 'aria-hidden': true }, '↑'),
555
+ 'Update available',
556
+ )
557
+ : null,
558
+ updateAvailable
559
+ ? h('span', { className: 'dlv-heading-divider', 'aria-hidden': true })
560
+ : null,
561
+ h(
562
+ 'a',
563
+ {
564
+ className: 'dlv-version-badge dlv-star-badge',
565
+ href: REPOSITORY_URL,
566
+ target: '_blank',
567
+ rel: 'noreferrer',
568
+ 'aria-label': 'Star DSH Live Voice on GitHub',
569
+ title: 'Star DSH Live Voice on GitHub',
570
+ },
571
+ h('span', { className: 'dlv-star-icon', 'aria-hidden': true }, '★'),
572
+ 'Star Us on GitHub',
573
+ ),
574
+ ),
486
575
  onClose
487
576
  ? h(Button, { label: 'Close voice settings', icon: 'close', onClick: onClose })
488
577
  : null,
@@ -709,11 +798,32 @@ export function createComponents(React) {
709
798
  : { recognitionEngine: event.target.value },
710
799
  ),
711
800
  },
712
- h('option', { value: 'qwen-http' }, 'Qwen3 ASR — local MLX server'),
713
- h('option', { value: 'browser' }, 'Browser SpeechRecognition'),
714
- h('option', { value: 'whisper-http' }, 'Whisper HTTP — DSH host'),
801
+ h('option', { value: 'browser' }, 'Browser SpeechRecognition — Default option'),
802
+ h('option', { value: 'qwen-http' }, 'Qwen3 ASR — HTTP API'),
803
+ h('option', { value: 'whisper-http' }, 'Whisper — HTTP API'),
804
+ h(
805
+ 'optgroup',
806
+ { label: 'Coming Soon — vote on repo issues' },
807
+ h('option', { value: 'webgpu', disabled: true }, 'Browser WebGPU Inference — Soon'),
808
+ h(
809
+ 'option',
810
+ { value: 'sherpa-onnx', disabled: true },
811
+ 'sherpa-onnx Streaming — Soon',
812
+ ),
813
+ h('option', { value: 'parakeet', disabled: true }, 'NVIDIA Parakeet — Soon'),
814
+ h('option', { value: 'voxtral', disabled: true }, 'Voxtral Realtime — Soon'),
815
+ ),
715
816
  ),
716
817
  ),
818
+ h(
819
+ 'small',
820
+ { className: 'dlv-recognition-engine-description' },
821
+ settings.recognitionEngine === 'qwen-http'
822
+ ? 'HTTP API at the configured base URL (default: http://127.0.0.1:8080/). Compatible with POST /v1/audio/transcriptions.'
823
+ : settings.recognitionEngine === 'whisper-http'
824
+ ? 'HTTP API at the configured DSH host URL (default: http://127.0.0.1:8080/inference). Audio uses the authenticated DSH host transcription route.'
825
+ : 'Uses the browser SpeechRecognition API. This is the default option.',
826
+ ),
717
827
  field('Input device', 'inputDeviceId', deviceOptions('audioinput', 'Microphone')),
718
828
  settings.recognitionEngine === 'browser'
719
829
  ? h(
@@ -884,6 +994,28 @@ export function createComponents(React) {
884
994
  null,
885
995
  'Controls how long a pause must last before captured speech is sent for recognition.',
886
996
  ),
997
+ h(
998
+ 'label',
999
+ { className: 'dlv-setting-field' },
1000
+ 'Maximum continuous speech (seconds)',
1001
+ h('input', {
1002
+ type: 'number',
1003
+ min: 10,
1004
+ max: 300,
1005
+ step: 1,
1006
+ value: settings.recognitionMaxUtteranceSeconds ?? 60,
1007
+ onChange: (event) => {
1008
+ const value = Number(event.target.value);
1009
+ if (Number.isInteger(value) && value >= 10 && value <= 300)
1010
+ invoke('updateSettings', { recognitionMaxUtteranceSeconds: value });
1011
+ },
1012
+ }),
1013
+ h(
1014
+ 'small',
1015
+ null,
1016
+ 'If speech never pauses, start a new transcription chunk after this duration. Default: 60 seconds.',
1017
+ ),
1018
+ ),
887
1019
  h(
888
1020
  'div',
889
1021
  {
@@ -959,6 +1091,22 @@ export function createComponents(React) {
959
1091
  ? 'Sending or steering a new user message stops current or paused assistant speech.'
960
1092
  : 'Sending another message does not stop the assistant audio you are already hearing.',
961
1093
  ),
1094
+ h(
1095
+ 'label',
1096
+ { key: 'hold-to-talk', className: 'dlv-check' },
1097
+ h('input', {
1098
+ type: 'checkbox',
1099
+ checked: settings.holdToTalkEnabled !== false,
1100
+ onChange: (event) =>
1101
+ invoke('updateSettings', { holdToTalkEnabled: event.target.checked }),
1102
+ }),
1103
+ ' Hold Control to talk',
1104
+ ),
1105
+ h(
1106
+ 'p',
1107
+ { key: 'hold-to-talk-description', className: 'dlv-setting-description' },
1108
+ 'While a composer is open, hold Control anywhere on the page to capture speech. Release it to flush queued transcription, wait the configured send delay, queue the message, and close voice capture. Press Escape while holding to cancel.',
1109
+ ),
962
1110
  field('Listening mode', 'mode', [
963
1111
  { value: 'speaker', label: 'Speakers — gated listening' },
964
1112
  { value: 'headphones', label: 'Headphones — open microphone' },
@@ -13,7 +13,12 @@ import { QwenHttpRecognitionEngine } from '../engines/recognition/qwen-http.ts';
13
13
  import { QwenHttpSpeakingEngine } from '../engines/speaking/qwen-http.ts';
14
14
  import { createComponents } from './components.ts';
15
15
  import { styles } from './styles.ts';
16
- import { assistantMessages, addressedTurn, latestUserSequence, pendingQuestionSpeech } from './chat.ts';
16
+ import {
17
+ assistantMessages,
18
+ addressedTurn,
19
+ latestUserSequence,
20
+ pendingQuestionSpeech,
21
+ } from './chat.ts';
17
22
 
18
23
  export const inject = ['slots', 'connection', 'uiConversation', 'uiSession'];
19
24
  export function apply(ctx) {
@@ -27,16 +32,27 @@ export function apply(ctx) {
27
32
  // composer should inherit the user's explicit choice to remain in voice mode.
28
33
  let voiceModeActive = false;
29
34
  const ownership = new VoiceOwnership();
35
+ const recognitionSettingKeys = new Set([
36
+ 'recognitionEngine',
37
+ 'recognitionProcessLocally',
38
+ 'recognitionAutoInstall',
39
+ 'voiceDetectionPreset',
40
+ 'recognitionMaxUtteranceSeconds',
41
+ ]);
42
+ const changesRecognition = (next) =>
43
+ Object.keys(next).some((key) => recognitionSettingKeys.has(key));
30
44
  const recognitionFor = (settings, meter) =>
31
45
  settings.recognitionEngine === 'qwen-http'
32
46
  ? new QwenHttpRecognitionEngine({
33
47
  meter,
34
48
  voiceDetectionPreset: settings.voiceDetectionPreset,
49
+ maxUtteranceSeconds: settings.recognitionMaxUtteranceSeconds,
35
50
  })
36
51
  : settings.recognitionEngine === 'whisper-http'
37
52
  ? new WhisperHttpRecognitionEngine({
38
53
  meter,
39
54
  voiceDetectionPreset: settings.voiceDetectionPreset,
55
+ maxUtteranceSeconds: settings.recognitionMaxUtteranceSeconds,
40
56
  })
41
57
  : new BrowserRecognitionEngine({
42
58
  processLocally: settings.recognitionProcessLocally,
@@ -116,7 +132,8 @@ export function apply(ctx) {
116
132
  if (capture.index < capture.interaction.questions.length) {
117
133
  capture.answering = false;
118
134
  const text = pendingQuestionSpeech(capture.interaction, capture.index);
119
- if (text) run(entry.controller, entry.controller.speak(text, capture.interaction.key));
135
+ if (text)
136
+ run(entry.controller, entry.controller.speak(text, capture.interaction.key));
120
137
  } else {
121
138
  entry.questionCapture = null;
122
139
  entry.controller.patch({ answeringQuestion: false });
@@ -228,7 +245,7 @@ export function apply(ctx) {
228
245
  return original(...args);
229
246
  };
230
247
  }
231
- for (const method of ['startDictation', 'startConversation', 'speak']) {
248
+ for (const method of ['startDictation', 'startHoldToTalk', 'startConversation', 'speak']) {
232
249
  const original = controller[method].bind(controller);
233
250
  controller[method] = (...args) => {
234
251
  if (disposed || entry.closed || !entry.refs) return Promise.resolve();
@@ -351,13 +368,11 @@ export function apply(ctx) {
351
368
  let settingsRevision = 0;
352
369
  c.updateSettings = async (next) => {
353
370
  const revision = ++settingsRevision;
354
- const previousEngine = c.getSnapshot().settings.recognitionEngine;
355
371
  update(next);
356
372
  browser.lang = c.getSnapshot().settings.lang;
357
373
  qwen.lang = c.getSnapshot().settings.lang;
358
374
  const settings = c.getSnapshot().settings;
359
- if (previousEngine !== settings.recognitionEngine)
360
- c.replaceRecognition(recognitionFor(settings, c.meter));
375
+ if (changesRecognition(next)) c.replaceRecognition(recognitionFor(settings, c.meter));
361
376
  localStorage.setItem('dsh-live-voice.settings', JSON.stringify(settings));
362
377
  run(c, c.refreshCapabilities());
363
378
  const active = [...controllers.values()];
@@ -368,9 +383,7 @@ export function apply(ctx) {
368
383
  if (revision !== settingsRevision || disposed || c.disposed) return;
369
384
  for (const entry of controllers.values()) {
370
385
  if (entry.closed) continue;
371
- if (
372
- entry.controller.getSnapshot().settings.recognitionEngine !== settings.recognitionEngine
373
- )
386
+ if (changesRecognition(next))
374
387
  entry.controller.replaceRecognition(recognitionFor(settings, entry.controller.meter));
375
388
  entry.applySettings(settings);
376
389
  run(entry.controller, entry.controller.refreshCapabilities());
@@ -488,28 +501,49 @@ export function apply(ctx) {
488
501
  for (const entry of controllers.values())
489
502
  run(entry.controller, entry.controller.endConversation());
490
503
  };
491
- const onKey = (event) => {
492
- if (
493
- disposed ||
494
- event.defaultPrevented ||
495
- !event.ctrlKey ||
496
- !event.shiftKey ||
497
- event.code !== 'Space' ||
498
- event.repeat
499
- )
500
- return;
504
+ let holdToTalk = null;
505
+ const candidate = () => {
501
506
  const candidates = [...controllers.values()].filter(
502
507
  (entry) => entry.buttons > 0 && entry.composers.size > 0,
503
508
  );
504
- if (candidates.length !== 1) return;
505
- event.preventDefault();
506
- const c = candidates[0].controller;
507
- run(
508
- c,
509
- c.getSnapshot().listening || c.getSnapshot().starting
510
- ? c.stopListening()
511
- : c.startDictation(),
512
- );
509
+ return candidates.length === 1 ? candidates[0] : null;
510
+ };
511
+ const releaseHoldToTalk = () => {
512
+ const entry = holdToTalk;
513
+ holdToTalk = null;
514
+ if (!entry || entry.closed) return;
515
+ run(entry.controller, entry.controller.releaseHoldToTalk());
516
+ };
517
+ const onKeyDown = (event) => {
518
+ if (disposed || event.defaultPrevented || event.repeat) return;
519
+ if (event.key === 'Escape' && holdToTalk) {
520
+ const entry = holdToTalk;
521
+ holdToTalk = null;
522
+ event.preventDefault();
523
+ run(entry.controller, entry.controller.cancelDictation());
524
+ return;
525
+ }
526
+ if (event.ctrlKey && event.shiftKey && event.code === 'Space') {
527
+ const entry = candidate();
528
+ if (!entry) return;
529
+ event.preventDefault();
530
+ const c = entry.controller;
531
+ run(
532
+ c,
533
+ c.getSnapshot().listening || c.getSnapshot().starting
534
+ ? c.stopListening()
535
+ : c.startDictation(),
536
+ );
537
+ return;
538
+ }
539
+ if (event.key !== 'Control' || event.altKey || event.metaKey || event.shiftKey) return;
540
+ const entry = candidate();
541
+ if (!entry || !entry.controller.getSnapshot().settings.holdToTalkEnabled) return;
542
+ holdToTalk = entry;
543
+ run(entry.controller, entry.controller.startHoldToTalk());
544
+ };
545
+ const onKeyUp = (event) => {
546
+ if (event.key === 'Control') releaseHoldToTalk();
513
547
  };
514
548
  const refreshCapabilities = () => {
515
549
  for (const entry of controllers.values())
@@ -522,7 +556,9 @@ export function apply(ctx) {
522
556
  window.speechSynthesis?.addEventListener?.('voiceschanged', refreshCapabilities);
523
557
  navigator.mediaDevices?.addEventListener?.('devicechange', refreshCapabilities);
524
558
  document.addEventListener('visibilitychange', visibilityChanged);
525
- document.addEventListener('keydown', onKey);
559
+ document.addEventListener('keydown', onKeyDown);
560
+ document.addEventListener('keyup', onKeyUp);
561
+ window.addEventListener('blur', releaseHoldToTalk);
526
562
  window.addEventListener('pagehide', stop);
527
563
  return () => {
528
564
  disposed = true;
@@ -530,7 +566,9 @@ export function apply(ctx) {
530
566
  window.speechSynthesis?.removeEventListener?.('voiceschanged', refreshCapabilities);
531
567
  navigator.mediaDevices?.removeEventListener?.('devicechange', refreshCapabilities);
532
568
  document.removeEventListener('visibilitychange', visibilityChanged);
533
- document.removeEventListener('keydown', onKey);
569
+ document.removeEventListener('keydown', onKeyDown);
570
+ document.removeEventListener('keyup', onKeyUp);
571
+ window.removeEventListener('blur', releaseHoldToTalk);
534
572
  window.removeEventListener('pagehide', stop);
535
573
  for (const entry of controllers.values()) retire(entry);
536
574
  };
@@ -0,0 +1,117 @@
1
+ declare const __DLV_VERSION__: string;
2
+ declare const __DLV_TESTED_DSH_VERSION__: string;
3
+
4
+ // Both values are injected from package.json by scripts/build.ts.
5
+ export const CURRENT_VERSION = __DLV_VERSION__;
6
+ export const TESTED_DSH_VERSION = __DLV_TESTED_DSH_VERSION__;
7
+ export const REPOSITORY_URL = 'https://github.com/victorwads/dsh-live-voice';
8
+ export const RELEASES_URL = `${REPOSITORY_URL}/releases`;
9
+ export const LATEST_RELEASE_API_URL =
10
+ 'https://api.github.com/repos/victorwads/dsh-live-voice/releases/latest';
11
+ export const RELEASE_CHECK_STORAGE_KEY = 'dsh-live-voice.latest-release-check';
12
+ export const RELEASE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
13
+
14
+ export const LIVE_VOICE_BADGE_URL = 'https://cdn.simpleicons.org/npm/white';
15
+ export const DSH_BADGE_URL = 'https://cdn.simpleicons.org/deepseek/white';
16
+ export const TESTED_DSH_RELEASE_URL = `https://github.com/deepseek-ai/deepseek-harness/releases/tag/v${TESTED_DSH_VERSION}`;
17
+
18
+ function versionParts(version) {
19
+ const normalized = String(version || '')
20
+ .trim()
21
+ .replace(/^v/i, '')
22
+ .split('+', 1)[0];
23
+ const [core = '', prerelease = ''] = normalized.split('-', 2);
24
+ const numbers = core.split('.').map((part) => {
25
+ const match = part.match(/^\d+/);
26
+ return match ? Number(match[0]) : 0;
27
+ });
28
+ while (numbers.length < 3) numbers.push(0);
29
+ return { numbers, prerelease: prerelease ? prerelease.split('.') : [] };
30
+ }
31
+
32
+ function compareIdentifier(left, right) {
33
+ const leftNumber = /^\d+$/.test(left) ? Number(left) : null;
34
+ const rightNumber = /^\d+$/.test(right) ? Number(right) : null;
35
+ if (leftNumber !== null && rightNumber !== null) return Math.sign(leftNumber - rightNumber);
36
+ if (leftNumber !== null) return -1;
37
+ if (rightNumber !== null) return 1;
38
+ return left.localeCompare(right);
39
+ }
40
+
41
+ export function compareVersions(left, right) {
42
+ const a = versionParts(left);
43
+ const b = versionParts(right);
44
+ for (let index = 0; index < Math.max(a.numbers.length, b.numbers.length); index++) {
45
+ const difference = (a.numbers[index] || 0) - (b.numbers[index] || 0);
46
+ if (difference) return Math.sign(difference);
47
+ }
48
+ if (!a.prerelease.length && !b.prerelease.length) return 0;
49
+ if (!a.prerelease.length) return 1;
50
+ if (!b.prerelease.length) return -1;
51
+ for (let index = 0; index < Math.max(a.prerelease.length, b.prerelease.length); index++) {
52
+ if (a.prerelease[index] === undefined) return -1;
53
+ if (b.prerelease[index] === undefined) return 1;
54
+ const difference = compareIdentifier(a.prerelease[index], b.prerelease[index]);
55
+ if (difference) return difference;
56
+ }
57
+ return 0;
58
+ }
59
+
60
+ function readCached(storage, now) {
61
+ try {
62
+ const cached = JSON.parse(storage?.getItem(RELEASE_CHECK_STORAGE_KEY) || 'null');
63
+ if (
64
+ cached &&
65
+ Number.isFinite(cached.checkedAt) &&
66
+ now - cached.checkedAt >= 0 &&
67
+ now - cached.checkedAt < RELEASE_CHECK_INTERVAL_MS
68
+ )
69
+ return cached;
70
+ } catch {}
71
+ return null;
72
+ }
73
+
74
+ function writeCached(storage, value) {
75
+ try {
76
+ storage?.setItem(RELEASE_CHECK_STORAGE_KEY, JSON.stringify(value));
77
+ } catch {}
78
+ }
79
+
80
+ export async function checkLatestRelease({
81
+ fetchImpl = globalThis.window?.fetch?.bind(globalThis.window),
82
+ storage = globalThis.window?.localStorage,
83
+ now = Date.now(),
84
+ } = {}) {
85
+ const cached = readCached(storage, now);
86
+ if (cached) return cached;
87
+
88
+ // Record the attempt before requesting so a failing public endpoint is not retried on every mount.
89
+ const attempted = { checkedAt: now, release: null };
90
+ writeCached(storage, attempted);
91
+ if (typeof fetchImpl !== 'function') return attempted;
92
+
93
+ try {
94
+ const response = await fetchImpl(LATEST_RELEASE_API_URL, {
95
+ headers: { Accept: 'application/vnd.github+json' },
96
+ });
97
+ if (!response.ok) return attempted;
98
+ const body = await response.json();
99
+ if (typeof body?.tag_name !== 'string' || !body.tag_name.trim()) return attempted;
100
+ const release = {
101
+ tag: body.tag_name.trim(),
102
+ url:
103
+ typeof body.html_url === 'string' && body.html_url.startsWith('https://github.com/')
104
+ ? body.html_url
105
+ : RELEASES_URL,
106
+ };
107
+ const result = { checkedAt: now, release };
108
+ writeCached(storage, result);
109
+ return result;
110
+ } catch {
111
+ return attempted;
112
+ }
113
+ }
114
+
115
+ export function hasNewerRelease(release, currentVersion = CURRENT_VERSION) {
116
+ return Boolean(release?.tag && compareVersions(release.tag, currentVersion) > 0);
117
+ }
@@ -29,12 +29,14 @@ export const styles = `
29
29
  .dlv-error{color:var(--dsw-alias-state-error-primary);overflow-wrap:anywhere;font-size:13px;max-width:720px;margin:8px auto}
30
30
  .dlv-settings{box-sizing:border-box;padding:18px;width:100%;display:grid;gap:16px;max-width:640px;color:var(--dsw-alias-label-primary)}
31
31
  .dlv-settings h3,.dlv-settings p{margin:0}
32
+ .dlv-settings-heading{display:flex;align-items:center;gap:8px;min-width:0}.dlv-settings-heading h3{white-space:nowrap}.dlv-heading-divider{height:1px;min-width:12px;flex:1;background:var(--dsw-alias-border-l1)}.dlv-settings-heading .dlv-star-badge{flex:none}
33
+ .dlv-version-badges{display:flex;flex:none;gap:5px;align-items:center}.dlv-shields-badge{display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;height:20px;gap:4px;padding:3px 5px;border:1px solid var(--dsw-alias-border-l1);border-radius:5px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);font-size:10px;font-weight:650;line-height:12px;text-decoration:none}.dlv-shields-badge img{display:block;width:12px;height:12px;max-width:100%}.dlv-shields-badge:focus-visible{border-radius:4px;outline:2px solid var(--dsw-alias-label-primary);outline-offset:3px}.dlv-version-badge{display:inline-flex;align-items:center;min-height:24px;box-sizing:border-box;padding:3px 8px;border:1px solid var(--dsw-alias-border-l1);border-radius:999px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);font-size:11px;font-weight:650;line-height:16px;text-decoration:none}.dlv-version-badges a.dlv-version-badge:hover{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary)}.dlv-star-badge{padding:4px 10px;color:var(--dsw-alias-label-primary)}.dlv-update-icon{margin-right:4px;font-size:14px;line-height:1}.dlv-star-icon{margin-right:4px;color:#f59e0b;font-size:14px;line-height:1;text-shadow:0 0 8px rgba(245,158,11,.28)}.dlv-version-badges a.dlv-star-badge:hover{border-color:#f59e0b;background:rgba(245,158,11,.08)}.dlv-update-badge{border-color:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-state-business-primary)}
32
34
  .dlv-settings-tabs{border-bottom:.5px solid var(--dsw-alias-border-l2);display:flex;align-items:flex-end;gap:22px;overflow-x:auto}.dlv-settings-tab{position:relative;flex:none;padding:7px 1px 9px;border:0;background:transparent;color:var(--dsw-alias-label-tertiary);font:inherit;font-size:13px;line-height:20px;cursor:pointer}.dlv-settings-tab:hover,.dlv-settings-tab[data-active=true]{color:var(--dsw-alias-label-primary)}.dlv-settings-tab[data-active=true]:after,.dlv-settings-tab:focus-visible:after{content:"";position:absolute;right:0;bottom:-1px;left:0;height:2px;border-radius:2px 2px 0 0;background:var(--dsw-alias-label-primary)}.dlv-settings-tab:focus-visible{border-radius:2px;outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px;color:var(--dsw-alias-label-primary)}.dlv-settings-panel{min-width:0;padding-top:2px}.dlv-settings-panel[hidden]{display:none}.dlv-settings-group{margin:0;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;min-width:0;display:grid;gap:14px;padding:16px}.dlv-settings-group legend{padding:0 6px;font-weight:600;color:var(--dsw-alias-label-primary)}.dlv-settings-card-body{display:grid;gap:12px;padding:12px 0}.dlv-settings-subcard{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;min-width:0}.dlv-settings-subcard>summary{cursor:pointer;font-weight:650;list-style:none;display:flex;align-items:center;justify-content:space-between;padding:10px}.dlv-settings-subcard>summary::-webkit-details-marker{display:none}.dlv-settings-subcard>summary:after{content:"›";transform:rotate(90deg);transition:transform .15s}.dlv-settings-subcard:not([open])>summary:after{transform:rotate(0)}.dlv-settings-subcard-body{display:grid;gap:10px;padding:0 10px 10px}
33
35
  .dlv-settings label{display:grid;gap:6px;font-size:14px}.dlv-settings label.dlv-check{display:flex;align-items:center;gap:8px}.dlv-settings label.dlv-check input{width:auto}
34
36
  .dlv-settings :is(input,select,textarea){box-sizing:border-box;width:100%;padding:8px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:inherit;font:inherit}
35
37
  .dlv-settings small,.dlv-settings p{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.5}
36
38
  .dlv-preset-group{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.dlv-settings label.dlv-preset{display:flex;align-items:flex-start;gap:8px;padding:10px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;cursor:pointer}.dlv-settings label.dlv-preset:has(input:checked){border-color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1)}.dlv-settings label.dlv-preset input{width:auto;margin-top:3px}.dlv-preset span{display:grid;gap:3px}.dlv-vad-summary{font-weight:500}
37
39
  .dlv-settings-actions{display:flex;flex-wrap:wrap;gap:8px}.dlv-settings-actions button{padding:8px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:inherit;cursor:pointer}.dlv-settings-actions button:disabled{opacity:.45;cursor:default}
38
- @media(max-width:480px){.dlv-preset-group{grid-template-columns:1fr}.dlv-pill{flex-wrap:wrap}.dlv-wave{flex-basis:90px}.dlv-status{flex-basis:100px}}
40
+ @media(max-width:480px){.dlv-settings-heading{gap:6px}.dlv-heading-divider{min-width:4px}.dlv-settings-heading .dlv-star-badge{padding:3px 6px;font-size:10px}.dlv-preset-group{grid-template-columns:1fr}.dlv-pill{flex-wrap:wrap}.dlv-wave{flex-basis:90px}.dlv-status{flex-basis:100px}}
39
41
  `;
40
42
  export default styles;