Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions .github/workflows/macos.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
name: macOS
on:
push:
paths: ['apps/assets/**', 'apps/macos/**', 'modules/**/*.swift', 'plugins/**', 'build.sh', 'project.yml', 'tools/macos/**', 'tests/**/*.swift', 'scripts/*.sh', '.github/workflows/macos.yml']
paths: ['apps/assets/**', 'apps/macos/**', 'modules/**/*.swift', 'plugins/**', 'build.sh', 'Package.swift', 'project.yml', 'tools/macos/**', 'tests/**/*.swift', 'scripts/*.sh', '.github/workflows/macos.yml']
pull_request:
paths: ['apps/assets/**', 'apps/macos/**', 'modules/**/*.swift', 'plugins/**', 'build.sh', 'project.yml', 'tools/macos/**', 'tests/**/*.swift', 'scripts/*.sh', '.github/workflows/macos.yml']
paths: ['apps/assets/**', 'apps/macos/**', 'modules/**/*.swift', 'plugins/**', 'build.sh', 'Package.swift', 'project.yml', 'tools/macos/**', 'tests/**/*.swift', 'scripts/*.sh', '.github/workflows/macos.yml']
workflow_dispatch:
permissions:
contents: read
Expand All @@ -17,16 +17,21 @@ jobs:
rm -f docs/validation/visualization-host-tests.json docs/validation/visualization-appearance-tests.json
zsh scripts/test-visualization-host.sh
zsh scripts/test-appearance.sh
- name: Embedded core regression tests
run: swift test
- name: Compile complete application without personal signing identity
shell: zsh {0}
run: |
root="$PWD"
source scripts/swift-sources.sh
mkdir -p build
swift build -c release --product MiucsCore
core_bin="$(swift build -c release --show-bin-path)"
swiftc -parse-as-library -swift-version 6 -O \
-framework AppKit -framework SwiftUI -framework ScreenCaptureKit \
-framework CoreMedia -framework CoreAudio -framework Accelerate \
-o build/Miucs "${app_sources[@]}"
-I "$core_bin/Modules" -L "$core_bin" -lMiucsCore \
-o build/Miucs "${linked_app_sources[@]}"
- uses: actions/upload-artifact@v4
if: always()
with:
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/plugins.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ jobs:
with:
python-version: '3.12'
- run: python -m pip install -r tools/plugins/requirements.txt
- run: python tests/plugins/dependency_download_test.py
- run: sh scripts/build-plugins.sh
- run: sh scripts/test-plugins.sh
- run: zsh scripts/test-plugin-macos.sh
Expand Down Expand Up @@ -48,6 +49,7 @@ jobs:
with:
python-version: '3.12'
- run: python -m pip install -r tools/plugins/requirements.txt
- run: python tests/plugins/dependency_download_test.py
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,7 @@ __pycache__/
# Windows/.NET build products
**/bin/
**/obj/

# Swift Package Manager
.build/
.swiftpm/
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ open dist/Miucs.app
```


只修改模块时优先进入相应 `core/` 或 `platforms/macos/`。`scripts/swift-sources.sh` 通过模块目录通配符定义编译分组;增加目录时也要更新 `project.yml`。应用组合点是 `apps/macos/PluginVisualizations.swift`,只注册插件目录中的可视化。
只修改模块时优先进入相应 `core/` 或 `platforms/macos/`。`MiucsCore` 的模块边界在 `Package.swift`,`build.sh` 和 Xcode 工程均链接该库。`scripts/swift-sources.sh` 保留源码回归测试分组;增加目录时同步维护。嵌入方式见 [核心库指南](docs/embedding.md)。应用组合点是 `apps/macos/PluginVisualizations.swift`,只注册插件目录中的可视化。

离线参考和资源工具需要 Python;应用运行不依赖 Python:

Expand All @@ -34,6 +34,7 @@ python3 -m pip install -r tests/requirements.txt
以下命令从仓库根目录执行,不改变个人位置或配色偏好。窗口与外观测试使用独立偏好域。

```sh
swift test # 核心库皮肤、输入、恢复与释放
zsh scripts/test-contracts.sh # 依赖边界、Schema、通道与会话切换
zsh scripts/test-keyboard.sh # 键盘通道、并行输入、权限/休眠/重连和原生事件解码
zsh scripts/test-beat-tracker.sh # 原始 PCM 节拍、半速/倍速、变速、静音和时序
Expand Down
21 changes: 21 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// swift-tools-version: 6.0
import PackageDescription

