diff --git a/.github/workflows/plugins.yml b/.github/workflows/plugins.yml index 53ac862..91d7af6 100644 --- a/.github/workflows/plugins.yml +++ b/.github/workflows/plugins.yml @@ -1,9 +1,9 @@ name: UI plugins on: push: - paths: ['apps/assets/**', 'apps/macos/**', 'apps/windows/**', 'modules/plugins/**', 'modules/visualizations/api/**', 'scripts/swift-sources.sh', 'plugins/**', 'contracts/plugins/**', 'tools/plugins/**', 'tests/plugins/**', 'scripts/*plugin*', '.github/workflows/plugins.yml'] + paths: ['apps/assets/**', 'apps/macos/**', 'apps/windows/**', 'modules/plugins/**', 'modules/visualizations/api/**', 'scripts/swift-sources.sh', 'scripts/test-settings.sh', 'tests/platforms/macos/SettingsTests.swift', 'plugins/**', 'contracts/plugins/**', 'tools/plugins/**', 'tests/plugins/**', 'scripts/*plugin*', '.github/workflows/plugins.yml'] pull_request: - paths: ['apps/assets/**', 'apps/macos/**', 'apps/windows/**', 'modules/plugins/**', 'modules/visualizations/api/**', 'scripts/swift-sources.sh', 'plugins/**', 'contracts/plugins/**', 'tools/plugins/**', 'tests/plugins/**', 'scripts/*plugin*', '.github/workflows/plugins.yml'] + paths: ['apps/assets/**', 'apps/macos/**', 'apps/windows/**', 'modules/plugins/**', 'modules/visualizations/api/**', 'scripts/swift-sources.sh', 'scripts/test-settings.sh', 'tests/platforms/macos/SettingsTests.swift', 'plugins/**', 'contracts/plugins/**', 'tools/plugins/**', 'tests/plugins/**', 'scripts/*plugin*', '.github/workflows/plugins.yml'] workflow_dispatch: permissions: contents: read @@ -19,6 +19,7 @@ jobs: - run: sh scripts/build-plugins.sh - run: sh scripts/test-plugins.sh - run: zsh scripts/test-plugin-macos.sh + - run: zsh scripts/test-settings.sh - run: | python3 tools/plugins/fixture.py build/plugins/plugin-surface-probe build/plugins/miucs-plugin-worker plugins/pixel-keyboard build/plugin-fixture.json @@ -38,6 +39,7 @@ jobs: build/plugin-preview/ build/plugin-replay/report.json build/plugin-benchmark.json + build/settings-preview/ windows: runs-on: windows-latest steps: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 36d51cb..24f5c73 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,10 +4,10 @@ ## 平台支持 -| 平台 | 音频采集 / 恢复 | 分析后端 | 四个插件绘制 | 插件适配 | 应用外壳 | +| 平台 | 音频采集 / 恢复 | 分析后端 | 插件绘制 | 插件适配 | 应用外壳 | | --- | --- | --- | --- | --- | --- | | macOS 14+ / Apple Silicon | 已实现,含自动恢复 | Swift 分析 + Accelerate FFT | Lua/Skia,共用五种笔色 | Lua/Skia,共用五种笔色 | 已实现 | -| Windows 10/11 x64 | WASAPI 已实现;实机待验 | C# FFT/混音旋律/节拍;合成测试 | Lua/Skia;实机待验 | Lua/Skia;实机待验 | 托盘/透明窗口已实现;实机待验 | +| Windows 10/11 x64 | WASAPI 已实现;实机待验 | C# FFT/混音旋律/节拍;合成测试 | Lua/Skia;实机待验 | Lua/Skia;实机待验 | 设置/任务栏/托盘/透明窗口已实现;实机待验 | 通用核心当前以 Swift 源码实现。语言无关契约与样本为跨平台协作入口,不表示 Windows 已能直接构建这些代码。 @@ -40,6 +40,8 @@ zsh scripts/test-beat-tracker.sh # 原始 PCM 节拍、半速/倍速、变速 zsh scripts/test-melody.sh # 合成独奏/伴奏与响应时序 zsh scripts/test-audio-pipeline.sh # 分析编排等价与格式切换 zsh scripts/test-audio-recovery.sh # 采集生命周期、退避与迟到回调 +sh scripts/build-plugins.sh # 设置测试需要正式插件运行库 +zsh scripts/test-settings.sh # 独立设置、预览、安全路径、共享位置和重启恢复 zsh scripts/test-appearance.sh # 全局五色、菜单选择、偏好迁移与会话切换 zsh scripts/test-visualization-host.sh # 原生视图切换、资源释放和窗口兼容 zsh scripts/validate-audio.sh # 基础音频与独立 NumPy 参考 @@ -95,3 +97,5 @@ Windows 使用 .NET 8/C# 与 WPF,不引入 macOS 目录依赖。`scripts/build ## 悬浮锁回归 双端悬浮 3 秒的小锁入口与菜单共用调整状态,设计与实机检查见 [悬浮锁说明](docs/hover-lock.md)。Windows 核心测试包含悬浮等待状态;macOS 窗口测试包含按钮与穿透检查。`.github/workflows/macos.yml` 运行窗口与外观回归,并编译完整应用(不生成个人签名证书)。原生 CI 通过也不替代混合 DPI、跨应用点击穿透的真实桌面验收。 + +Windows 设置的命中区域、滚动、任务栏重开和 GIF 生命周期由 `Miucs.exe --smoke-test build/windows-smoke` 在 Windows CI 验证。测试只使用合成皮肤和临时目录,不开启真实音频/键盘捕获。可移植共享位置与预览目录测试运行于 `CoreTests`。 diff --git a/README.md b/README.md index f28ca2a..1f8cc70 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ | 摇曳露营 | 抚子随打字吃面,凛随音乐捧杯轻啜,篝火随拍点轻跳 | [摇曳露营](plugins/yuru-camp/README.md) | | 犬夜叉 | 打字时犬夜叉逞强,节拍触发戈薇指地、扑地和七宝受惊,全程无对白 | [犬夜叉](plugins/inuyasha/README.md) | -[查看七个插件的动态预览](docs/plugin-previews.md)。首次启动默认选择奶牛猫;之后记住你的选择。每个插件分别保存位置和大小,切换不重启输入采集。 +[查看七个插件的动态预览](docs/plugin-previews.md)。首次启动默认选择奶牛猫;之后记住你的选择。所有皮肤共用当前位置和大小,切换只换画面,重启恢复同一位置;不重启输入采集。 ## 构建与启动 @@ -35,19 +35,22 @@ zsh build.sh open dist/Miucs.app ``` -首次运行按提示授权系统音频捕获;要让猫爪响应打字,授予「输入监控」权限。音频和键盘可以同时使用,也能独立运行。系统休眠或音频中断后自动恢复,也可在菜单选择「重新连接音频」。更新后先退出旧版,再打开新构建。 +首次运行按提示授权系统音频捕获;要让猫爪响应打字,授予「输入监控」权限。音频和键盘可以同时使用,也能独立运行。系统休眠或音频中断后自动恢复,也可在「设置 → 声音与键盘」重新连接音频。更新后先退出旧版,再打开新构建。 ### Windows 提供 .NET 8/WPF、WASAPI 实现。构建方法见 [Windows README](apps/windows/README.md),实机验证范围见 [验收表](docs/windows-acceptance.md)。 -## 常用操作 +## 设置与常用操作 -- **可视化**:切换七个桌面伙伴,奶牛猫排在首位。 -- **显示 / 隐藏画面**:控制浮窗显示。 -- **画面大小、不透明度**:等比缩放,调整 10%~100% 不透明度。 -- **调整位置与大小**:拖动画面或右下角缩放,完成后恢复鼠标穿透;也可悬停 3 秒点击小锁。 -- **托盘猫摇头**:应用自己的猫图标,独立于当前插件。 +macOS 和 Windows 都有独立设置窗口,分为「皮肤」「外观与位置」「声音与键盘」「关于 Miucs」。皮肤卡片直接显示图片或 GIF;整张卡片和整行侧栏都可点击,并有悬浮与按下反馈。五线谱与铅笔飞鸟不再提供。 + +- **皮肤**:卡片选择、打开皮肤目录、重新扫描、重新加载当前皮肤。 +- **外观与位置**:调整大小、不透明度和桌面位置;切换皮肤保持当前矩形。 +- **声音与键盘**:查看连接状态、重新连接;Windows 键盘响应需主动启用,托盘动画开关也在这里。 +- **桌面调整**:鼠标悬停 3 秒点击小锁,再拖动或从右下角缩放,完成后恢复鼠标穿透。 + +macOS 在程序坞显示 Miucs,点击打开设置;Windows 设置显示在任务栏,关闭时最小化并继续运行,点击任务栏或再次运行应用可恢复设置。菜单栏 / 托盘仅保留打开设置、显示 / 隐藏皮肤、调整位置与大小和退出。 ## 插件与隐私 diff --git a/apps/macos/App.swift b/apps/macos/App.swift index 83d5f05..7387ffc 100644 --- a/apps/macos/App.swift +++ b/apps/macos/App.swift @@ -5,72 +5,34 @@ import Combine @main struct MiucsApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate - var body: some Scene { - Settings { - EmptyView() - } - .commands { - CommandMenu("可视化") { - ForEach(appDelegate.visualizationDescriptors, id: \.id) { descriptor in - Button(descriptor.name) { appDelegate.selectVisualization(id: descriptor.id) } + Settings { EmptyView() } + .commands { + CommandGroup(replacing: .appSettings) { + Button("设置…") { appDelegate.showSettings() }.keyboardShortcut(",") } } - CommandGroup(replacing: .appSettings) { - VisualizationColorCommand(store: appDelegate.appearanceStore) - VisualizationTransparencyCommand(store: appDelegate.appearanceStore) - } - } - } -} - -private struct VisualizationTransparencyCommand: View { - @ObservedObject var store: VisualizationAppearanceStore - var body: some View { - Picker("画面不透明度", selection: Binding(get: { store.opacity }, set: { store.setOpacity($0) })) { - ForEach(VisualizationAppearanceStore.opacityPresets, id: \.self) { percent in - Text(percent == 100 ? "100%(原始效果)" : "\(percent)%").tag(percent) - } - } - .pickerStyle(.menu) - } -} - -private struct VisualizationColorCommand: View { - @ObservedObject var store: VisualizationAppearanceStore - var body: some View { - Picker("线条颜色", selection: Binding(get: { store.selection }, set: { store.select($0) })) { - ForEach(VisualizationInk.allCases) { ink in Text(ink.name).tag(ink) } - } - .pickerStyle(.menu) } } @MainActor final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, ObservableObject { let appearanceStore = VisualizationAppearanceStore() - private var pluginStatusItem: NSMenuItem? + let settings = MiucsSettingsModel() + private var settingsWindow: MiucsSettingsWindow? private var statusItem: NSStatusItem? private var trayIcon: AppTrayIcon? private var panel: VisualizerPanel? private let engine = AudioEngine() private let keyboardEngine = KeyboardEngine() - private var keyboardItem: NSMenuItem? - private var keyboardSubscription: AnyCancellable? private var diagnostics: AudioDiagnostics? + private var visibilityItem: NSMenuItem? private var moveItem: NSMenuItem? - private var sizeItem: NSMenuItem? private let registry = PluginVisualizations.registry() - var visualizationDescriptors: [VisualizationDescriptor] { registry.descriptors } - private var visualizationMenu: NSMenu? - private var colorMenu: VisualizationColorMenu? - private var transparencyMenu: VisualizationTransparencyMenu? - private var appearanceSubscription: AnyCancellable? - private var transparencySubscription: AnyCancellable? + private var subscriptions = Set() func applicationDidFinishLaunching(_ notification: Notification) { - NSApp.setActivationPolicy(.accessory) - + NSApp.setActivationPolicy(.regular) if CommandLine.arguments.contains("--diagnose-audio") || CommandLine.arguments.contains("--verify-audio-recovery") { let diagnostics = AudioDiagnostics(engine: engine) self.diagnostics = diagnostics @@ -81,71 +43,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, Observ } return } - if CommandLine.arguments.contains("--diagnose-inputs") { Task { await InputDiagnostics.run(audio: engine, keyboard: keyboardEngine, arguments: CommandLine.arguments) } return } - appearanceStore.saveSelection() + configureSettings() let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - trayIcon = AppTrayIcon(item:item,source:keyboardEngine.channel) - let menu = NSMenu();menu.delegate=self - menu.addItem( - NSMenuItem(title: "显示拾音器", action: #selector(showPanel), keyEquivalent: "s") - ) - menu.addItem( - NSMenuItem(title: "隐藏拾音器", action: #selector(hidePanel), keyEquivalent: "h") - ) + trayIcon = AppTrayIcon(item: item, source: keyboardEngine.channel) + let menu = NSMenu() + menu.delegate = self + func add(_ title: String, _ selector: Selector, _ key: String) -> NSMenuItem { + let entry = NSMenuItem(title: title, action: selector, keyEquivalent: key) + entry.target = self + menu.addItem(entry) + return entry + } + _ = add("打开设置…", #selector(showSettings), ",") + visibilityItem = add("隐藏皮肤", #selector(toggleVisibility), "h") + moveItem = add("调整位置与大小…", #selector(togglePositioning), "m") menu.addItem(.separator()) - let move = NSMenuItem(title: "调整位置与大小…", action: #selector(togglePositioning), keyEquivalent: "m") - menu.addItem(move) - moveItem = move - let size = NSMenuItem(title: "画面大小", action: nil, keyEquivalent: "") - let sizes = NSMenu(title: "画面大小") - for (percent, label) in [(10, "极小"), (80, "小"), (100, "标准"), (125, "舒展"), (150, "大"), (200, "特大")] { - let preset = NSMenuItem(title: "\(label) · \(percent)%", action: #selector(changeSize(_:)), keyEquivalent: "") - preset.target = self; preset.tag = percent - sizes.addItem(preset) - } - size.submenu = sizes - menu.addItem(size) - sizeItem = size - let visualizations = NSMenu(title: "可视化") - for descriptor in registry.descriptors { - let option = NSMenuItem(title: descriptor.name, action: #selector(selectVisualization(_:)), keyEquivalent: "") - option.target = self; option.representedObject = descriptor.id - option.state = descriptor.id == PluginVisualizations.defaultID ? .on : .off - visualizations.addItem(option) - } - let visualizationItem = NSMenuItem(title: "可视化", action: nil, keyEquivalent: "") - visualizationItem.submenu = visualizations; menu.addItem(visualizationItem) - visualizationMenu = visualizations - let status=NSMenuItem(title:"插件运行中",action:nil,keyEquivalent:"");status.isEnabled=false;pluginStatusItem=status;menu.addItem(status) - let rescan=NSMenuItem(title:"重新扫描插件",action:#selector(rescanPlugins),keyEquivalent:"");rescan.target=self;menu.addItem(rescan) - let reload=NSMenuItem(title:"重新加载当前插件",action:#selector(reloadPlugins),keyEquivalent:"");reload.target=self;menu.addItem(reload) - let colors = VisualizationColorMenu(store: appearanceStore) - colorMenu = colors; menu.addItem(colors.item) - let transparency = VisualizationTransparencyMenu(store: appearanceStore) - transparencyMenu = transparency; menu.addItem(transparency.item) - menu.addItem(.separator()) - menu.addItem( - NSMenuItem(title: "重新连接音频", action: #selector(reconnect), keyEquivalent: "r") - ) - let keyboard = NSMenuItem(title: "键盘监听:正在连接…", action: #selector(connectKeyboard), keyEquivalent: "") - keyboard.target = self; keyboardItem = keyboard; menu.addItem(keyboard) - keyboardSubscription = keyboardEngine.$state.sink { [weak self] state in - self?.updateKeyboardMenu(state) - } - menu.addItem( - NSMenuItem(title: "退出 Miucs", action: #selector(quit), keyEquivalent: "q") - ) + _ = add("退出 Miucs", #selector(quit), "q") item.menu = menu statusItem = item PluginHost.shared.start(audio: engine.channel, keyboard: keyboardEngine.channel) - createPanelIfNeeded() - + refreshSkins() + showSettings() Task { await engine.start() } Task { let key = "MiucsKeyboardPermissionRequested.v1" @@ -154,110 +78,113 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, Observ await keyboardEngine.start(requestAccess: request) } } - + private func configureSettings() { + settings.selectSkin = { [weak self] id in self?.selectSkin(id: id) } + settings.setVisible = { [weak self] value in self?.setVisible(value) } + settings.setScale = { [weak self] value in + guard let self, let panel = self.panel else { return } + panel.setDrawingWidth(panel.descriptor.layout.width * value / 100) + self.syncPanelState() + } + settings.setOpacity = { [weak self] value in self?.appearanceStore.setOpacity(Int(value.rounded())) } + settings.togglePosition = { [weak self] in self?.togglePositioning() } + settings.reconnectAudio = { [weak self] in self?.reconnect() } + settings.connectKeyboard = { [weak self] in self?.connectKeyboard() } + settings.rescan = { [weak self] in self?.rescanSkins() } + settings.reload = { PluginHost.shared.reloadActive() } + settings.openDirectory = { [weak self] in self?.openSkinDirectory() } + engine.$status.sink { [weak self] in self?.settings.audioStatus = $0 }.store(in: &subscriptions) + keyboardEngine.$state.sink { [weak self] in self?.settings.keyboardState = $0 }.store(in: &subscriptions) + PluginHost.shared.$errors.sink { [weak self] in self?.settings.errors = $0 }.store(in: &subscriptions) + appearanceStore.$transparency.sink { [weak self] percent in + self?.panel?.visualizationHost.applyTransparency(percent) + self?.settings.opacity = Double(100 - percent) + }.store(in: &subscriptions) + } private func createPanelIfNeeded() { guard panel == nil, !registry.descriptors.isEmpty else { return } - let panel = VisualizerPanel(source: engine.channel, registry: registry, keyboardSource: keyboardEngine.channel) + let panel = VisualizerPanel(source: engine.channel, registry: registry, keyboardSource: keyboardEngine.channel, + geometryPersistence: .sharedSkin) self.panel = panel - appearanceSubscription = appearanceStore.$selection.sink { [weak self] ink in - self?.panel?.visualizationHost.applyAppearance(ink.appearance) - } - transparencySubscription = appearanceStore.$transparency.sink { [weak self] percent in - self?.panel?.visualizationHost.applyTransparency(percent) - } + panel.visualizationHost.applyAppearance(appearanceStore.appearance) + panel.visualizationHost.applyTransparency(appearanceStore.transparency) + panel.onAdjustmentChange = { [weak self] in self?.syncPanelState() } + panel.onSizeChange = { [weak self] in self?.syncPanelState() } panel.orderFrontRegardless() - panel.onAdjustmentChange = { [weak self, weak panel] in - self?.moveItem?.title = panel?.isAdjustingPosition == true - ? "完成调整(恢复鼠标穿透)" : "调整位置与大小…" - } - panel.onSizeChange = { [weak self] in self?.updateSizeMenu() } - updateSizeMenu() - updateVisualizationMenu() - - } - - func menuWillOpen(_ menu: NSMenu) { - let error=PluginHost.shared.errors.last - pluginStatusItem?.title=error.map { "插件:" + String($0.prefix(120)) } ?? "插件运行中" - pluginStatusItem?.toolTip=error - } - @objc private func rescanPlugins() { - objectWillChange.send() - PluginHost.shared.register(in: registry) - visualizationMenu?.removeAllItems() - for descriptor in registry.descriptors { - let item=NSMenuItem(title:descriptor.name,action:#selector(selectVisualization(_:)),keyEquivalent:"") - item.target=self;item.representedObject=descriptor.id;visualizationMenu?.addItem(item) + syncPanelState() + } + private func syncPanelState() { + settings.selectedID = panel?.descriptor.id ?? "" + settings.isVisible = panel?.isVisible == true + settings.isAdjusting = panel?.isAdjustingPosition == true + if let panel { + settings.scale = panel.drawingScale * 100 + let layout = panel.descriptor.layout + settings.scaleRange = (layout.minimumWidth / layout.width * 100)...(layout.maximumWidth / layout.width * 100) + } + visibilityItem?.title = settings.isVisible ? "隐藏皮肤" : "显示皮肤" + visibilityItem?.isEnabled = panel != nil + moveItem?.title = settings.isAdjusting ? "完成调整" : "调整位置与大小…" + moveItem?.isEnabled = panel != nil + } + private func refreshSkins() { + settings.skins = PluginHost.shared.metadata.compactMap { info in + guard let id = info["id"] as? String, let name = info["name"] as? String, + let directory = info["directory"] as? String else { return nil } + return SkinItem(id: id, name: name, directory: URL(fileURLWithPath: directory)) + } + syncPanelState() + } + private func selectSkin(id: String) { + guard registry.descriptor(id: id) != nil else { return } + createPanelIfNeeded() + _ = panel?.selectVisualization(id: id) + syncPanelState() + } + private func rescanSkins() { + PluginHost.shared.register(in: registry, excluding: SkinItem.retiredIDs) + if let panel, registry.descriptor(id: panel.descriptor.id) == nil { + if let next = registry.descriptors.first { + _ = panel.selectVisualization(id: next.id) + } else { + panel.orderOut(nil) + panel.visualizationHost.dispose() + self.panel = nil + } } createPanelIfNeeded() - updateVisualizationMenu() - } - @objc private func reloadPlugins() { PluginHost.shared.reloadActive() } - @objc private func showPanel() { - panel?.orderFrontRegardless() - } - - @objc private func hidePanel() { - panel?.orderOut(nil) - moveItem?.title = "调整位置与大小…" + refreshSkins() + } + private func openSkinDirectory() { + let url = URL(fileURLWithPath: PluginNative.shared.user) + do { + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + if !NSWorkspace.shared.open(url) { settings.errors = ["无法打开皮肤文件夹。"] } + } catch { settings.errors = ["无法创建皮肤文件夹:\(error.localizedDescription)"] } + } + @objc func showSettings() { + if settingsWindow == nil { settingsWindow = MiucsSettingsWindow(model: settings) } + syncPanelState() + settingsWindow?.present() + } + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + showSettings() + return false + } + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false } + func menuWillOpen(_ menu: NSMenu) { syncPanelState() } + @objc private func toggleVisibility() { setVisible(panel?.isVisible != true) } + private func setVisible(_ value: Bool) { + if value { panel?.orderFrontRegardless() } else { panel?.orderOut(nil) } + syncPanelState() } - @objc private func togglePositioning() { guard let panel else { return } panel.setAdjustingPosition(!panel.isAdjustingPosition) - moveItem?.title = panel.isAdjustingPosition ? "完成调整(恢复鼠标穿透)" : "调整位置与大小…" - } - - @objc private func changeSize(_ sender: NSMenuItem) { - guard let panel else { return } - panel.setDrawingWidth(panel.descriptor.layout.width * Double(sender.tag) / 100) + syncPanelState() } - - @objc private func selectVisualization(_ sender: NSMenuItem) { - guard let id = sender.representedObject as? String else { return } - selectVisualization(id: id) - } - - func selectVisualization(id: String) { - guard panel?.selectVisualization(id: id) == true else { return } - updateVisualizationMenu() - updateSizeMenu() - } - - private func updateVisualizationMenu() { - for option in visualizationMenu?.items ?? [] { - option.state = option.representedObject as? String == panel?.descriptor.id ? .on : .off - } - } - - private func updateSizeMenu() { - guard let panel else { return } - let percent = panel.drawingScale * 100 - sizeItem?.title = "画面大小(\(Int(percent.rounded()))%)" - for preset in sizeItem?.submenu?.items ?? [] { - preset.state = abs(percent - Double(preset.tag)) < 0.5 ? .on : .off - } - } - - @objc private func reconnect() { - Task { await engine.reconnect() } - } - - private func updateKeyboardMenu(_ state: KeyboardSourceState) { - let label: String - switch state { - case .running: label = "已连接" - case .permissionRequired: label = "点击授权…" - case .secureInput: label = "安全输入期间暂停" - case .suspended: label = "等待系统恢复" - case .recovering: label = "正在重连…" - case .connecting: label = "正在连接…" - case .stopped: label = "点击连接…" - } - keyboardItem?.title = "键盘监听:" + label - } - - @objc private func connectKeyboard() { + private func reconnect() { Task { await engine.reconnect() } } + private func connectKeyboard() { Task { await keyboardEngine.start(requestAccess: true) if keyboardEngine.state == .permissionRequired, @@ -266,19 +193,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, Observ } } } - - @objc private func quit() { - keyboardEngine.stop() - engine.stop() - NSApp.terminate(nil) - } - + @objc private func quit() { NSApp.terminate(nil) } func applicationWillTerminate(_ notification: Notification) { trayIcon?.stop() - PluginHost.shared.stop() keyboardEngine.stop() engine.stop() panel?.saveDrawingFrame() panel?.visualizationHost.dispose() + PluginHost.shared.stop() } } diff --git a/apps/macos/Info.plist b/apps/macos/Info.plist index 87f581c..ae64282 100644 --- a/apps/macos/Info.plist +++ b/apps/macos/Info.plist @@ -25,7 +25,7 @@ LSMinimumSystemVersion 14.0 LSUIElement - + NSHighResolutionCapable NSAudioCaptureUsageDescription diff --git a/apps/macos/MiucsSettings.swift b/apps/macos/MiucsSettings.swift new file mode 100644 index 0000000..9d7fb69 --- /dev/null +++ b/apps/macos/MiucsSettings.swift @@ -0,0 +1,379 @@ +import AppKit +import SwiftUI + +enum SettingsPage: String, CaseIterable, Identifiable { + case skins = "皮肤", appearance = "外观与位置", inputs = "声音与键盘", about = "关于 Miucs" + var id: String { rawValue } + var symbol: String { + switch self { + case .skins: "square.grid.2x2" + case .appearance: "slider.horizontal.3" + case .inputs: "waveform" + case .about: "info.circle" + } + } + var subtitle: String { + switch self { + case .skins: "点击卡片,使用你喜欢的皮肤。" + case .appearance: "调整皮肤的大小、透明度和摆放位置。" + case .inputs: "连接声音与按键,让皮肤动起来。" + case .about: "让音乐自己飞。" + } + } +} + +@MainActor +final class MiucsSettingsModel: ObservableObject { + @Published var page = SettingsPage.skins + @Published var skins = [SkinItem]() + @Published var selectedID = "" + @Published var isVisible = false + @Published var scale = 100.0 + @Published var scaleRange = 10.0...200.0 + @Published var opacity = 100.0 + @Published var isAdjusting = false + @Published var audioStatus = "正在连接系统音频…" + @Published var keyboardState = KeyboardSourceState.connecting + @Published var errors = [String]() + var selectSkin: (String) -> Void = { _ in } + var setVisible: (Bool) -> Void = { _ in } + var setScale: (Double) -> Void = { _ in } + var setOpacity: (Double) -> Void = { _ in } + var togglePosition: () -> Void = {} + var reconnectAudio: () -> Void = {} + var connectKeyboard: () -> Void = {} + var rescan: () -> Void = {} + var reload: () -> Void = {} + var openDirectory: () -> Void = {} + var currentSkin: SkinItem? { skins.first { $0.id == selectedID } } + var keyboardStatus: String { + switch keyboardState { + case .running: "已连接" + case .permissionRequired: "需要输入监控权限" + case .secureInput: "安全输入期间暂停" + case .suspended: "等待系统恢复" + case .recovering: "正在重新连接…" + case .connecting: "正在连接…" + case .stopped: "未连接" + } + } +} + +@MainActor +final class MiucsSettingsWindow: NSWindowController { + init(model: MiucsSettingsModel) { + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 920, height: 570), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, defer: false) + window.title = "Miucs 设置" + window.minSize = NSSize(width: 780, height: 520) + window.isReleasedWhenClosed = false + window.contentView = SettingsHostingView(rootView: MiucsSettingsView(model: model)) + window.setFrameAutosaveName("MiucsSettings") + super.init(window: window) + window.center() + } + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + func present() { + NSApp.activate(ignoringOtherApps: true) + if window?.isMiniaturized == true { window?.deminiaturize(nil) } + showWindow(nil) + window?.makeKeyAndOrderFront(nil) + } +} + +private final class SettingsHostingView: NSHostingView { + // A click on a visible control also works when another application is active. + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } +} + +private struct MiucsSettingsView: View { + @ObservedObject var model: MiucsSettingsModel + private let accent = Color(red: 0.35, green: 0.44, blue: 0.64) + + var body: some View { + HStack(spacing: 0) { + sidebar + Divider() + VStack(alignment: .leading, spacing: 22) { + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 6) { + Text(model.page.rawValue).font(.system(size: 24, weight: .semibold)) + Text(model.page.subtitle).font(.system(size: 12)).foregroundStyle(.secondary) + } + Spacer() + if model.page == .skins || model.page == .appearance { + Toggle("显示皮肤", isOn: Binding(get: { model.isVisible }, set: { model.setVisible($0) })) + .toggleStyle(.switch).controlSize(.small).fixedSize() + .disabled(model.currentSkin == nil) + } + } + ScrollView { + VStack(alignment: .leading, spacing: 18) { + switch model.page { + case .skins: skins + case .appearance: appearance + case .inputs: inputs + case .about: about + } + }.frame(maxWidth: .infinity, alignment: .leading) + } + HStack(spacing: 5) { + Image(systemName: "checkmark").font(.system(size: 10)) + Text(model.page == .skins ? "选择皮肤即时生效" : "更改即时生效,自动保存") + }.font(.system(size: 11)).foregroundStyle(.secondary) + } + .padding(28) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(nsColor: .windowBackgroundColor)) + } + .tint(accent) + .frame(minWidth: 760, minHeight: 480) + } + + private var sidebar: some View { + VStack(alignment: .leading, spacing: 24) { + HStack(spacing: 10) { + Image(nsImage: NSImage(named: NSImage.applicationIconName) ?? NSImage()) + .resizable().frame(width: 38, height: 38) + VStack(alignment: .leading, spacing: 2) { + Text("Miucs").font(.system(size: 19, weight: .semibold)) + Text("让音乐自己飞。").font(.system(size: 10)).foregroundStyle(.secondary) + } + }.padding(.horizontal, 8) + VStack(spacing: 5) { + ForEach(SettingsPage.allCases.filter { $0 != .about }) { page in navigation(page) } + } + Spacer() + navigation(.about) + Label("在后台运行", systemImage: "circle.fill") + .font(.system(size: 10)).foregroundStyle(.secondary).padding(.horizontal, 12) + } + .padding(.horizontal, 12).padding(.vertical, 28).frame(width: 180) + .background(Color(nsColor: .controlBackgroundColor)) + } + + private func navigation(_ page: SettingsPage) -> some View { + Button { model.page = page } label: { + Label(page.rawValue, systemImage: page.symbol) + .font(.system(size: 13, weight: model.page == page ? .semibold : .regular)) + .frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal, 12).padding(.vertical, 10) + .foregroundStyle(model.page == page ? accent : .secondary) + .contentShape(RoundedRectangle(cornerRadius: 7)) + }.buttonStyle(SettingsSelectionButtonStyle(kind: .navigation, selected: model.page == page, accent: accent)) + } + + private var skins: some View { + VStack(alignment: .leading, spacing: 20) { + HStack(spacing: 10) { + Button("打开皮肤文件夹…", action: model.openDirectory) + Button("重新扫描", action: model.rescan) + Spacer() + Menu { + Button("重新加载当前皮肤", action: model.reload).disabled(model.currentSkin == nil) + } label: { Image(systemName: "ellipsis") } + .menuStyle(.borderlessButton).fixedSize().accessibilityLabel("皮肤操作") + }.controlSize(.small) + if model.skins.isEmpty { + VStack(spacing: 12) { + Image(systemName: "square.dashed").font(.system(size: 34)).foregroundStyle(.secondary) + Text("还没有可用的皮肤").font(.headline) + Text("将皮肤文件夹放入皮肤目录,再点击「重新扫描」。") + .font(.system(size: 12)).foregroundStyle(.secondary) + }.frame(maxWidth: .infinity).padding(.vertical, 60) + } else { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 190, maximum: 260), spacing: 16)], alignment: .leading, spacing: 16) { + ForEach(model.skins) { skin in + Button { model.selectSkin(skin.id) } label: { + VStack(alignment: .leading, spacing: 0) { + ZStack(alignment: .topTrailing) { + SkinPreview(url: skin.previewURL) + .frame(height: 155).frame(maxWidth: .infinity) + .allowsHitTesting(false) + if model.selectedID == skin.id { + Image(systemName: "checkmark.circle.fill").font(.system(size: 18)) + .foregroundStyle(accent).background(.background, in: Circle()).padding(8) + .allowsHitTesting(false) + } + } + Text(skin.name).font(.system(size: 13, weight: .medium)) + .lineLimit(2).padding(.horizontal, 6).padding(.vertical, 12) + } + .padding(7) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(RoundedRectangle(cornerRadius: 10)) + }.buttonStyle(SettingsSelectionButtonStyle(kind: .card, selected: model.selectedID == skin.id, accent: accent)) + .accessibilityLabel(skin.name) + .accessibilityValue(model.selectedID == skin.id ? "当前皮肤" : "未选择") + } + } + } + if !model.errors.isEmpty { + DisclosureGroup("皮肤加载遇到问题") { + Text(model.errors.joined(separator: "\n")).font(.system(size: 12)) + .textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading).padding(.top, 8) + }.foregroundStyle(.orange) + } + } + } + + private var appearance: some View { + VStack(alignment: .leading, spacing: 20) { + if let skin = model.currentSkin { + HStack(spacing: 12) { + SkinPreview(url: skin.previewURL).frame(width: 76, height: 50) + VStack(alignment: .leading, spacing: 3) { + Text(skin.name).font(.headline) + Text("当前皮肤").font(.system(size: 11)).foregroundStyle(.secondary) + } + } + settingsGroup { + VStack(alignment: .leading, spacing: 12) { + HStack { Text("画面大小"); Spacer(); Text("\(Int(model.scale.rounded()))%").monospacedDigit().foregroundStyle(.secondary) } + Slider(value: Binding(get: { model.scale }, set: { model.setScale($0) }), in: model.scaleRange) + Text("切换皮肤时保持当前位置与大小").font(.system(size: 11)).foregroundStyle(.secondary) + } + Divider() + VStack(alignment: .leading, spacing: 12) { + HStack { Text("不透明度"); Spacer(); Text("\(Int(model.opacity))%").monospacedDigit().foregroundStyle(.secondary) } + Slider(value: Binding(get: { model.opacity }, set: { model.setOpacity($0) }), in: 10...100, step: 10) + } + Divider() + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("位置与大小") + Text(model.isAdjusting ? "完成后恢复鼠标穿透" : "拖动移动,拖动右下角缩放") + .font(.system(size: 11)).foregroundStyle(.secondary) + } + Spacer() + Button(model.isAdjusting ? "完成调整" : "在桌面上调整…", action: model.togglePosition) + } + } + } else { + Text("先在「皮肤」中选择一个皮肤。").foregroundStyle(.secondary) + } + } + } + + private var inputs: some View { + VStack(alignment: .leading, spacing: 20) { + settingsGroup { + HStack { + Label("系统音频", systemImage: "speaker.wave.2") + Spacer() + Button("重新连接", action: model.reconnectAudio) + } + Text(model.audioStatus).font(.system(size: 12)).foregroundStyle(.secondary) + } + settingsGroup { + HStack { + Label("键盘响应", systemImage: "keyboard") + Spacer() + Button(model.keyboardState == .permissionRequired ? "前往系统设置…" : "检查连接", action: model.connectKeyboard) + } + Text(model.keyboardStatus).font(.system(size: 12)) + .foregroundStyle(model.keyboardState == .permissionRequired ? Color.orange : .secondary) + if model.keyboardState == .permissionRequired { + Text("允许 Miucs 使用「输入监控」后,按键才能驱动皮肤。").font(.system(size: 12)).foregroundStyle(.secondary) + } + } + Label("音频和按键只在本机处理,不保存真实音频、屏幕画面或输入文字。", systemImage: "lock.shield") + .font(.system(size: 12)).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + } + } + + private var about: some View { + VStack(spacing: 16) { + Image(nsImage: NSImage(named: NSImage.applicationIconName) ?? NSImage()).resizable().frame(width: 84, height: 84) + Text("Miucs").font(.system(size: 28, weight: .semibold)) + Text("让音乐自己飞。").foregroundStyle(.secondary) + Text("用声音与按键,让桌面上的皮肤动起来。").font(.system(size: 13)) + Text("版本 \(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0")") + .font(.system(size: 11)).foregroundStyle(.secondary) + }.frame(maxWidth: .infinity).padding(.vertical, 35) + } + + private func settingsGroup(@ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 18, content: content) + .padding(20).frame(maxWidth: .infinity, alignment: .leading) + .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.primary.opacity(0.08))) + } +} + +private struct SettingsSelectionButtonStyle: ButtonStyle { + enum Kind { case navigation, card } + let kind: Kind + let selected: Bool + let accent: Color + + func makeBody(configuration: Configuration) -> some View { + SettingsSelectionSurface(label: configuration.label, kind: kind, selected: selected, + pressed: configuration.isPressed, accent: accent) + } +} + +private struct SettingsSelectionSurface: View { + let label: Label + let kind: SettingsSelectionButtonStyle.Kind + let selected: Bool + let pressed: Bool + let accent: Color + @State private var hovered = false + @Environment(\.isEnabled) private var isEnabled + + private var shape: RoundedRectangle { RoundedRectangle(cornerRadius: kind == .card ? 10 : 7) } + private var background: Color { + if kind == .card { return Color(nsColor: .controlBackgroundColor) } + return selected ? accent.opacity(0.14) : .clear + } + private var border: Color { + if kind == .card && selected { return accent } + if hovered || pressed { return accent.opacity(0.55) } + return kind == .card ? Color.primary.opacity(0.12) : .clear + } + + var body: some View { + label + .frame(maxWidth: .infinity, alignment: .leading) + .background(background, in: shape) + .overlay { + shape.fill(accent.opacity(pressed ? 0.16 : hovered ? 0.07 : 0)) + .allowsHitTesting(false) + } + .overlay { + shape.strokeBorder(border, lineWidth: kind == .card && selected ? 2 : 1) + .allowsHitTesting(false) + } + .contentShape(shape) + .onHover { hovered = $0 } + .animation(.easeOut(duration: 0.1), value: hovered) + .animation(.easeOut(duration: 0.08), value: pressed) + .opacity(isEnabled ? 1 : 0.5) + } +} + +/// Displays package artwork without activating a second plugin or input subscription. +private struct SkinPreview: NSViewRepresentable { + let url: URL? + func makeNSView(context: Context) -> NSImageView { + let view = SkinPreviewImageView() + view.imageScaling = .scaleProportionallyUpOrDown + view.animates = true + view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + view.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + return view + } + func updateNSView(_ view: NSImageView, context: Context) { + guard context.coordinator.url != url || view.image == nil else { return } + context.coordinator.url = url + view.image = url.flatMap { NSImage(contentsOf: $0) } ?? NSImage(systemSymbolName: "photo", accessibilityDescription: "暂无预览图片") + } + func makeCoordinator() -> Coordinator { Coordinator() } + final class Coordinator { var url: URL? } +} + +private final class SkinPreviewImageView: NSImageView { + // Let the enclosing SwiftUI button own the complete card, including GIF/image pixels. + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} diff --git a/apps/macos/PluginVisualizations.swift b/apps/macos/PluginVisualizations.swift index b5ceb0e..0b39417 100644 --- a/apps/macos/PluginVisualizations.swift +++ b/apps/macos/PluginVisualizations.swift @@ -4,9 +4,9 @@ import Foundation @MainActor enum PluginVisualizations { static let defaultID = "pixel-keyboard" - static func registry() -> VisualizationRegistry { + static func registry(userDirectory: String? = nil) -> VisualizationRegistry { let registry = VisualizationRegistry() - PluginHost.shared.register(in: registry) + PluginHost.shared.register(in: registry, excluding: SkinItem.retiredIDs, userDirectory: userDirectory) return registry } } diff --git a/apps/macos/SkinCatalog.swift b/apps/macos/SkinCatalog.swift new file mode 100644 index 0000000..28439e9 --- /dev/null +++ b/apps/macos/SkinCatalog.swift @@ -0,0 +1,26 @@ +import AppKit + +/// A skin is one plugin package. Retired demos are not application choices. +struct SkinItem: Identifiable { + let id: String + let name: String + let directory: URL + let previewURL: URL? + + static let retiredIDs: Set = ["staff-note", "pencil-flight"] + + init(id: String, name: String, directory: URL) { + self.id = id + self.name = name + self.directory = directory + let root = directory.resolvingSymlinksInPath().standardizedFileURL.path + "/" + previewURL = ["gif", "png", "webp", "jpg", "jpeg"].compactMap { ext -> URL? in + let url = directory.appendingPathComponent("preview.\(ext)").resolvingSymlinksInPath() + guard url.path.hasPrefix(root), + let info = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]), + info.isRegularFile == true, let size = info.fileSize, size <= 20_000_000, + NSImage(contentsOf: url) != nil else { return nil } + return url + }.first + } +} diff --git a/apps/macos/VisualizationHostView.swift b/apps/macos/VisualizationHostView.swift index 8f72e42..f7f21e2 100644 --- a/apps/macos/VisualizationHostView.swift +++ b/apps/macos/VisualizationHostView.swift @@ -156,8 +156,8 @@ private final class HostOverlayView: NSView { case .recovering: text = "声音中断 · 正在自动恢复…" case .suspended: text = "等待系统唤醒…" case .permissionRequired: text = "等待系统音频授权…" - case .userStopped: text = "捕获已停止 · 菜单栏可重新连接" - default: text = "音频未连接 · 从菜单栏重新连接" + case .userStopped: text = "捕获已停止 · 在设置中重新连接" + default: text = "音频未连接 · 在设置中重新连接" } } let font = NSFont(name: "Bradley Hand", size: 12) ?? NSFont.systemFont(ofSize: 12) diff --git a/apps/macos/VisualizerPanel.swift b/apps/macos/VisualizerPanel.swift index 65c6696..ecaebbb 100644 --- a/apps/macos/VisualizerPanel.swift +++ b/apps/macos/VisualizerPanel.swift @@ -1,9 +1,11 @@ import AppKit -/// A transparent, nonactivating desktop canvas with independent module geometry. +/// A transparent canvas. Skin mode keeps one desktop frame across package switches. @MainActor final class VisualizerPanel: NSPanel, NSWindowDelegate { static let selectionPreferencesKey = "MiucsSelectedVisualization.v1" + static let sharedFramePreferencesKey = "MiucsSkinFrame.v1" + enum GeometryPersistence { case perVisualization, sharedSkin } static func framePreferencesKey(for id: String) -> String { "MiucsVisualizationFrame.v1.\(id)" } @@ -14,6 +16,13 @@ final class VisualizerPanel: NSPanel, NSWindowDelegate { private let preferences: UserDefaults private let persistsFrame: Bool private let monitorsHover: Bool + private let geometryPersistence: GeometryPersistence + private var geometryPreferencesKey: String { + geometryPersistence == .sharedSkin ? Self.sharedFramePreferencesKey : Self.framePreferencesKey(for: descriptor.id) + } + private var geometryAutosaveName: String { + geometryPersistence == .sharedSkin ? "MiucsSkin" : Self.autosaveName(for: descriptor.id) + } private(set) var descriptor: VisualizationDescriptor private var isRestoringFrame = true private var isUpdatingDrawingSize = false @@ -31,7 +40,8 @@ final class VisualizerPanel: NSPanel, NSWindowDelegate { init(source: any AudioFeatureSource, registry: VisualizationRegistry, selectedID: String? = nil, preferences: UserDefaults = .standard, persistsFrame: Bool = true, - keyboardSource: (any KeyboardFeatureSource)? = nil, monitorsHover: Bool = true) { + keyboardSource: (any KeyboardFeatureSource)? = nil, monitorsHover: Bool = true, + geometryPersistence: GeometryPersistence = .perVisualization) { let requestedID = selectedID ?? preferences.string(forKey: Self.selectionPreferencesKey) let initial = requestedID.flatMap { registry.descriptor(id: $0) } ?? registry.descriptor(id: PluginVisualizations.defaultID) ?? registry.descriptors.first! descriptor = initial @@ -39,6 +49,7 @@ final class VisualizerPanel: NSPanel, NSWindowDelegate { keyboardSource: keyboardSource) self.preferences = preferences; self.persistsFrame = persistsFrame self.monitorsHover = monitorsHover + self.geometryPersistence = geometryPersistence super.init(contentRect: NSRect(x: 80, y: 80, width: initial.layout.width, height: initial.layout.height), styleMask: [.borderless, .nonactivatingPanel, .fullSizeContentView], backing: .buffered, defer: false) @@ -80,12 +91,18 @@ final class VisualizerPanel: NSPanel, NSWindowDelegate { if persistsFrame { setFrameAutosaveName("") } guard visualizationHost.select(id: id) else { isRestoringFrame = false - if persistsFrame { setFrameAutosaveName(Self.autosaveName(for: descriptor.id)) } + if persistsFrame { setFrameAutosaveName(geometryAutosaveName) } if wasRendering { visualizationHost.setRendering(true) } return false } descriptor = next; updateTitle() - restoreGeometry(fallbackTopLeft: anchor) + if geometryPersistence == .sharedSkin { + // Replacing the artwork must not resize, reposition or round the window. + visualizationHost.updateViewport() + if persistsFrame { setFrameAutosaveName(geometryAutosaveName) } + } else { + restoreGeometry(fallbackTopLeft: anchor) + } isRestoringFrame = false saveDrawingFrame() if wasRendering { visualizationHost.setRendering(true) } @@ -102,7 +119,10 @@ final class VisualizerPanel: NSPanel, NSWindowDelegate { var restored = false if let saved { setFrame(saved, display: false); restored = true } else if persistsFrame { - restored = setFrameUsingName(Self.autosaveName(for: descriptor.id), force: true) + restored = setFrameUsingName(geometryAutosaveName, force: true) + if !restored, geometryPersistence == .sharedSkin { + restored = setFrameUsingName(Self.autosaveName(for: descriptor.id), force: true) + } } let layout = descriptor.layout if !restored { @@ -112,10 +132,25 @@ final class VisualizerPanel: NSPanel, NSWindowDelegate { setFrame(NSRect(x: topLeft.x, y: topLeft.y - layout.height, width: layout.width, height: layout.height), display: false) } - let width: Double = saved.map { Double($0.width) } ?? (restored ? Double(frame.width) : layout.width) - setDrawingWidth(width, persist: false) + if geometryPersistence == .sharedSkin, restored { + fitSharedFrameToDisplay() + } else { + let width: Double = saved.map { Double($0.width) } ?? (restored ? Double(frame.width) : layout.width) + setDrawingWidth(width, persist: false) + } + visualizationHost.updateViewport() + if persistsFrame { setFrameAutosaveName(geometryAutosaveName) } + } + + private func fitSharedFrameToDisplay() { + let visible = visibleFrame(), current = frame + guard !visible.contains(current) else { return } + let scale = min(1, min(visible.width / current.width, visible.height / current.height)) + let size = NSSize(width: current.width * scale, height: current.height * scale) + let origin = NSPoint(x: min(max(current.minX, visible.minX), visible.maxX - size.width), + y: min(max(current.minY, visible.minY), visible.maxY - size.height)) + setFrame(NSRect(origin: origin, size: size), display: false) visualizationHost.updateViewport() - if persistsFrame { setFrameAutosaveName(Self.autosaveName(for: descriptor.id)) } } private func visibleFrame() -> NSRect { @@ -149,13 +184,20 @@ final class VisualizerPanel: NSPanel, NSWindowDelegate { frame.origin.x.isFinite, frame.origin.y.isFinite, frame.width.isFinite, frame.height.isFinite, frame.width > 0, frame.height > 0 else { return } preferences.set(["x": frame.minX, "y": frame.minY, - "width": frame.width, "height": frame.height], forKey: Self.framePreferencesKey(for: descriptor.id)) + "width": frame.width, "height": frame.height], forKey: geometryPreferencesKey) preferences.set(descriptor.id, forKey: Self.selectionPreferencesKey) - if persistsFrame { saveFrame(usingName: Self.autosaveName(for: descriptor.id)) } + if persistsFrame { saveFrame(usingName: geometryAutosaveName) } } private func readSavedFrame() -> NSRect? { - guard let saved = preferences.dictionary(forKey: Self.framePreferencesKey(for: descriptor.id)), + if let saved = readFrame(forKey: geometryPreferencesKey) { return saved } + // One-time migration starts from the selected skin's existing placement. + if geometryPersistence == .sharedSkin { return readFrame(forKey: Self.framePreferencesKey(for: descriptor.id)) } + return nil + } + + private func readFrame(forKey key: String) -> NSRect? { + guard let saved = preferences.dictionary(forKey: key), let x = saved["x"] as? Double, let y = saved["y"] as? Double, let width = saved["width"] as? Double, let height = saved["height"] as? Double, [x, y, width, height].allSatisfy(\.isFinite), width > 0, height > 0 else { return nil } @@ -197,7 +239,10 @@ final class VisualizerPanel: NSPanel, NSWindowDelegate { if let control = hoverLockPanel { removeChildWindow(control); control.orderOut(nil) } } @objc private func displaysChanged() { - hideHoverLock(); setAdjustingPosition(false); setDrawingWidth(frame.width) + hideHoverLock(); setAdjustingPosition(false) + if geometryPersistence == .sharedSkin { + fitSharedFrameToDisplay(); positionHoverLock(); saveDrawingFrame(); onSizeChange?() + } else { setDrawingWidth(frame.width) } } @objc private func workspaceStateChanged(_ notification: Notification) { let reason: String diff --git a/apps/windows/Core/Preferences.cs b/apps/windows/Core/Preferences.cs index c61030b..f7cdcb6 100644 --- a/apps/windows/Core/Preferences.cs +++ b/apps/windows/Core/Preferences.cs @@ -12,6 +12,14 @@ public sealed record WindowFrame(double X, double Y, double Width, double Height public static class WindowPlacement { + public static WindowFrame FitPreservingSize(WindowFrame requested, WindowFrame workArea) + { + if (!requested.IsValid || !workArea.IsValid) throw new ArgumentException("Invalid display geometry"); + double scale = Math.Min(1, Math.Min(workArea.Width / requested.Width, workArea.Height / requested.Height)); + double width = requested.Width * scale, height = requested.Height * scale; + return new(Math.Clamp(requested.X, workArea.X, Math.Max(workArea.X, workArea.X + workArea.Width - width)), + Math.Clamp(requested.Y, workArea.Y, Math.Max(workArea.Y, workArea.Y + workArea.Height - height)), width, height); + } // All coordinates must use the same monitor-local logical coordinate space. public static WindowFrame Fit(WindowFrame requested, WindowFrame workArea, double aspect, double minWidth, double maxWidth) { @@ -34,6 +42,10 @@ public sealed class Preferences public bool KeyboardEnabled { get; set; } public bool TrayAnimationEnabled { get; set; } = true; public Dictionary Frames { get; set; } = new(); + public WindowFrame? SkinFrame { get; set; } + public WindowFrame RestoreSkinFrame(string selectedId, WindowFrame fallback) => + SkinFrame is { IsValid: true } ? SkinFrame : + Frames.TryGetValue(selectedId, out var old) && old.IsValid ? old : fallback; public static string DefaultPath => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Miucs", "preferences.v1.json"); public static Preferences Load(string path) { @@ -42,10 +54,11 @@ public static Preferences Load(string path) var result = JsonSerializer.Deserialize(File.ReadAllText(path)); if (result == null) return new(); result.Frames ??= new(); - if (string.IsNullOrWhiteSpace(result.Visualization) || result.Visualization.Length > 128) result.Visualization = "pixel-keyboard"; + if (string.IsNullOrWhiteSpace(result.Visualization) || result.Visualization.Length > 128 || SkinCatalog.IsRetired(result.Visualization)) result.Visualization = "pixel-keyboard"; result.Color = Palette.ContainsKey(result.Color ?? "") ? result.Color! : "graphite"; foreach (var key in new List(result.Frames.Keys)) if (result.Frames[key] == null || !result.Frames[key].IsValid) result.Frames.Remove(key); + if (result.SkinFrame?.IsValid != true) result.SkinFrame = null; return result; } catch (Exception e) when (e is IOException or UnauthorizedAccessException or JsonException or NotSupportedException) { return new(); } diff --git a/apps/windows/Core/SkinCatalog.cs b/apps/windows/Core/SkinCatalog.cs new file mode 100644 index 0000000..c27cd43 --- /dev/null +++ b/apps/windows/Core/SkinCatalog.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace Miucs.Windows; + +public static class SkinCatalog +{ + public const long MaximumPreviewBytes = 20_000_000; + public static bool IsRetired(string id) => id is "staff-note" or "pencil-flight"; + public static IEnumerable PreviewCandidates(string directory) + { + foreach (string extension in new[] { "gif", "png", "webp", "jpg", "jpeg" }) + { + string candidate = Path.Combine(directory, "preview." + extension); + bool valid; + try + { + var file = new FileInfo(candidate); + valid = file.Exists && file.Length is > 0 and <= MaximumPreviewBytes && + (file.Attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) == 0; + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) { valid = false; } + if (valid) yield return candidate; + } + } +} diff --git a/apps/windows/DesktopHost.cs b/apps/windows/DesktopHost.cs index 45fd803..92efde3 100644 --- a/apps/windows/DesktopHost.cs +++ b/apps/windows/DesktopHost.cs @@ -25,13 +25,15 @@ internal sealed class DesktopHost : IDisposable private readonly LoopbackCapture capture; private readonly KeyboardCapture keyboardCapture; private readonly Window window; + private readonly SettingsWindow settingsWindow; + private string shellError = ""; private readonly HoverLockControl hoverLock; private readonly Grid root = new(); private readonly Border adjustment; private readonly TextBlock status; private readonly Forms.NotifyIcon tray; private readonly AnimatedTrayIcon trayAnimation; - private readonly PluginHost pluginHost; + private readonly PluginHost? pluginHost; private readonly TrayHeadAnimation catHead = new(); private KeyboardSubscription headSubscription; private double lastHeadTime; @@ -57,9 +59,14 @@ public DesktopHost(Application app) capture = new LoopbackCapture(audio); keyboardCapture = new KeyboardCapture(keyboard); headSubscription = keyboard.Subscribe(); lastHeadTime = Now; - if (!File.Exists(Path.Combine(AppContext.BaseDirectory,"miucs_plugins.dll")) || !File.Exists(Path.Combine(AppContext.BaseDirectory,"miucs-plugin-worker.exe"))) - throw new InvalidOperationException("插件运行库缺失,请重新安装完整 Miucs 应用,或运行 scripts/build-windows.ps1 重新构建。"); - pluginHost = new PluginHost(audio, keyboard); + try + { + if (File.Exists(Path.Combine(AppContext.BaseDirectory, "miucs_plugins.dll"))) + pluginHost = new PluginHost(audio, keyboard, include: id => !SkinCatalog.IsRetired(id)); + else shellError = "皮肤运行库未找到,请重新解压完整应用。"; + } + catch (Exception error) when (error is DllNotFoundException or BadImageFormatException or EntryPointNotFoundException or System.Text.Json.JsonException) + { shellError = "皮肤运行库加载失败:" + error.Message; } window = new Window { Title = "Miucs", WindowStyle = WindowStyle.None, AllowsTransparency = false, @@ -90,7 +97,7 @@ public DesktopHost(Application app) trayAnimation = new AnimatedTrayIcon(tray, () => AdvanceCatHead(Now)); tray.Visible = true; UpdateTrayAnimation(); - tray.DoubleClick += (_, _) => Dispatch(() => SetVisible(!isVisible)); + tray.DoubleClick += (_, _) => Dispatch(ShowSettings); menu.Opening += (_, _) => BuildMenu(); SystemEvents.PowerModeChanged += OnPowerModeChanged; SystemEvents.SessionSwitch += OnSessionSwitch; @@ -104,6 +111,44 @@ public DesktopHost(Application app) window.Show(); hoverLock = new HoverLockControl(window, () => isVisible && !suspended && !disposed, () => SetAdjusting(!adjusting)); StartRendering(); + settingsWindow = new SettingsWindow(new SettingsActions + { + SelectSkin = SwitchVisualization, SetVisible = SetVisible, TogglePosition = () => SetAdjusting(!adjusting), + SetScale = value => { SetFrame(CurrentFrame() with { Width = view.BaseWidth * value / 100 }, preserveSize: false); SaveFrame(); SavePreferences(); RefreshSettings(); }, + SetOpacity = value => { preferences.Transparency = (int)Math.Round(100 - value); view.Opacity = 1 - preferences.Transparency / 100d; ApplyColor(); SavePreferences(); RefreshSettings(); }, + SetKeyboardEnabled = value => { preferences.KeyboardEnabled = value; if (value && !suspended) keyboardCapture.Start(); else keyboardCapture.Stop(); ResetCatHead(); SavePreferences(); UpdateStatus(); }, + SetTrayAnimation = value => { preferences.TrayAnimationEnabled = value; UpdateTrayAnimation(); SavePreferences(); RefreshSettings(); }, + ReconnectAudio = () => { if (!suspended) capture.Reconnect(); }, + Rescan = RescanSkins, Reload = () => pluginHost?.ReloadActive(), OpenDirectory = OpenSkinDirectory + }); + app.MainWindow = settingsWindow; + ShowSettings(); + } + + internal void ShowSettings() { if (disposed) return; RefreshSettings(); settingsWindow.Present(); } + private void RefreshSettings() + { + if (settingsWindow == null) return; + using var subscription = keyboard.Subscribe(); + string keyboardStatus = subscription.Read(Now).State.ToString(); + settingsWindow.UpdateState(new SettingsState( + pluginHost?.Views.Values.ToArray() ?? Array.Empty(), view.Id, isVisible, adjusting, + window.Width / view.BaseWidth * 100, view.LayoutMinWidth / view.BaseWidth * 100, + view.LayoutMaxWidth / view.BaseWidth * 100, 100 - preferences.Transparency, + preferences.KeyboardEnabled, preferences.TrayAnimationEnabled, lastStatus, keyboardStatus, + string.Join("\n", new[] { shellError, capture.LastError, pluginHost?.Error }.Where(e => !string.IsNullOrEmpty(e))))); + } + private void OpenSkinDirectory() + { + try { Directory.CreateDirectory(PluginHost.UserDirectory); Process.Start(new ProcessStartInfo(PluginHost.UserDirectory) { UseShellExecute = true }); shellError = ""; } + catch (Exception e) when (e is IOException or UnauthorizedAccessException or System.ComponentModel.Win32Exception) { shellError = "无法打开皮肤文件夹:" + e.Message; } + RefreshSettings(); + } + private void RescanSkins() + { + pluginHost?.Scan(); + if (pluginHost?.Views.ContainsKey(view.Id) != true) SwitchVisualization(preferences.Visualization); + RefreshSettings(); } private void Dispatch(Action action) @@ -112,37 +157,35 @@ private void Dispatch(Action action) } private void SwitchVisualization(string id) { - if (!pluginHost.Views.ContainsKey(id)) - id=pluginHost.Views.ContainsKey("pixel-keyboard") ? "pixel-keyboard" : pluginHost.Views.Keys.FirstOrDefault() ?? ""; + if (pluginHost?.Views.ContainsKey(id) != true) + id = pluginHost?.Views.ContainsKey("pixel-keyboard") == true ? "pixel-keyboard" : pluginHost?.Views.Keys.FirstOrDefault() ?? ""; if (view != null && view.Id == id) return; WindowFrame? previous = view == null ? null : CurrentFrame(); hoverLock?.Reset(); StopRendering(); - if (view != null) - { - SaveFrame(); root.Children.Remove(view); view.Dispose(); - } - view = pluginHost.Views.ContainsKey(id) ? new PluginView(pluginHost, id) : new EmptyPluginView(); - if (id.Length>0) preferences.Visualization = id; - ApplyColor(); - view.Opacity = 1 - preferences.Transparency / 100d; + if (view != null) { SaveFrame(); root.Children.Remove(view); view.Dispose(); } + view = id.Length > 0 && pluginHost != null ? new PluginView(pluginHost, id) : new EmptyPluginView(); + if (id.Length > 0) preferences.Visualization = id; + ApplyColor(); view.Opacity = 1 - preferences.Transparency / 100d; root.Children.Insert(0, view); - var requested = preferences.Frames.TryGetValue(id, out var saved) ? saved : - new WindowFrame(previous?.X ?? 80, previous?.Y ?? 80, view.BaseWidth, view.BaseHeight); - SetFrame(requested); - SavePreferences(); + if (previous == null) + SetFrame(preferences.RestoreSkinFrame(id, new WindowFrame(80, 80, view.BaseWidth, view.BaseHeight))); + else { view.Width = window.Width; view.Height = window.Height; } + SaveFrame(); SavePreferences(); RefreshSettings(); if (isVisible) StartRendering(); } + private WindowFrame CurrentFrame() => new(window.Left, window.Top, window.Width, window.Height); - private void SetFrame(WindowFrame requested) + private void SetFrame(WindowFrame requested, bool preserveSize = true) { // Move first so MonitorFromWindow identifies the saved monitor; disconnected displays fall back to nearest. if (requested.IsValid) { window.Left = requested.X; window.Top = requested.Y; } - var frame = WindowPlacement.Fit(requested, NativeWindow.WorkArea(window), view.BaseWidth / view.BaseHeight, view.LayoutMinWidth, view.LayoutMaxWidth); + var area = NativeWindow.WorkArea(window); + var frame = preserveSize ? WindowPlacement.FitPreservingSize(requested, area) : WindowPlacement.Fit(requested, area, view.BaseWidth / view.BaseHeight, view.LayoutMinWidth, view.LayoutMaxWidth); window.Left = frame.X; window.Top = frame.Y; window.Width = frame.Width; window.Height = frame.Height; view.Width = frame.Width; view.Height = frame.Height; } - private void SaveFrame() { if (view != null && view.Id.Length>0 && CurrentFrame().IsValid) preferences.Frames[view.Id] = CurrentFrame(); } + private void SaveFrame() { if (view != null && view.Id.Length>0 && CurrentFrame().IsValid) preferences.SkinFrame = CurrentFrame(); } private void SavePreferences() { if (!preferences.Save(Preferences.DefaultPath) && tray != null) @@ -152,7 +195,7 @@ private void ApplyColor() { var p = Preferences.Palette[preferences.Color]; view.ApplyAppearance(Color.FromRgb(p.R, p.G, p.B)); - pluginHost.Appearance($"#{p.R:X2}{p.G:X2}{p.B:X2}",1-preferences.Transparency/100d); + pluginHost?.Appearance($"#{p.R:X2}{p.G:X2}{p.B:X2}",1-preferences.Transparency/100d); } private void StartRendering() { @@ -231,6 +274,7 @@ private void SetVisible(bool value) hoverLock?.Reset(); if (value) { SetFrame(CurrentFrame()); window.Show(); StartRendering(); } else { StopRendering(); SaveFrame(); window.Hide(); SavePreferences(); } + RefreshSettings(); } private void SetAdjusting(bool value) { @@ -240,6 +284,7 @@ private void SetAdjusting(bool value) NativeWindow.SetInteractive(window, value); if (value) SetVisible(true); else { root.ReleaseMouseCapture(); SaveFrame(); SavePreferences(); } + RefreshSettings(); } private void BeginDrag(object sender, MouseButtonEventArgs e) { @@ -254,62 +299,24 @@ private void Drag(object sender, MouseEventArgs e) var p = root.PointToScreen(e.GetPosition(root)); var dpi = VisualTreeHelper.GetDpi(window); double dx = (p.X - dragOrigin.Value.X) / dpi.DpiScaleX, dy = (p.Y - dragOrigin.Value.Y) / dpi.DpiScaleY; - if (resizing) SetFrame(dragFrame with { Width = Math.Max(1, dragFrame.Width + dx) }); + if (resizing) SetFrame(dragFrame with { Width = Math.Max(1, dragFrame.Width + dx) }, preserveSize: false); else { window.Left = dragFrame.X + dx; window.Top = dragFrame.Y + dy; } } private void EndDrag(object sender, MouseButtonEventArgs e) { if (dragOrigin == null) return; - root.ReleaseMouseCapture(); SetFrame(CurrentFrame()); SaveFrame(); SavePreferences(); + root.ReleaseMouseCapture(); SetFrame(CurrentFrame()); SaveFrame(); SavePreferences(); RefreshSettings(); } private void BuildMenu() { while (menu.Items.Count > 0) { var old = menu.Items[0]; menu.Items.RemoveAt(0); old.Dispose(); } - menu.Items.Add(new Forms.ToolStripMenuItem("Miucs · " + lastStatus) { Enabled = false }); - if (capture.LastError is string error) menu.Items.Add(new Forms.ToolStripMenuItem(error) { Enabled = false }); - using (var keys = keyboard.Subscribe()) - menu.Items.Add(new Forms.ToolStripMenuItem("键盘状态:" + keys.Read(Now).State) { Enabled = false }); - Add(menu.Items, isVisible ? "隐藏画面" : "显示画面", () => SetVisible(!isVisible)); - Add(menu.Items, "托盘猫摇头", () => - { - preferences.TrayAnimationEnabled = !preferences.TrayAnimationEnabled; - UpdateTrayAnimation(); SavePreferences(); - }, preferences.TrayAnimationEnabled); - var modes = new Forms.ToolStripMenuItem("可视化"); menu.Items.Add(modes); - foreach (var plugin in pluginHost.Views.Values) { var id=plugin.Id;Add(modes.DropDownItems,plugin.Name,()=>SwitchVisualization(id),view.Id==id); } - Add(menu.Items,"重新扫描插件",()=>{pluginHost.Scan();if(view.Id.Length==0)SwitchVisualization(preferences.Visualization);}); - Add(menu.Items,"重新加载当前插件",()=>pluginHost.ReloadActive()); - var sizes = new Forms.ToolStripMenuItem("画面大小"); menu.Items.Add(sizes); - foreach (int percent in new[] { 10, 80, 100, 125, 150, 200 }) - Add(sizes.DropDownItems, $"{percent}%", () => { SetFrame(CurrentFrame() with { Width = view.BaseWidth * percent / 100d }); SaveFrame(); SavePreferences(); }); - var colors = new Forms.ToolStripMenuItem("线条颜色"); menu.Items.Add(colors); - foreach (var entry in Preferences.Palette) - Add(colors.DropDownItems, entry.Value.Name, () => { preferences.Color = entry.Key; ApplyColor(); SavePreferences(); }, preferences.Color == entry.Key); - var transparency = new Forms.ToolStripMenuItem($"画面不透明度({100 - preferences.Transparency}%)"); menu.Items.Add(transparency); - for (int value = 10; value <= 100; value += 10) - { - int percent = value; - Add(transparency.DropDownItems, percent == 100 ? "100%(原始效果)" : $"{percent}%", () => - { - preferences.Transparency = 100 - percent; - view.Opacity = 1 - preferences.Transparency / 100d; - ApplyColor(); - SavePreferences(); - }, preferences.Transparency == 100 - percent); - } - Add(menu.Items, adjusting ? "完成调整(恢复鼠标穿透)" : "调整位置与大小…", () => SetAdjusting(!adjusting)); + Add(menu.Items, "打开设置…", ShowSettings); + Add(menu.Items, isVisible ? "隐藏皮肤" : "显示皮肤", () => SetVisible(!isVisible)); + Add(menu.Items, adjusting ? "完成调整" : "调整位置与大小…", () => SetAdjusting(!adjusting)); menu.Items.Add(new Forms.ToolStripSeparator()); - Add(menu.Items, "重新连接音频", () => { if (!suspended) capture.Reconnect(); }); - Add(menu.Items, "键盘输入 · 星爆 / 猫爪", () => - { - preferences.KeyboardEnabled = !preferences.KeyboardEnabled; - if (preferences.KeyboardEnabled && !suspended) keyboardCapture.Start(); else keyboardCapture.Stop(); - ResetCatHead(); - SavePreferences(); - }, preferences.KeyboardEnabled); - menu.Items.Add(new Forms.ToolStripSeparator()); - Add(menu.Items, "退出", () => { Dispose(); app.Shutdown(); }); + Add(menu.Items, "退出 Miucs", () => { Dispose(); app.Shutdown(); }); } + private void Add(Forms.ToolStripItemCollection items, string text, Action action, bool check = false) { var item = new Forms.ToolStripMenuItem(text) { Checked = check }; @@ -325,22 +332,24 @@ private void UpdateStatus() AudioState.Connecting => "正在连接音频", AudioState.Recovering => "正在恢复音频", AudioState.PermissionRequired => "音频访问受限", AudioState.Suspended => "已暂停", _ => "音频未连接" }; - if (pluginHost.Error.Length > 0) lastStatus = "插件:" + pluginHost.Error; - status.Text = pluginHost.Error.Length > 0 ? lastStatus : input.State == AudioState.Running && input.Frame != null ? "" : lastStatus; - tray.Text = ("Miucs · " + lastStatus)[..Math.Min(63,8+lastStatus.Length)]; + if (pluginHost?.Error.Length > 0) lastStatus = "插件:" + pluginHost.Error; + status.Text = pluginHost?.Error.Length > 0 ? lastStatus : input.State == AudioState.Running && input.Frame != null ? "" : lastStatus; + string tooltip = "Miucs · " + lastStatus; + tray.Text = tooltip[..Math.Min(63, tooltip.Length)]; + RefreshSettings(); } private void Suspend() { if (suspended) return; SetAdjusting(false); hoverLock.Reset(); - suspended = true; pluginHost.Suspend(true); StopRendering(); capture.Suspend(); keyboardCapture.Suspend(); + suspended = true; pluginHost?.Suspend(true); StopRendering(); capture.Suspend(); keyboardCapture.Suspend(); ResetCatHead(); UpdateTrayAnimation(); } private void Resume() { if (!suspended) return; - suspended = false; pluginHost.Suspend(false); capture.Resume(); + suspended = false; pluginHost?.Suspend(false); capture.Resume(); if (preferences.KeyboardEnabled) keyboardCapture.Resume(); ResetCatHead(); UpdateTrayAnimation(); StartRendering(); @@ -369,6 +378,7 @@ public void Dispose() SystemParameters.StaticPropertyChanged -= OnSystemParametersChanged; headSubscription.Dispose(); capture.Dispose(); keyboardCapture.Dispose(); view.Dispose(); - tray.Visible = false; pluginHost.Dispose(); trayAnimation.Dispose(); tray.Dispose(); menu.Dispose(); window.Close(); + settingsWindow?.CloseForExit(); + tray.Visible = false; pluginHost?.Dispose(); trayAnimation.Dispose(); tray.Dispose(); menu.Dispose(); window.Close(); } } diff --git a/apps/windows/Program.cs b/apps/windows/Program.cs index 456aa29..da73fcc 100644 --- a/apps/windows/Program.cs +++ b/apps/windows/Program.cs @@ -14,13 +14,19 @@ public static int Main(string[] args) try { WindowsSmokeTests.Run(args.Length > 1 ? args[1] : "build/windows-smoke"); return 0; } catch (Exception error) { Console.Error.WriteLine(error); return 1; } } + using var reopen = new EventWaitHandle(false, EventResetMode.AutoReset, @"Local\Miucs.Windows.ShowSettings"); using var instance = new Mutex(true, @"Local\Miucs.Windows", out bool first); - if (!first) return 0; + if (!first) { reopen.Set(); return 0; } var app = new Application { ShutdownMode = ShutdownMode.OnExplicitShutdown }; try { using var host = new DesktopHost(app); - app.Run(); + var registration = ThreadPool.RegisterWaitForSingleObject(reopen, (_, _) => + { + if (!app.Dispatcher.HasShutdownStarted) app.Dispatcher.BeginInvoke(new Action(host.ShowSettings)); + }, null, Timeout.Infinite, false); + try { app.Run(); } + finally { registration.Unregister(null); } return 0; } catch (Exception error) diff --git a/apps/windows/README.md b/apps/windows/README.md index b3e6a80..0edfe60 100644 --- a/apps/windows/README.md +++ b/apps/windows/README.md @@ -2,25 +2,22 @@ .NET 8 / WPF + Win32 + WASAPI,面向 Windows 10/11 x64;运行时不依赖 macOS、Python、云音频服务或外部模型;Lua/Skia 运行库随应用提供。ARM64 可从源码交叉发布,但当前交付与检查以 x64 为主。 -**实现与验证不是一回事:** 本轮在 Linux 云端实现、执行可移植测试并交叉构建 Windows 产物;没有访问用户电脑,也没有 Windows 桌面/声卡可实测。因此不是已验证稳定版。真实 WASAPI、全局键盘、透明穿透、多屏 DPI、休眠恢复仍须按 [验收表](../../docs/windows-acceptance.md) 在 Windows 上验证。画面由共享 Lua/Skia 插件绘制,WPF 负责窗口和菜单。 +**实现与验证不是一回事:** 核心测试可跨平台运行;原生设置、鼠标命中、GIF 与插件绘制测试由 Windows CI 执行;真实音频设备和多屏环境仍需实机验收。因此不是已验证稳定版。真实 WASAPI、全局键盘、透明穿透、多屏 DPI、休眠恢复仍须按 [验收表](../../docs/windows-acceptance.md) 在 Windows 上验证。画面由共享 Lua/Skia 插件绘制,WPF 负责窗口和菜单。 ## 直接运行 解压 `Miucs-windows-x64.zip`,保留整个目录,运行 `Miucs.exe`。自包含版本无需单独安装 .NET,也不需要管理员权限。退出后可直接删除目录。无安装器、无签名、无开机自启、无注册表持久化;未签名的新程序可能显示 Windows 信任提示,应先核对来源及 SHA-256,不要关闭系统安全设置。 -启动后在通知区域寻找 Miucs 图标(可能位于折叠区)。默认显示奶牛猫,捕获**当前默认多媒体播放设备**的系统混音,不使用麦克风。播放音乐后自动驱动画面。WASAPI 空闲设备可能没有 PCM;此时不伪造静音帧,也不因为没有音乐不断重连。 +启动后直接打开设置,在任务栏和通知区域都能找到 Miucs。首次默认选择奶牛猫;其他皮肤在卡片中选择。系统音频来自当前默认多媒体播放设备,不使用麦克风。 -- **可视化**:仅加载插件目录中的画面;运行库缺失时显示启动错误。切换不重启两路输入。 -- **托盘猫摇头**:所有模式统一显示同一只像素猫;托盘动画独立于插件,开启键盘输入后随打字速度摇头,停下后回正。隐藏画面仍可响应,切换模式不换猫、不重启动作。可从菜单关闭托盘动画;锁屏/休眠或 Windows 关闭界面动画时保持静止。不会因此自动开启键盘监听。 -- **画面大小**:10%、80%、100%、125%、150%、200%;拖动缩放同样可以到基准宽度的 10%,并始终保留模块比例。 -- **线条颜色**:五种公共笔色,即时更新,不清空轨迹。 -- **调整位置与大小**:鼠标停留画面 3 秒后,点击中心偏上的手绘小锁解锁;拖动画面移动、右下角等比缩放,再点小锁恢复穿透。托盘入口仍可用。锁定时仅显示出来的小锁按钮接收点击,画面其余部分仍穿透;锁定时移出画面后重新计时。解锁可拖动时,小锁一直显示。 -- **隐藏/显示**:暂停/恢复绘制,恢复时不补播旧事件。 -- **重新连接音频**:手动重建采集;设备切换与故障也会后台自动恢复。 -- **键盘输入(星爆 / 猫爪)**:默认关闭,需明确从菜单启用;状态显示于托盘菜单。只处理短暂物理扫描码,不读取字符、输入法文本、剪贴板,不写按键日志、不发送网络请求、不拦截快捷键。不能监听安全桌面;锁屏/休眠时暂停。 -- **退出**:释放捕获、线程、绘制会话和托盘。 +- **皮肤**:图片 / GIF 卡片、重新扫描、重新加载和打开皮肤文件夹。图片、文字和留白都属于同一个按钮,有悬浮、按下与键盘焦点反馈。 +- **外观与位置**:大小、不透明度和桌面调整。切换只换皮肤,当前位置与大小保持不变;首次升级从当前皮肤迁移,重启恢复共享记录。 +- **声音与键盘**:状态、音频重连、键盘响应和托盘动画。键盘响应默认关闭,需要主动启用;只处理短暂物理键位,不读取输入文字。 +- **程序入口**:关闭设置时最小化到任务栏,皮肤继续运行;点击任务栏、双击托盘图标或再次运行应用会恢复设置,不产生第二个实例。 +- **托盘快捷操作**:打开设置、显示 / 隐藏皮肤、调整位置与大小、退出。 +- **桌面调整**:悬停皮肤 3 秒点击小锁,拖动移动、右下角等比缩放,完成后恢复穿透。显示 / 隐藏皮肤独立于设置窗口。 -偏好仅保存在 `%LOCALAPPDATA%\Miucs\preferences.v1.json`,包括模式、颜色、键盘开关及各模式独立窗口位置/大小,不包含音频或按键内容。删除此文件可重置偏好。异常/损坏偏好自动回退。 +偏好保存在 `%LOCALAPPDATA%\Miucs\preferences.v1.json`,共享窗口位置为 `SkinFrame`,旧的 `Frames` 用于首次迁移。皮肤放在 `%LOCALAPPDATA%\Miucs\Plugins`;升级应用不会覆盖这些用户目录。预览只显示图片,不启动第二个插件。缺失运行库时设置仍可打开并显示错误。 托盘保留原猫头的颜色,并尽量铺满图标槽,不随画面透明度淡出;浅色描边适配深浅任务栏。25 个角度的 ICO 预先缓存,包含 16~64px 多种尺寸;更新最多每秒 16 次,仅在角度档位改变时通知系统,不逐帧创建图标句柄。退出释放全部图标与计时器。 @@ -51,7 +48,7 @@ Linux/macOS 可交叉编译外壳以检查 C# 代码;完整分发包须在 Win dotnet publish apps/windows/Miucs.Windows.csproj -c Release -r win-x64 --self-contained true -o dist/windows/win-x64 ``` -`.github/workflows/windows.yml` 提供 Linux 核心测试和 Windows 构建/原生离屏检查。工作流仅作为源文件交付,本轮**未推送、未触发远程 CI**。Windows CI 的离屏检查也不等于真实声卡/桌面验收。 +`.github/workflows/windows.yml` 提供 Linux 核心测试和 Windows 构建/原生离屏检查。每次 PR 会运行构建和原生测试,结果以该 PR 的检查记录为准。Windows CI 的离屏检查也不等于真实声卡/桌面验收。 ## 结构与职责 diff --git a/apps/windows/SettingsSmokeTests.cs b/apps/windows/SettingsSmokeTests.cs new file mode 100644 index 0000000..fa918bd --- /dev/null +++ b/apps/windows/SettingsSmokeTests.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Threading; +using Miucs.Audio; +using Miucs.Keyboard; +using Miucs.Plugins; + +namespace Miucs.Windows; + +internal static class SettingsSmokeTests +{ + internal static void Run(string output) + { + string temporary = Path.Combine(Path.GetTempPath(), "miucs-settings-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(temporary); + SettingsWindow? window = null; + try + { + using var plugins = new PluginHost(new AudioChannel(), new KeyboardChannel(), user: temporary, include: id => !SkinCatalog.IsRetired(id)); + Check(plugins.Views.ContainsKey("pixel-keyboard") && !plugins.Views.Keys.Any(SkinCatalog.IsRetired), "supported plugin-only catalog"); + var original = plugins.Views["pixel-keyboard"]; + var skins = Enumerable.Range(0, 12).Select(i => original with { Id = "skin-" + i, Name = "测试皮肤 " + i }).ToArray(); + string selected = skins[0].Id; + var state = new SettingsState(skins, selected, true, false, 38, 10, 200, 70, false, true, "音频已连接", "stopped", ""); + window = new SettingsWindow(new SettingsActions + { + SelectSkin = id => { selected = id; state = state with { SelectedId = id }; window!.UpdateState(state); } + }); + window.UpdateState(state); window.Show(); Pump(); window.UpdateLayout(); + Check(window.ShowInTaskbar, "settings appear in the taskbar"); + var navigation = (RadioButton)window.FindName("AppearanceNavigation"); + Check(Hit(window, navigation, new(navigation.ActualWidth - 7, navigation.ActualHeight / 2)) == navigation, "sidebar trailing whitespace hits the entire row"); + navigation.IsChecked = true; window.UpdateLayout(); + Check(((FrameworkElement)window.FindName("AppearancePage")).Visibility == Visibility.Visible, "navigation opens appearance"); + ((RadioButton)window.FindName("SkinsNavigation")).IsChecked = true; window.UpdateLayout(); + var buttons = Descendants + + + + + + + + + + + + + + + +