From 015f1089ea875a153dd2a18ef42c25145d4868f5 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Sat, 11 Jul 2026 22:28:32 +0800
Subject: [PATCH] freeze(mas): snapshot tested 2.2.3 build 20260711.1807

---
 C1.source/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte |  300 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 1 files changed, 291 insertions(+), 9 deletions(-)

diff --git a/C1.source/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte b/C1.source/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte
index 7b48d66..5cbd49c 100755
--- a/C1.source/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte
+++ b/C1.source/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte
@@ -1,9 +1,9 @@
 <script lang="ts">
-  import { onDestroy } from 'svelte'
-  import { Call } from '@wailsio/runtime'
+  import { onDestroy, onMount } from 'svelte'
+  import { Call, Events } from '@wailsio/runtime'
   import ToggleSwitch from '../shared/ToggleSwitch.svelte'
   import { t } from '../../lib/i18n'
-  import { autoHide, soundFeedback, copyToClipboard, startAtLogin, hotkeyVK, hotkeyMode } from '../../lib/stores/config'
+  import { autoHide, soundFeedback, copyToClipboard, autoPasteExperiment, startAtLogin, hotkeyVK, hotkeyMode } from '../../lib/stores/config'
   import { deviceName, engineStatus, engineHardwareInfo } from '../../lib/stores/app'
   import { hotkeyName } from '../../lib/stores/indicator'
 
@@ -12,22 +12,45 @@
     isDefault: boolean
   }
 
+  interface TextOutputResult {
+    ok?: boolean
+    needsPermission?: boolean
+    directInserted?: boolean
+    fallbackAvailable?: boolean
+    createdAt?: string
+  }
+
+  interface ClipboardFallbackResult {
+    ok?: boolean
+    prepared?: boolean
+    restored?: boolean
+    token?: string
+  }
+
   let autoHideVal = $state(true)
   let soundFeedbackVal = $state(true)
   let copyToClipboardVal = $state(true)
+  let autoPasteExperimentVal = $state(false)
   let startAtLoginVal = $state(false)
   let hideDockIconVal = $state(false)
   let settingsLoaded = $state(false)
   let device = $state('Default')
   let status = $state('loading')
   let hwInfo = $state('')
-  let currentKeyName = $state('Ctrl')
+  let currentKeyName = $state('')
   let hotkeyModeVal: 'tap' | 'hold' = $state('hold')
   let isRecording = $state(false)
   let hintText = $state('')
   let devices = $state<InputDevice[]>([])
   let showDeviceDropdown = $state(false)
   let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null
+  let textOutput = $state<TextOutputResult | null>(null)
+  let outputStatusText = $state('')
+  let outputActionText = $state('')
+  let fallbackToken = $state('')
+  let fallbackPrepared = $state(false)
+  let fallbackBusy = $state(false)
+  let lastOutputCreatedAt = $state('')
 
   const unsub1 = autoHide.subscribe(v => { autoHideVal = v })
   const unsub2 = startAtLogin.subscribe(v => { startAtLoginVal = v })
@@ -38,9 +61,10 @@
   const unsub7 = soundFeedback.subscribe(v => { soundFeedbackVal = v })
   const unsub8 = copyToClipboard.subscribe(v => { copyToClipboardVal = v })
   const unsub9 = hotkeyMode.subscribe(v => { hotkeyModeVal = v })
+  const unsub10 = autoPasteExperiment.subscribe(v => { autoPasteExperimentVal = v })
 
   onDestroy(() => {
-    unsub1(); unsub2(); unsub3(); unsub4(); unsub5(); unsub6(); unsub7(); unsub8(); unsub9()
+    unsub1(); unsub2(); unsub3(); unsub4(); unsub5(); unsub6(); unsub7(); unsub8(); unsub9(); unsub10()
     clearHotkeyCapture()
   })
 