let package = Package(
name: "Miucs",
platforms: [.macOS(.v14)],
products: [.library(name: "MiucsCore", type: .static, targets: ["MiucsCore"])],
targets: [
.target(
name: "MiucsCore", path: ".",
exclude: ["apps", "build", "contracts", "dist", "docs", "hardware", "scripts", "tests",
"third_party", "tools", "modules/plugins", "modules/visualizations",
"modules/audio/platforms/windows", "modules/keyboard/platforms/windows",
"build.sh", "project.yml", "README.md", "CONTRIBUTING.md"],
sources: ["modules/audio/core", "modules/audio/platforms/macos",
"modules/keyboard/core", "modules/keyboard/platforms/macos", "modules/hardware/macos"],
resources: [.copy("plugins")]
),
.testTarget(name: "MiucsCoreTests", dependencies: ["MiucsCore"], path: "tests/core")
]
)
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,16 @@ macOS 和 Windows 都有独立设置窗口,分为「皮肤」「外观与位

macOS 在程序坞显示 Miucs,点击打开设置;Windows 设置显示在任务栏,关闭时最小化并继续运行,点击任务栏或再次运行应用可恢复设置。菜单栏 / 托盘仅保留打开设置、显示 / 隐藏皮肤、调整位置与大小和退出。

## USB 硬件屏幕(macOS)

「设置 → 硬件屏幕」可把当前皮肤通过 USB 发到 ESP32-P4 小屏。使用支持原生桌宠的桌屿固件时,七套内置皮肤上传 Lua 和资源包,由开发板绘制;Mac 发送按键与音频特征,支持实例恢复和连续输入。不支持原生桌宠的设备保留 JPEG 卡片适配。使用 Miucs 屏幕固件时,画面铺到整屏,支持横竖屏及反向切换。

将已烧录对应固件的开发板接到原生 USB 数据口,再开启「在硬件屏幕上显示皮肤」。使用支持 CH343 的桌屿固件时,也可以把线接到烧录口,由运行中的桌屿 Mac 应用转发。连接中断后会自动重连。音乐和按键由电脑处理,隐藏桌面皮肤后小屏可继续显示。Miucs 整屏固件的型号、烧录与回退见 [硬件适配说明](hardware/esp32-display/README.md)。

## 作为核心库使用

本仓库提供 `MiucsCore` Swift Package。桌屿等宿主可以在自己的进程中使用 Miucs 的键盘、音频、皮肤资源和原生桌宠会话,无需另开 Miucs 应用。核心库不启动桌面窗口或 Lua/Skia worker;独立应用仍保留完整桌面功能。仓库依赖、资源打包、权限和生命周期见 [嵌入指南](docs/embedding.md)。

## 插件与隐私

