From 94190dfd72c2004c74297d5320df09e43e50015f Mon Sep 17 00:00:00 2001 From: qiin2333 <414382190@qq.com> Date: Tue, 20 Oct 2026 22:44:34 +0800 Subject: [PATCH 01/11] fix(config): serialize sunshine.conf full-table writers under DEVICE_CONFIG_LOCK /api/config uses whole-file replacement semantics while /api/gamepad/config is an atomic per-key patch. The vmouse toggle, VDD sync and locale writers all read the file and POST the full map back, so anything written between their read and POST was silently reverted or erased by the stale snapshot. Hold the existing DEVICE_CONFIG_LOCK across read-modify-write in all three, replacing the locale-only LOCALE_WRITE_LOCK. --- src-tauri/src/sunshine.rs | 5 +++-- src-tauri/src/vdd.rs | 3 +++ src-tauri/src/vmouse.rs | 3 +++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/sunshine.rs b/src-tauri/src/sunshine.rs index aa94ed00..0628c189 100644 --- a/src-tauri/src/sunshine.rs +++ b/src-tauri/src/sunshine.rs @@ -22,7 +22,6 @@ pub struct SunshineConfig { // 缓存 Sunshine 路径,避免重复查找和记录日志 static SUNSHINE_PATH_CACHE: Lazy>> = Lazy::new(|| Mutex::new(None)); static RUNTIME_SUNSHINE_URL: Lazy>> = Lazy::new(|| RwLock::new(None)); -static LOCALE_WRITE_LOCK: Lazy> = Lazy::new(|| tokio::sync::Mutex::new(())); static HTTPS_CLIENT: Lazy> = Lazy::new(|| { reqwest::Client::builder() .danger_accept_invalid_certs(true) @@ -1419,7 +1418,9 @@ pub async fn get_sunshine_locale() -> Result { /// 设置 Sunshine 配置中的 locale /// 先读取完整配置,合并 locale 字段后再写回,避免覆盖其他配置项 async fn serialize_locale_write(operation: impl Future) -> T { - let _guard = LOCALE_WRITE_LOCK.lock().await; + // /api/config 是整表替换语义:读文件与 POST 之间必须与其他全表写者互斥, + // 否则并发写者的字段会被旧快照覆盖丢失(见 controller_hub.rs 的锁约定)。 + let _guard = crate::device_config::DEVICE_CONFIG_LOCK.lock().await; operation.await } diff --git a/src-tauri/src/vdd.rs b/src-tauri/src/vdd.rs index 10721a61..20089a87 100644 --- a/src-tauri/src/vdd.rs +++ b/src-tauri/src/vdd.rs @@ -632,6 +632,9 @@ pub async fn read_full_sunshine_config() /// 调用 Sunshine Config API 保存 VDD 配置 /// Sunshine 的 saveVddSettings() 会负责写入 vdd_settings.xml 文件 async fn sync_vdd_config_to_sunshine(settings: &VddSettings) -> Result<(), String> { + // /api/config 是整表替换语义:读文件与 POST 之间必须与其他全表写者互斥, + // 否则并发写者的字段会被旧快照覆盖丢失(见 controller_hub.rs 的锁约定)。 + let _config_guard = crate::device_config::DEVICE_CONFIG_LOCK.lock().await; // 读取完整的现有配置,然后更新 VDD 相关的配置项 // 这样可以避免丢失其他配置 let mut config_data = read_full_sunshine_config().await?; diff --git a/src-tauri/src/vmouse.rs b/src-tauri/src/vmouse.rs index eaf08d85..7be24fb7 100644 --- a/src-tauri/src/vmouse.rs +++ b/src-tauri/src/vmouse.rs @@ -252,6 +252,9 @@ pub async fn uninstall_vmouse_driver() -> Result { /// 设置 sunshine.conf 中的 virtual_mouse 配置 #[tauri::command] pub async fn set_vmouse_config(enabled: bool) -> Result { + // /api/config 是整表替换语义:读文件与 POST 之间必须与其他全表写者互斥, + // 否则并发写者的字段会被旧快照覆盖丢失(见 controller_hub.rs 的锁约定)。 + let _config_guard = crate::device_config::DEVICE_CONFIG_LOCK.lock().await; // 读取当前完整配置 let mut config_map = crate::vdd::read_full_sunshine_config() .await From 87271ba3cecba1290f23174b79dbed4c313aa7b1 Mon Sep 17 00:00:00 2001 From: qiin2333 <414382190@qq.com> Date: Tue, 20 Oct 2026 22:44:45 +0800 Subject: [PATCH 02/11] refactor(device-hub): share vmouse state and remove dead routes and entries - useVmouse becomes a module-level shared store so every device-hub surface in a window observes one virtual-mouse state instead of private copies; concurrent loads are coalesced - delete the orphaned VmouseSettings.vue (no references) - drop the DUALSENSE/CONTROLLERS_HUB route aliases and their navigation helpers; the sidebar device-hub entry is the only live route - remove the Electron-era scaffold entry (App.vue/index.html/main.js) from the build inputs plus the unreferenced dashboard/ pages; the real UI chain is sunshine-frame.html - drop DualSenseSettings' never-used standalone header and embedded prop Verified: vite build, 133 renderer tests, 274 cargo tests. --- src/renderer/App.vue | 75 ---- src/renderer/components/DualSenseSettings.vue | 10 - src/renderer/components/SidebarMenu.vue | 6 +- src/renderer/components/VmouseSettings.vue | 391 ------------------ .../controllersHub/ControllersHub.vue | 1 - src/renderer/composables/toolsRegistry.js | 3 +- src/renderer/composables/useRouter.js | 14 - src/renderer/composables/useSidebarState.js | 4 - src/renderer/composables/useVmouse.js | 45 +- src/renderer/dashboard/index.html | 19 - src/renderer/dashboard/index.vue | 133 ------ src/renderer/index.html | 20 - src/renderer/main.js | 14 - vite.config.js | 1 - 14 files changed, 30 insertions(+), 706 deletions(-) delete mode 100644 src/renderer/App.vue delete mode 100644 src/renderer/components/VmouseSettings.vue delete mode 100644 src/renderer/dashboard/index.html delete mode 100644 src/renderer/dashboard/index.vue delete mode 100644 src/renderer/index.html delete mode 100644 src/renderer/main.js diff --git a/src/renderer/App.vue b/src/renderer/App.vue deleted file mode 100644 index 80293801..00000000 --- a/src/renderer/App.vue +++ /dev/null @@ -1,75 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/src/renderer/components/DualSenseSettings.vue b/src/renderer/components/DualSenseSettings.vue index ea576ed0..d7b67128 100644 --- a/src/renderer/components/DualSenseSettings.vue +++ b/src/renderer/components/DualSenseSettings.vue @@ -1,14 +1,5 @@ - - - - diff --git a/src/renderer/components/controllersHub/ControllersHub.vue b/src/renderer/components/controllersHub/ControllersHub.vue index 2d699173..0f651a0a 100644 --- a/src/renderer/components/controllersHub/ControllersHub.vue +++ b/src/renderer/components/controllersHub/ControllersHub.vue @@ -16,7 +16,6 @@ diff --git a/src/renderer/composables/toolsRegistry.js b/src/renderer/composables/toolsRegistry.js index df3f00ce..05f1e22d 100644 --- a/src/renderer/composables/toolsRegistry.js +++ b/src/renderer/composables/toolsRegistry.js @@ -58,8 +58,7 @@ export function createNavigationGroups(ctx) { icon: IconGamepad, label: labels.deviceHub, action: ctx.openControllers, - isActive: () => - [ROUTES.CONTROLLERS, ROUTES.DUALSENSE, ROUTES.CONTROLLERS_HUB].some((route) => ctx.router.isRoute(route)), + isActive: () => ctx.router.isRoute(ROUTES.CONTROLLERS), }, ], }, diff --git a/src/renderer/composables/useRouter.js b/src/renderer/composables/useRouter.js index 0e9a74b9..2a3eef9e 100644 --- a/src/renderer/composables/useRouter.js +++ b/src/renderer/composables/useRouter.js @@ -11,10 +11,6 @@ export const ROUTES = { WEB_STREAM: 'web-stream', AI_ASSISTANT: 'ai-assistant', CONTROLLERS: 'controllers', - // Compatibility alias for callers that still navigate to the original page id. - DUALSENSE: 'dualsense', - // Compatibility alias for the device hub's original page id. - CONTROLLERS_HUB: 'controllers-hub', HDR_ENHANCED: 'hdr-enhanced', TOOLBOX: 'toolbox', PANEL_SETTINGS: 'panel-settings', @@ -55,16 +51,6 @@ const routeConfig = { component: 'ControllersHub', titleKey: 'deviceHub', }, - [ROUTES.DUALSENSE]: { - name: ROUTES.DUALSENSE, - component: 'ControllersHub', - titleKey: 'deviceHub', - }, - [ROUTES.CONTROLLERS_HUB]: { - name: ROUTES.CONTROLLERS_HUB, - component: 'ControllersHub', - titleKey: 'deviceHub', - }, [ROUTES.HDR_ENHANCED]: { name: ROUTES.HDR_ENHANCED, component: 'HdrEnhancedManager', diff --git a/src/renderer/composables/useSidebarState.js b/src/renderer/composables/useSidebarState.js index dd4843ef..2ecf21dd 100644 --- a/src/renderer/composables/useSidebarState.js +++ b/src/renderer/composables/useSidebarState.js @@ -108,8 +108,6 @@ export function useSidebarState() { const openWebStream = () => router.navigate(ROUTES.WEB_STREAM) const openAiAssistant = () => router.navigate(ROUTES.AI_ASSISTANT) const openControllers = () => router.navigate(ROUTES.CONTROLLERS) - const openDualSense = openControllers - const openControllersHub = () => router.navigate(ROUTES.CONTROLLERS_HUB) // RTX HDR 的定向入口也进入公共 HDR 管理页;具体实现由页面内的组件卡片负责。 const openRtxHdr = () => router.navigate(ROUTES.HDR_ENHANCED) @@ -314,8 +312,6 @@ export function useSidebarState() { openWebStream, openAiAssistant, openControllers, - openDualSense, - openControllersHub, openRtxHdr, skipVersion, setIncludePrerelease, diff --git a/src/renderer/composables/useVmouse.js b/src/renderer/composables/useVmouse.js index 42021726..75f1e081 100644 --- a/src/renderer/composables/useVmouse.js +++ b/src/renderer/composables/useVmouse.js @@ -1,24 +1,35 @@ import { computed, ref } from 'vue' import { vmouse } from '../tauri-adapter.js' -export function useVmouse(t) { - const vmouseStatus = ref({ installed: false, running: false }) - const vmouseEnabled = computed(() => vmouseStatus.value.config_enabled === true) - const vmouseStatusKnown = ref(false) - const vmouseNotice = ref('') - const vmouseConfigSaving = ref(false) +// 模块级共享状态:同一窗口内的所有入口(设备中心输入驱动、桌面流设置) +// 看到的是同一份虚拟鼠标状态,一处切换处处更新。 +// 注意:主窗口与 desktop 窗口是两个 JS 上下文,各自持有独立实例; +// 跨窗口同步需要后端事件广播,当前未做。 +const vmouseStatus = ref({ installed: false, running: false }) +const vmouseStatusKnown = ref(false) +const vmouseNotice = ref('') +const vmouseConfigSaving = ref(false) +const vmouseEnabled = computed(() => vmouseStatus.value.config_enabled === true) +let loadInflight = null +export function useVmouse(t) { async function loadVmouseStatus() { - try { - const result = await vmouse.getStatus() - if (!result?.success || typeof result.data?.config_enabled !== 'boolean') throw new Error('Invalid status') - vmouseStatus.value = result.data - vmouseStatusKnown.value = true - vmouseNotice.value = '' - } catch { - vmouseStatusKnown.value = false - vmouseNotice.value = t.value.stream.vmouseReadFailed - } + if (loadInflight) return loadInflight + loadInflight = (async () => { + try { + const result = await vmouse.getStatus() + if (!result?.success || typeof result.data?.config_enabled !== 'boolean') throw new Error('Invalid status') + vmouseStatus.value = result.data + vmouseStatusKnown.value = true + vmouseNotice.value = '' + } catch { + vmouseStatusKnown.value = false + vmouseNotice.value = t.value.stream.vmouseReadFailed + } finally { + loadInflight = null + } + })() + return loadInflight } async function toggleVmouse() { @@ -28,7 +39,7 @@ export function useVmouse(t) { const enabled = !vmouseEnabled.value const result = await vmouse.setConfig(enabled) if (!result?.success) throw new Error('Save failed') - vmouseStatus.value.config_enabled = enabled + vmouseStatus.value = { ...vmouseStatus.value, config_enabled: enabled } vmouseNotice.value = t.value.stream.vmouseSaved } catch { vmouseNotice.value = t.value.stream.vmouseSaveFailed diff --git a/src/renderer/dashboard/index.html b/src/renderer/dashboard/index.html deleted file mode 100644 index 38efacd2..00000000 --- a/src/renderer/dashboard/index.html +++ /dev/null @@ -1,19 +0,0 @@ - - - - - Dashboard - - -
- - - diff --git a/src/renderer/dashboard/index.vue b/src/renderer/dashboard/index.vue deleted file mode 100644 index 3ce37ffe..00000000 --- a/src/renderer/dashboard/index.vue +++ /dev/null @@ -1,133 +0,0 @@ - - - - - diff --git a/src/renderer/index.html b/src/renderer/index.html deleted file mode 100644 index 5d8e2ddc..00000000 --- a/src/renderer/index.html +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - Sunshine 基地版 - 让游戏串流更优雅 - - - - - - - - - - -
- - - diff --git a/src/renderer/main.js b/src/renderer/main.js deleted file mode 100644 index c040638e..00000000 --- a/src/renderer/main.js +++ /dev/null @@ -1,14 +0,0 @@ -import { createApp } from 'vue' -import './styles/global.less' -import './styles/dialog.less' // 导入对话框样式 -import App from './App.vue' -// 导入 Tauri polyfill 以支持全局 API -import './tauri-polyfill.js' -import { getInitialSimpleLocale, setDocumentLocale, syncSimpleLocaleFromSunshine } from './shared/simple-i18n.js' - -setDocumentLocale(getInitialSimpleLocale()) -syncSimpleLocaleFromSunshine({ zh: {}, en: {} }, setDocumentLocale) - -const app = createApp(App); - -app.mount('#app'); diff --git a/vite.config.js b/vite.config.js index 6ec85463..45df6910 100644 --- a/vite.config.js +++ b/vite.config.js @@ -38,7 +38,6 @@ export default defineConfig(() => ({ sourcemap: !!process.env.TAURI_DEBUG, rollupOptions: { input: { - index: resolve(rendererSrcPath, 'index.html'), sunshineFrame: resolve(rendererSrcPath, 'sunshine-frame.html'), console: resolve(rendererSrcPath, 'console/index.html'), clock: resolve(rendererSrcPath, 'stop-clock-canvas/index.html'), From 31800fdd12082436cec6e59f7a329f91b69704f2 Mon Sep 17 00:00:00 2001 From: qiin2333 <414382190@qq.com> Date: Tue, 20 Oct 2026 23:56:20 +0800 Subject: [PATCH 03/11] refactor(device-hub): share device status probes and keep panes alive - add deviceStatusStore: one cached, coalesced channel per window for the dualsense and usbip probes; explicit refreshes and pre-operation checks bypass the cache, operations invalidate/prime it, and a fetch started before an invalidation can no longer be joined afterwards - route useDeviceRuntime, useDualSenseComponent and useUsbTransport through the store so tab remounts reuse recent probes instead of respawning the sidecar probe each time - wrap the hub panes in KeepAlive (new ControllersPane hosts the controllers tab) so in-flight install progress and loaded state survive tab switches; microphone/dualsense polling pauses while a pane is hidden and recalibrates on return - drop the dead i18n controllers section and dualSense.experimental key Verified: vite build, 141 renderer tests (8 new for the store). --- .../controllersHub/ControllersHub.vue | 68 +++++----- .../controllersHub/ControllersPane.vue | 28 ++++ .../controllersHub/DeviceOverviewPanel.vue | 4 +- .../controllersHub/MicrophonePanel.vue | 22 ++- .../controllersHub/UsbPassthroughPanel.vue | 10 +- src/renderer/composables/deviceStatusStore.js | 81 +++++++++++ .../composables/deviceStatusStore.test.js | 127 ++++++++++++++++++ src/renderer/composables/useDeviceRuntime.js | 7 +- .../composables/useDualSenseComponent.js | 38 ++++-- .../composables/useDualSenseSettings.js | 4 + src/renderer/composables/useUsbTransport.js | 26 ++-- src/renderer/desktop/i18n/en.js | 7 - src/renderer/desktop/i18n/zh.js | 7 - 13 files changed, 355 insertions(+), 74 deletions(-) create mode 100644 src/renderer/components/controllersHub/ControllersPane.vue create mode 100644 src/renderer/composables/deviceStatusStore.js create mode 100644 src/renderer/composables/deviceStatusStore.test.js diff --git a/src/renderer/components/controllersHub/ControllersHub.vue b/src/renderer/components/controllersHub/ControllersHub.vue index 0f651a0a..0bcc3822 100644 --- a/src/renderer/components/controllersHub/ControllersHub.vue +++ b/src/renderer/components/controllersHub/ControllersHub.vue @@ -11,31 +11,9 @@
- - - - - - + + +
@@ -43,13 +21,11 @@ diff --git a/src/renderer/components/controllersHub/DeviceOverviewPanel.vue b/src/renderer/components/controllersHub/DeviceOverviewPanel.vue index d149aef4..23cf238a 100644 --- a/src/renderer/components/controllersHub/DeviceOverviewPanel.vue +++ b/src/renderer/components/controllersHub/DeviceOverviewPanel.vue @@ -28,7 +28,7 @@ diff --git a/src/renderer/components/controllersHub/MicrophonePanel.vue b/src/renderer/components/controllersHub/MicrophonePanel.vue index 48835309..f6b98e50 100644 --- a/src/renderer/components/controllersHub/MicrophonePanel.vue +++ b/src/renderer/components/controllersHub/MicrophonePanel.vue @@ -53,7 +53,7 @@ diff --git a/src/renderer/components/controllersHub/UsbPassthroughPanel.vue b/src/renderer/components/controllersHub/UsbPassthroughPanel.vue index 65c94f09..de8ab1d9 100644 --- a/src/renderer/components/controllersHub/UsbPassthroughPanel.vue +++ b/src/renderer/components/controllersHub/UsbPassthroughPanel.vue @@ -122,7 +122,7 @@ async function discover() { discoveryDone.value = true if (!result?.success) { remoteDevices.value = [] - await refreshStatus({ afterCurrent: true }) + await refreshStatus({ force: true, afterCurrent: true }) return ElMessage.error(friendlyError(result?.message)) } remoteDevices.value = result.data || [] @@ -144,10 +144,10 @@ async function attachDevice(device) { const result = await usbip.attach(remote.value.trim(), device.bus_id, tcpPort.value) attachingBusId.value = '' if (!result?.success) { - await refreshStatus({ afterCurrent: true }) + await refreshStatus({ force: true, afterCurrent: true }) return ElMessage.error(friendlyError(result?.message)) } - await refreshStatus({ afterCurrent: true }) + await refreshStatus({ force: true, afterCurrent: true }) ElMessage.success(t.value.deviceHub.usb.attachSuccess) } @@ -163,10 +163,10 @@ async function detachDevice(device) { const result = await usbip.detach(device.port) detachingPort.value = 0 if (!result?.success) { - await refreshStatus({ afterCurrent: true }) + await refreshStatus({ force: true, afterCurrent: true }) return ElMessage.error(friendlyError(result?.message)) } - await refreshStatus({ afterCurrent: true }) + await refreshStatus({ force: true, afterCurrent: true }) ElMessage.success(t.value.deviceHub.usb.detachSuccess) } diff --git a/src/renderer/composables/deviceStatusStore.js b/src/renderer/composables/deviceStatusStore.js new file mode 100644 index 00000000..b987babb --- /dev/null +++ b/src/renderer/composables/deviceStatusStore.js @@ -0,0 +1,81 @@ +import { ref, shallowRef } from 'vue' +import { dualsense, usbip } from '../tauri-adapter.js' + +/** + * 单窗口共享的设备状态通道:同一窗口内的所有面板(总览/控制器/USB/组件) + * 共用一份 dualsense / usbip 探测结果,避免每次挂载都重新 spawn + * sidecar --probe 或 usbip 全量探测。 + * + * - fetch({ force }):force=false 且缓存未过期时直接返回缓存; + * 并发请求合并为一次后端调用。 + * - invalidate():操作(安装/卸载/附加设备)前调用,使下一次读取强制重探。 + * - prime(result):操作返回的新状态直接写入缓存,免去紧随的重复探测。 + * + * 注意:主窗口与 desktop 窗口是两个 JS 上下文,各自持有独立通道实例。 + */ +export function createStatusChannel(fetcher, ttlMs) { + const data = shallowRef(null) + const error = ref(null) + let inflight = null + let inflightEpoch = 0 + let epoch = 0 + let fetchedAt = 0 + + const isFresh = () => data.value !== null && error.value === null && Date.now() - fetchedAt < ttlMs + + async function fetch({ force = false } = {}) { + if (!force && isFresh()) { + return { success: true, data: data.value, cached: true } + } + // invalidate() 之后不再并入旧的在途请求:操作后的刷新必须反映操作后的状态, + // 而不是操作开始前就已发出的旧探测结果。 + if (inflight && inflightEpoch === epoch) return inflight + const requestEpoch = epoch + inflightEpoch = epoch + inflight = (async () => { + try { + const result = await fetcher() + if (requestEpoch !== epoch) { + // 请求期间通道被失效:结果可能早于操作,不写入缓存。 + return result + } + if (result?.success) { + data.value = result.data + error.value = null + } else { + error.value = result?.message || 'probe failed' + } + fetchedAt = Date.now() + return result + } finally { + if (inflightEpoch === epoch && inflightEpoch === requestEpoch) inflight = null + } + })() + return inflight + } + + function invalidate() { + fetchedAt = 0 + epoch += 1 + } + + /** 操作成功后用返回的最新状态直接填充缓存。 */ + function prime(result) { + if (result?.success && result.data) { + data.value = result.data + error.value = null + fetchedAt = Date.now() + } + } + + return { fetch, invalidate, prime, data, error } +} + +const STATUS_TTL_MS = 10_000 +const POLL_INTERVAL_MS = 30_000 + +export const STATUS_POLL_INTERVAL_MS = POLL_INTERVAL_MS + +export const dualsenseStatusChannel = createStatusChannel(() => dualsense.getStatus(), STATUS_TTL_MS) + +export const usbipStatusChannel = createStatusChannel(() => usbip.getStatus(), STATUS_TTL_MS) diff --git a/src/renderer/composables/deviceStatusStore.test.js b/src/renderer/composables/deviceStatusStore.test.js new file mode 100644 index 00000000..168929c8 --- /dev/null +++ b/src/renderer/composables/deviceStatusStore.test.js @@ -0,0 +1,127 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { createStatusChannel } from './deviceStatusStore.js' + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +test('concurrent fetches share one backend call', async () => { + let calls = 0 + const channel = createStatusChannel(async () => { + calls += 1 + await sleep(5) + return { success: true, data: { n: calls } } + }, 50) + const [a, b, c] = await Promise.all([channel.fetch(), channel.fetch(), channel.fetch()]) + assert.equal(calls, 1) + assert.equal(a.data.n, 1) + assert.equal(b.data.n, 1) + assert.deepEqual(a, b) +}) + +test('successful results are reused within the TTL', async () => { + let calls = 0 + const channel = createStatusChannel(async () => { + calls += 1 + return { success: true, data: { n: calls } } + }, 50) + await channel.fetch() + const second = await channel.fetch() + assert.equal(calls, 1) + assert.equal(second.cached, true) + assert.equal(second.data.n, 1) +}) + +test('force bypasses a fresh cache', async () => { + let calls = 0 + const channel = createStatusChannel(async () => { + calls += 1 + return { success: true, data: { n: calls } } + }, 50000) + await channel.fetch() + const forced = await channel.fetch({ force: true }) + assert.equal(calls, 2) + assert.equal(forced.cached, undefined) +}) + +test('invalidate makes the next plain fetch refetch', async () => { + let calls = 0 + const channel = createStatusChannel(async () => { + calls += 1 + return { success: true, data: { n: calls } } + }, 50000) + await channel.fetch() + channel.invalidate() + await channel.fetch() + assert.equal(calls, 2) +}) + +test('prime seeds the cache and satisfies plain fetches', async () => { + let calls = 0 + const channel = createStatusChannel(async () => { + calls += 1 + return { success: true, data: { n: calls } } + }, 50000) + channel.prime({ success: true, data: { n: 42 } }) + const result = await channel.fetch() + assert.equal(calls, 0) + assert.equal(result.data.n, 42) + assert.equal(result.cached, true) + channel.invalidate() + const refetched = await channel.fetch() + assert.equal(calls, 1) + assert.equal(refetched.data.n, 1) +}) + +test('failed probes are retried on the next plain fetch', async () => { + let calls = 0 + const channel = createStatusChannel(async () => { + calls += 1 + return calls === 1 + ? { success: false, message: 'boom' } + : { success: true, data: { n: calls } } + }, 50000) + const failed = await channel.fetch() + assert.equal(failed.success, false) + const retried = await channel.fetch() + assert.equal(calls, 2) + assert.equal(retried.success, true) +}) + +test('expired cache refetches instead of returning stale data', async () => { + let calls = 0 + const channel = createStatusChannel(async () => { + calls += 1 + return { success: true, data: { n: calls } } + }, 5) + await channel.fetch() + await sleep(10) + const result = await channel.fetch() + assert.equal(calls, 2) + assert.equal(result.data.n, 2) +}) + +test('invalidate during an in-flight fetch starts a fresh call; stale result never wins the cache', async () => { + let calls = 0 + let resolveFirst + const channel = createStatusChannel(() => { + calls += 1 + if (calls === 1) { + return new Promise((resolve) => { resolveFirst = resolve }) + } + return Promise.resolve({ success: true, data: { n: calls, tag: 'post' } }) + }, 50000) + const first = channel.fetch() + await sleep(1) + channel.invalidate() + const second = channel.fetch() + await sleep(1) + resolveFirst({ success: true, data: { n: 1, tag: 'pre' } }) + const firstResult = await first + const secondResult = await second + assert.equal(calls, 2) + assert.equal(firstResult.data.tag, 'pre') + assert.equal(secondResult.data.tag, 'post') + const third = await channel.fetch() + assert.equal(third.cached, true) + assert.equal(third.data.tag, 'post') +}) diff --git a/src/renderer/composables/useDeviceRuntime.js b/src/renderer/composables/useDeviceRuntime.js index 3f3265ab..5825999c 100644 --- a/src/renderer/composables/useDeviceRuntime.js +++ b/src/renderer/composables/useDeviceRuntime.js @@ -1,5 +1,6 @@ import { reactive, ref } from 'vue' -import { dualsense, usbip, virtualMicrophone } from '../tauri-adapter.js' +import { virtualMicrophone } from '../tauri-adapter.js' +import { dualsenseStatusChannel, usbipStatusChannel } from './deviceStatusStore.js' // Overview status is scoped to its mounted tab; coalesce concurrent refreshes. export function useDeviceRuntime() { @@ -16,9 +17,9 @@ export function useDeviceRuntime() { async function fetchOnce() { const results = await Promise.allSettled([ - dualsense.getStatus(), + dualsenseStatusChannel.fetch(), virtualMicrophone.getStatus(), - usbip.getStatus(), + usbipStatusChannel.fetch(), ]) for (const [index, key] of ['ds', 'mic', 'usb'].entries()) { const result = results[index].status === 'fulfilled' ? results[index].value : null diff --git a/src/renderer/composables/useDualSenseComponent.js b/src/renderer/composables/useDualSenseComponent.js index 34f93cec..78e17f94 100644 --- a/src/renderer/composables/useDualSenseComponent.js +++ b/src/renderer/composables/useDualSenseComponent.js @@ -1,12 +1,13 @@ -import { computed, onMounted, onUnmounted, ref } from 'vue' +import { computed, onActivated, onDeactivated, onMounted, onUnmounted, ref } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import { open } from '@tauri-apps/plugin-dialog' -import { dualsense, usbip } from '../tauri-adapter.js' +import { dualsense } from '../tauri-adapter.js' import { friendlyDualSenseError, safeDualSenseTechnicalError } from './dualsenseErrors.js' import { dualSenseConfigReadable, mergeDualSenseStatus } from './dualsenseConfigSync.js' import { installSelectedDualSensePackages } from './dualsenseInstallFlow.js' import { dualSenseComponentAction, dualSenseComponentOperational, usbTransportPlan } from './deviceHubStatus.js' import { useDeviceComponentOperation } from './useDeviceComponentOperation.js' +import { dualsenseStatusChannel, usbipStatusChannel, STATUS_POLL_INTERVAL_MS } from './deviceStatusStore.js' import { useI18n } from '../desktop/i18n/index.js' const STATUS_REFRESH_WAIT_TIMEOUT_MS = 5000 @@ -125,6 +126,7 @@ export function useDualSenseComponent({ busy = ref(false), onStatus, afterInstal const invalidateStatusRefresh = () => { statusRefreshGeneration += 1 + dualsenseStatusChannel.invalidate() } const waitForStatusRefresh = async () => { @@ -144,7 +146,7 @@ export function useDualSenseComponent({ busy = ref(false), onStatus, afterInstal } } - const refresh = async (quiet = false, allowBusy = false) => { + const refresh = async (quiet = false, allowBusy = false, force = false) => { if ((!allowBusy && controlsBusy.value) || refreshing.value || (quiet && statusRefreshPromise)) return false if (!quiet && statusRefreshPromise) { await waitForStatusRefresh() @@ -154,7 +156,8 @@ export function useDualSenseComponent({ busy = ref(false), onStatus, afterInstal if (!quiet) operationError.value = '' if (!quiet) refreshing.value = true let refreshed = false - const request = dualsense.getStatus() + // 显式刷新与非 quiet 读取必须穿透缓存;quiet 读刷新在操作失效后同样回源。 + const request = dualsenseStatusChannel.fetch({ force: force || !quiet }) statusRefreshPromise = request try { const result = await request @@ -189,7 +192,7 @@ export function useDualSenseComponent({ busy = ref(false), onStatus, afterInstal const localPackage = packagePaths.length > 0 operation.value = 'confirm-install' try { - const transport = await usbip.getStatus() + const transport = await usbipStatusChannel.fetch({ force: true }) if (!transport?.success) { showError(transport?.message, 'install') operation.value = '' @@ -247,6 +250,7 @@ export function useDualSenseComponent({ busy = ref(false), onStatus, afterInstal rebootRecommended.value = result.data.reboot_recommended const configReadable = dualSenseConfigReadable(result.data) status.value = mergeDualSenseStatus(status.value, result.data) + dualsenseStatusChannel.prime(result.data) statusKnown.value = true if (configReadable) { onStatus?.(result.data, false) @@ -316,11 +320,23 @@ export function useDualSenseComponent({ busy = ref(false), onStatus, afterInstal } rebootRecommended.value = false status.value = mergeDualSenseStatus(status.value, result.data) + dualsenseStatusChannel.prime(result.data) statusKnown.value = true ElMessage.success(t.value.dualSense.uninstallSuccess) }) - sharedOperation.onOtherChange(() => refresh(true)) + sharedOperation.onOtherChange(() => refresh(true, false, true)) + const startPolling = () => { + if (pollTimer || disposed) return + pollTimer = window.setInterval(() => refresh(true), STATUS_POLL_INTERVAL_MS) + } + const stopPolling = () => { + if (pollTimer) { + window.clearInterval(pollTimer) + pollTimer = undefined + } + } + onMounted(async () => { try { const { listen } = await import('@tauri-apps/api/event') @@ -336,12 +352,18 @@ export function useDualSenseComponent({ busy = ref(false), onStatus, afterInstal if (disposed) return await refresh() if (disposed) return - pollTimer = window.setInterval(() => refresh(true), 30000) + startPolling() + }) + // KeepAlive 下被隐藏的面板暂停轮询;重新可见时先展示缓存再静默校准。 + onActivated(() => { + startPolling() + refresh(true) }) + onDeactivated(stopPolling) onUnmounted(() => { disposed = true invalidateStatusRefresh() - window.clearInterval(pollTimer) + stopPolling() unlistenProgress?.() }) diff --git a/src/renderer/composables/useDualSenseSettings.js b/src/renderer/composables/useDualSenseSettings.js index 591f3484..27d9f4f7 100644 --- a/src/renderer/composables/useDualSenseSettings.js +++ b/src/renderer/composables/useDualSenseSettings.js @@ -9,6 +9,7 @@ import { dualSenseConfigUiState, mergeDualSenseStatus, } from './dualsenseConfigSync.js' +import { dualsenseStatusChannel } from './deviceStatusStore.js' import { useDualSenseComponent } from './useDualSenseComponent.js' import { useI18n } from '../desktop/i18n/index.js' @@ -162,6 +163,7 @@ export function useDualSenseSettings() { return { success: false, preserveTuning, message: result.message } } status.value = mergeDualSenseStatus(status.value, result.data) + dualsenseStatusChannel.prime(result.data) statusKnown.value = true return { success: true, preserveTuning } } @@ -257,6 +259,8 @@ export function useDualSenseSettings() { status.value.legacy_curve = result.data.legacy_curve status.value.legacy_noise_gate = result.data.legacy_noise_gate status.value.config_revision = result.data.revision + // tuning 结果不是完整状态,无法 prime;让共享缓存失效以回源对齐。 + dualsenseStatusChannel.invalidate() if (!tuningChangedWhileSaving) ElMessage.success(t.value.dualSense.tuningSaved) })() tuningSavePromise = saveOperation diff --git a/src/renderer/composables/useUsbTransport.js b/src/renderer/composables/useUsbTransport.js index c1b62227..287b3e95 100644 --- a/src/renderer/composables/useUsbTransport.js +++ b/src/renderer/composables/useUsbTransport.js @@ -1,8 +1,9 @@ -import { computed, onMounted, reactive, ref } from 'vue' +import { computed, onActivated, onMounted, reactive, ref } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import { usbip } from '../tauri-adapter.js' import { useI18n } from '../desktop/i18n/index.js' import { useDeviceComponentOperation } from './useDeviceComponentOperation.js' +import { usbipStatusChannel } from './deviceStatusStore.js' import { usbipTransportNeedsCleanup } from './deviceHubStatus.js' /** Coordinates shared transport maintenance and refreshes actual state after failed operations. */ @@ -65,10 +66,10 @@ export function useUsbTransport() { status.detail = detail } - async function performStatusRefresh() { + async function performStatusRefresh({ force = false } = {}) { statusLoading.value = true try { - const result = await usbip.getStatus() + const result = await usbipStatusChannel.fetch({ force }) if (result?.success) { statusProbeFailed.value = false applyStatus(result.data) @@ -85,13 +86,14 @@ export function useUsbTransport() { async function refreshStatus(options = {}) { const afterCurrent = options?.afterCurrent === true + const force = options?.force === true const activeRefresh = statusRefreshPromise if (activeRefresh) { await activeRefresh if (!afterCurrent) return } - const nextRefresh = performStatusRefresh() + const nextRefresh = performStatusRefresh({ force }) statusRefreshPromise = nextRefresh try { await nextRefresh @@ -114,20 +116,22 @@ export function useUsbTransport() { // of the operation that is about to run. if (statusRefreshPromise) await statusRefreshPromise sharedOperation.markChanged() + usbipStatusChannel.invalidate() let result try { result = await usbip.installTransport() } catch (error) { - await refreshStatus() + await refreshStatus({ force: true }) return ElMessage.error(friendlyError(error)) } finally { installing.value = false } if (!result?.success) { - await refreshStatus() + await refreshStatus({ force: true }) return ElMessage.error(friendlyError(result?.message)) } applyStatus(result.data) + usbipStatusChannel.prime(result.data) if (result.data?.ready) ElMessage.success(t.value.deviceHub.usb.installSuccess) else ElMessage.warning(t.value.deviceHub.usb.rebootRequired) }) @@ -145,11 +149,12 @@ export function useUsbTransport() { } catch { cleaning.value = false; return } if (statusRefreshPromise) await statusRefreshPromise sharedOperation.markChanged() + usbipStatusChannel.invalidate() let result try { result = await usbip.cleanupTransport() } catch (error) { - await refreshStatus() + await refreshStatus({ force: true }) return ElMessage.error(friendlyError(error)) } finally { cleaning.value = false @@ -157,15 +162,18 @@ export function useUsbTransport() { if (!result?.success) { // The failed cleanup may still have removed some devnodes; refresh so the // residual flag reflects the actual state instead of the stale snapshot. - await refreshStatus() + await refreshStatus({ force: true }) return ElMessage.error(friendlyError(result?.message)) } applyStatus(result.data) + usbipStatusChannel.prime(result.data) if (result.data?.vhci_residual) ElMessage.error(friendlyError('USBIP-CLEAN-002')) else ElMessage.success(removing ? t.value.deviceHub.components.transportRemoved : t.value.deviceHub.usb.cleanupSuccess) }) - sharedOperation.onOtherChange(() => refreshStatus({ afterCurrent: true })) + sharedOperation.onOtherChange(() => refreshStatus({ afterCurrent: true, force: true })) onMounted(() => refreshStatus()) + // KeepAlive 重新显示时用缓存立即校准,过期则静默重探。 + onActivated(() => refreshStatus()) return { status, statusLoading, statusLoaded, statusProbeFailed, installing, cleaning, attachedDevices, needsCleanup, transportBusy, statusClass, statusTitle, friendlyError, refreshStatus, installTransport, cleanupResidual } } diff --git a/src/renderer/desktop/i18n/en.js b/src/renderer/desktop/i18n/en.js index e0b34423..2dc41ef1 100644 --- a/src/renderer/desktop/i18n/en.js +++ b/src/renderer/desktop/i18n/en.js @@ -1112,12 +1112,6 @@ Output only one short sentence, no explanation. Reply in English.`, restartAsAdmin: 'Restart as Admin', }, - controllers: { - eyebrow: 'MANAGE / CONTROLLERS', - title: 'Controllers', - intro: 'Manage Sunshine virtual controllers.', - }, - hdrEnhanced: { open: 'Manage image enhancement', }, @@ -1315,7 +1309,6 @@ Output only one short sentence, no explanation. Reply in English.`, dualSense: { title: 'DualSense Emulation', - experimental: '[Experimental]', states: { not_installed: 'Not installed', repair_required: 'Needs repair', diff --git a/src/renderer/desktop/i18n/zh.js b/src/renderer/desktop/i18n/zh.js index e4899dc1..333fa2ef 100644 --- a/src/renderer/desktop/i18n/zh.js +++ b/src/renderer/desktop/i18n/zh.js @@ -1112,12 +1112,6 @@ export const zh = { restartAsAdmin: '以管理员重启', }, - controllers: { - eyebrow: 'MANAGE / CONTROLLERS', - title: '控制器', - intro: '管理 Sunshine 的虚拟控制器。', - }, - hdrEnhanced: { open: '管理画质增强', }, @@ -1315,7 +1309,6 @@ export const zh = { dualSense: { title: 'DualSense 模拟', - experimental: '[实验性功能]', states: { not_installed: '未安装', repair_required: '需要修复', From 6ff4ca9aba49310b4972873818e9d1440382313d Mon Sep 17 00:00:00 2001 From: qiin2333 <414382190@qq.com> Date: Wed, 21 Oct 2026 00:39:42 +0800 Subject: [PATCH 04/11] fix(device-hub): address review findings on status channel and panes - prime() call sites now pass the full { success, data } envelope; they previously passed result.data which failed the success check, so priming never happened. prime() also advances the epoch so a probe that started during an operation cannot overwrite the confirmed result afterwards - useVmouse: a getStatus() read that was in flight while a config save landed is now stale (epoch guard) and no longer overwrites the saved config_enabled - useDeviceRuntime: refresh() takes { force } and the overview refresh button forces, so explicit refreshes bypass the 10s cache while mount/activation reads stay cache-aware - useDualSenseComponent: force is now an explicit refresh() argument instead of being implied by non-quiet; the initial mount read reuses a fresh shared cache (still shows errors), the component refresh button and post-test recalibration force. A paneActive flag closes the mount-callback race where hiding the pane during the initial awaits would leave a hidden 30s poll running - UsbPassthroughPanel: attach/detach invalidate the usbip channel after the mutation settles (including partial-failure paths) so the following forced refresh cannot join a pre-mutation probe - UsbTransportPanel refresh button forces through the cache Verified: vite build, 142 renderer tests (new: prime advances epoch). --- .../controllersHub/DeviceOverviewPanel.vue | 2 +- .../DualSenseComponentPanel.vue | 2 +- .../controllersHub/UsbPassthroughPanel.vue | 5 +++++ .../controllersHub/UsbTransportPanel.vue | 2 +- src/renderer/composables/deviceStatusStore.js | 5 ++++- .../composables/deviceStatusStore.test.js | 18 ++++++++++++++++ src/renderer/composables/useDeviceRuntime.js | 11 +++++----- .../composables/useDualSenseComponent.js | 21 ++++++++++++------- .../composables/useDualSenseSettings.js | 4 ++-- src/renderer/composables/useUsbTransport.js | 4 ++-- src/renderer/composables/useVmouse.js | 16 +++++++++++--- 11 files changed, 67 insertions(+), 23 deletions(-) diff --git a/src/renderer/components/controllersHub/DeviceOverviewPanel.vue b/src/renderer/components/controllersHub/DeviceOverviewPanel.vue index 23cf238a..792b6257 100644 --- a/src/renderer/components/controllersHub/DeviceOverviewPanel.vue +++ b/src/renderer/components/controllersHub/DeviceOverviewPanel.vue @@ -2,7 +2,7 @@
- {{ t.deviceHub.refresh }} + {{ t.deviceHub.refresh }}

{{ t.deviceHub.statusUnavailable }}

diff --git a/src/renderer/components/controllersHub/DualSenseComponentPanel.vue b/src/renderer/components/controllersHub/DualSenseComponentPanel.vue index a87c09ac..968ee50e 100644 --- a/src/renderer/components/controllersHub/DualSenseComponentPanel.vue +++ b/src/renderer/components/controllersHub/DualSenseComponentPanel.vue @@ -25,7 +25,7 @@ class="ds5-action" :loading="refreshing" :disabled="refreshing || controlsBusy" - @click="refresh()" + @click="refresh(false, false, true)" > {{ t.dualSense.refresh }} diff --git a/src/renderer/components/controllersHub/UsbPassthroughPanel.vue b/src/renderer/components/controllersHub/UsbPassthroughPanel.vue index de8ab1d9..a1afc9fd 100644 --- a/src/renderer/components/controllersHub/UsbPassthroughPanel.vue +++ b/src/renderer/components/controllersHub/UsbPassthroughPanel.vue @@ -94,6 +94,7 @@ import { ElMessage, ElMessageBox } from 'element-plus' import { usbip } from '../../tauri-adapter.js' import { useI18n } from '../../desktop/i18n/index.js' import { useUsbTransport } from '../../composables/useUsbTransport.js' +import { usbipStatusChannel } from '../../composables/deviceStatusStore.js' import UsbForwardingSettings from './UsbForwardingSettings.vue' import UsbTransportPanel from './UsbTransportPanel.vue' @@ -143,6 +144,8 @@ async function attachDevice(device) { attachingBusId.value = device.bus_id const result = await usbip.attach(remote.value.trim(), device.bus_id, tcpPort.value) attachingBusId.value = '' + // 变更落定后先失效通道:随后的强制刷新不得并入变更前发出的在途探测。 + usbipStatusChannel.invalidate() if (!result?.success) { await refreshStatus({ force: true, afterCurrent: true }) return ElMessage.error(friendlyError(result?.message)) @@ -162,6 +165,8 @@ async function detachDevice(device) { detachingPort.value = device.port const result = await usbip.detach(device.port) detachingPort.value = 0 + // 变更落定后先失效通道:随后的强制刷新不得并入变更前发出的在途探测。 + usbipStatusChannel.invalidate() if (!result?.success) { await refreshStatus({ force: true, afterCurrent: true }) return ElMessage.error(friendlyError(result?.message)) diff --git a/src/renderer/components/controllersHub/UsbTransportPanel.vue b/src/renderer/components/controllersHub/UsbTransportPanel.vue index 9223fcb9..44e102f4 100644 --- a/src/renderer/components/controllersHub/UsbTransportPanel.vue +++ b/src/renderer/components/controllersHub/UsbTransportPanel.vue @@ -10,7 +10,7 @@
v{{ status.version }} - {{ t.deviceHub.refresh }} + {{ t.deviceHub.refresh }}
diff --git a/src/renderer/composables/deviceStatusStore.js b/src/renderer/composables/deviceStatusStore.js index b987babb..f44d8a59 100644 --- a/src/renderer/composables/deviceStatusStore.js +++ b/src/renderer/composables/deviceStatusStore.js @@ -59,9 +59,12 @@ export function createStatusChannel(fetcher, ttlMs) { epoch += 1 } - /** 操作成功后用返回的最新状态直接填充缓存。 */ + /** 操作成功后用返回的完整 { success, data } 信封直接填充缓存。 */ function prime(result) { if (result?.success && result.data) { + // prime 视为一次确认写入:推进纪元,使操作期间发出的在途探测 + // 完成后不能覆盖已确认的操作结果。 + epoch += 1 data.value = result.data error.value = null fetchedAt = Date.now() diff --git a/src/renderer/composables/deviceStatusStore.test.js b/src/renderer/composables/deviceStatusStore.test.js index 168929c8..a1ec7cf6 100644 --- a/src/renderer/composables/deviceStatusStore.test.js +++ b/src/renderer/composables/deviceStatusStore.test.js @@ -125,3 +125,21 @@ test('invalidate during an in-flight fetch starts a fresh call; stale result nev assert.equal(third.cached, true) assert.equal(third.data.tag, 'post') }) + +test('prime advances the epoch so an in-flight probe cannot overwrite the primed result', async () => { + let calls = 0 + let resolveProbe + const channel = createStatusChannel(() => { + calls += 1 + if (calls === 1) return new Promise((resolve) => { resolveProbe = resolve }) + return Promise.resolve({ success: true, data: { n: calls } }) + }, 50000) + const probe = channel.fetch() + await sleep(1) + channel.prime({ success: true, data: { n: 99, tag: 'primed' } }) + resolveProbe({ success: true, data: { n: 1, tag: 'stale' } }) + await probe + const next = await channel.fetch() + assert.equal(next.cached, true) + assert.equal(next.data.tag, 'primed') +}) diff --git a/src/renderer/composables/useDeviceRuntime.js b/src/renderer/composables/useDeviceRuntime.js index 5825999c..511b8948 100644 --- a/src/renderer/composables/useDeviceRuntime.js +++ b/src/renderer/composables/useDeviceRuntime.js @@ -15,11 +15,11 @@ export function useDeviceRuntime() { const probeFailed = reactive({ ds: false, mic: false, usb: false }) let inflight = null - async function fetchOnce() { + async function fetchOnce(force = false) { const results = await Promise.allSettled([ - dualsenseStatusChannel.fetch(), + dualsenseStatusChannel.fetch({ force }), virtualMicrophone.getStatus(), - usbipStatusChannel.fetch(), + usbipStatusChannel.fetch({ force }), ]) for (const [index, key] of ['ds', 'mic', 'usb'].entries()) { const result = results[index].status === 'fulfilled' ? results[index].value : null @@ -30,10 +30,11 @@ export function useDeviceRuntime() { loaded.value = true } - function refresh() { + function refresh(options = {}) { + const force = options?.force === true if (inflight) return inflight loading.value = true - inflight = fetchOnce().finally(() => { + inflight = fetchOnce(force).finally(() => { loading.value = false inflight = null }) diff --git a/src/renderer/composables/useDualSenseComponent.js b/src/renderer/composables/useDualSenseComponent.js index 78e17f94..5b0acfce 100644 --- a/src/renderer/composables/useDualSenseComponent.js +++ b/src/renderer/composables/useDualSenseComponent.js @@ -38,6 +38,7 @@ export function useDualSenseComponent({ busy = ref(false), onStatus, afterInstal }) let pollTimer let unlistenProgress + let paneActive = true let disposed = false let statusRefreshGeneration = 0 let statusRefreshPromise = null @@ -156,8 +157,9 @@ export function useDualSenseComponent({ busy = ref(false), onStatus, afterInstal if (!quiet) operationError.value = '' if (!quiet) refreshing.value = true let refreshed = false - // 显式刷新与非 quiet 读取必须穿透缓存;quiet 读刷新在操作失效后同样回源。 - const request = dualsenseStatusChannel.fetch({ force: force || !quiet }) + // 缓存语义由显式 force 参数决定:初始挂载与激活读取复用近期探测, + // 显式刷新按钮与测试后的校准读取必须穿透缓存。 + const request = dualsenseStatusChannel.fetch({ force }) statusRefreshPromise = request try { const result = await request @@ -250,7 +252,7 @@ export function useDualSenseComponent({ busy = ref(false), onStatus, afterInstal rebootRecommended.value = result.data.reboot_recommended const configReadable = dualSenseConfigReadable(result.data) status.value = mergeDualSenseStatus(status.value, result.data) - dualsenseStatusChannel.prime(result.data) + dualsenseStatusChannel.prime(result) statusKnown.value = true if (configReadable) { onStatus?.(result.data, false) @@ -320,14 +322,14 @@ export function useDualSenseComponent({ busy = ref(false), onStatus, afterInstal } rebootRecommended.value = false status.value = mergeDualSenseStatus(status.value, result.data) - dualsenseStatusChannel.prime(result.data) + dualsenseStatusChannel.prime(result) statusKnown.value = true ElMessage.success(t.value.dualSense.uninstallSuccess) }) sharedOperation.onOtherChange(() => refresh(true, false, true)) const startPolling = () => { - if (pollTimer || disposed) return + if (pollTimer || disposed || !paneActive) return pollTimer = window.setInterval(() => refresh(true), STATUS_POLL_INTERVAL_MS) } const stopPolling = () => { @@ -351,17 +353,22 @@ export function useDualSenseComponent({ busy = ref(false), onStatus, afterInstal } if (disposed) return await refresh() - if (disposed) return + if (disposed || !paneActive) return startPolling() }) // KeepAlive 下被隐藏的面板暂停轮询;重新可见时先展示缓存再静默校准。 onActivated(() => { + paneActive = true startPolling() refresh(true) }) - onDeactivated(stopPolling) + onDeactivated(() => { + paneActive = false + stopPolling() + }) onUnmounted(() => { disposed = true + paneActive = false invalidateStatusRefresh() stopPolling() unlistenProgress?.() diff --git a/src/renderer/composables/useDualSenseSettings.js b/src/renderer/composables/useDualSenseSettings.js index 27d9f4f7..49a8e63f 100644 --- a/src/renderer/composables/useDualSenseSettings.js +++ b/src/renderer/composables/useDualSenseSettings.js @@ -163,7 +163,7 @@ export function useDualSenseSettings() { return { success: false, preserveTuning, message: result.message } } status.value = mergeDualSenseStatus(status.value, result.data) - dualsenseStatusChannel.prime(result.data) + dualsenseStatusChannel.prime(result) statusKnown.value = true return { success: true, preserveTuning } } @@ -298,7 +298,7 @@ export function useDualSenseSettings() { if (!result.success) return showError(result.message, 'test') testCompleted.value = true ElMessage.success(t.value.dualSense.testSuccess) - await refresh() + await refresh(false, false, true) }) onMounted(() => dualsense.logPanelOpened().catch(() => {})) diff --git a/src/renderer/composables/useUsbTransport.js b/src/renderer/composables/useUsbTransport.js index 287b3e95..8b1b384e 100644 --- a/src/renderer/composables/useUsbTransport.js +++ b/src/renderer/composables/useUsbTransport.js @@ -131,7 +131,7 @@ export function useUsbTransport() { return ElMessage.error(friendlyError(result?.message)) } applyStatus(result.data) - usbipStatusChannel.prime(result.data) + usbipStatusChannel.prime(result) if (result.data?.ready) ElMessage.success(t.value.deviceHub.usb.installSuccess) else ElMessage.warning(t.value.deviceHub.usb.rebootRequired) }) @@ -166,7 +166,7 @@ export function useUsbTransport() { return ElMessage.error(friendlyError(result?.message)) } applyStatus(result.data) - usbipStatusChannel.prime(result.data) + usbipStatusChannel.prime(result) if (result.data?.vhci_residual) ElMessage.error(friendlyError('USBIP-CLEAN-002')) else ElMessage.success(removing ? t.value.deviceHub.components.transportRemoved : t.value.deviceHub.usb.cleanupSuccess) }) diff --git a/src/renderer/composables/useVmouse.js b/src/renderer/composables/useVmouse.js index 75f1e081..93f80e93 100644 --- a/src/renderer/composables/useVmouse.js +++ b/src/renderer/composables/useVmouse.js @@ -11,22 +11,31 @@ const vmouseNotice = ref('') const vmouseConfigSaving = ref(false) const vmouseEnabled = computed(() => vmouseStatus.value.config_enabled === true) let loadInflight = null +let loadInflightEpoch = 0 +let statusEpoch = 0 export function useVmouse(t) { async function loadVmouseStatus() { - if (loadInflight) return loadInflight + if (loadInflight && loadInflightEpoch === statusEpoch) return loadInflight + const requestEpoch = statusEpoch + loadInflightEpoch = requestEpoch loadInflight = (async () => { try { const result = await vmouse.getStatus() - if (!result?.success || typeof result.data?.config_enabled !== 'boolean') throw new Error('Invalid status') + if (requestEpoch !== statusEpoch) { + // 加载期间发生过保存:本次读取已过期,不得覆盖保存后的状态。 + return + } + if (!result?.success || typeof result?.data?.config_enabled !== 'boolean') throw new Error('Invalid status') vmouseStatus.value = result.data vmouseStatusKnown.value = true vmouseNotice.value = '' } catch { + if (requestEpoch !== statusEpoch) return vmouseStatusKnown.value = false vmouseNotice.value = t.value.stream.vmouseReadFailed } finally { - loadInflight = null + if (loadInflightEpoch === statusEpoch && loadInflightEpoch === requestEpoch) loadInflight = null } })() return loadInflight @@ -40,6 +49,7 @@ export function useVmouse(t) { const result = await vmouse.setConfig(enabled) if (!result?.success) throw new Error('Save failed') vmouseStatus.value = { ...vmouseStatus.value, config_enabled: enabled } + statusEpoch += 1 vmouseNotice.value = t.value.stream.vmouseSaved } catch { vmouseNotice.value = t.value.stream.vmouseSaveFailed From 12f0e7e20fef654cc38096211bb4fda964be8989 Mon Sep 17 00:00:00 2001 From: qiin2333 <414382190@qq.com> Date: Wed, 21 Oct 2026 00:50:12 +0800 Subject: [PATCH 05/11] fix(device-hub): forced overview refresh supersedes an in-flight read A plain (cache-aware) refresh in flight would swallow a forced one: the button click joined the older request and could be served 10s-old cached channel data. refresh() now starts a new generation for forced refreshes; a superseded request may no longer touch runtime/probeFailed/loadError/ loaded, nor reset loading or inflight of the newer one. onActivated stays cache-aware on purpose: reusing a fresh cache on reactivation was the agreed behavior in the previous round, and the supersede mechanism covers the case that suggestion was aiming at. --- src/renderer/composables/useDeviceRuntime.js | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/renderer/composables/useDeviceRuntime.js b/src/renderer/composables/useDeviceRuntime.js index 511b8948..53e17f51 100644 --- a/src/renderer/composables/useDeviceRuntime.js +++ b/src/renderer/composables/useDeviceRuntime.js @@ -14,13 +14,18 @@ export function useDeviceRuntime() { const loadError = ref(false) const probeFailed = reactive({ ds: false, mic: false, usb: false }) let inflight = null + let refreshGeneration = 0 - async function fetchOnce(force = false) { + async function fetchOnce(force, generation) { const results = await Promise.allSettled([ dualsenseStatusChannel.fetch({ force }), virtualMicrophone.getStatus(), usbipStatusChannel.fetch({ force }), ]) + if (generation !== refreshGeneration) { + // 已被强制刷新取代:不得覆盖较新请求的结果或状态。 + return + } for (const [index, key] of ['ds', 'mic', 'usb'].entries()) { const result = results[index].status === 'fulfilled' ? results[index].value : null probeFailed[key] = !result?.success @@ -32,13 +37,18 @@ export function useDeviceRuntime() { function refresh(options = {}) { const force = options?.force === true - if (inflight) return inflight + // 普通刷新并入在途请求;强制刷新取代它——旧请求完成后不得 + // 覆盖结果,也不得重置新请求的 loading/inflight。 + if (inflight && !force) return inflight + const generation = ++refreshGeneration loading.value = true - inflight = fetchOnce(force).finally(() => { + const request = fetchOnce(force, generation).finally(() => { + if (generation !== refreshGeneration) return loading.value = false - inflight = null + if (inflight === request) inflight = null }) - return inflight + inflight = request + return request } return { runtime, loading, loaded, loadError, probeFailed, refresh } } From 91f2a500f918b9825ea9aadc0282d4e189f59008 Mon Sep 17 00:00:00 2001 From: qiin2333 <414382190@qq.com> Date: Wed, 21 Oct 2026 01:15:08 +0800 Subject: [PATCH 06/11] refactor(device-hub): converge remaining redundant state and probe paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Driver/probe layer (A): - ViGEm and microphone statuses join the shared channel pattern (PowerShell probes are seconds-long; concurrent reads coalesce, recent results reused) and every 'sleep 2s then rescan' guess after install/uninstall is replaced by explicit channel invalidation + immediate refresh — the backend bats already settle before returning - vmouse driver install/uninstall flows through one shared reload helper; StreamView and the hub input-drivers panel keep their shell-appropriate dialogs but no longer duplicate the state machinery - GamepadTypePicker and AdvancedControllerOptions share one config copy via useControllerHubConfig: one backend read on mount, saves propagate to both Structured transport errors (C1): UsbipStatus gains an error_code field extracted from the well-shaped USBIP-XXX-NNN detail prefix; the frontend cleanup heuristic reads the field instead of string-matching detail text Probe reuse (C2): dualsense_set_config passes its validation probe and transport read into status assembly, halving the sidecar spawns and usbip probes per config save; the sidecar cannot change within one command Cross-reference comments mark the paired frontend/backend DualSenseStatus defaults (C5). C3 (hub IA placement) and C4 (config patch endpoint) need product/Core-side decisions and are tracked separately. Verified: vite build, 142 renderer tests, 275 cargo tests (new: code shape extraction), browser mock run — config fetched once for both hub components, DSU save propagates, ViGEm channel cache hits on tab round-trip. --- src-tauri/src/dualsense/commands.rs | 28 +++++++-- src-tauri/src/dualsense/probe.rs | 28 +++++++-- src-tauri/src/dualsense/tests.rs | 1 + src-tauri/src/usbip/mod.rs | 3 + src-tauri/src/usbip/status.rs | 51 +++++++++++++++- .../AdvancedControllerOptions.vue | 32 ++++------ .../controllersHub/GamepadTypePicker.vue | 38 ++++-------- .../controllersHub/MicrophonePanel.vue | 9 +-- src/renderer/composables/deviceHubStatus.js | 4 +- .../composables/deviceHubStatus.test.js | 2 +- src/renderer/composables/deviceStatusStore.js | 8 ++- .../composables/useControllerHubConfig.js | 44 ++++++++++++++ src/renderer/composables/useDeviceRuntime.js | 5 +- .../composables/useDualSenseComponent.js | 2 + src/renderer/composables/useInputDrivers.js | 23 +++---- src/renderer/composables/useVmouse.js | 60 ++++++++++++------- src/renderer/desktop/views/StreamView.vue | 6 +- 17 files changed, 237 insertions(+), 107 deletions(-) create mode 100644 src/renderer/composables/useControllerHubConfig.js diff --git a/src-tauri/src/dualsense/commands.rs b/src-tauri/src/dualsense/commands.rs index 8d25570a..5248395e 100644 --- a/src-tauri/src/dualsense/commands.rs +++ b/src-tauri/src/dualsense/commands.rs @@ -18,6 +18,7 @@ use super::packages::classify_local_component_packages; use super::probe::{ dualsense_get_status_with_config, ensure_no_active_session, ensure_no_active_session_for_uninstall, run_installed_probe, validate_requested_profile, + StatusProbeReuse, usbip_transport_status, }; use super::{ COMPONENT_OPERATION, DualSenseStatus, DualSenseTuningResult, MAX_LOCAL_COMPONENT_PACKAGES, @@ -52,7 +53,7 @@ pub async fn dualsense_set_haptics_tuning( #[tauri::command] pub async fn dualsense_get_status() -> Result { - dualsense_get_status_with_config(None).await + dualsense_get_status_with_config(None, StatusProbeReuse::default()).await } #[tauri::command] @@ -112,15 +113,20 @@ pub async fn dualsense_set_config( "DS5-RUN-002: another DualSense component operation is still running".to_string() })?; ensure_no_active_session().await?; + // Validation probes double as the status-assembly inputs: the sidecar + // binary cannot change between validation and the final status within + // this command, so reuse them instead of probing twice. + let mut reuse = StatusProbeReuse::default(); if audio_haptics { let probe = tokio::task::spawn_blocking(run_installed_probe) .await .map_err(|error| format!("DS5-PKG-003: sidecar probe task failed: {error}"))??; - let transport = crate::usbip::usbip_get_status().await?; - if !transport.ready && !transport.detail.is_empty() { - return Err(transport.detail); + let (usbip_version, usbip_version_valid, usbip_ready, usbip_error_code, usbip_detail) = + usbip_transport_status(crate::usbip::usbip_get_status().await); + if !usbip_ready && !usbip_detail.is_empty() { + return Err(usbip_detail.clone()); } - let usbip_available = probe.usbip_available && transport.ready; + let usbip_available = probe.usbip_available && usbip_ready; if genshin_compatibility && !probe.genshin_compatibility_identity { return Err( "DS5-PROTO-001: the installed sidecar does not support Genshin compatibility mode" @@ -128,6 +134,16 @@ pub async fn dualsense_set_config( ); } validate_requested_profile(audio_haptics, genshin_compatibility, usbip_available)?; + reuse = StatusProbeReuse { + probe: Some(probe), + transport: Some(( + usbip_version, + usbip_version_valid, + usbip_ready, + usbip_error_code, + usbip_detail, + )), + }; } else { validate_requested_profile(audio_haptics, genshin_compatibility, false)?; } @@ -144,7 +160,7 @@ pub async fn dualsense_set_config( "DS5-CFG-004: timed out while applying DualSense configuration; the resulting state is unknown" .to_string() })??; - dualsense_get_status_with_config(Some(applied.response)).await + dualsense_get_status_with_config(Some(applied.response), reuse).await } #[tauri::command] diff --git a/src-tauri/src/dualsense/probe.rs b/src-tauri/src/dualsense/probe.rs index 6fb7179d..d2d0c1f0 100644 --- a/src-tauri/src/dualsense/probe.rs +++ b/src-tauri/src/dualsense/probe.rs @@ -357,6 +357,8 @@ pub(crate) fn usbip_transport_status( } pub(crate) fn local_uninstalled_status() -> DualSenseStatus { + // 前端 useDualSenseComponent 另有一份首次渲染前的占位默认值, + // 与此处的 DualSenseStatus 字段一一对应,改动时两处同步。 let (usbip_version, usbip_version_valid, error_code, detail) = usbip_discovery_status(installed_usbip_version()); DualSenseStatus { @@ -407,9 +409,20 @@ pub(crate) fn validate_requested_profile( } } +/// Probe/transport results captured earlier in the same command. The sidecar +/// binary cannot change between validation and status assembly within one +/// command, so `dualsense_set_config` reuses them instead of probing twice. +#[derive(Default)] +pub(crate) struct StatusProbeReuse { + pub(crate) probe: Option, + /// `usbip_transport_status` output: (version, version_valid, ready, code, detail). + pub(crate) transport: Option<(String, bool, bool, String, String)>, +} + /// Combines component integrity, runtime capabilities, shared transport and Core settings. pub(crate) async fn dualsense_get_status_with_config( confirmed: Option, + reuse: StatusProbeReuse, ) -> Result { let (settings, config_revision, config_error) = resolve_core_config(confirmed, get_core_ds5_settings).await; @@ -420,15 +433,15 @@ pub(crate) async fn dualsense_get_status_with_config( let executable = sidecar_path(); let installed = executable.is_file(); let in_use = has_active_session().await; - let probe = if installed { - Some( + let probe = match reuse.probe { + Some(result) => Some(Ok(result)), + None if installed => Some( tokio::task::spawn_blocking(run_installed_probe) .await .map_err(|error| format!("DS5-PKG-003: sidecar probe task failed: {error}")) .and_then(|result| result), - ) - } else { - None + ), + None => None, }; let (probe_succeeded, result, mut error_code, mut detail) = match probe { Some(Ok(result)) => (true, result, String::new(), String::new()), @@ -453,7 +466,10 @@ pub(crate) async fn dualsense_get_status_with_config( // A valid uninstall registration does not prove that the active driver // accepts the installed tools. Share the transport panel's actual probe. let (usbip_version, usbip_version_valid, usbip_ready, usbip_error_code, usbip_detail) = - usbip_transport_status(crate::usbip::usbip_get_status().await); + match reuse.transport { + Some(transport) => transport, + None => usbip_transport_status(crate::usbip::usbip_get_status().await), + }; let usbip_available = result.usbip_available && usbip_ready; let manifest = installed.then(installed_component_manifest).flatten(); let update_available = installed && component_update_available(manifest.as_ref()); diff --git a/src-tauri/src/dualsense/tests.rs b/src-tauri/src/dualsense/tests.rs index 884dcded..61415d3e 100644 --- a/src-tauri/src/dualsense/tests.rs +++ b/src-tauri/src/dualsense/tests.rs @@ -866,6 +866,7 @@ fn usbip_transport_probe_rejects_a_broken_driver_with_a_valid_registration() { version_valid: true, reboot_recommended: false, vhci_residual: false, + error_code: "USBIP-EXEC-006".to_string(), attached_devices: Vec::new(), detail: detail.to_string(), }; diff --git a/src-tauri/src/usbip/mod.rs b/src-tauri/src/usbip/mod.rs index b17c6756..d6bf89da 100644 --- a/src-tauri/src/usbip/mod.rs +++ b/src-tauri/src/usbip/mod.rs @@ -53,6 +53,9 @@ pub struct UsbipStatus { pub version_valid: bool, pub reboot_recommended: bool, pub vhci_residual: bool, + /// 结构化错误码("USBIP-SETUP-003" 等),取自 detail 的码前缀; + /// 前端按码判断语义,不得解析 detail 文本。 + pub error_code: String, pub attached_devices: Vec, pub detail: String, } diff --git a/src-tauri/src/usbip/status.rs b/src-tauri/src/usbip/status.rs index 0b9264c3..bc73c694 100644 --- a/src-tauri/src/usbip/status.rs +++ b/src-tauri/src/usbip/status.rs @@ -2,6 +2,28 @@ use super::{REQUIRED_VERSION, UsbipStatus}; +/// Extracts the structured "USBIP-XXX-NNN" code from a detail string. +/// Returns an empty string when the prefix is not a well-shaped code, so +/// consumers can branch on `error_code` instead of parsing detail text. +pub(super) fn detail_error_code(detail: &str) -> String { + let code = detail.split(':').next().unwrap_or_default().trim(); + let mut segments = code.split('-'); + let shaped = matches!( + (segments.next(), segments.next(), segments.next(), segments.next()), + (Some(area), Some(kind), Some(number), None) + if area == "USBIP" + && (3..=8).contains(&kind.len()) + && kind.bytes().all(|b| b.is_ascii_uppercase()) + && number.len() == 3 + && number.bytes().all(|b| b.is_ascii_digit()) + ); + if shaped { + code.to_string() + } else { + String::new() + } +} + /// Spawns a `usbip.exe port` subprocess on every call to enumerate attached /// devices. Intended for on-demand refreshes — do not poll at high frequency. #[tauri::command] @@ -15,6 +37,7 @@ pub async fn usbip_get_status() -> Result { version_valid: false, reboot_recommended: false, vhci_residual: false, + error_code: String::new(), attached_devices: Vec::new(), detail: "USB/IP passthrough is only supported on Windows".to_string(), }); @@ -43,6 +66,7 @@ pub async fn usbip_get_status() -> Result { vhci_residual: !interfaces.is_empty() || !devnodes.is_empty() || partially_installed, + error_code: detail_error_code(&detail), attached_devices: Vec::new(), detail, }); @@ -51,6 +75,9 @@ pub async fn usbip_get_status() -> Result { let version_valid = super::supported_usbip_installed(Some(&installation.version)); let vhci_residual = super::device::has_vhci_residual(&interfaces, &devnodes); if !version_valid { + let detail = format!( + "USBIP-SETUP-003: the tested USB/IP {REQUIRED_VERSION} tools and driver are required" + ); return Ok(UsbipStatus { supported: true, installed: true, @@ -59,10 +86,9 @@ pub async fn usbip_get_status() -> Result { version_valid, reboot_recommended: false, vhci_residual, + error_code: detail_error_code(&detail), attached_devices: Vec::new(), - detail: format!( - "USBIP-SETUP-003: the tested USB/IP {REQUIRED_VERSION} tools and driver are required" - ), + detail, }); } match super::exec::list_attached().await { @@ -76,6 +102,7 @@ pub async fn usbip_get_status() -> Result { version_valid, reboot_recommended: false, vhci_residual, + error_code: String::new(), attached_devices, detail: String::new(), }), @@ -87,9 +114,27 @@ pub async fn usbip_get_status() -> Result { version_valid, reboot_recommended: false, vhci_residual, + error_code: detail_error_code(&detail), attached_devices: Vec::new(), detail, }), } } } + +#[cfg(test)] +mod tests { + use super::detail_error_code; + + #[test] + fn extracts_only_well_shaped_codes() { + assert_eq!(detail_error_code("USBIP-EXEC-006: the shared transport"), "USBIP-EXEC-006"); + assert_eq!(detail_error_code("USBIP-SETUP-001: not installed"), "USBIP-SETUP-001"); + assert_eq!(detail_error_code("USBIP-EXEC-006"), "USBIP-EXEC-006"); + // 非码前缀的 detail 必须得到空串,前端不得据此推断语义 + assert_eq!(detail_error_code("USB/IP passthrough is only supported"), ""); + assert_eq!(detail_error_code("usbip-exec-006: lowercase"), ""); + assert_eq!(detail_error_code("USBIP-EXEC-6: short number"), ""); + assert_eq!(detail_error_code(""), ""); + } +} diff --git a/src/renderer/components/controllersHub/AdvancedControllerOptions.vue b/src/renderer/components/controllersHub/AdvancedControllerOptions.vue index d1f4891a..19825be0 100644 --- a/src/renderer/components/controllersHub/AdvancedControllerOptions.vue +++ b/src/renderer/components/controllersHub/AdvancedControllerOptions.vue @@ -52,51 +52,45 @@ diff --git a/src/renderer/components/controllersHub/GamepadTypePicker.vue b/src/renderer/components/controllersHub/GamepadTypePicker.vue index ecedb24e..1c71472f 100644 --- a/src/renderer/components/controllersHub/GamepadTypePicker.vue +++ b/src/renderer/components/controllersHub/GamepadTypePicker.vue @@ -85,7 +85,8 @@