@@ -78,10 +102,26 @@
       copyToClipboardVal = copy
     } catch {}
     try {
+      const autoPaste: any = await Call.ByName('voicesnap/services.ConfigService.GetAutoPasteExperiment')
+      autoPasteExperiment.set(!!autoPaste)
+      autoPasteExperimentVal = !!autoPaste
+    } catch {}
+    try {
       const mode: any = await Call.ByName('voicesnap/services.ConfigService.GetHotkeyMode')
       const normalized = mode === 'tap' ? 'tap' : 'hold'
       hotkeyMode.set(normalized)
       hotkeyModeVal = normalized
+    } catch {}
+    try {
+      const vk: any = await Call.ByName('voicesnap/services.HotkeyService.GetCurrentHotkey')
+      if (typeof vk === 'number') {
+        hotkeyVK.set(vk)
+      }
+      const name: any = await Call.ByName('voicesnap/services.HotkeyService.GetHotkeyName')
+      if (typeof name === 'string') {
+        hotkeyName.set(name)
+        currentKeyName = name
+      }
     } catch {}
     try {
       const hideDock: any = await Call.ByName('voicesnap/services.ConfigService.GetHideDockIcon')
@@ -90,6 +130,20 @@
     settingsLoaded = true
   }
   loadSettings()
+
+  onMount(() => {
+    const offTextOutputUpdated = Events.On('text-output:updated', () => {
+      void refreshTextOutput()
+    })
+    const pollTextOutput = window.setInterval(() => {
+      void refreshTextOutput(true)
+    }, 1000)
+    void refreshTextOutput()
+    return () => {
+      window.clearInterval(pollTextOutput)
+      offTextOutputUpdated()
+    }
+  })
 
   async function onAutoHideChange(checked: boolean) {
     autoHide.set(checked)
@@ -109,6 +163,13 @@
     copyToClipboard.set(checked)
     try {
       await Call.ByName('voicesnap/services.ConfigService.SetCopyToClipboard', checked)
+    } catch {}
+  }
+
+  async function onAutoPasteExperimentChange(checked: boolean) {
+    autoPasteExperiment.set(checked)
+    try {
+      await Call.ByName('voicesnap/services.ConfigService.SetAutoPasteExperiment', checked)
     } catch {}
   }
 
@@ -132,6 +193,101 @@
     try {
       await Call.ByName('voicesnap/services.ConfigService.SetHideDockIcon', checked)
     } catch {}