应用内置七个 Lua 5.4/Skia 插件,也可安装自己编写的插件。首次构建自动下载并校验运行库依赖。开发和调试见 [插件开发指南](docs/plugin-development.md) 与 [插件工作台](docs/plugin-lab.md)。
Expand Down
43 changes: 39 additions & 4 deletions apps/macos/App.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
#if canImport(MiucsCore)
import MiucsCore
#endif
import AppKit
import SwiftUI
import Combine
Expand All @@ -17,6 +20,7 @@ struct MiucsApp: App {

@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, ObservableObject {
private static let audioCaptureEnabledKey = "MiucsAudioCapture.enabled"
let appearanceStore = VisualizationAppearanceStore()
let settings = MiucsSettingsModel()
private var settingsWindow: MiucsSettingsWindow?
Expand Down Expand Up @@ -50,7 +54,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, Observ
appearanceStore.saveSelection()
configureSettings()
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
trayIcon = AppTrayIcon(item: item, source: keyboardEngine.channel)
trayIcon = AppTrayIcon(item: item, source: keyboardEngine.channel,
animationEnabled: appearanceStore.trayAnimationEnabled)
let menu = NSMenu()
menu.delegate = self
func add(_ title: String, _ selector: Selector, _ key: String) -> NSMenuItem {
Expand All @@ -69,8 +74,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, Observ
PluginHost.shared.start(audio: engine.channel, keyboard: keyboardEngine.channel)
createPanelIfNeeded()
refreshSkins()
HardwareDisplay.shared.start()
showSettings()
Task { await engine.start() }
startAudioIfEnabled()
Task {
let key = "MiucsKeyboardPermissionRequested.v1"
let request = !UserDefaults.standard.bool(forKey: key)
Expand All @@ -87,11 +93,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, Observ
self.syncPanelState()
}
settings.setOpacity = { [weak self] value in self?.appearanceStore.setOpacity(Int(value.rounded())) }
settings.setTrayAnimationEnabled = { [weak self] enabled in self?.appearanceStore.setTrayAnimationEnabled(enabled) }
settings.togglePosition = { [weak self] in self?.togglePositioning() }
settings.audioCaptureEnabled = UserDefaults.standard.object(forKey: Self.audioCaptureEnabledKey) as? Bool ?? true
settings.setAudioCaptureEnabled = { [weak self] enabled in self?.setAudioCaptureEnabled(enabled) }
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.reload = {
PluginHost.shared.reloadActive()
HardwareDisplay.shared.reloadSkin()
}
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)
Expand All @@ -100,6 +112,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, Observ
self?.panel?.visualizationHost.applyTransparency(percent)
self?.settings.opacity = Double(100 - percent)
}.store(in: &subscriptions)
appearanceStore.$trayAnimationEnabled.sink { [weak self] enabled in
self?.settings.trayAnimationEnabled = enabled
self?.trayIcon?.setAnimationEnabled(enabled)
}.store(in: &subscriptions)
}
private func createPanelIfNeeded() {
guard panel == nil, !registry.descriptors.isEmpty else { return }
Expand All @@ -115,6 +131,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, Observ
}
private func syncPanelState() {
settings.selectedID = panel?.descriptor.id ?? ""
HardwareDisplay.shared.selectSkin(settings.selectedID)
settings.isVisible = panel?.isVisible == true
settings.isAdjusting = panel?.isAdjustingPosition == true
if let panel {
Expand Down Expand Up @@ -183,7 +200,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, Observ
panel.setAdjustingPosition(!panel.isAdjustingPosition)
syncPanelState()
}
private func reconnect() { Task { await engine.reconnect() } }
private func setAudioCaptureEnabled(_ enabled: Bool) {
guard settings.audioCaptureEnabled != enabled else { return }
settings.audioCaptureEnabled = enabled
UserDefaults.standard.set(enabled, forKey: Self.audioCaptureEnabledKey)
if enabled { startAudioIfEnabled() } else { engine.stop() }
}
private func startAudioIfEnabled() {
Task {
guard settings.audioCaptureEnabled else { return }
await engine.start()
}
}
private func reconnect() {
Task {
guard settings.audioCaptureEnabled else { return }
await engine.reconnect()
}
}
private func connectKeyboard() {
Task {
await keyboardEngine.start(requestAccess: true)
Expand All @@ -195,6 +229,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, Observ
}
@objc private func quit() { NSApp.terminate(nil) }
func applicationWillTerminate(_ notification: Notification) {
HardwareDisplay.shared.stop()
trayIcon?.stop()
keyboardEngine.stop()
engine.stop()
Expand Down
42 changes: 32 additions & 10 deletions apps/macos/AppTrayIcon.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
#if canImport(MiucsCore)
import MiucsCore
#endif
import AppKit

/// Application identity and animation are owned by the shell, independently of Lua workers.
@MainActor
final class AppTrayIcon {
private let item: NSStatusItem
private let source: any KeyboardFeatureSource
private var subscription: KeyboardSubscription
private var subscription: KeyboardSubscription?
private var animationEnabled: Bool
private var head = TrayHeadAnimation()
private var timer: Timer?
private var last = ProcessInfo.processInfo.systemUptime
Expand All @@ -15,12 +19,13 @@ final class AppTrayIcon {
private var frames = [Int:NSImage]()
private let portrait: NSImage

init(item: NSStatusItem, source: any KeyboardFeatureSource) {
self.item=item;self.source=source;subscription=source.subscribe()
init(item: NSStatusItem, source: any KeyboardFeatureSource, animationEnabled: Bool = true) {
self.item = item
self.source = source
self.animationEnabled = animationEnabled
portrait=(Bundle.main.url(forResource:"PixelCatHead",withExtension:"png") ?? Bundle.main.url(forResource:"cat-head",withExtension:"png")).flatMap(NSImage.init(contentsOf:)) ?? NSImage(size:NSSize(width:64,height:64))
NSApp.applicationIconImage=portrait
item.button?.setAccessibilityLabel("Miucs")
present(0)
let center=NSWorkspace.shared.notificationCenter
for (name,reason,paused) in [(NSWorkspace.willSleepNotification,"sleep",true),(NSWorkspace.didWakeNotification,"sleep",false),(NSWorkspace.sessionDidResignActiveNotification,"session",true),(NSWorkspace.sessionDidBecomeActiveNotification,"session",false)] {
observers.append(center.addObserver(forName:name,object:nil,queue:.main) { [weak self] _ in
Expand All @@ -29,18 +34,34 @@ final class AppTrayIcon {
}
schedule()
}

func setAnimationEnabled(_ enabled: Bool) {
guard animationEnabled != enabled else { return }
animationEnabled = enabled
schedule()
}

private func suspend(_ paused: Bool, reason: String) {
if paused { reasons.insert(reason) } else { reasons.remove(reason) }
head.reset();subscription=source.subscribe();last=ProcessInfo.processInfo.systemUptime
present(0);schedule()
schedule()
}
private func schedule() {
timer?.invalidate();timer=nil
guard reasons.isEmpty else { return }
timer=Timer(timeInterval:1.0/16,repeats:true) { [weak self] _ in MainActor.assumeIsolated { self?.tick() } }
RunLoop.main.add(timer!,forMode:.common)
timer?.invalidate()
timer = nil
subscription = nil
head.reset()
present(0)
guard animationEnabled, reasons.isEmpty else { return }
subscription = source.subscribe()
last = ProcessInfo.processInfo.systemUptime
let timer = Timer(timeInterval: 1.0 / 16, repeats: true) { [weak self] _ in
MainActor.assumeIsolated { self?.tick() }
}
RunLoop.main.add(timer, forMode: .common)
self.timer = timer
}
private func tick() {
guard let subscription else { return }
let now=ProcessInfo.processInfo.systemUptime
head.advance(seconds:max(0,now-last),input:subscription.read());last=now
present(Int((head.angle * 180 / .pi).rounded()))
Expand All @@ -64,6 +85,7 @@ final class AppTrayIcon {
}
func stop() {
timer?.invalidate();timer=nil
subscription = nil
for observer in observers { NSWorkspace.shared.notificationCenter.removeObserver(observer) };observers=[]
}
}
Loading
Loading