+  }
+
+  async function refreshTextOutput(preserveActionText = false) {
+    try {
+      const result: TextOutputResult = await Call.ByName('voicesnap/services.TextOutputService.GetLastResult')
+      const nextOutputCreatedAt = outputResultKey(result)
+      const isNewOutput = !!nextOutputCreatedAt && !!lastOutputCreatedAt && nextOutputCreatedAt !== lastOutputCreatedAt
+      if (isNewOutput) {
+        fallbackToken = ''
+        fallbackPrepared = false
+        outputActionText = ''
+      }
+      if (nextOutputCreatedAt) {
+        lastOutputCreatedAt = nextOutputCreatedAt
+      }
+      textOutput = result || null
+      outputStatusText = textOutputStatus(result)
+      if (!isNewOutput && !preserveActionText && !fallbackPrepared) {
+        outputActionText = ''
+      }
+    } catch {
+      textOutput = null
+      outputStatusText = t('settings.textOutputUnavailable')
+    }
+  }
+
+  async function prepareClipboardFallback() {
+    if (fallbackPrepared) return
+    fallbackBusy = true
+    try {
+      const result: ClipboardFallbackResult = await Call.ByName('voicesnap/services.TextOutputService.PrepareLastClipboardFallback')
+      if (result?.ok && result?.prepared && result?.token) {
+        fallbackToken = result.token
+        fallbackPrepared = true
+        outputActionText = t('settings.textOutputFallbackPrepared')
+      } else {
+        outputActionText = t('settings.textOutputFallbackUnavailable')
+      }
+    } catch {
+      outputActionText = t('settings.textOutputFallbackUnavailable')
+    } finally {
+      fallbackBusy = false
+      void refreshTextOutput()
+    }
+  }
+
+  async function restoreClipboardFallback() {
+    if (!fallbackToken) return
+    fallbackBusy = true
+    try {
+      const result: ClipboardFallbackResult = await Call.ByName('voicesnap/services.TextOutputService.RestoreClipboardFallback', fallbackToken)
+      fallbackToken = ''
+      fallbackPrepared = false
+      if (result?.ok && result?.restored) {
+        outputActionText = t('settings.textOutputClipboardRestored')
+      } else {
+        outputActionText = t('settings.textOutputClipboardKept')
+      }
+    } catch {
+      outputActionText = t('settings.textOutputClipboardRestoreFailed')
+    } finally {
+      fallbackBusy = false
+      void refreshTextOutput(true)
+    }
+  }
+
+  function textOutputStatus(result: TextOutputResult | null | undefined): string {
+    if (!hasOutputResult(result)) {
+      return t('settings.textOutputNoResult')
+    }
+    if (result.ok && result.directInserted) {
+      return t('settings.textOutputInserted')
+    }
+    if (result.ok) {
+      return t('settings.textOutputCompleted')
+    }
+    if (result.needsPermission) {
+      return t('settings.textOutputNeedsPermission')
+    }
+    if (result.fallbackAvailable) {
+      return t('settings.textOutputFallbackAvailable')
+    }
+    return t('settings.textOutputFailed')
+  }
+
+  function hasOutputResult(result: TextOutputResult | null | undefined): result is TextOutputResult {
+    if (!result) return false
+    if (result.ok || result.needsPermission || result.directInserted || result.fallbackAvailable) return true
+    return !!outputResultKey(result)
+  }
+
+  function outputResultKey(result: TextOutputResult | null | undefined): string {
+    const createdAt = result?.createdAt || ''
+    if (!createdAt || createdAt.startsWith('0001-')) return ''
+    return createdAt
   }
 
   async function toggleDeviceDropdown() {
@@ -179,6 +335,12 @@
       e.preventDefault()
       e.stopPropagation()
 
+      if (isHotkeyCaptureCancel(e)) {
+        hintText = t('status.cancelled')
+        clearHotkeyCapture()
+        return
+      }
+
       const vk = mapKeyToVK(e)
       if (vk > 0) {
         isRecording = false
@@ -189,7 +351,7 @@
           try {
             await Call.ByName('voicesnap/services.HotkeyService.SetHotkey', vk)
             const name: any = await Call.ByName('voicesnap/services.HotkeyService.GetKeyName', vk)
-            if (name) {
+            if (typeof name === 'string') {
               hotkeyName.set(name)
               currentKeyName = name
             }
@@ -205,6 +367,10 @@
 
     activeKeyHandler = onKey
     document.addEventListener('keydown', onKey)
+  }
+
+  function isHotkeyCaptureCancel(e: KeyboardEvent): boolean {
+    return e.key === 'Escape' || e.key === 'Esc'
   }
 
   function clearHotkeyCapture() {
@@ -256,14 +422,18 @@
     <div class="hotkey-display">
       <div class="hotkey-info">
         <span class="hotkey-label">{t('home.hotkeyLabel')}</span>
-        <div class="hotkey-key" class:recording={isRecording}>
-          {isRecording ? '...' : currentKeyName}
+        <div class="hotkey-key" class:recording={isRecording} class:unset={!isRecording && !currentKeyName}>
+          {isRecording ? '...' : (currentKeyName || t('hotkeys.unset'))}
         </div>
       </div>
       <button class="change-btn" onclick={startRecordingHotkey} disabled={isRecording}>
-        {t('hotkeys.change')}
+        {currentKeyName ? t('hotkeys.change') : t('hotkeys.set')}
       </button>
     </div>
+
+    {#if !currentKeyName && !isRecording}
+      <p class="hint">{t('hotkeys.unsetDesc')}</p>
+    {/if}
 
     {#if hintText}
       <p class="hint" class:recording={isRecording}>{hintText}</p>
@@ -376,6 +546,48 @@
 
     <div class="setting-row">
       <div class="setting-info">
+        <span class="setting-label">{t('settings.autoPasteExperiment')}</span>
+        <span class="setting-desc">{t('settings.autoPasteExperimentDesc')}</span>
+      </div>
+      <ToggleSwitch checked={autoPasteExperimentVal} onchange={onAutoPasteExperimentChange} />
+    </div>
+
+    <div class="divider"></div>
+
+    <div class="text-output-panel">
+      <div class="setting-info">
+        <span class="setting-label">{t('settings.textOutputTitle')}</span>
+        <span class="setting-desc">{t('settings.textOutputDesc')}</span>
+      </div>
+      <div class="text-output-status" class:success={!!textOutput?.ok} class:warn={!!textOutput && !textOutput.ok}>
+        {outputStatusText || t('settings.textOutputNoResult')}
+      </div>
+      {#if outputActionText}
+        <div class="fallback-message">{outputActionText}</div>
+      {/if}
+      <div class="fallback-actions">
+        <button class="secondary-btn" onclick={() => refreshTextOutput()} disabled={fallbackBusy}>
+          {t('settings.textOutputRefresh')}
+        </button>
+        <button
+          class="primary-btn"
+          onclick={prepareClipboardFallback}
+          disabled={fallbackBusy || fallbackPrepared || !textOutput?.fallbackAvailable}
+        >
+          {t('settings.textOutputUseClipboard')}
+        </button>
+        {#if fallbackPrepared}
+          <button class="secondary-btn" onclick={restoreClipboardFallback} disabled={fallbackBusy || !fallbackToken}>
+            {t('settings.textOutputRestoreClipboard')}
+          </button>
+        {/if}
+      </div>
+    </div>
+
+    <div class="divider"></div>
+
+    <div class="setting-row">
+      <div class="setting-info">
         <span class="setting-label">{t('settings.autoHide')}</span>
         <span class="setting-desc">{t('settings.autoHideDesc')}</span>
       </div>
@@ -465,6 +677,12 @@
     animation: pulse 1s infinite;
   }
 
+  .hotkey-key.unset {
+    color: var(--color-secondary-label);
+    font-size: var(--font-size-base);
+    font-weight: 500;
+  }
+
   @keyframes pulse {
     0%, 100% { opacity: 1; }
     50% { opacity: 0.5; }
@@ -537,6 +755,70 @@
     color: var(--color-tertiary-label);
   }
 
+  .text-output-panel {
+    display: flex;
+    flex-direction: column;
+    gap: var(--spacing-sm);
+    padding: var(--spacing-xs) 0;
+  }
+
+  .text-output-status {
+    display: inline-flex;
+    align-self: flex-start;
+    padding: 4px 10px;
+    border-radius: var(--radius-sm);
+    background: var(--color-bg-secondary);
+    color: var(--color-secondary-label);
+    font-size: var(--font-size-sm);
+    font-weight: 500;
+  }
+
+  .text-output-status.success {
+    color: var(--color-green);
+  }
+
+  .text-output-status.warn {
+    color: var(--color-orange);
+  }
+
+  .fallback-message {
+    color: var(--color-secondary-label);
+    font-size: var(--font-size-sm);
+    line-height: 1.4;
+  }
+
+  .fallback-actions {
+    display: flex;
+    flex-wrap: wrap;
+    gap: var(--spacing-sm);
+  }
+
+  .primary-btn,
+  .secondary-btn {
+    border: none;
+    border-radius: var(--radius-sm);
+    padding: 7px 12px;
+    font-size: var(--font-size-sm);
+    font-weight: 600;
+    cursor: pointer;
+  }
+
+  .primary-btn {
+    background: var(--color-blue);
+    color: white;
+  }
+
+  .secondary-btn {
+    background: var(--color-bg-secondary);
+    color: var(--color-label);
+  }
+
+  .primary-btn:disabled,
+  .secondary-btn:disabled {
+    opacity: 0.5;
+    cursor: not-allowed;
+  }
+
   .segmented {
     display: inline-grid;
     grid-template-columns: 1fr 1fr;

--
Gitblit v1.9.3