From c3ab5cbfc9a7125dc892cf3437256f33dbdd815d Mon Sep 17 00:00:00 2001 From: PathGao Date: Wed, 16 Sep 2026 01:17:16 +0800 Subject: [PATCH 1/2] feat(environment): organize tools and configuration with source-aware updates Group tools by installation source, collapse diagnostics, and reveal configuration file locations. Reuse Homebrew upgrades and restrict update hints to eligible user installations. Validation: 12576 core checks, 14 editor lifecycle checks, 393 upstream checks, developer build and application self-test. --- .github/CONTRIBUTING.md | 2 + README.md | 4 +- README.zh-Hans.md | 6 +- .../Core/EnvironmentUpdateStrings.swift | 89 +++++ .../EnvironmentConfiguration.swift | 32 ++ .../Environment/EnvironmentInspector.swift | 42 ++- .../EnvironmentUpdateChecker.swift | 141 ++++++++ .../EnvironmentUpdateSupport.swift | 144 ++++++++ .../Services/Homebrew/HomebrewManager.swift | 37 ++- .../Services/Homebrew/HomebrewSupport.swift | 22 +- .../UI/Settings/EnvironmentSettings.swift | 311 ++++++++++++++---- Tests/EnvironmentConfigurationTests.swift | 22 ++ Tests/EnvironmentUpdateTests.swift | 126 +++++++ Tests/MetricsTests.swift | 2 + build.sh | 5 + 15 files changed, 909 insertions(+), 76 deletions(-) create mode 100644 Sources/Vorssaint/Core/EnvironmentUpdateStrings.swift create mode 100644 Sources/Vorssaint/Services/Environment/EnvironmentConfiguration.swift create mode 100644 Sources/Vorssaint/Services/Environment/EnvironmentUpdateChecker.swift create mode 100644 Sources/Vorssaint/Services/Environment/EnvironmentUpdateSupport.swift create mode 100644 Tests/EnvironmentConfigurationTests.swift create mode 100644 Tests/EnvironmentUpdateTests.swift diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 3e64632..08b3b91 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -232,6 +232,8 @@ kururu opens only a few kinds of connection, and each one belongs to a visible f 6. **Command bar destinations and scripts you configure.** Opening a saved website or web search hands its expanded URL to macOS. The destination can include the query, clipboard text or selected text when you put those placeholders in its configuration; the receiving website can receive those values. Saved scripts execute the chosen local file with the matched query argument and can make their own network requests. Scripts produce live results while you type a matching query after a brief pause, so execution does not always wait for Return. Local search and ranking do not make these external destinations private or prevent a configured script from communicating. +7. **Environment update checks, only when you ask.** Local diagnostics always identify recognized installation sources, including macOS and Apple developer tools, without a release request. “Check for updates” requests stable release metadata from `api.github.com` for Bun and uv found at recognized standalone locations. Requests contain the repository name, not local paths or installed versions. Recognized Homebrew tools use local `brew info` and `brew outdated` results. Homebrew metadata may need a separate refresh in Packages. Upgrade results are shown only for supported user installations; bundled tools, version-manager installations, wrappers, unconfirmed sources, pinned versions and Homebrew dependencies receive no upgrade prompts. This page can invoke the existing Homebrew upgrade flow for eligible packages after confirmation. Standalone tools link to upgrade instructions. It does not change version pins or inspect project compatibility. The configuration tab checks known user and system file locations and, when selected, a project directory. It lists paths without reading file contents or claiming that the files are active. + ### Independent storage Release and development builds use separate application identities. Existing upstream preferences, clipboard history, notes and shelf files are not read automatically. Explicit settings-file import accepts portable preferences and excludes machine restoration records and login registration. macOS permissions are granted separately to the new identity. diff --git a/README.md b/README.md index e36ddb3..71bcf29 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Adjust volume per app, switch audio outputs and mute microphones. Access display ### Apps and maintenance -Find cleanup candidates, inspect app leftovers with the uninstaller, and review installed Homebrew packages. Upgrade packages you explicitly installed, and inspect local development tools in the environment page. Homebrew may also upgrade dependencies as part of a requested package upgrade. +Find cleanup candidates, inspect app leftovers with the uninstaller, and review installed Homebrew packages. Upgrade packages you explicitly installed, and inspect local development tools in the environment page. It distinguishes bundled tools from recognized user installations. Optional update hints cover standalone Bun and uv and explicitly installed Homebrew tools, excluding pinned versions and dependencies. The environment page uses compact source groups and expandable paths, and lists discovered configuration files with their full locations. Eligible Homebrew tools use the existing package upgrade flow directly; standalone tools link to upgrade instructions. Homebrew may also upgrade dependencies as part of a requested package upgrade. ### Your choice of entry point @@ -77,7 +77,7 @@ Feature availability depends on your selections, macOS permissions and hardware. Preferences, clipboard history, notes and shelf data stay in kururu's own local storage. Screen text recognition uses Apple's on-device Vision framework. There is no account, subscription or automatic telemetry. -Network access belongs to specific actions: an internet speed test, Homebrew operations, or websites and scripts you configure in the command bar. Feedback is prepared locally for you to review and copy; opening the issue page does not submit it. +Network access belongs to specific actions: an internet speed test, Homebrew operations, checking tool releases on GitHub, or websites and scripts you configure in the command bar. Feedback is prepared locally for you to review and copy; opening the issue page does not submit it. Grant macOS permissions for the features you use and review them in **System Settings → Privacy & Security**. See the [data and privacy documentation](.github/CONTRIBUTING.md#privacy) for storage details and the scope of network access. diff --git a/README.zh-Hans.md b/README.zh-Hans.md index 37635ac..1ab0c15 100644 --- a/README.zh-Hans.md +++ b/README.zh-Hans.md @@ -65,7 +65,9 @@ kururu 分叉自 [Vorssaint](https://github.com/vorssaint/vorssaint-utils),目 ### 应用与维护 -查找可清理的项目,用卸载器检查应用残留,并查看已安装的 Homebrew 软件包。升级自己主动安装的软件包,在环境页检查本地开发工具。升级指定软件包时,Homebrew 也可能连带升级其依赖。 +查找可清理的项目,用卸载器检查应用残留,并查看已安装的 Homebrew 软件包。升级自己主动安装的软件包,在环境页区分系统附带工具与已识别的后装工具。手动检查更新只提示独立安装的 Bun、uv 和主动安装的 Homebrew 工具,跳过固定版本和依赖包。升级指定软件包时,Homebrew 也可能连带升级其依赖。 + +本地环境页支持在工具列表与配置文件列表之间切换。工具按安装来源分组,路径详情按需展开;配置文件显示完整位置,可在 Finder 中定位。Homebrew 更新可直接沿用软件包页的升级流程,独立安装的 Bun、uv 提供明确标注的升级说明入口。 ### 选择适合你的入口 @@ -77,7 +79,7 @@ kururu 分叉自 [Vorssaint](https://github.com/vorssaint/vorssaint-utils),目 偏好设置、剪贴板历史、便条和暂存架数据保存在 kururu 自己的本地存储中。屏幕文字识别使用 Apple 的设备端 Vision 框架。无需账号,没有订阅,也不自动收集遥测数据。 -网络访问对应具体操作:网络测速、Homebrew 操作,或你在命令栏中配置的网站和脚本。反馈内容先在本地生成,供你检查和复制;打开 issue 页面不会提交反馈。 +网络访问对应具体操作:网络测速、Homebrew 操作、查询 GitHub 上的工具版本,或你在命令栏中配置的网站和脚本。反馈内容先在本地生成,供你检查和复制;打开 issue 页面不会提交反馈。 按所用功能授予 macOS 权限,并在**系统设置 → 隐私与安全性**中查看和调整。存储细节与网络访问范围见开发文档中的[数据与隐私说明](.github/CONTRIBUTING.md#privacy)。 diff --git a/Sources/Vorssaint/Core/EnvironmentUpdateStrings.swift b/Sources/Vorssaint/Core/EnvironmentUpdateStrings.swift new file mode 100644 index 0000000..7415344 --- /dev/null +++ b/Sources/Vorssaint/Core/EnvironmentUpdateStrings.swift @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +struct EnvironmentUpdateStrings { + let language: AppLanguage + + private func text(_ en: String, _ zh: String, _ de: String, _ fr: String, _ es: String, _ ja: String) -> String { + switch language { + case .zhHans: return zh + case .de: return de + case .fr: return fr + case .es: return es + case .ja: return ja + default: return en + } + } + + var check: String { + text("Check for updates", "检查更新", "Nach Updates suchen", "Rechercher des mises à jour", "Buscar actualizaciones", "更新を確認") + } + var checking: String { + text("Checking…", "正在检查…", "Wird geprüft…", "Vérification…", "Comprobando…", "確認中…") + } + var note: String { + text("Update checks cover standalone Bun and uv on GitHub and explicitly installed Homebrew tools. Bundled tools, dependencies and pinned versions receive no upgrade prompts. Project compatibility is not checked.", + "只检查独立安装的 Bun、uv 和主动安装的 Homebrew 工具。系统附带、依赖包和固定版本不提醒升级。不检查项目兼容性。", + "Prüft eigenständig installiertes Bun und uv auf GitHub sowie ausdrücklich installierte Homebrew-Tools. Mitgelieferte Tools, Abhängigkeiten und fixierte Versionen erhalten keine Updatehinweise. Projektkompatibilität wird nicht geprüft.", + "Vérifie Bun et uv autonomes sur GitHub et les outils Homebrew installés explicitement. Aucun rappel pour les outils fournis, les dépendances et les versions épinglées. La compatibilité des projets n’est pas vérifiée.", + "Comprueba Bun y uv independientes en GitHub y herramientas Homebrew instaladas explícitamente. No avisa para herramientas incluidas, dependencias ni versiones fijadas. No comprueba la compatibilidad de los proyectos.", + "単独でインストールした Bun・uv の GitHub リリースと、明示的にインストールした Homebrew ツールを確認します。付属ツール・依存関係・固定バージョンの更新は案内しません。プロジェクトの互換性は確認しません。") + } + var instructions: String { + text("Upgrade instructions", "升级说明", "Update-Anleitung", "Instructions de mise à jour", "Instrucciones de actualización", "更新手順") + } + var packages: String { + text("View in Packages", "在软件包页查看", "In Pakete ansehen", "Voir dans Paquets", "Ver en Paquetes", "パッケージで表示") + } + var checked: String { + text("Checked", "检查时间", "Geprüft", "Vérifié", "Comprobado", "確認日時") + } + var pinned: String { + text("Version pinned in Homebrew. Review the pin in Homebrew before upgrading.", "Homebrew 已固定版本。升级前请在 Homebrew 中检查固定设置。", + "Version in Homebrew fixiert. Vor dem Update die Fixierung in Homebrew prüfen.", "Version épinglée dans Homebrew. Vérifiez cet épinglage avant toute mise à jour.", + "Versión fijada en Homebrew. Revise esta configuración antes de actualizar.", "Homebrew でバージョンが固定されています。更新前に固定設定を確認してください。") + } + var dependency: String { + text("Installed as a dependency. Update through the package that requires it.", "作为依赖安装,随需要它的软件包更新。", + "Als Abhängigkeit installiert. Über das benötigende Paket aktualisieren.", "Installé comme dépendance. À mettre à jour via le paquet qui en dépend.", + "Instalado como dependencia. Actualícelo a través del paquete que lo necesita.", "依存関係としてインストールされています。必要とするパッケージから更新してください。") + } + var brewNote: String { + text("Based on local Homebrew metadata. Refresh Homebrew metadata in Packages if needed.", "结果基于本地 Homebrew 元数据,需要时请到软件包页更新元数据。", + "Basiert auf lokalen Homebrew-Metadaten. Bei Bedarf unter Pakete aktualisieren.", "Selon les métadonnées Homebrew locales. Actualisez-les dans Paquets si nécessaire.", + "Basado en los metadatos locales de Homebrew. Actualícelos en Paquetes si es necesario.", "ローカルの Homebrew メタデータに基づく結果です。必要に応じてパッケージ画面で更新してください。") + } + + func source(_ source: EnvironmentUpdateSource) -> String { + switch source { + case .standalone: + return text("User-installed · standalone", "后装 · 独立安装", "Nachinstalliert · eigenständig", "Ajouté · installation autonome", "Añadido · instalación independiente", "追加インストール · 単独") + case let .homebrew(prefix, formula, _): + return text("User-installed", "后装", "Nachinstalliert", "Ajouté", "Añadido", "追加インストール") + " · Homebrew · \(formula) · \(prefix)" + case let .managed(manager): + return text("User-installed · managed by \(manager)", "后装 · 由 \(manager) 管理", "Nachinstalliert · von \(manager) verwaltet", "Ajouté · géré par \(manager)", "Añadido · gestionado por \(manager)", "追加インストール · \(manager) で管理") + case .system: + return text("macOS built-in", "macOS 自带", "In macOS enthalten", "Fourni avec macOS", "Incluido en macOS", "macOS 標準搭載") + case .appleDeveloperTools: + return text("Included with Apple developer tools", "Apple 开发工具附带", "Mit Apple-Entwicklerwerkzeugen geliefert", "Fourni avec les outils de développement Apple", "Incluido en las herramientas de desarrollo de Apple", "Apple 開発ツールに付属") + case .forwarded: + return text("Wrapper or alias · see target", "命令转发或别名 · 见转发目标", "Wrapper oder Alias · siehe Ziel", "Relais ou alias · voir la cible", "Intermediario o alias · consulte el destino", "ラッパーまたは別名 · 転送先を参照") + case .unknown: + return text("Installation source not confirmed", "安装来源未确认", "Installationsquelle nicht bestätigt", "Source d’installation non confirmée", "Origen de instalación sin confirmar", "インストール元を確認できません") + } + } + + func status(_ status: EnvironmentUpdateStatus) -> String { + switch status { + case let .available(version): + return text("Update available: \(version)", "有更新:\(version)", "Update verfügbar: \(version)", "Mise à jour disponible : \(version)", "Actualización disponible: \(version)", "更新あり:\(version)") + case let .current(version): + return text("No update found · latest stable: \(version)", "未发现更新 · 查询到的稳定版:\(version)", "Kein Update gefunden · stabile Version: \(version)", "Aucune mise à jour trouvée · version stable : \(version)", "Sin actualizaciones · versión estable: \(version)", "更新なし · 安定版:\(version)") + case let .ahead(version): + return text("Installed version is newer than stable \(version)", "当前版本高于查询到的稳定版 \(version)", "Installierte Version ist neuer als die stabile Version \(version)", "Version installée plus récente que la version stable \(version)", "La versión instalada es más reciente que la estable \(version)", "インストール済みのバージョンは安定版 \(version) より新しいです") + case .unsupported: + return text("Automatic update check unavailable for this installation or version", "暂不支持检查此安装方式或版本", "Automatische Updateprüfung für diese Installation oder Version nicht verfügbar", "Vérification automatique indisponible pour cette installation ou version", "Comprobación automática no disponible para esta instalación o versión", "このインストール方法またはバージョンの自動確認には未対応です") + case .failed: + return text("Check failed. Check the network or Homebrew status and retry.", "检查失败,请检查网络或 Homebrew 状态后重试。", "Prüfung fehlgeschlagen. Netzwerk oder Homebrew-Status prüfen und erneut versuchen.", "Échec de la vérification. Vérifiez le réseau ou l’état de Homebrew, puis réessayez.", "Error al comprobar. Revise la red o el estado de Homebrew e inténtelo de nuevo.", "確認に失敗しました。ネットワークまたは Homebrew の状態を確認して再試行してください。") + } + } +} diff --git a/Sources/Vorssaint/Services/Environment/EnvironmentConfiguration.swift b/Sources/Vorssaint/Services/Environment/EnvironmentConfiguration.swift new file mode 100644 index 0000000..ce726ef --- /dev/null +++ b/Sources/Vorssaint/Services/Environment/EnvironmentConfiguration.swift @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import Foundation + +struct EnvironmentConfiguration: Identifiable { + let path: String + let scope: String + var id: String { path } + var name: String { URL(fileURLWithPath: path).lastPathComponent } + + static func discover(home: String = NSHomeDirectory(), + environment: [String: String] = ProcessInfo.processInfo.environment, + systemRoot: String = "/etc") -> [Self] { + let xdg = environment["XDG_CONFIG_HOME"].flatMap { $0.hasPrefix("/") ? $0 : nil } ?? home + "/.config" + let zsh = environment["ZDOTDIR"].flatMap { $0.hasPrefix("/") ? $0 : nil } ?? home + let user = [".zshenv", ".zprofile", ".zshrc", ".zlogin"].map { zsh + "/" + $0 } + + [".bash_profile", ".bashrc", ".profile", ".npmrc", ".gitconfig", ".tool-versions", ".condarc", ".bunfig.toml"].map { home + "/" + $0 } + + ["fish/config.fish", "mise/config.toml", "uv/uv.toml", "pip/pip.conf"].map { xdg + "/" + $0 } + return existing(user, scope: "user") + existing( + ["zshenv", "zprofile", "zshrc", "profile", "paths"].map { systemRoot + "/" + $0 }, scope: "system") + } + + static func discoverProject(_ directory: String) -> [Self] { + existing([".python-version", ".node-version", ".nvmrc", ".tool-versions", "mise.toml", ".mise.toml", "pyproject.toml", "uv.toml", "package.json", ".npmrc", "bunfig.toml", ".envrc"] + .map { directory + "/" + $0 }, scope: "project") + } + + private static func existing(_ paths: [String], scope: String) -> [Self] { + paths.filter { path in + (try? URL(fileURLWithPath: path).resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true + }.map { Self(path: $0, scope: scope) } + } +} diff --git a/Sources/Vorssaint/Services/Environment/EnvironmentInspector.swift b/Sources/Vorssaint/Services/Environment/EnvironmentInspector.swift index c72a2f3..08c880d 100644 --- a/Sources/Vorssaint/Services/Environment/EnvironmentInspector.swift +++ b/Sources/Vorssaint/Services/Environment/EnvironmentInspector.swift @@ -13,6 +13,7 @@ struct EnvironmentTool: Identifiable, Hashable { let version: String? let shimTarget: String? let shadowedPaths: [String] + var source: EnvironmentUpdateSource = .unknown var id: String { command } var isShim: Bool { shimTarget != nil } @@ -69,7 +70,7 @@ final class EnvironmentInspector: ObservableObject { private init() {} - func refresh() { + func refresh(completion: ((EnvironmentReport) -> Void)? = nil) { guard !isLoading else { return } isLoading = true workQueue.async { [weak self] in @@ -77,6 +78,7 @@ final class EnvironmentInspector: ObservableObject { DispatchQueue.main.async { self?.report = report self?.isLoading = false + completion?(report) } } } @@ -162,11 +164,31 @@ final class EnvironmentInspector: ObservableObject { return EnvironmentTool(command: command, path: nil, version: nil, shimTarget: nil, shadowedPaths: []) } + let shim = shimTarget(of: first, command: command) return EnvironmentTool(command: command, path: first, version: version(of: first), - shimTarget: shimTarget(of: first, command: command), - shadowedPaths: Array(winner.dropFirst())) + shimTarget: shim, + shadowedPaths: Array(winner.dropFirst()), + source: installationSource(of: first, command: command, isWrapper: shim != nil)) + } + + static func installationSource(of path: String, command: String, isWrapper: Bool) -> EnvironmentUpdateSource { + let resolved = URL(fileURLWithPath: path).resolvingSymlinksInPath().path + for location in [path, resolved] { + let root = URL(fileURLWithPath: location).deletingLastPathComponent().deletingLastPathComponent() + if FileManager.default.fileExists(atPath: root.appendingPathComponent("conda-meta").path) { + return .managed("Conda") + } + if FileManager.default.fileExists(atPath: root.appendingPathComponent("pyvenv.cfg").path) { + return .managed("venv") + } + } + let handle = FileHandle(forReadingAtPath: path) + let head = try? handle?.read(upToCount: 2) + try? handle?.close() + return EnvironmentUpdateSupport.source(command: command, path: path, resolvedPath: resolved, + home: NSHomeDirectory(), isWrapper: isWrapper || head == Data("#!".utf8)) } static func version(of path: String) -> String? { @@ -252,8 +274,10 @@ final class EnvironmentInspector: ObservableObject { /// Plain text for a bug report: everything the page shows, in the order it /// shows it, so a person can paste it instead of answering ten questions. - static func diagnosticText(_ report: EnvironmentReport) -> String { + static func diagnosticText(_ report: EnvironmentReport, + updates: [String: EnvironmentUpdateRecord] = [:]) -> String { var lines: [String] = [] + let updateText = EnvironmentUpdateStrings(language: .enUS) lines.append("macOS \(ProcessInfo.processInfo.operatingSystemVersionString)") lines.append("shell: \(loginShell() ?? "unknown")") lines.append("") @@ -267,6 +291,16 @@ final class EnvironmentInspector: ObservableObject { if let version = tool.version { line += " [\(version)]" } if let shim = tool.shimTarget { line += " -> \(shim)" } lines.append(line) + lines.append(" source: \(updateText.source(tool.source))") + if let record = updates[tool.command], record.isVisible { + if let result = record.result { + lines.append(" update: \(updateText.status(result.status))") + if case .homebrew = record.source { lines.append(" \(updateText.brewNote)") } + if result.isPinned { lines.append(" \(updateText.pinned)") } + if result.isDependency { lines.append(" \(updateText.dependency)") } + } + if let date = record.checkedAt { lines.append(" checked: \(date.ISO8601Format())") } + } for shadowed in tool.shadowedPaths { lines.append(" shadowed: \(shadowed)") } diff --git a/Sources/Vorssaint/Services/Environment/EnvironmentUpdateChecker.swift b/Sources/Vorssaint/Services/Environment/EnvironmentUpdateChecker.swift new file mode 100644 index 0000000..a6796be --- /dev/null +++ b/Sources/Vorssaint/Services/Environment/EnvironmentUpdateChecker.swift @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import Combine +import Foundation + +@MainActor +final class EnvironmentUpdateChecker: ObservableObject { + static let shared = EnvironmentUpdateChecker() + + @Published private(set) var records: [String: EnvironmentUpdateRecord] = [:] + @Published private(set) var isChecking = false + private var task: Task? + private var generation = 0 + private var operationObservation: AnyCancellable? + private var observedOperation = false + + private init() { + let manager = HomebrewManager.shared + operationObservation = Publishers.CombineLatest4( + manager.$operation.map { $0 != nil }.removeDuplicates(), + manager.$isLoadingInstalled.removeDuplicates(), + manager.$isCheckingEnvironment.removeDuplicates(), + EnvironmentInspector.shared.$isLoading.removeDuplicates()) + .combineLatest($isChecking.removeDuplicates()) + .sink { [weak self] state, _ in + guard let self else { return } + if state.0 { self.observedOperation = true } + // Published values arrive before assignment. Check the settled state, + // including the catalog refresh that follows a completed operation. + DispatchQueue.main.async { [weak self] in + guard let self, self.observedOperation, + !HomebrewManager.shared.isBusy, + !EnvironmentInspector.shared.isLoading, !self.isChecking else { return } + self.observedOperation = false + if !self.records.isEmpty { self.refreshAndCheck() } + } + } + } + + func refreshAndCheck() { + guard !isChecking, !EnvironmentInspector.shared.isLoading else { return } + reset() + isChecking = true + let generation = generation + EnvironmentInspector.shared.refresh { [weak self] report in + guard let self, self.generation == generation else { return } + self.isChecking = false + self.check(report.tools) + } + } + + func reset() { + generation += 1 + task?.cancel() + task = nil + records = [:] + isChecking = false + } + + func check(_ tools: [EnvironmentTool]) { + guard !isChecking else { return } + reset() + isChecking = true + let generation = generation + task = Task { + let sources = tools.filter { $0.path != nil && $0.source.supportsUpdateCheck } + .map { ($0.command, $0.source) } + guard !Task.isCancelled, generation == self.generation else { return } + records = Dictionary(uniqueKeysWithValues: sources.map { + ($0.0, EnvironmentUpdateRecord(source: $0.1)) + }) + var brewSnapshots: [String: EnvironmentHomebrewSnapshot] = [:] + var checkedPrefixes = Set() + for (command, source) in sources { + guard !Task.isCancelled, generation == self.generation else { return } + let result: EnvironmentUpdateResult + switch source { + case let .standalone(tool): + do { + let version = try await Self.latestVersion(tool) + let current = tools.first { $0.command == command }?.version ?? "" + result = EnvironmentUpdateResult(status: EnvironmentUpdateSupport.comparison(current: current, latest: version)) + } catch { + result = EnvironmentUpdateResult(status: .failed) + } + case let .homebrew(prefix, _, _): + if checkedPrefixes.insert(prefix).inserted { + brewSnapshots[prefix] = await withCheckedContinuation { continuation in + let brewPath = URL(fileURLWithPath: prefix).appendingPathComponent("bin/brew").path + HomebrewManager.shared.checkEnvironmentUpdates(brewPath: brewPath) { + continuation.resume(returning: $0) + } + } + } + if let snapshot = brewSnapshots[prefix] { + result = EnvironmentUpdateSupport.homebrewResult(source: source, + packages: snapshot.packages, updates: snapshot.updates) + } else { + result = EnvironmentUpdateResult(status: .failed) + } + default: + result = EnvironmentUpdateResult(status: .unsupported) + } + guard !Task.isCancelled, generation == self.generation else { return } + records[command]?.result = result + records[command]?.checkedAt = Date() + } + isChecking = false + task = nil + } + } + + private static func latestVersion(_ tool: String) async throws -> String { + let repository = tool == "bun" ? "oven-sh/bun" : "astral-sh/uv" + let url = URL(string: "https://api.github.com/repos/\(repository)/releases/latest")! + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 10 + configuration.timeoutIntervalForResource = 15 + configuration.httpShouldSetCookies = false + configuration.urlCache = nil + let session = URLSession(configuration: configuration) + defer { session.invalidateAndCancel() } + var request = URLRequest(url: url) + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + request.setValue("kururu-environment", forHTTPHeaderField: "User-Agent") + let (bytes, response) = try await session.bytes(for: request) + let limit = 1_024 * 1_024 + guard let response = response as? HTTPURLResponse, response.statusCode == 200, + response.url?.scheme == "https", response.url?.host == "api.github.com", + response.expectedContentLength <= limit else { throw URLError(.badServerResponse) } + var data = Data() + for try await byte in bytes { + try Task.checkCancellation() + guard data.count < limit else { throw URLError(.dataLengthExceedsMaximum) } + data.append(byte) + } + guard let version = EnvironmentUpdateSupport.releaseVersion(data, tool: tool) else { + throw URLError(.cannotParseResponse) + } + return version + } +} diff --git a/Sources/Vorssaint/Services/Environment/EnvironmentUpdateSupport.swift b/Sources/Vorssaint/Services/Environment/EnvironmentUpdateSupport.swift new file mode 100644 index 0000000..9d15145 --- /dev/null +++ b/Sources/Vorssaint/Services/Environment/EnvironmentUpdateSupport.swift @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import Foundation + +enum EnvironmentUpdateSource: Hashable { + case standalone(String) + case homebrew(prefix: String, formula: String, version: String) + case managed(String) + case system, appleDeveloperTools, forwarded, unknown + + var supportsUpdateCheck: Bool { + switch self { + case .standalone, .homebrew: return true + default: return false + } + } +} + +enum EnvironmentUpdateStatus: Equatable { + case available(String) + case current(String) + case ahead(String) + case unsupported, failed +} + +struct EnvironmentUpdateResult { + var status: EnvironmentUpdateStatus + var isPinned = false + var isDependency = false + var package: HomebrewPackage? +} + +struct EnvironmentHomebrewSnapshot { + let packages: [HomebrewPackage] + let updates: [String: HomebrewPackageUpdate] +} + +struct EnvironmentUpdateRecord { + let source: EnvironmentUpdateSource + var result: EnvironmentUpdateResult? + var checkedAt: Date? + + var isVisible: Bool { + source.supportsUpdateCheck && result?.status != .unsupported + && result?.isPinned != true && result?.isDependency != true + } +} + +enum EnvironmentUpdateSupport { + static func source(command: String, path: String, resolvedPath: String, + home: String, isWrapper: Bool) -> EnvironmentUpdateSource { + let paths = [path, resolvedPath] + // This macOS entry point dispatches to the selected Apple developer toolchain. + if paths.contains("/usr/bin/python3") { return .appleDeveloperTools } + if paths.contains(where: { $0.hasPrefix("/usr/bin/") || $0.hasPrefix("/System/") }) { + return .system + } + if paths.contains(where: { + $0.hasPrefix("/Library/Developer/CommandLineTools/") + || $0.hasPrefix("/Applications/Xcode.app/Contents/Developer/") + }) { + return .appleDeveloperTools + } + let managers = [ + ("mise", [home + "/.local/share/mise", home + "/.config/mise"]), + ("asdf", [home + "/.asdf"]), ("nvm", [home + "/.nvm"]), + ("fnm", [home + "/.local/share/fnm", home + "/Library/Application Support/fnm"]), + ("Volta", [home + "/.volta"]), ("pyenv", [home + "/.pyenv"]), + ("Conda", [home + "/miniforge3", home + "/miniconda3", home + "/anaconda3", + "/opt/anaconda3", "/opt/miniconda3", "/opt/miniforge3"]) + ] + for (manager, roots) in managers { + if roots.contains(where: { path.hasPrefix($0 + "/") || resolvedPath.hasPrefix($0 + "/") }) { + return .managed(manager) + } + } + for prefix in ["/opt/homebrew", "/usr/local"] { + let cellar = prefix + "/Cellar/" + guard resolvedPath.hasPrefix(cellar) else { continue } + let parts = resolvedPath.dropFirst(cellar.count).split(separator: "/") + if parts.count >= 3, HomebrewCommandBuilder.isValidToken(String(parts[0])) { + return .homebrew(prefix: prefix, formula: String(parts[0]), version: String(parts[1])) + } + } + if isWrapper || (resolvedPath as NSString).lastPathComponent != command { return .forwarded } + if command == "bun", resolvedPath == home + "/.bun/bin/bun" { return .standalone("bun") } + if command == "uv", resolvedPath == home + "/.local/bin/uv" { return .standalone("uv") } + return .unknown + } + + // Validate at this boundary: the app updater's legacy parser accepts partial versions. + static func stableVersion(_ raw: String) -> UpdateServiceSupport.SemanticVersion? { + guard raw.range(of: #"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\+[0-9A-Za-z.-]+)?$"#, + options: .regularExpression) != nil else { return nil } + let numbers = raw.split(separator: "+")[0].split(separator: ".").compactMap { Int($0) } + guard numbers.count == 3 else { return nil } + return UpdateServiceSupport.SemanticVersion(major: numbers[0], minor: numbers[1], patch: numbers[2]) + } + + static func comparison(current: String, latest: String) -> EnvironmentUpdateStatus { + let raw = current.hasPrefix("uv ") + ? String(current.dropFirst(3).split(separator: " ").first ?? "") : current + guard let installed = stableVersion(raw) else { return .unsupported } + guard let available = stableVersion(latest) else { return .failed } + if available > installed { return .available(latest) } + return available == installed ? .current(latest) : .ahead(latest) + } + + static func releaseVersion(_ data: Data, tool: String) -> String? { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + json["draft"] as? Bool == false, json["prerelease"] as? Bool == false, + let tag = json["tag_name"] as? String else { return nil } + let prefix = tool == "bun" ? "bun-v" : "" + guard tag.hasPrefix(prefix) else { return nil } + let version = String(tag.dropFirst(prefix.count)) + return stableVersion(version) == nil ? nil : version + } + + static func homebrewResult(source: EnvironmentUpdateSource, packages: [HomebrewPackage], + updates: [String: HomebrewPackageUpdate]) -> EnvironmentUpdateResult { + guard case let .homebrew(_, formula, version) = source else { + return EnvironmentUpdateResult(status: .unsupported) + } + let matches = packages.filter { + $0.kind == .formula && ($0.name as NSString).lastPathComponent == formula + && ($0.installedVersion?.components(separatedBy: ", ").contains(version) == true) + } + guard matches.count == 1, var package = matches.first else { + return EnvironmentUpdateResult(status: .unsupported) + } + let update = updates[package.id] ?? updates["formula:" + formula] + let status: EnvironmentUpdateStatus + if let update { + status = .available(update.currentVersion) + } else if let stable = package.stableVersion, !stable.isEmpty { + status = .current(stable) + } else { + status = .unsupported + } + package.update = update + return EnvironmentUpdateResult(status: status, + isPinned: update?.isPinned ?? false, + isDependency: !package.installedOnRequest, package: package) + } +} diff --git a/Sources/Vorssaint/Services/Homebrew/HomebrewManager.swift b/Sources/Vorssaint/Services/Homebrew/HomebrewManager.swift index 91e1f81..e3d6075 100644 --- a/Sources/Vorssaint/Services/Homebrew/HomebrewManager.swift +++ b/Sources/Vorssaint/Services/Homebrew/HomebrewManager.swift @@ -16,6 +16,7 @@ final class HomebrewManager: ObservableObject { @Published private(set) var masApps: [HomebrewPackage] = [] @Published private(set) var isLoadingInstalled = false @Published private(set) var isLoadingOutdated = false + @Published private(set) var isCheckingEnvironment = false @Published private(set) var operation: HomebrewOperation? @Published private(set) var operationStatus: HomebrewOperationStatus? @Published private(set) var log = "" @@ -38,7 +39,7 @@ final class HomebrewManager: ObservableObject { private var completedOperationCleanup: DispatchWorkItem? var isBusy: Bool { - isLoadingInstalled || operation != nil + isLoadingInstalled || isCheckingEnvironment || operation != nil } /// Formulae the person asked for by name, plus every cask. What is left @@ -131,6 +132,38 @@ final class HomebrewManager: ObservableObject { perform(.uninstall, package: package) } + /// Shares the brew execution queue with package operations; failed reads never mean "current". + func checkEnvironmentUpdates(brewPath: String, + completion: @escaping (EnvironmentHomebrewSnapshot?) -> Void) { + guard HomebrewCommandBuilder.candidatePaths.contains(brewPath), !isBusy, + FileManager.default.isExecutableFile(atPath: brewPath) else { + completion(nil) + return + } + isCheckingEnvironment = true + let finish: (EnvironmentHomebrewSnapshot?) -> Void = { [weak self] snapshot in + DispatchQueue.main.async { + self?.isCheckingEnvironment = false + completion(snapshot) + } + } + run(HomebrewCommandBuilder.installed(brewPath: brewPath)) { [weak self] status, output in + guard let self, status == 0, + let packages = try? HomebrewParser.parseInfoCommandOutput(output) else { + finish(nil) + return + } + self.run(HomebrewCommandBuilder.outdated(brewPath: brewPath)) { status, output in + guard status == 0, + let updates = try? HomebrewParser.parseOutdatedCommandOutput(output) else { + finish(nil) + return + } + finish(EnvironmentHomebrewSnapshot(packages: packages, updates: updates)) + } + } + } + /// Looks up whether Homebrew owns this exact app bundle. The cached /// installed catalog is reused when fresh; otherwise one read-only command /// answers every concurrent request for the same path. @@ -226,7 +259,7 @@ final class HomebrewManager: ObservableObject { private func perform(_ action: HomebrewOperation.Action, package: HomebrewPackage?, command commandOverride: HomebrewCommand? = nil) { - guard operation == nil else { return } + guard operation == nil, !isCheckingEnvironment else { return } guard let brewPath = brewPath ?? detectBrewPath() else { return } guard let command = commandOverride ?? standardCommand(for: action, package: package, brewPath: brewPath) else { return } diff --git a/Sources/Vorssaint/Services/Homebrew/HomebrewSupport.swift b/Sources/Vorssaint/Services/Homebrew/HomebrewSupport.swift index 2f080b1..5a81833 100644 --- a/Sources/Vorssaint/Services/Homebrew/HomebrewSupport.swift +++ b/Sources/Vorssaint/Services/Homebrew/HomebrewSupport.swift @@ -552,14 +552,22 @@ enum HomebrewParser { } static func parseOutdatedJSON(_ data: Data) throws -> [String: HomebrewPackageUpdate] { - guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return [:] + guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any], + root["formulae"] != nil || root["casks"] != nil else { + throw CocoaError(.propertyListReadCorrupt) + } + var updates: [String: HomebrewPackageUpdate] = [:] + for (key, kind) in [("formulae", HomebrewPackageKind.formula), ("casks", .cask)] { + guard let raw = root[key] else { continue } + guard let items = raw as? [[String: Any]] else { throw CocoaError(.propertyListReadCorrupt) } + for item in items { + guard let update = parseOutdatedItem(item, kind: kind), updates[update.id] == nil else { + throw CocoaError(.propertyListReadCorrupt) + } + updates[update.id] = update + } } - let formulae = (root["formulae"] as? [[String: Any]] ?? []) - .compactMap { parseOutdatedItem($0, kind: .formula) } - let casks = (root["casks"] as? [[String: Any]] ?? []) - .compactMap { parseOutdatedItem($0, kind: .cask) } - return Dictionary(uniqueKeysWithValues: (formulae + casks).map { ($0.id, $0) }) + return updates } static func parseOutdatedCommandOutput(_ output: String) throws -> [String: HomebrewPackageUpdate] { diff --git a/Sources/Vorssaint/UI/Settings/EnvironmentSettings.swift b/Sources/Vorssaint/UI/Settings/EnvironmentSettings.swift index 8a2934f..3caa7c5 100644 --- a/Sources/Vorssaint/UI/Settings/EnvironmentSettings.swift +++ b/Sources/Vorssaint/UI/Settings/EnvironmentSettings.swift @@ -4,19 +4,28 @@ import AppKit import SwiftUI -/// Read-only view of the command environment. It answers three questions a -/// person cannot answer from a GUI: which copy of a command wins, which of -/// them are wrappers around something else, and why an app started from -/// Finder cannot find any of them. The only thing it hands back is text on -/// the clipboard. +/// Environment diagnostics and user-triggered update checks; installation stays with each tool's manager. struct EnvironmentSettings: View { @ObservedObject private var l10n = L10n.shared @ObservedObject private var inspector = EnvironmentInspector.shared + @ObservedObject private var updates = EnvironmentUpdateChecker.shared @State private var copyFeedback = EnvironmentCopyFeedback() + @ObservedObject private var homebrew = HomebrewManager.shared + @State private var tab = 0 + @State private var expanded: Set = [] + @State private var showDiagnostics = false + @State private var showOperationDetails = false + @State private var pendingPackage: HomebrewPackage? + @State private var configurations: [EnvironmentConfiguration] = [] + @State private var projectDirectory: String? + + private func label(_ zh: String, _ en: String) -> String { l10n.language == .zhHans ? zh : en } + private var busy: Bool { inspector.isLoading || updates.isChecking || homebrew.isBusy } private var detailText: EnvironmentDetailStrings { EnvironmentDetailStrings(language: l10n.language) } private var text: EnvironmentFeatureStrings { FeatureStrings.environment(l10n.language) } + private var updateText: EnvironmentUpdateStrings { EnvironmentUpdateStrings(language: l10n.language) } var body: some View { VStack(spacing: 0) { @@ -24,16 +33,35 @@ struct EnvironmentSettings: View { .padding(.horizontal, SettingsVisualStyle.current.pageInset) .padding(.vertical, 12) SettingsForm { - SettingsInfo(text: text.hubDescription, systemImage: "terminal") - commandsSection - pathSection - cachesSection + if tab == 0 { + if let tap = homebrew.untrustedTap { + HomebrewTrustCard(tap: tap) + } + if let status = homebrew.operationStatus { + HomebrewOperationStatusView(status: status, log: homebrew.log, + terminalFallbackCommand: homebrew.terminalFallbackCommand, compact: false, + showDetails: $showOperationDetails, onCancel: homebrew.cancelOperation, + onClear: homebrew.clearLog, onOpenTerminal: homebrew.openTerminalFallback) + } + commandsSection + DisclosureGroup(isExpanded: $showDiagnostics) { + pathSection + cachesSection + SettingsExplanation(updateText.note) + } label: { + Text(label("环境诊断", "Environment diagnostics")) + .font(SettingsTypography.body.weight(.medium)) + } + } else { + configurationSections + } } } .font(SettingsTypography.body) .controlSize(.regular) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .onAppear { + reloadConfigurations() if inspector.report.tools.isEmpty { copyFeedback.clear() inspector.refresh() @@ -42,89 +70,254 @@ struct EnvironmentSettings: View { .onChange(of: inspector.isLoading) { _, _ in copyFeedback.clear() } + .confirmationDialog(l10n.s.homebrewConfirmUpgradeTitle, + isPresented: Binding(get: { pendingPackage != nil }, set: { if !$0 { pendingPackage = nil } }), + titleVisibility: .visible) { + if let package = pendingPackage { + Button(l10n.s.homebrewUpgrade) { homebrew.upgrade(package); pendingPackage = nil } + } + Button(l10n.s.uninstallerCancel, role: .cancel) { pendingPackage = nil } + } message: { + if let package = pendingPackage { + Text(String(format: l10n.s.homebrewConfirmUpgradeBodyFormat, package.displayName)) + } + } } private var header: some View { HStack(spacing: 8) { - Spacer(minLength: 0) - EnvironmentCopyButton(title: text.copyReport, - succeeded: copyFeedback.result(for: "report"), - copied: text.copied, failed: text.copyFailed) { - copy(EnvironmentInspector.diagnosticText(inspector.report), target: "report") + Picker("", selection: $tab) { + Text(label("工具", "Tools")).tag(0) + Text(label("配置文件", "Configuration files")).tag(1) } - .disabled(inspector.isLoading) + .pickerStyle(.segmented) + .frame(width: 190) + Spacer(minLength: 8) Button { + reloadConfigurations() copyFeedback.clear() + updates.reset() inspector.refresh() - } label: { - HStack(spacing: 6) { - if inspector.isLoading { - ProgressView().controlSize(.small) + } label: { Label(text.refresh, systemImage: "arrow.clockwise") } + .settingsAction(.secondary) + .disabled(busy) + if tab == 0 { + Button { updates.refreshAndCheck() } label: { + HStack(spacing: 6) { + if updates.isChecking { ProgressView().controlSize(.small) } + Text(updateText.check) } - Label(text.refresh, systemImage: "arrow.clockwise") } + .settingsAction(.primary) + .disabled(busy) } - .settingsAction(.primary) + Menu { + Button(text.copyReport) { + copy(EnvironmentInspector.diagnosticText(inspector.report, updates: updates.records), target: "report") + } + } label: { Image(systemName: "ellipsis") } + .menuStyle(.borderlessButton) .fixedSize() - .help(text.refresh) - .accessibilityLabel(text.refresh) - .disabled(inspector.isLoading) + .disabled(busy) } - .frame(maxWidth: .infinity, alignment: .leading) } - // MARK: - Which copy wins + private func group(_ tool: EnvironmentTool) -> Int { + switch tool.source { + case .standalone, .homebrew, .managed: return 0 + case .system, .appleDeveloperTools: return 1 + case .forwarded, .unknown: return 2 + } + } + + private func available(_ tool: EnvironmentTool) -> String? { + guard let record = updates.records[tool.command], record.isVisible, + case let .available(version) = record.result?.status else { return nil } + return version + } private var commandsSection: some View { - section(text.commandsTitle, systemImage: "terminal", note: text.commandsNote) { - VStack(alignment: .leading, spacing: 2) { - ForEach(inspector.report.tools) { tool in - commandRow(tool) + VStack(alignment: .leading, spacing: 16) { + if inspector.isLoading && inspector.report.tools.isEmpty { ProgressView() } + ForEach(0..<3) { index in + let tools = inspector.report.tools.filter { group($0) == index }.sorted { + if (available($0) != nil) != (available($1) != nil) { return available($0) != nil } + return $0.command.localizedStandardCompare($1.command) == .orderedAscending } + if !tools.isEmpty { + SettingsSection { + LazyVStack(spacing: 2) { + ForEach(tools) { tool in commandRow(tool) } + } + } header: { + HStack(spacing: 8) { + Text(index == 0 ? label("用户安装", "User installed") : index == 1 + ? label("系统与开发工具附带", "Bundled tools") : label("其他命令", "Other commands")) + Text("\(tools.count)") + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .foregroundStyle(.secondary) + .padding(.horizontal, 5).padding(.vertical, 1) + .background(Capsule().fill(Color.primary.opacity(0.06))) + } + } + } + } + if let checked = updates.records.values.compactMap(\.checkedAt).max(), !updates.isChecking { + Text("\(updateText.checked): \(checked.formatted(date: .abbreviated, time: .shortened))") + .font(SettingsTypography.caption).foregroundStyle(.secondary) } } } private func commandRow(_ tool: EnvironmentTool) -> some View { VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(tool.command) - .font(.system(size: 13, weight: .semibold, design: .monospaced)) - if let version = tool.version { - Text("\(detailText.version(reportedByForwarder: tool.isShim)): \(version)") - .font(SettingsTypography.body) - .foregroundStyle(.secondary) - .textSelection(.enabled) - .fixedSize(horizontal: false, vertical: true) - } - } - if let path = tool.path { - pathRow(label: detailText.resolvedPath, path: path) - if let target = tool.shimTarget { - pathRow(label: detailText.forwardTarget, path: target) + HStack(spacing: 10) { + Button { + if !expanded.insert(tool.id).inserted { expanded.remove(tool.id) } + } label: { + Image(systemName: expanded.contains(tool.id) ? "chevron.down" : "chevron.right") + .font(.system(size: 10, weight: .semibold)).frame(width: 12) } - if !tool.shadowedPaths.isEmpty { - DisclosureGroup(detailText.otherCopies(tool.shadowedPaths.count)) { - VStack(alignment: .leading, spacing: 8) { - ForEach(tool.shadowedPaths, id: \.self) { path in - pathRow(label: nil, path: path) - } - } - .padding(.top, 6) + .buttonStyle(.plain) + .accessibilityLabel(label("显示详情:", "Show details: ") + tool.command) + Text(tool.command).font(SettingsTypography.body.weight(.semibold)) + Spacer(minLength: 8) + HStack(spacing: 5) { + Text(displayVersion(tool)).foregroundStyle(.secondary) + if let version = available(tool) { + Image(systemName: "arrow.right").foregroundStyle(.tertiary) + Text(version) } - .font(SettingsTypography.body) - .accessibilityLabel("\(tool.command): \(detailText.otherCopies(tool.shadowedPaths.count))") } - } else { - Text(text.notFound) - .font(SettingsTypography.body) - .foregroundStyle(.secondary) + .font(SettingsTypography.caption.monospaced()) + .textSelection(.enabled) + .lineLimit(1) + if available(tool) != nil { upgradeAction(tool) } + if let record = updates.records[tool.id], record.isVisible, record.result?.status == .failed { + Image(systemName: "exclamationmark.circle").foregroundStyle(.orange) + .help(updateText.status(.failed)) + .accessibilityLabel(updateText.status(.failed)) + } + } + Text(compactSource(tool.source)) + .font(SettingsTypography.caption).foregroundStyle(.secondary) + .padding(.leading, 22) + if expanded.contains(tool.id) { + VStack(alignment: .leading, spacing: 10) { + if let version = tool.version { + Text("\(detailText.version(reportedByForwarder: tool.isShim)): \(version)") + .font(SettingsTypography.caption).foregroundStyle(.secondary).textSelection(.enabled) + } + if let path = tool.path { + pathRow(label: detailText.resolvedPath, path: path) + Button(label("在 Finder 中显示", "Show in Finder")) { + NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) + }.buttonStyle(.link) + } + if let target = tool.shimTarget { pathRow(label: detailText.forwardTarget, path: target) } + if let record = updates.records[tool.id], record.isVisible, let result = record.result { + Text(updateText.status(result.status)).font(SettingsTypography.caption) + if case .homebrew = record.source { SettingsExplanation(updateText.brewNote) } + } + if !tool.shadowedPaths.isEmpty { + Text(detailText.otherCopies(tool.shadowedPaths.count)).font(SettingsTypography.caption) + ForEach(tool.shadowedPaths, id: \.self) { path in pathRow(label: nil, path: path) } + } + }.padding(.leading, 22).padding(.top, 4) } } .frame(maxWidth: .infinity, alignment: .leading) .settingsItemSurface() } + private func displayVersion(_ tool: EnvironmentTool) -> String { + if available(tool) != nil, case let .homebrew(_, _, version) = tool.source { return version } + guard let raw = tool.version else { + return tool.path == nil ? text.notFound : label("版本未读出", "Version unavailable") + } + let tokens = raw.split(whereSeparator: { $0.isWhitespace }) + return tokens.first(where: { $0.first?.isNumber == true || ($0.hasPrefix("v") && $0.dropFirst().first?.isNumber == true) }).map(String.init) ?? raw + } + + private func compactSource(_ source: EnvironmentUpdateSource) -> String { + if case let .homebrew(_, formula, _) = source { return label("后装 · Homebrew · ", "User-installed · Homebrew · ") + formula } + return updateText.source(source) + } + + @ViewBuilder + private func upgradeAction(_ tool: EnvironmentTool) -> some View { + if case let .homebrew(prefix, _, _) = tool.source, + homebrew.brewPath == URL(fileURLWithPath: prefix).appendingPathComponent("bin/brew").path, + let package = updates.records[tool.id]?.result?.package { + Button { + pendingPackage = package + } label: { Label(l10n.s.homebrewUpgrade, systemImage: "arrow.up.circle") } + .settingsAction(.primary).fixedSize().disabled(busy) + } else if case let .standalone(name) = tool.source { + Link(destination: URL(string: name == "bun" ? "https://bun.sh/docs/installation#upgrading" + : "https://docs.astral.sh/uv/getting-started/installation/#upgrading-uv")!) { + Label(updateText.instructions, systemImage: "arrow.up.right") + }.settingsAction(.secondary).fixedSize() + } + } + + private func reloadConfigurations() { + configurations = EnvironmentConfiguration.discover() + if let projectDirectory { configurations += EnvironmentConfiguration.discoverProject(projectDirectory) } + } + + private var configurationSections: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text(label("已发现的配置文件;是否生效取决于工具的加载方式。", "Discovered files; tools determine which configuration is loaded.")) + .font(SettingsTypography.caption).foregroundStyle(.secondary) + Spacer() + Button(label("选择项目…", "Choose project…")) { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + if panel.runModal() == .OK, let url = panel.url { + projectDirectory = url.path + reloadConfigurations() + } + }.settingsAction(.secondary).fixedSize() + } + ForEach(["user", "project", "system"], id: \.self) { scope in + let files = configurations.filter { $0.scope == scope } + if !files.isEmpty || (scope == "project" && projectDirectory != nil) { + SettingsSection(title: scope == "user" ? label("用户配置", "User configuration") : scope == "project" + ? label("项目配置", "Project configuration") : label("系统配置", "System configuration"), systemImage: "doc.text") { + if scope == "project", let projectDirectory { + Text(projectDirectory).font(SettingsTypography.caption).foregroundStyle(.secondary).textSelection(.enabled) + } + if files.isEmpty { + Text(label("此目录未发现支持识别的配置文件。", "No recognized configuration files in this directory.")) + .font(SettingsTypography.caption).foregroundStyle(.secondary) + } + ForEach(files) { file in + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(file.name).font(SettingsTypography.body.weight(.semibold)) + Text(file.path).font(SettingsTypography.caption.monospaced()) + .foregroundStyle(.secondary).textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 8) + Button(label("定位", "Reveal")) { + NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: file.path)]) + }.settingsAction(.secondary).fixedSize() + EnvironmentCopyButton(title: text.copyPath, succeeded: copyFeedback.result(for: file.path), + copied: text.copied, failed: text.copyFailed) { copy(file.path, target: file.path) } + .fixedSize() + }.settingsItemSurface() + } + } + } + } + } + } + private func pathRow(label: String?, path: String) -> some View { HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 4) { diff --git a/Tests/EnvironmentConfigurationTests.swift b/Tests/EnvironmentConfigurationTests.swift new file mode 100644 index 0000000..557213f --- /dev/null +++ b/Tests/EnvironmentConfigurationTests.swift @@ -0,0 +1,22 @@ +import Foundation + +enum EnvironmentConfigurationTests { + static func run(_ expect: (Bool, String) -> Void) { + let root = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + .appendingPathComponent("build/config-fixture-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: root) } + do { + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try Data().write(to: root.appendingPathComponent(".zshrc")) + try FileManager.default.createDirectory(at: root.appendingPathComponent(".zprofile"), withIntermediateDirectories: true) + try Data().write(to: root.appendingPathComponent(".python-version")) + let user = EnvironmentConfiguration.discover(home: root.path, environment: [:], systemRoot: root.appendingPathComponent("absent").path) + expect(user.map(\.name) == [".zshrc"], "configuration discovery excludes missing files and directories") + expect(user.first?.path == root.appendingPathComponent(".zshrc").path, "configuration retains its complete location") + let project = EnvironmentConfiguration.discoverProject(root.path) + expect(project.map(\.name) == [".python-version"], "project discovery uses only the selected directory") + } catch { + expect(false, "configuration fixture: \(error)") + } + } +} diff --git a/Tests/EnvironmentUpdateTests.swift b/Tests/EnvironmentUpdateTests.swift new file mode 100644 index 0000000..f38e17d --- /dev/null +++ b/Tests/EnvironmentUpdateTests.swift @@ -0,0 +1,126 @@ +import Foundation + +enum EnvironmentUpdateTests { + static func run(_ expect: (Bool, String) -> Void) { + let home = "/Users/example" + func source(_ command: String, _ path: String, resolved: String? = nil, + wrapper: Bool = false) -> EnvironmentUpdateSource { + EnvironmentUpdateSupport.source(command: command, path: path, + resolvedPath: resolved ?? path, home: home, isWrapper: wrapper) + } + expect(source("bun", home + "/.bun/bin/bun") == .standalone("bun"), + "standalone Bun is eligible for a stable release check") + expect(source("uv", home + "/.local/bin/uv") == .standalone("uv"), + "standalone uv location offers release information") + expect(source("bun", home + "/.bun/bin/bun", resolved: home + "/.local/share/mise/installs/bun/1.3.0/bin/bun") == .managed("mise"), + "a symlink into a managed version must not offer standalone updates") + expect(source("bun", home + "/.asdf/shims/bun", wrapper: true) == .managed("asdf"), + "manager shims retain the manager identity") + expect(source("node", home + "/.nvm/versions/node/v24.0.0/bin/node") == .managed("nvm"), + "versioned Node stays with its manager") + expect(source("python3", "/usr/bin/python3") == .appleDeveloperTools, + "Apple's Python entry point retains its developer-tool origin") + expect(source("true", "/usr/bin/true") == .system, + "an ordinary macOS executable retains its system origin") + expect(source("python3", "/Library/Developer/CommandLineTools/usr/bin/python3") == .appleDeveloperTools, + "Apple developer tools are identified separately from user-managed Python") + expect(source("python3", "/usr/local/bin/python3", resolved: "/Library/Developer/CommandLineTools/usr/bin/python3") == .appleDeveloperTools, + "a user bin symlink to Apple Python retains its bundled origin") + expect(source("node", home + "/.local/bin/node", resolved: home + "/.bun/bin/bun") == .forwarded, + "Node forwarding to Bun must not be compared with Node releases") + expect(source("bun", home + "/.bun/bin/bun", wrapper: true) == .forwarded, + "a wrapper in a standalone location is not an independent installation") + expect(source("uv", "/opt/homebrew/bin/uv", resolved: "/opt/homebrew/Cellar/uv/0.12.9/bin/uv") + == .homebrew(prefix: "/opt/homebrew", formula: "uv", version: "0.12.9"), + "Homebrew ownership is matched to the resolved keg, not the command name") + expect(source("uv", "/opt/homebrew/bin/uv") == .unknown, + "merely being inside a Homebrew bin directory does not establish ownership") + expect(source("bun", "/elsewhere/bun") == .unknown, + "unknown installations do not get a guessed update channel") + expect(EnvironmentUpdateSupport.comparison(current: "1.4.0", latest: "1.4.2") == .available("1.4.2"), + "older stable Bun reports the available version") + expect(EnvironmentUpdateSupport.comparison(current: "uv 0.9.9 (build)", latest: "0.10.0") == .available("0.10.0"), + "versions are compared numerically, with uv's version prefix removed") + expect(EnvironmentUpdateSupport.comparison(current: "1.4.2", latest: "1.4.2") == .current("1.4.2"), + "equal stable versions report no update") + expect(EnvironmentUpdateSupport.comparison(current: "1.5.0", latest: "1.4.2") == .ahead("1.4.2"), + "a newer installed version must not be presented as a downgrade target") + expect(EnvironmentUpdateSupport.comparison(current: "1.5.0-canary.1", latest: "1.4.2") == .unsupported, + "prerelease installations do not silently switch to the stable channel") + for invalid in ["unknown", "1.oops.2", "1.2", "1.2.3.4", "", "999999999999999999999.0.0"] { + expect(EnvironmentUpdateSupport.comparison(current: invalid, latest: "1.4.2") == .unsupported, + "unparseable installed version must not look up to date: \(invalid)") + } + let release = Data(#"{"tag_name":"bun-v1.4.2","draft":false,"prerelease":false}"#.utf8) + expect(EnvironmentUpdateSupport.releaseVersion(release, tool: "bun") == "1.4.2", + "Bun's official tag prefix is normalized") + for body in [#"{"tag_name":"bun-v1.4.2","prerelease":true,"draft":false}"#, + #"{"tag_name":"bun-v1.4.2","prerelease":false,"draft":true}"#, + #"{"message":"API rate limit exceeded"}"#, + #"{"tag_name":"bun-v1.x.2","prerelease":false,"draft":false}"#] { + expect(EnvironmentUpdateSupport.releaseVersion(Data(body.utf8), tool: "bun") == nil, + "invalid releases and API failures never become a successful version check") + } + let brew = EnvironmentUpdateSource.homebrew(prefix: "/opt/homebrew", formula: "uv", version: "0.12.9") + let package = HomebrewPackage(kind: .formula, name: "uv", displayName: "uv", + installedVersion: "0.12.9", stableVersion: "0.12.10", installedOnRequest: false) + let update = HomebrewPackageUpdate(kind: .formula, name: "uv", installedVersions: ["0.12.9"], + currentVersion: "0.12.10", isPinned: true) + let result = EnvironmentUpdateSupport.homebrewResult(source: brew, packages: [package], updates: [update.id: update]) + expect(result.status == .available("0.12.10") && result.isPinned && result.isDependency, + "pinned dependencies keep both restrictions alongside the available update") + expect(EnvironmentUpdateSupport.homebrewResult(source: brew, packages: [], updates: [:]).status == .unsupported, + "an unverified keg cannot be declared up to date") + expect(EnvironmentUpdateSupport.homebrewResult(source: brew, packages: [package], updates: [:]).status == .current("0.12.10"), + "a successful Homebrew check uses Homebrew's decision, not upstream stableVersion") + for invalid in ["{}", "[]", #"{"formulae":"invalid","casks":[]}"#, + #"{"formulae":[{"name":"uv"}],"casks":[]}"#] { + expect((try? HomebrewParser.parseOutdatedCommandOutput(invalid)) == nil, + "malformed Homebrew output must fail rather than become no updates: \(invalid)") + } + expect((try? HomebrewParser.parseOutdatedCommandOutput(#"{"formulae":[],"casks":[]}"#))?.isEmpty == true, + "a valid empty Homebrew response still means no updates") + var headOnly = package + headOnly.stableVersion = nil + expect(EnvironmentUpdateSupport.homebrewResult(source: brew, packages: [headOnly], updates: [:]).status == .unsupported, + "missing stable metadata must not be replaced by an invented stable version") + let tapped = HomebrewPackage(kind: .formula, name: "owner/tap/uv", displayName: "uv", + installedVersion: "0.12.9", stableVersion: "0.12.10") + let tappedResult = EnvironmentUpdateSupport.homebrewResult(source: brew, packages: [tapped], updates: [update.id: update]) + expect(tappedResult.package?.name == "owner/tap/uv" && tappedResult.package?.update?.isPinned == true, + "upgrade target preserves full tap identity and pin metadata") + let report = EnvironmentReport(tools: [EnvironmentTool(command: "bun", path: home + "/.bun/bin/bun", + version: "1.5.0", shimTarget: nil, shadowedPaths: [])]) + let records = ["bun": EnvironmentUpdateRecord(source: .standalone("bun"), + result: EnvironmentUpdateResult(status: .ahead("1.4.2")), checkedAt: Date(timeIntervalSince1970: 0))] + let diagnostic = EnvironmentInspector.diagnosticText(report, updates: records) + expect(diagnostic.contains("1.5.0") && diagnostic.contains("1.4.2") && diagnostic.contains("1970-01-01"), + "diagnostics retain installed version, queried stable version and check time") + for language in [AppLanguage.enUS, .zhHans, .de, .fr, .es, .ja] { + let strings = EnvironmentUpdateStrings(language: language) + expect(strings.status(.ahead("1.4.2")).contains("1.4.2") + && strings.status(.current("1.4.2")).contains("1.4.2"), + "equal and newer installations retain the queried version in \(language)") + } + for origin in [EnvironmentUpdateSource.system, .appleDeveloperTools, .managed("Conda"), .forwarded, .unknown] { + expect(!origin.supportsUpdateCheck, "non-independent sources must not enter update checks: \(origin)") + expect(!EnvironmentUpdateRecord(source: origin, result: EnvironmentUpdateResult(status: .available("9.0.0"))).isVisible, + "even a stale update result must not show an upgrade prompt for \(origin)") + } + expect(EnvironmentUpdateRecord(source: .standalone("bun"), result: EnvironmentUpdateResult(status: .available("1.4.2"))).isVisible, + "user-installed Bun keeps its useful update prompt") + expect(EnvironmentUpdateRecord(source: brew, result: EnvironmentUpdateResult(status: .failed)).isVisible, + "a supported tool's failed check remains visible rather than looking current") + for excluded in [EnvironmentUpdateResult(status: .unsupported), result] { + expect(!EnvironmentUpdateRecord(source: brew, result: excluded).isVisible, + "unsupported installations, pinned versions and dependencies do not produce upgrade clutter") + } + let systemTool = EnvironmentInspector.tool(named: "python3", in: ["/usr/bin"]) + expect(systemTool.source == .appleDeveloperTools, "local inspection identifies Apple's Python without a network update check") + let systemReport = EnvironmentInspector.diagnosticText(EnvironmentReport(tools: [systemTool]), + updates: ["python3": EnvironmentUpdateRecord(source: .appleDeveloperTools, + result: EnvironmentUpdateResult(status: .available("9.0.0")))]) + expect(systemReport.contains("source: Included with Apple developer tools") && !systemReport.contains("update:"), + "system Python retains its source in reports but cannot inherit an upgrade prompt") + } +} diff --git a/Tests/MetricsTests.swift b/Tests/MetricsTests.swift index cd6237d..2a3cb28 100644 --- a/Tests/MetricsTests.swift +++ b/Tests/MetricsTests.swift @@ -60,6 +60,8 @@ struct MetricsTests { MonitorHistoryTests.run { expect($0, $1) } MusicLaunchBlockerTests.run { expect($0, $1) } EnvironmentCopyTests.run { expect($0, $1) } + EnvironmentUpdateTests.run { expect($0, $1) } + EnvironmentConfigurationTests.run { expect($0, $1) } CommandBarActionTests.run { expect($0, $1) } CommandBarDestinationTests.run { expect($0, $1) } LocalPortTests.run { expect($0, $1) } diff --git a/build.sh b/build.sh index 79a1edc..e08f40b 100755 --- a/build.sh +++ b/build.sh @@ -344,6 +344,11 @@ if (( TEST )); then Sources/Vorssaint/Core/EnvironmentStrings.swift \ Sources/Vorssaint/Core/EnvironmentCopyFeedback.swift \ Tests/EnvironmentCopyTests.swift \ + Sources/Vorssaint/Services/Environment/EnvironmentUpdateSupport.swift \ + Sources/Vorssaint/Core/EnvironmentUpdateStrings.swift \ + Tests/EnvironmentUpdateTests.swift \ + Sources/Vorssaint/Services/Environment/EnvironmentConfiguration.swift \ + Tests/EnvironmentConfigurationTests.swift \ Sources/Vorssaint/Services/CommandBar/CommandBarActionSupport.swift \ Tests/CommandBarActionTests.swift \ Sources/Vorssaint/Services/CommandBar/CommandBarDestinationSupport.swift \ From 8e8b5efad8f22b23201972f848ec833aa923d84e Mon Sep 17 00:00:00 2001 From: PathGao Date: Wed, 16 Sep 2026 16:36:17 +0800 Subject: [PATCH 2/2] refactor(app): simplify native controls and service ownership --- ROADMAP.md | 10 +- .../App/AppAppearanceController.swift | 8 - Sources/Vorssaint/App/AppDelegate.swift | 50 +- Sources/Vorssaint/App/FeatureLifecycle.swift | 56 ++ Sources/Vorssaint/App/FeatureRuntime.swift | 306 ++++++---- .../Vorssaint/Core/AppearanceStrings.swift | 37 +- .../Vorssaint/Core/BrightnessStrings.swift | 52 +- Sources/Vorssaint/Core/Defaults.swift | 18 +- Sources/Vorssaint/Core/FeatureCatalog.swift | 4 +- Sources/Vorssaint/Core/GlobalShortcut.swift | 2 +- .../Vorssaint/Core/LiquidGlassSupport.swift | 21 - .../Core/SettingsBackupSupport.swift | 43 +- .../Vorssaint/Core/ThemeImportSupport.swift | 169 ------ .../Core/ThemeLinkImportSupport.swift | 58 -- .../Vorssaint/Core/ThemeSettingsStrings.swift | 215 ------- .../Services/BoundedProcessRunner.swift | 66 ++- .../CommandBar/CommandBarCatalog.swift | 10 +- .../CommandBar/CommandBarExecutor.swift | 64 +++ .../CommandBar/CommandBarPresentation.swift | 228 ++++++++ .../CommandBar/CommandBarService.swift | 530 ++++++------------ .../Services/Display/BrightnessService.swift | 354 +++++++++--- .../Services/Display/BrightnessSupport.swift | 98 +++- .../Environment/EnvironmentInspector.swift | 63 ++- .../EnvironmentUpdateChecker.swift | 12 + .../HomebrewEnvironmentCheckSettlement.swift | 34 ++ .../Services/Homebrew/HomebrewManager.swift | 68 ++- .../Services/Shelf/ShelfImportStore.swift | 79 +++ .../Services/Shelf/ShelfService.swift | 65 +-- .../Services/ThemeLinkImportService.swift | 125 ----- .../Vorssaint/Services/ThemePreferences.swift | 29 - .../UI/MenuPanel/BrightnessSection.swift | 33 +- .../UI/MenuPanel/CPUCoreMatrix.swift | 15 +- .../MenuPanel/DisplayBrightnessControl.swift | 46 ++ .../UI/MenuPanel/MenuPanelView.swift | 23 +- .../UI/MenuPanel/MetricDetailView.swift | 3 +- .../Vorssaint/UI/MenuPanel/MixerSection.swift | 142 +---- .../UI/MenuPanel/MonitorTrendPlot.swift | 17 +- .../UI/MenuPanel/MonitorTrendView.swift | 3 +- .../MenuPanel/PanelNavigationScrollView.swift | 43 ++ .../UI/MenuPanel/SystemSection.swift | 3 +- .../UI/RadialMenu/RadialMenuView.swift | 15 - .../UI/Settings/AdvancedSettings.swift | 6 - .../UI/Settings/BrightnessSettings.swift | 45 +- .../UI/Settings/ClipboardSettings.swift | 9 +- .../UI/Settings/CommandBarSettings.swift | 19 +- .../UI/Settings/CutPasteSettings.swift | 3 +- .../Vorssaint/UI/Settings/DockSettings.swift | 4 +- .../UI/Settings/FeatureBehaviorStrings.swift | 74 +++ .../UI/Settings/FeatureHubSettings.swift | 69 +-- .../UI/Settings/KeepAwakeSettings.swift | 18 +- .../Vorssaint/UI/Settings/MixerSettings.swift | 5 +- .../UI/Settings/MonitorPanelConfig.swift | 6 +- .../UI/Settings/MonitorSettings.swift | 2 +- .../UI/Settings/MusicBlockSettings.swift | 5 +- .../UI/Settings/RadialMenuSettings.swift | 8 +- .../UI/Settings/ScreenshotSettings.swift | 4 +- .../UI/Settings/SettingsSection.swift | 2 +- .../Vorssaint/UI/Settings/SettingsView.swift | 31 +- .../UI/Settings/SettingsVisualStyle.swift | 135 ++--- .../Vorssaint/UI/Settings/ShelfSettings.swift | 20 +- .../UI/Settings/TextSnippetsSections.swift | 2 +- .../Vorssaint/UI/Settings/ThemeSettings.swift | 217 ------- .../UI/Settings/TrackpadSettings.swift | 1 - Sources/Vorssaint/UI/SharedUI.swift | 25 - Sources/Vorssaint/UI/Theme.swift | 87 +-- Tests/BoundedProcessCancellationTests.swift | 73 +++ Tests/BrightnessModuleMigrationTests.swift | 46 ++ Tests/BrightnessPipelineTests.swift | 43 ++ Tests/BrightnessReadbackRegression.py | 120 +--- Tests/BrightnessServiceContract.py | 167 ++++++ Tests/CommandBarExecutorTests.swift | 80 +++ Tests/DisplayBrightnessShortcutTests.swift | 3 +- Tests/FeatureLifecycleTests.swift | 85 +++ ...ebrewEnvironmentCheckSettlementTests.swift | 51 ++ Tests/MetricsTests.swift | 54 +- Tests/MonitorThemeRenderingTests.swift | 37 +- Tests/SettingsBackupIdentityTests.swift | 19 + Tests/ShelfImportStoreTests.swift | 58 ++ Tests/ThemeImportTests.swift | 169 ------ Tools/InspectEventTaps.swift | 18 + build.sh | 37 +- docs/architecture/backend.md | 75 +++ 82 files changed, 2645 insertions(+), 2504 deletions(-) create mode 100644 Sources/Vorssaint/App/FeatureLifecycle.swift delete mode 100644 Sources/Vorssaint/Core/LiquidGlassSupport.swift delete mode 100644 Sources/Vorssaint/Core/ThemeImportSupport.swift delete mode 100644 Sources/Vorssaint/Core/ThemeLinkImportSupport.swift delete mode 100644 Sources/Vorssaint/Core/ThemeSettingsStrings.swift create mode 100644 Sources/Vorssaint/Services/CommandBar/CommandBarExecutor.swift create mode 100644 Sources/Vorssaint/Services/CommandBar/CommandBarPresentation.swift create mode 100644 Sources/Vorssaint/Services/Homebrew/HomebrewEnvironmentCheckSettlement.swift create mode 100644 Sources/Vorssaint/Services/Shelf/ShelfImportStore.swift delete mode 100644 Sources/Vorssaint/Services/ThemeLinkImportService.swift delete mode 100644 Sources/Vorssaint/Services/ThemePreferences.swift create mode 100644 Sources/Vorssaint/UI/MenuPanel/DisplayBrightnessControl.swift create mode 100644 Sources/Vorssaint/UI/MenuPanel/PanelNavigationScrollView.swift create mode 100644 Sources/Vorssaint/UI/Settings/FeatureBehaviorStrings.swift delete mode 100644 Sources/Vorssaint/UI/Settings/ThemeSettings.swift create mode 100644 Tests/BoundedProcessCancellationTests.swift create mode 100644 Tests/BrightnessModuleMigrationTests.swift create mode 100644 Tests/BrightnessPipelineTests.swift create mode 100644 Tests/BrightnessServiceContract.py create mode 100644 Tests/CommandBarExecutorTests.swift create mode 100644 Tests/FeatureLifecycleTests.swift create mode 100644 Tests/HomebrewEnvironmentCheckSettlementTests.swift create mode 100644 Tests/ShelfImportStoreTests.swift delete mode 100644 Tests/ThemeImportTests.swift create mode 100644 Tools/InspectEventTaps.swift create mode 100644 docs/architecture/backend.md diff --git a/ROADMAP.md b/ROADMAP.md index 8481b47..699b116 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -153,15 +153,9 @@ Dock 保留英文,和 App 同一条规则 **总开关与信息排布已完成首轮源码整理(2026-09-10,自动验证通过,UI待验收)。** 34 个设置路由已逐页静态审计。功能库管理整页可用性,页内只保留具体持续行为的开关。六个多行为组和四个按需捕获工具移除重复外层门禁,旧关闭状态迁移到具体动作或快捷键,参数与名单保留。开关放到所属选项旁,暂停后仍可准备配置。实际界面验收与缺口见 [交互审计](../kururu-note/验收记录/页面与流程.md)。 -### 外观主题:导入 VS Code 配色 +### 外观 -**已实现并有代表性UI实测。** 第60轮主题链接错误、预览、应用及恢复默认通过,第64轮六语窗口通过;其他主题、完整页面及无障碍组合仍待。 支持本地 JSON / JSONC 主题,限制 256 KiB,将背景、卡片、主文字、次文字、边框、强调色映射到现有语义表面。导入先局部预览,点击应用才保存,可恢复默认灰阶。监控曲线与核心图的填充、轨道和边界已接配色,预览直接复用生产绘图并传固定数据,不启动采样或修改已应用主题。浅深、缺字段、低对比主题的离屏11项检查通过,四张渲染已查看;完整窗口、原生控件、实际悬停和菜单栏仍待实机验收。 - -- **T1 · 配色范围。** 覆盖面板与设置页的背景、卡片、文字、边框、强调色,以及监视曲线和核心负载图。只更换颜色,保留章鱼标记的形状、布局、字体与交互。菜单栏图标需继续适应 macOS 的明暗背景。 -- **T2 · 导入与映射。** 先核对 VS Code 官方主题格式及实际主题样本,再确定首版支持的文件类型与字段。把可用颜色映射到 kururu 的语义角色,缺失颜色回落到内置主题。代码语法高亮色不直接当成界面颜色,也不承诺完整复刻编辑器外观。只读取主题配色,不安装或运行扩展。 -- **T3 · 预览与验收。** 应用前预览监视页和设置页,可恢复默认。检查浅色、深色、低对比度及字段不完整的主题。热压力、警告等状态保留明确的语义与文字标识,避免换色后正常和危急难以区分。导入解析失败时说明原因,保留当前主题。 - -首版解析与持久化专项已验证,正式完成状态以完整回归及实际界面验收记录为准。跨明暗模式和无障碍组合仍需继续验收。 +2026-09-16 按用户要求移除 Liquid Glass 开关及专用渲染分支,移除 VS Code 主题导入、预览、持久化及配色覆盖。保留系统/浅色/深色选择和当前 baseline 的信息组织。旧备份里的两项外观字段按已退役设置忽略,不影响其他设置导入。首轮原生外观调整使用系统按钮与强调色,统一正文和小号开关比例;以中性底色、浅色分组卡片和细边界保留分区。菜单栏左侧导航改为固定圆角容器内滚动,溢出内容使用边缘渐隐。功能名称后保留权限图标,能耗与补充解释放入问号弹窗;部分设置说明改为按需帮助,保持唤醒的菜单栏外观选项可折叠。权限缺失、失败状态和操作后果继续直接显示。 ### 监视 diff --git a/Sources/Vorssaint/App/AppAppearanceController.swift b/Sources/Vorssaint/App/AppAppearanceController.swift index f41e800..030f8eb 100644 --- a/Sources/Vorssaint/App/AppAppearanceController.swift +++ b/Sources/Vorssaint/App/AppAppearanceController.swift @@ -23,18 +23,10 @@ final class AppAppearanceController: ObservableObject { } } - @Published var liquidGlassEnabled: Bool { - didSet { - guard liquidGlassEnabled != oldValue else { return } - UserDefaults.standard.set(liquidGlassEnabled, forKey: DefaultsKey.liquidGlassEnabled) - } - } - private init() { appearance = AppAppearance.sanitized( UserDefaults.standard.string(forKey: DefaultsKey.appearance) ) - liquidGlassEnabled = UserDefaults.standard.bool(forKey: DefaultsKey.liquidGlassEnabled) } /// The panel is a popover anchored to the menu bar item, so it takes its diff --git a/Sources/Vorssaint/App/AppDelegate.swift b/Sources/Vorssaint/App/AppDelegate.swift index 83ec2ae..95504c9 100644 --- a/Sources/Vorssaint/App/AppDelegate.swift +++ b/Sources/Vorssaint/App/AppDelegate.swift @@ -137,13 +137,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSW .removeDuplicates() .receive(on: DispatchQueue.main) .sink { _ in - FeatureRuntime.shared.sync([ - .scrollInverter, .focusFollowsMouse, .smoothScroll, .mouseNavigation, .switcher, - .dockPreview, .finderCutPaste, .finderRename, .autoQuit, .dockClick, - .middleClick, .windowMaximizer, .keyboardDebounce, - .textSnippets, .brightness, .radialMenu, .mouseButtonShortcuts, - .mouseClickDebounce, .superKey, .quitWindowProtection, .mixer, - ]) + FeatureRuntime.shared.permissionChanged(.accessibility) } .store(in: &cancellables) @@ -151,7 +145,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSW .removeDuplicates() .receive(on: DispatchQueue.main) .sink { _ in - FeatureRuntime.shared.sync([.dockPreview, .screenRecorder]) + FeatureRuntime.shared.permissionChanged(.screenRecording) } .store(in: &cancellables) @@ -252,46 +246,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSW isTerminating = true // Quitting properly means the start worked, whenever it happened. endStartupWatch() - if AppFeature.brightness.isAvailable { - BrightnessService.shared.restoreDisplaysBeforeTermination() - } ProcessUsageService.shared.stopNetworkMonitoring(force: true) - URLCleanerService.shared.stop() - FocusFollowsMouseService.shared.stop() - WindowMaximizer.shared.stop() - KeyboardDebounceService.shared.suspend() - MouseClickDebounceService.shared.suspend() - TextSnippetService.shared.suspend() - // Takes the Super key mapping back out before the process goes away. - SuperKeyService.shared.suspend() - // Dock's app and window switcher hotkeys persist after quit. - AppSwitcher.shared.suspend() - MouseButtonShortcutService.shared.suspend() - MiddleClickService.shared.suspend() - ScrollInverter.shared.suspend() - SmoothScrollService.shared.suspend() - if AppFeature.mouseAcceleration.isAvailable - || MouseAccelerationRecovery.hasPendingEntries() { - MouseAccelerationService.shared.stop() - } - MouseNavigationService.shared.suspend() - DockPreviewService.shared.stop() - SoundOutputSwitcher.shared.stop() - PreciseVolumeRollerService.shared.stop() - AppVolumeMixer.shared.stopAll() - FanControlService.restoreBeforeTerminationIfNeeded() - // Puts the system input back if a microphone was chosen here: the - // app's audio settings must not outlive the app. - AudioInputDeviceManager.shared.stop() - // Flushes any scratchpad edit still inside the save debounce. - ScratchpadService.shared.suspend() - // The clipboard history persists through an async pipeline; the last - // mutation (often a Clear) must land before the process dies. - if AppFeature.clipboardHistory.isAvailable { - ClipboardHistoryService.shared.flushBeforeTermination() - } - ShelfService.shared.flushBeforeTermination() - KeepAwakeManager.shared.deactivate(reason: .quit) + FeatureRuntime.shared.terminate() } /// The lifeline when the menu bar icon goes missing. Opening the app again diff --git a/Sources/Vorssaint/App/FeatureLifecycle.swift b/Sources/Vorssaint/App/FeatureLifecycle.swift new file mode 100644 index 0000000..5977b3d --- /dev/null +++ b/Sources/Vorssaint/App/FeatureLifecycle.swift @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +/// Main-thread lifecycle routing. Registrations describe resource owners, so a +/// service shared by several features is synchronized once per change. +enum FeaturePermission: Hashable { + case accessibility, screenRecording +} + +final class FeatureLifecycle { + struct Registration { + let members: Set + var permissions: Set = [] + let synchronize: () -> Void + var terminate: () -> Void = {} + } + + private let registrations: [Registration] + private var active = Set() + private var loaded = Set() + private var terminated = false + + init(_ registrations: [Registration]) { self.registrations = registrations } + func sync(changed: Set, available: Set) { + guard !terminated else { return } + for (index, registration) in registrations.enumerated() + where !registration.members.isDisjoint(with: changed) { + let enabled = !registration.members.isDisjoint(with: available) + guard enabled || active.contains(index) else { continue } + if enabled { + active.insert(index) + loaded.insert(index) + } else { + active.remove(index) + } + registration.synchronize() + } + } + func permissionChanged(_ permission: FeaturePermission, available: Set) { + guard !terminated else { return } + for (index, registration) in registrations.enumerated() + where registration.permissions.contains(permission) + && !registration.members.isDisjoint(with: available) { + active.insert(index) + loaded.insert(index) + registration.synchronize() + } + } + func terminate() { + guard !terminated else { return } + terminated = true + for index in registrations.indices where loaded.contains(index) { + registrations[index].terminate() + } + active.removeAll() + } +} diff --git a/Sources/Vorssaint/App/FeatureRuntime.swift b/Sources/Vorssaint/App/FeatureRuntime.swift index 13361c0..565823f 100644 --- a/Sources/Vorssaint/App/FeatureRuntime.swift +++ b/Sources/Vorssaint/App/FeatureRuntime.swift @@ -23,6 +23,9 @@ final class FeatureRuntime: ObservableObject { /// keys off, including the install-then-uninstall-again case. private var loadedThisSession = Set(AppFeature.allCases.filter(\.isAvailable)) + private lazy var lifecycle = FeatureLifecycle(Self.registrations) + private var availableFeatures: Set { Set(AppFeature.allCases.filter(\.isAvailable)) } + private init() {} /// True while something that loaded this session is now uninstalled, so @@ -95,6 +98,7 @@ final class FeatureRuntime: ObservableObject { func setAvailable(_ unit: FeatureUnit, _ available: Bool) { guard mayFlip(unit, to: available) else { return } install(unit, available) + lifecycle.sync(changed: Set(unit.features), available: availableFeatures) finishAvailabilityChange() } @@ -102,7 +106,6 @@ final class FeatureRuntime: ObservableObject { UserDefaults.standard.set(available, forKey: unit.availabilityKey) for feature in unit.features { if feature.isAvailable { loadedThisSession.insert(feature) } - Self.bindings[feature]?() } } @@ -115,7 +118,7 @@ final class FeatureRuntime: ObservableObject { guard let key = feature.pageSwitchKey, !on || feature.isHardwareSupported else { return } UserDefaults.standard.set(on, forKey: key) if on { loadedThisSession.insert(feature) } - Self.bindings[feature]?() + lifecycle.sync(changed: [feature], available: availableFeatures) finishAvailabilityChange() } @@ -130,9 +133,7 @@ final class FeatureRuntime: ObservableObject { for (key, value) in changes { defaults.set(value, forKey: key) } let available = Set(AppFeature.allCases.filter(\.isAvailable)) loadedThisSession.formUnion(available) - for feature in previouslyAvailable.union(available) { - Self.bindings[feature]?() - } + lifecycle.sync(changed: previouslyAvailable.union(available), available: available) finishAvailabilityChange() } @@ -151,47 +152,52 @@ final class FeatureRuntime: ObservableObject { for key in keys { UserDefaults.standard.set(true, forKey: key) } + var changed = Set() for unit in FeatureUnit.allCases where mayFlip(unit, to: selected.contains(unit)) { install(unit, selected.contains(unit)) + changed.formUnion(unit.features) } - // Units that stayed installed still need a sync: their enable keys - // may have just flipped on. Syncs are idempotent, so a repeat for the - // ones handled above costs nothing. A selected unit the gate refused - // is not installed, so it is skipped like any other unavailable one - // and its services never come to life. - for unit in selected where unit.isAvailable { - for feature in unit.features { - Self.bindings[feature]?() - } - } + // Retained units may have newly enabled actions from the preset. + for unit in selected where unit.isAvailable { changed.formUnion(unit.features) } + lifecycle.sync(changed: changed, available: availableFeatures) finishAvailabilityChange() } /// Bulk install or uninstall for the hub's "all" buttons: one revision /// bump, every changed unit's bindings run. func setAllAvailable(_ available: Bool) { - var changed = false + var changed = Set() for unit in FeatureUnit.allCases where mayFlip(unit, to: available) { install(unit, available) - changed = true + changed.formUnion(unit.features) } - if changed { finishAvailabilityChange() } + guard !changed.isEmpty else { return } + lifecycle.sync(changed: changed, available: availableFeatures) + finishAvailabilityChange() } - /// Launch path: replaces the old unconditional sync block. Only available - /// features get their binding run, so nothing else even instantiates. func syncAtLaunch() { - for feature in AppFeature.allCases where feature.isAvailable { - Self.bindings[feature]?() - } + let available = availableFeatures + lifecycle.sync(changed: available, available: available) } - /// Re-syncs a set of features (used by the permission sinks); skips - /// unavailable ones so their singletons never come to life. func sync(_ features: [AppFeature]) { - for feature in features where feature.isAvailable { - Self.bindings[feature]?() + lifecycle.sync(changed: Set(features), available: availableFeatures) + } + + func permissionChanged(_ permission: FeaturePermission) { + lifecycle.permissionChanged(permission, available: availableFeatures) + } + + func terminate() { + lifecycle.terminate() + // Recovery markers can survive while their module is unavailable. + if MouseAccelerationRecovery.hasPendingEntries() { + MouseAccelerationService.shared.stop() } + FanControlService.restoreBeforeTerminationIfNeeded() + // Docked shelves can be opened by scratchpad even when shelf is unavailable. + ShelfService.shared.flushBeforeTermination() } /// One bump for Settings, and the Command Bar drops rows of features that @@ -201,88 +207,184 @@ final class FeatureRuntime: ObservableObject { CommandBarService.shared.noteHubChange() } - /// What each feature must re-evaluate when its availability (or a - /// permission it depends on) changes. Most on-demand tools have no binding; - /// Media only binds so uninstalling it can cancel work already in flight. - private static let bindings: [AppFeature: () -> Void] = [ - .switcher: { + // Services shared by multiple members appear once. Closures defer singleton + // creation until an available owner needs synchronization or final cleanup. + private static let registrations: [FeatureLifecycle.Registration] = [ + .init(members: [.brightness], permissions: [.accessibility], synchronize: { + BrightnessService.shared.syncWithPreferences() + }, terminate: { + BrightnessService.shared.restoreDisplaysBeforeTermination() + }), + .init(members: [.switcher], permissions: [.accessibility], synchronize: { WindowUseTracker.shared.syncWithFeatures() AppSwitcher.shared.syncWithPreferences() - }, - .dockPreview: { DockPreviewService.shared.syncWithPreferences() }, - .dockClick: { DockClickService.shared.syncWithPreferences() }, - .windowMaximizer: { WindowMaximizer.shared.syncWithPreferences() }, - .autoQuit: { AutoQuitService.shared.syncWithPreferences() }, - .scrollInverter: { ScrollInverter.shared.syncWithPreferences() }, - .focusFollowsMouse: { FocusFollowsMouseService.shared.syncWithPreferences() }, - .smoothScroll: { SmoothScrollService.shared.syncWithPreferences() }, - .mouseAcceleration: { MouseAccelerationService.shared.syncWithPreferences() }, - .mouseNavigation: { MouseNavigationService.shared.syncWithPreferences() }, - .mouseButtonShortcuts: { MouseButtonShortcutService.shared.syncWithPreferences() }, - .middleClick: { MiddleClickService.shared.syncWithPreferences() }, - .mouseClickDebounce: { MouseClickDebounceService.shared.syncWithPreferences() }, - .keyboardDebounce: { KeyboardDebounceService.shared.syncWithPreferences() }, - .quitWindowProtection: { QuitProtectionService.shared.syncWithPreferences() }, - .superKey: { SuperKeyService.shared.syncWithPreferences() }, - .textSnippets: { + }, terminate: { + AppSwitcher.shared.suspend() + }), + .init(members: [.dockPreview], permissions: [.accessibility, .screenRecording], synchronize: { + DockPreviewService.shared.syncWithPreferences() + }, terminate: { + DockPreviewService.shared.stop() + }), + .init(members: [.dockClick], permissions: [.accessibility], synchronize: { + DockClickService.shared.syncWithPreferences() + }), + .init(members: [.windowMaximizer], permissions: [.accessibility], synchronize: { + WindowMaximizer.shared.syncWithPreferences() + }, terminate: { + WindowMaximizer.shared.stop() + }), + .init(members: [.autoQuit], permissions: [.accessibility], synchronize: { + AutoQuitService.shared.syncWithPreferences() + }), + .init(members: [.scrollInverter], permissions: [.accessibility], synchronize: { + ScrollInverter.shared.syncWithPreferences() + }, terminate: { + ScrollInverter.shared.suspend() + }), + .init(members: [.focusFollowsMouse], permissions: [.accessibility], synchronize: { + FocusFollowsMouseService.shared.syncWithPreferences() + }, terminate: { + FocusFollowsMouseService.shared.stop() + }), + .init(members: [.smoothScroll], permissions: [.accessibility], synchronize: { + SmoothScrollService.shared.syncWithPreferences() + }, terminate: { + SmoothScrollService.shared.suspend() + }), + .init(members: [.mouseAcceleration], synchronize: { + MouseAccelerationService.shared.syncWithPreferences() + }, terminate: { + MouseAccelerationService.shared.stop() + }), + .init(members: [.mouseNavigation], permissions: [.accessibility], synchronize: { + MouseNavigationService.shared.syncWithPreferences() + }, terminate: { + MouseNavigationService.shared.suspend() + }), + .init(members: [.mouseButtonShortcuts], permissions: [.accessibility], synchronize: { + MouseButtonShortcutService.shared.syncWithPreferences() + }, terminate: { + MouseButtonShortcutService.shared.suspend() + }), + .init(members: [.middleClick], permissions: [.accessibility], synchronize: { + MiddleClickService.shared.syncWithPreferences() + }, terminate: { + MiddleClickService.shared.suspend() + }), + .init(members: [.mouseClickDebounce], permissions: [.accessibility], synchronize: { + MouseClickDebounceService.shared.syncWithPreferences() + }, terminate: { + MouseClickDebounceService.shared.suspend() + }), + .init(members: [.keyboardDebounce], permissions: [.accessibility], synchronize: { + KeyboardDebounceService.shared.syncWithPreferences() + }, terminate: { + KeyboardDebounceService.shared.suspend() + }), + .init(members: [.quitWindowProtection], permissions: [.accessibility], synchronize: { + QuitProtectionService.shared.syncWithPreferences() + }), + .init(members: [.superKey], permissions: [.accessibility], synchronize: { + SuperKeyService.shared.syncWithPreferences() + }, terminate: { + SuperKeyService.shared.suspend() + }), + .init(members: [.textSnippets], permissions: [.accessibility], synchronize: { TextSnippetService.shared.syncWithPreferences() SnippetLibraryService.shared.syncWithPreferences() - }, - .clipboardHistory: { + }, terminate: { + TextSnippetService.shared.suspend() + }), + .init(members: [.clipboardHistory], synchronize: { ClipboardHistoryService.shared.syncWithPreferences() // Auto clear rides the clipboard feature's availability but not its // capture toggle: uninstalling the feature stops it, turning history // off does not. ClipboardAutoClearService.shared.syncWithPreferences() - }, - .mediaTools: { + }, terminate: { + ClipboardHistoryService.shared.flushBeforeTermination() + }), + .init(members: [.mediaTools], synchronize: { guard !AppFeature.mediaTools.isAvailable else { return } MediaService.shared.cancel() ScreenRecorderService.shared.closeEditors(ownedBy: .mediaTools) - }, - .pastePlain: { PastePlainService.shared.syncWithPreferences() }, - .finderCutPaste: { FinderCutPaste.shared.syncWithPreferences() }, - .finderRename: { FinderRenameService.shared.syncWithPreferences() }, - .shelf: { ShelfService.shared.syncWithPreferences() }, - .urlCleaner: { URLCleanerService.shared.syncWithPreferences() }, - .mixer: { + }, terminate: { + MediaService.shared.cancel() + }), + .init(members: [.pastePlain], synchronize: { + PastePlainService.shared.syncWithPreferences() + }), + .init(members: [.finderCutPaste], permissions: [.accessibility], synchronize: { + FinderCutPaste.shared.syncWithPreferences() + }), + .init(members: [.finderRename], permissions: [.accessibility], synchronize: { + FinderRenameService.shared.syncWithPreferences() + }), + .init(members: [.shelf], synchronize: { + ShelfService.shared.syncWithPreferences() + }), + .init(members: [.urlCleaner], synchronize: { + URLCleanerService.shared.syncWithPreferences() + }, terminate: { + URLCleanerService.shared.stop() + }), + .init(members: [.mixer], permissions: [.accessibility], synchronize: { PreciseVolumeRollerService.shared.syncWithPreferences() AppVolumeMixer.shared.syncWithPreferences() AudioInputDeviceManager.shared.syncWithPreferences() - }, - .soundOutputSwitcher: { SoundOutputSwitcher.shared.syncWithPreferences() }, - .micMute: { MicMuteService.shared.syncWithPreferences() }, - .musicBlock: { MusicLaunchBlocker.shared.syncWithPreferences() }, - .keepAwake: { + }, terminate: { + PreciseVolumeRollerService.shared.stop() + AppVolumeMixer.shared.stopAll() + AudioInputDeviceManager.shared.stop() + }), + .init(members: [.soundOutputSwitcher], synchronize: { + SoundOutputSwitcher.shared.syncWithPreferences() + }, terminate: { + SoundOutputSwitcher.shared.stop() + }), + .init(members: [.micMute], synchronize: { + MicMuteService.shared.syncWithPreferences() + }), + .init(members: [.musicBlock], synchronize: { + MusicLaunchBlocker.shared.syncWithPreferences() + }), + .init(members: [.keepAwake], synchronize: { KeepAwakeManager.shared.syncWithFeatures() HotkeyManager.shared.syncWithPreferences() - }, - .brightness: { BrightnessService.shared.syncWithPreferences() }, - .bluetoothSleep: { BluetoothSleepService.shared.syncWithPreferences() }, - .colorPicker: { - ScreenCaptureService.shared.syncWithPreferences() - }, - .screenOCR: { + }, terminate: { + KeepAwakeManager.shared.deactivate(reason: .quit) + }), + .init(members: [.bluetoothSleep], synchronize: { + BluetoothSleepService.shared.syncWithPreferences() + }), + .init(members: [.colorPicker, .screenOCR, .screenshot, .screenRecorder], permissions: [.screenRecording], synchronize: { ScreenCaptureService.shared.syncWithPreferences() + }, terminate: { + ScreenCaptureService.shared.suspend() + }), + .init(members: [.screenOCR], synchronize: { ScreenTextService.shared.syncWithPreferences() - }, - .screenshot: { - ScreenCaptureService.shared.syncWithPreferences() + }), + .init(members: [.screenshot], synchronize: { ScreenshotService.shared.syncWithPreferences() - RecentCaptureService.shared.syncWithPreferences() - }, - .screenRecorder: { - ScreenCaptureService.shared.syncWithPreferences() + }), + .init(members: [.screenRecorder], permissions: [.screenRecording], synchronize: { ScreenRecorderService.shared.syncWithPreferences() - RecentCaptureService.shared.syncWithPreferences() - }, - .radialMenu: { RadialMenuService.shared.syncWithPreferences() }, - .scratchpad: { + }), + .init(members: [.radialMenu], permissions: [.accessibility], synchronize: { + RadialMenuService.shared.syncWithPreferences() + }), + .init(members: [.scratchpad], synchronize: { ScratchpadService.shared.syncWithPreferences() ShelfService.shared.syncDockedShelf() - }, - .commandBar: { CommandBarService.shared.syncWithPreferences() }, - .cleaner: { + }, terminate: { + ScratchpadService.shared.suspend() + }), + .init(members: [.commandBar], synchronize: { + CommandBarService.shared.syncWithPreferences() + }), + .init(members: [.cleaner], synchronize: { CleanerScheduler.shared.syncWithPreferences() WhatsAppDownloadScheduler.shared.syncWithPreferences() WhatsAppDownloadOrganizer.shared.syncWithPreferences() @@ -290,28 +392,32 @@ final class FeatureRuntime: ObservableObject { WhatsAppDownloadManager.shared.reset() WhatsAppDownloadOrganizer.shared.stop() } - }, - .monitorCPU: { FeatureRuntime.syncMonitor() }, - .monitorGPU: { FeatureRuntime.syncMonitor() }, - .monitorMemory: { FeatureRuntime.syncMonitor() }, - .monitorNetwork: { FeatureRuntime.syncMonitor() }, - .monitorDisk: { FeatureRuntime.syncMonitor() }, - .monitorPower: { FeatureRuntime.syncMonitor() }, - .fanControl: { - SystemMonitor.shared.planDidChange() + }), + .init(members: [.fanControl], synchronize: { let defaults = UserDefaults.standard let needsRecovery = defaults.bool(forKey: DefaultsKey.fanControlRecoveryNeeded) let hasRegisteredHelper = !(defaults.string(forKey: DefaultsKey.fanControlHelperVersion) ?? "").isEmpty if needsRecovery || (!AppFeature.fanControl.isAvailable && hasRegisteredHelper) { FanControlService.shared.syncWithPreferences() } - }, + }), + .init(members: [.screenshot, .screenRecorder], permissions: [.screenRecording], synchronize: { + RecentCaptureService.shared.syncWithPreferences() + }), + .init(members: [.monitorCPU, .monitorGPU, .monitorMemory, .monitorNetwork, .monitorDisk, .monitorPower, .fanControl], synchronize: { + SystemMonitor.shared.planDidChange() + }), + .init(members: [.monitorCPU, .monitorGPU, .monitorMemory, .monitorNetwork, .monitorDisk, .monitorPower], synchronize: { + MonitorAlertService.shared.syncWithPreferences() + }), + .init(members: [.environment], synchronize: { + guard !AppFeature.environment.isAvailable else { return } + MainActor.assumeIsolated { EnvironmentUpdateChecker.shared.cancel() } + }, terminate: { + MainActor.assumeIsolated { EnvironmentUpdateChecker.shared.cancel() } + }), ] - private static func syncMonitor() { - SystemMonitor.shared.planDidChange() - MonitorAlertService.shared.syncWithPreferences() - } } /// Hardware a feature needs and this Mac may not have. One switch answers diff --git a/Sources/Vorssaint/Core/AppearanceStrings.swift b/Sources/Vorssaint/Core/AppearanceStrings.swift index 6f075c1..fb3e816 100644 --- a/Sources/Vorssaint/Core/AppearanceStrings.swift +++ b/Sources/Vorssaint/Core/AppearanceStrings.swift @@ -8,7 +8,6 @@ struct AppearanceStrings { var system: String = "System" var light: String = "Light" var dark: String = "Dark" - var liquidGlass: String = "Liquid Glass" } extension FeatureStrings { @@ -38,95 +37,83 @@ extension AppearanceStrings { label: "Aparência", system: "Sistema", light: "Clara", - dark: "Escura", - liquidGlass: "Liquid Glass" + dark: "Escura" ) static let tr = AppearanceStrings( label: "Görünüm", system: "Sistem", light: "Açık", - dark: "Koyu", - liquidGlass: "Liquid Glass" + dark: "Koyu" ) static let ru = AppearanceStrings( label: "Оформление", system: "Системное", light: "Светлое", - dark: "Тёмное", - liquidGlass: "Liquid Glass" + dark: "Тёмное" ) static let es = AppearanceStrings( label: "Apariencia", system: "Sistema", light: "Clara", - dark: "Oscura", - liquidGlass: "Liquid Glass" + dark: "Oscura" ) static let de = AppearanceStrings( label: "Erscheinungsbild", system: "System", light: "Hell", - dark: "Dunkel", - liquidGlass: "Liquid Glass" + dark: "Dunkel" ) static let fr = AppearanceStrings( label: "Apparence", system: "Système", light: "Clair", - dark: "Sombre", - liquidGlass: "Liquid Glass" + dark: "Sombre" ) static let it = AppearanceStrings( label: "Aspetto", system: "Sistema", light: "Chiaro", - dark: "Scuro", - liquidGlass: "Liquid Glass" + dark: "Scuro" ) static let ja = AppearanceStrings( label: "外観", system: "システム", light: "ライト", - dark: "ダーク", - liquidGlass: "Liquid Glass" + dark: "ダーク" ) static let ko = AppearanceStrings( label: "외관", system: "시스템", light: "밝게", - dark: "어둡게", - liquidGlass: "Liquid Glass" + dark: "어둡게" ) static let zhHans = AppearanceStrings( label: "外观", system: "跟随系统", light: "浅色", - dark: "深色", - liquidGlass: "Liquid Glass" + dark: "深色" ) static let zhTW = AppearanceStrings( label: "外觀", system: "跟隨系統", light: "淺色", - dark: "深色", - liquidGlass: "Liquid Glass" + dark: "深色" ) static let zhHK = AppearanceStrings( label: "外觀", system: "跟隨系統", light: "淺色", - dark: "深色", - liquidGlass: "Liquid Glass" + dark: "深色" ) } diff --git a/Sources/Vorssaint/Core/BrightnessStrings.swift b/Sources/Vorssaint/Core/BrightnessStrings.swift index 90b5162..7c2692e 100644 --- a/Sources/Vorssaint/Core/BrightnessStrings.swift +++ b/Sources/Vorssaint/Core/BrightnessStrings.swift @@ -8,9 +8,9 @@ import Foundation /// per language, all in this file. struct BrightnessFeatureStrings { var pageTitle: String = "Displays" - var hubDescription: String = "External brightness and display power controls" + var hubDescription: String = "Display brightness and power controls" var enable: String = "Control displays" - var enableCaption: String = "Adjust external monitor brightness and turn displays on or off, here and in the menu bar panel. Use the system controls for built-in screen brightness." + var enableCaption: String = "Adjust built-in and external display brightness together, here and in the menu bar panel. You can also turn displays on or off." var externalCaption: String = "Adjusts the external display’s brightness directly when supported. Otherwise, it tries software dimming, which darkens the picture without changing the backlight." var noDisplays: String = "No display found." var displayOff: String = "Off" @@ -59,9 +59,9 @@ extension BrightnessFeatureStrings { static let ptBR = BrightnessFeatureStrings( pageTitle: "Telas", - hubDescription: "Brilho externo e controles de energia das telas", + hubDescription: "Brilho e controles de energia das telas", enable: "Controlar telas", - enableCaption: "Ajuste o brilho de monitores externos e ligue ou desligue telas aqui e no painel da barra de menus. Use os controles do sistema para o brilho da tela integrada.", + enableCaption: "Ajuste o brilho das telas integrada e externas no mesmo lugar, aqui e no painel da barra de menus. Você também pode ligar ou desligar telas.", externalCaption: "Monitores externos são ajustados pelo mesmo protocolo dos botões do próprio monitor. Quando a conexão não transmite esse protocolo, como em adaptadores HDMI, o controle escurece a imagem, então o ajuste funciona de qualquer forma.", noDisplays: "Nenhuma tela encontrada.", displayOff: "Desligada", @@ -83,9 +83,9 @@ extension BrightnessFeatureStrings { static let tr = BrightnessFeatureStrings( pageTitle: "Ekranlar", - hubDescription: "Harici ekran parlaklığı ve ekran güç denetimleri", + hubDescription: "Ekran parlaklığı ve güç denetimleri", enable: "Ekranları denetle", - enableCaption: "Buradan ve menü çubuğu panelinden harici monitör parlaklığını ayarlayın ve ekranları açıp kapatın. Yerleşik ekran parlaklığı için sistem denetimlerini kullanın.", + enableCaption: "Yerleşik ve harici ekran parlaklığını buradan ve menü çubuğu panelinden aynı yerde ayarlayın. Ekranları açıp kapatabilirsiniz.", externalCaption: "Harici monitörler, kendi düğmelerinin kullandığı protokolle ayarlanır. Bağlantı bu protokolü taşıyamadığında, örneğin HDMI adaptörlerinde, kaydırıcı bunun yerine görüntüyü karartır; parlaklık denetimi her durumda çalışır.", noDisplays: "Ekran bulunamadı.", displayOff: "Kapalı", @@ -107,9 +107,9 @@ extension BrightnessFeatureStrings { static let ru = BrightnessFeatureStrings( pageTitle: "Экраны", - hubDescription: "Яркость внешних мониторов и питание экранов", + hubDescription: "Яркость и питание экранов", enable: "Управлять экранами", - enableCaption: "Здесь и в панели строки меню можно менять яркость внешних мониторов и включать или выключать экраны. Яркость встроенного экрана регулируется средствами системы.", + enableCaption: "Регулируйте яркость встроенного и внешних экранов в одном месте: здесь и в панели строки меню. Здесь также можно включать и выключать экраны.", externalCaption: "Внешние мониторы настраиваются тем же протоколом, что и их собственные кнопки. Если соединение не передаёт этот протокол, например через адаптеры HDMI, ползунок затемняет изображение, так что регулировка работает в любом случае.", noDisplays: "Экраны не найдены.", displayOff: "Выключен", @@ -131,9 +131,9 @@ extension BrightnessFeatureStrings { static let es = BrightnessFeatureStrings( pageTitle: "Pantallas", - hubDescription: "Brillo externo y encendido de pantallas", + hubDescription: "Brillo y encendido de pantallas", enable: "Controlar las pantallas", - enableCaption: "Ajusta el brillo de monitores externos y enciende o apaga pantallas aquí y en el panel de la barra de menús. Usa los controles del sistema para el brillo de la pantalla integrada.", + enableCaption: "Ajusta el brillo de las pantallas integrada y externas en un mismo lugar, aquí y en el panel de la barra de menús. También puedes encender y apagar pantallas.", externalCaption: "Ajusta directamente el brillo del monitor externo cuando es compatible. De lo contrario, intenta oscurecer la imagen por software, sin cambiar la retroiluminación.", noDisplays: "No se encontró ninguna pantalla.", displayOff: "Apagada", @@ -159,9 +159,9 @@ extension BrightnessFeatureStrings { static let de = BrightnessFeatureStrings( pageTitle: "Displays", - hubDescription: "Externe Helligkeit und Display-Stromsteuerung", + hubDescription: "Displayhelligkeit und Stromsteuerung", enable: "Displays steuern", - enableCaption: "Hier und im Menüleistenpanel lässt sich die Helligkeit externer Monitore einstellen und lassen sich Displays ein- oder ausschalten. Die Helligkeit des eingebauten Displays wird über das System geregelt.", + enableCaption: "Die Helligkeit eingebauter und externer Displays lässt sich hier und im Menüleistenpanel gemeinsam einstellen. Displays können hier auch ein- oder ausgeschaltet werden.", externalCaption: "Regelt die Helligkeit externer Displays direkt, wenn dies unterstützt wird. Andernfalls wird versucht, das Bild per Software abzudunkeln; die Hintergrundbeleuchtung bleibt unverändert.", noDisplays: "Kein Display gefunden.", displayOff: "Aus", @@ -187,9 +187,9 @@ extension BrightnessFeatureStrings { static let fr = BrightnessFeatureStrings( pageTitle: "Écrans", - hubDescription: "Luminosité externe et alimentation des écrans", + hubDescription: "Luminosité et alimentation des écrans", enable: "Contrôler les écrans", - enableCaption: "Réglez la luminosité des moniteurs externes et allumez ou éteignez les écrans ici et dans le panneau de la barre des menus. Utilisez les commandes système pour la luminosité de l’écran intégré.", + enableCaption: "Réglez la luminosité de l’écran intégré et des écrans externes au même endroit, ici et dans le panneau de la barre des menus. Vous pouvez aussi allumer ou éteindre les écrans.", externalCaption: "Règle directement la luminosité de l’écran externe lorsque cela est possible. Sinon, tente d’assombrir l’image par logiciel, sans modifier le rétroéclairage.", noDisplays: "Aucun écran détecté.", displayOff: "Éteint", @@ -215,9 +215,9 @@ extension BrightnessFeatureStrings { static let it = BrightnessFeatureStrings( pageTitle: "Schermi", - hubDescription: "Luminosità esterna e accensione degli schermi", + hubDescription: "Luminosità e accensione degli schermi", enable: "Controlla gli schermi", - enableCaption: "Regola la luminosità dei monitor esterni e accendi o spegni gli schermi qui e nel pannello della barra dei menu. Usa i controlli di sistema per la luminosità dello schermo integrato.", + enableCaption: "Regola la luminosità degli schermi integrati ed esterni nello stesso posto, qui e nel pannello della barra dei menu. Puoi anche accendere e spegnere gli schermi.", externalCaption: "I monitor esterni vengono regolati con lo stesso protocollo dei loro pulsanti. Quando il collegamento non lo trasmette, come con gli adattatori HDMI, il cursore scurisce l’immagine, quindi la regolazione funziona comunque.", noDisplays: "Nessuno schermo trovato.", displayOff: "Spento", @@ -239,9 +239,9 @@ extension BrightnessFeatureStrings { static let ja = BrightnessFeatureStrings( pageTitle: "ディスプレイ", - hubDescription: "外部モニタの明るさとディスプレイの電源", + hubDescription: "ディスプレイの明るさと電源", enable: "ディスプレイを操作", - enableCaption: "こことメニューバーパネルで外部モニタの明るさを調整し、ディスプレイの電源を切り替えます。内蔵ディスプレイの明るさはシステムの操作で調整してください。", + enableCaption: "こことメニューバーパネルで、内蔵・外部ディスプレイの明るさをまとめて調整できます。ディスプレイの電源も切り替えられます。", externalCaption: "対応している場合は外部ディスプレイの明るさを直接調整します。対応していない場合はソフトウェアで画面を暗くすることを試みます。バックライトは変更しません。", noDisplays: "ディスプレイが見つかりません。", displayOff: "オフ", @@ -267,9 +267,9 @@ extension BrightnessFeatureStrings { static let ko = BrightnessFeatureStrings( pageTitle: "디스플레이", - hubDescription: "외부 모니터 밝기 및 디스플레이 전원 제어", + hubDescription: "디스플레이 밝기 및 전원 제어", enable: "디스플레이 제어", - enableCaption: "여기와 메뉴 막대 패널에서 외부 모니터 밝기를 조절하고 디스플레이 전원을 켜거나 끕니다. 내장 화면 밝기는 시스템 제어를 사용하세요.", + enableCaption: "여기와 메뉴 막대 패널에서 내장 및 외부 디스플레이 밝기를 한곳에서 조절합니다. 디스플레이 전원을 켜거나 끌 수도 있습니다.", externalCaption: "외부 모니터는 자체 버튼과 동일한 프로토콜로 조절됩니다. HDMI 어댑터처럼 연결이 이 프로토콜을 지원하지 않으면 슬라이더가 대신 화면을 어둡게 하므로 어느 경우든 밝기를 조절할 수 있습니다.", noDisplays: "디스플레이를 찾을 수 없습니다.", displayOff: "꺼짐", @@ -291,9 +291,9 @@ extension BrightnessFeatureStrings { static let zhHans = BrightnessFeatureStrings( pageTitle: "显示器", - hubDescription: "外接屏亮度与显示器开关", + hubDescription: "显示器亮度与开关", enable: "控制显示器", - enableCaption: "在这里和菜单栏面板中调节外接屏亮度、控制显示器开关。内置屏亮度使用系统控制。", + enableCaption: "在这里和菜单栏面板中统一调节内置屏与外接屏的亮度,也可控制显示器开关。", externalCaption: "优先直接调节外接显示器的亮度。不支持时,尝试通过软件调暗画面,显示器背光不变。", noDisplays: "未找到显示器。", displayOff: "已关闭", @@ -319,9 +319,9 @@ extension BrightnessFeatureStrings { static let zhTW = BrightnessFeatureStrings( pageTitle: "顯示器", - hubDescription: "外接螢幕亮度與顯示器開關", + hubDescription: "顯示器亮度與開關", enable: "控制顯示器", - enableCaption: "在這裡和選單列面板中調整外接螢幕亮度、控制顯示器開關。內建螢幕亮度使用系統控制。", + enableCaption: "在這裡和選單列面板中統一調整內建與外接螢幕的亮度,也可控制顯示器開關。", externalCaption: "優先直接調整外接顯示器的亮度。不支援時,嘗試透過軟體調暗畫面,顯示器背光不變。", noDisplays: "找不到顯示器。", displayOff: "已關閉", @@ -343,9 +343,9 @@ extension BrightnessFeatureStrings { static let zhHK = BrightnessFeatureStrings( pageTitle: "顯示器", - hubDescription: "外接螢幕亮度與顯示器開關", + hubDescription: "顯示器亮度與開關", enable: "控制顯示器", - enableCaption: "在這裏和選單列面板中調整外接螢幕亮度、控制顯示器開關。內置螢幕亮度使用系統控制。", + enableCaption: "在這裏和選單列面板中統一調整內置與外接螢幕的亮度,也可控制顯示器開關。", externalCaption: "優先直接調整外接顯示器的亮度。不支援時,嘗試透過軟體調暗畫面,顯示器背光不變。", noDisplays: "找不到顯示器。", displayOff: "已關閉", diff --git a/Sources/Vorssaint/Core/Defaults.swift b/Sources/Vorssaint/Core/Defaults.swift index 589ecfc..86e76f1 100644 --- a/Sources/Vorssaint/Core/Defaults.swift +++ b/Sources/Vorssaint/Core/Defaults.swift @@ -9,8 +9,6 @@ import Foundation enum DefaultsKey { static let language = "appLanguage" // AppLanguage.rawValue static let appearance = "appAppearance" // AppAppearance.rawValue - static let importedInterfaceTheme = "importedInterfaceTheme" // Data: normalized colors; empty uses built-in colors - static let liquidGlassEnabled = "liquidGlassEnabled" // Liquid Glass visual styling on macOS 26+ static let clamshellPreferred = "clamshellPreferred" // apply closed-lid mode to every session static let onboardingStep = "onboardingStep" // resume point if onboarding is interrupted static let featuresOnboardingVersion = "featuresOnboardingVersion" // last feature-tour marker handled @@ -156,7 +154,8 @@ enum DefaultsKey { static let shelfRemoveAfterDrop = "shelfRemoveAfterDrop" static let shelfClearOnClose = "shelfClearOnClose" static let shelfAutomaticExclusions = "shelfAutomaticExclusions" // [bundle id] blocks automatic opening only - static let brightnessControlEnabled = "brightnessControlEnabled" // sliders for every display + static let brightnessControlEnabled = "brightnessControlEnabled" // legacy, consumed by migration only + static let brightnessModuleGateMigrated = "brightnessModuleGateMigrated" static let brightnessKeysEnabled = "brightnessKeysEnabled" // brightness keys act on the display under the pointer static let brightnessOSDEnabled = "brightnessOSDEnabled" // brightness adjustment overlay static let keyboardBrightnessShortcutsEnabled = "keyboardBrightnessShortcutsEnabled" @@ -804,8 +803,6 @@ enum Defaults { static let registeredDefaults: [String: Any] = [ DefaultsKey.appearance: AppAppearance.fallback.rawValue, - DefaultsKey.importedInterfaceTheme: Data(), - DefaultsKey.liquidGlassEnabled: false, DefaultsKey.clamshellPreferred: false, DefaultsKey.defaultDuration: 0, DefaultsKey.batteryLimit: 10, @@ -929,7 +926,6 @@ enum Defaults { DefaultsKey.shelfRemoveAfterDrop: true, DefaultsKey.shelfClearOnClose: false, DefaultsKey.shelfAutomaticExclusions: [String](), - DefaultsKey.brightnessControlEnabled: false, DefaultsKey.brightnessKeysEnabled: false, DefaultsKey.brightnessOSDEnabled: false, BrightnessShortcutPreferenceKey.enabled: false, @@ -1283,6 +1279,7 @@ enum Defaults { migrateFanControlVisibility(in: defaults) migrateMetricPlacementSwitches(in: defaults) migrateFeatureUnits(in: defaults) + migrateBrightnessModuleGate(in: defaults) migrateWindowBehaviorUnit(in: defaults) migrateKeyboardUnit(in: defaults) migrateURLCleanerUnit(in: defaults) @@ -1306,6 +1303,15 @@ enum Defaults { migrateSplitWindowPreviewPreferences(in: defaults) } + static func migrateBrightnessModuleGate(in defaults: UserDefaults) { + let legacy = defaults.object(forKey: DefaultsKey.brightnessControlEnabled) as? Bool + guard legacy != nil || !defaults.bool(forKey: DefaultsKey.brightnessModuleGateMigrated) else { return } + let available = defaults.object(forKey: FeatureUnit.brightness.availabilityKey) as? Bool ?? true + defaults.set(available && (legacy ?? false), forKey: FeatureUnit.brightness.availabilityKey) + defaults.removeObject(forKey: DefaultsKey.brightnessControlEnabled) + defaults.set(true, forKey: DefaultsKey.brightnessModuleGateMigrated) + } + static func migrateBatteryTemperatureVisibility(in defaults: UserDefaults) { guard defaults.object(forKey: DefaultsKey.monitorPwrTemperature) == nil else { return } defaults.set(defaults.object(forKey: DefaultsKey.monitorSysTemps) as? Bool ?? true, diff --git a/Sources/Vorssaint/Core/FeatureCatalog.swift b/Sources/Vorssaint/Core/FeatureCatalog.swift index 5ebefd9..2b8ffd3 100644 --- a/Sources/Vorssaint/Core/FeatureCatalog.swift +++ b/Sources/Vorssaint/Core/FeatureCatalog.swift @@ -176,7 +176,7 @@ extension FeatureUnit { /// opt-in member inside a shipped unit keeps its switch off instead. static var availabilityDefaults: [String: Any] { Dictionary(uniqueKeysWithValues: allCases.map { - ($0.availabilityKey, true) + ($0.availabilityKey, $0 != .brightness) }) } @@ -420,7 +420,7 @@ extension AppFeature { case .urlCleaner: return [DefaultsKey.urlCleanerEnabled] case .soundOutputSwitcher: return [DefaultsKey.soundOutputSwitcherEnabled] case .musicBlock: return [DefaultsKey.musicBlockEnabled] - case .brightness: return [DefaultsKey.brightnessControlEnabled] + case .brightness: return [] case .bluetoothSleep: return [DefaultsKey.bluetoothSleepEnabled] case .mixer, .micMute, .keepAwake, .colorPicker, .screenOCR, .cleaningMode, .mediaTools, diff --git a/Sources/Vorssaint/Core/GlobalShortcut.swift b/Sources/Vorssaint/Core/GlobalShortcut.swift index 583ce18..e8ce336 100644 --- a/Sources/Vorssaint/Core/GlobalShortcut.swift +++ b/Sources/Vorssaint/Core/GlobalShortcut.swift @@ -820,7 +820,7 @@ enum GlobalShortcutRole: CaseIterable, Identifiable { case .keyboardBrightnessDecrease, .keyboardBrightnessIncrease: return [DefaultsKey.keyboardBrightnessShortcutsEnabled] case .displayBrightnessDecrease, .displayBrightnessIncrease: - return [DefaultsKey.brightnessControlEnabled, BrightnessShortcutPreferenceKey.enabled] + return [BrightnessShortcutPreferenceKey.enabled] } } diff --git a/Sources/Vorssaint/Core/LiquidGlassSupport.swift b/Sources/Vorssaint/Core/LiquidGlassSupport.swift deleted file mode 100644 index b35933c..0000000 --- a/Sources/Vorssaint/Core/LiquidGlassSupport.swift +++ /dev/null @@ -1,21 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// Copyright (C) 2026 Vorssaint - -import Foundation - -/// Detection and settings gate for Liquid Glass visuals on macOS 26 and later. -enum LiquidGlassSupport { - /// Whether the host operating system supports native Liquid Glass. - static var isSupported: Bool { - if #available(macOS 26.0, *) { - return true - } - return false - } - - /// Whether Liquid Glass is enabled by user preference on a supported system. - static func isEnabled(in defaults: UserDefaults = .standard) -> Bool { - guard isSupported else { return false } - return defaults.bool(forKey: DefaultsKey.liquidGlassEnabled) - } -} diff --git a/Sources/Vorssaint/Core/SettingsBackupSupport.swift b/Sources/Vorssaint/Core/SettingsBackupSupport.swift index 5cfeeb9..5898c8b 100644 --- a/Sources/Vorssaint/Core/SettingsBackupSupport.swift +++ b/Sources/Vorssaint/Core/SettingsBackupSupport.swift @@ -9,7 +9,7 @@ enum SettingsBackupSupport { static let formatVersionKey = "vorssaintBackupVersion" static let appVersionKey = "vorssaintBackupAppVersion" static let settingsKey = "settings" - static let formatVersion = 1 + static let formatVersion = 2 /// Keys the backup carries: every registered preference, the availability /// layer, and the deliberately unregistered selection/layout keys — minus @@ -144,7 +144,6 @@ enum SettingsBackupSupport { } settings = portableMediaSettings(settings) settings = portableMouseExceptions(settings) - settings = portableThemeSettings(settings) return [ formatVersionKey: formatVersion, appVersionKey: appVersion, @@ -160,20 +159,25 @@ enum SettingsBackupSupport { version >= 1, version <= formatVersion, let settings = payload[settingsKey] as? [String: Any] else { return nil } - // Dropping a malformed theme would clear the current one during replacement. - // Reject it before any settings are changed; absence still means the old default. - if let theme = settings[DefaultsKey.importedInterfaceTheme], - !valueLooksRight(DefaultsKey.importedInterfaceTheme, theme) { return nil } let allowed = exportKeys().union(legacyImportKeys.keys) - let filtered = settings.filter { allowed.contains($0.key) && valueLooksRight($0.key, $0.value) } - return portableThemeSettings(portableMouseExceptions(portableMediaSettings(filtered))) + var filtered = settings.filter { allowed.contains($0.key) && valueLooksRight($0.key, $0.value) } + if version == 1 { + let available = filtered[FeatureUnit.brightness.availabilityKey] as? Bool + ?? filtered[DefaultsKey.featureAvailable(AppFeature.brightness.rawValue)] as? Bool ?? true + filtered[FeatureUnit.brightness.availabilityKey] = available + && (filtered[DefaultsKey.brightnessControlEnabled] as? Bool ?? false) + } + filtered.removeValue(forKey: DefaultsKey.featureAvailable(AppFeature.brightness.rawValue)) + filtered.removeValue(forKey: DefaultsKey.brightnessControlEnabled) + return portableMouseExceptions(portableMediaSettings(filtered)) } /// Keys older backups still carry. They are written back as they were and /// the relaunch runs the same migrations a first launch does, so a file /// from before units and per-surface preview settings restores cleanly. static let legacyImportKeys: [String: Any] = { - var keys: [String: Any] = [DefaultsKey.previewSize: "normal", + var keys: [String: Any] = [DefaultsKey.brightnessControlEnabled: false, + DefaultsKey.previewSize: "normal", DefaultsKey.windowPreviewExcludedApps: [String]()] for feature in AppFeature.allCases { keys[DefaultsKey.featureAvailable(feature.rawValue)] = true @@ -186,6 +190,8 @@ enum SettingsBackupSupport { static func replaceExportedSettings(_ settings: [String: Any], in defaults: UserDefaults) { for key in exportKeys() { defaults.removeObject(forKey: key) } for (key, value) in settings { defaults.set(value, forKey: key) } + defaults.removeObject(forKey: DefaultsKey.brightnessControlEnabled) + defaults.set(true, forKey: DefaultsKey.brightnessModuleGateMigrated) } static func formatVersion(from payload: [String: Any]) -> Int? { @@ -253,21 +259,6 @@ enum SettingsBackupSupport { return settings } - private static func portableThemeSettings(_ source: [String: Any]) -> [String: Any] { - var settings = source - let key = DefaultsKey.importedInterfaceTheme - guard let value = source[key] else { return settings } - if let data = value as? Data, data.isEmpty { return settings } - if let data = value as? Data, - let theme = try? ThemeImportSupport.parse(data), - let normalized = try? ThemeImportSupport.savedData(theme) { - settings[key] = normalized - } else { - settings.removeValue(forKey: key) - } - return settings - } - private static func portableMediaSettings(_ source: [String: Any]) -> [String: Any] { var settings = source // Preset pictures are private files on this Mac. A backup carries the @@ -328,10 +319,6 @@ enum SettingsBackupSupport { guard let raw = value as? String else { return false } return ShelfDockPlacement(rawValue: raw) != nil } - if key == DefaultsKey.importedInterfaceTheme { - guard let data = value as? Data else { return false } - return data.isEmpty || (try? ThemeImportSupport.parse(data)) != nil - } if key == DefaultsKey.scratchpadDocument { return ScratchpadDocument.decoded(value as? Data, defaultName: "Scratchpad") != nil } diff --git a/Sources/Vorssaint/Core/ThemeImportSupport.swift b/Sources/Vorssaint/Core/ThemeImportSupport.swift deleted file mode 100644 index 585a046..0000000 --- a/Sources/Vorssaint/Core/ThemeImportSupport.swift +++ /dev/null @@ -1,169 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// Copyright (C) 2026 Vorssaint - -import Foundation - -enum ThemeColorRole: String, CaseIterable { - case background, card, primaryText, secondaryText, border, accent - - var sourceKeys: [String] { - switch self { - case .background: return ["editor.background"] - case .card: return ["editorWidget.background", "sideBar.background"] - case .primaryText: return ["editor.foreground", "foreground"] - case .secondaryText: return ["descriptionForeground"] - case .border: return ["panel.border", "contrastBorder"] - case .accent: return ["focusBorder", "button.background"] - } - } -} - -struct ThemeRGBA: Equatable { - let red: Double - let green: Double - let blue: Double - let alpha: Double - - init?(hex: String) { - let digits = Array(hex.utf8) - guard digits.first == 35, [4, 5, 7, 9].contains(digits.count), - digits.dropFirst().allSatisfy({ (48...57).contains($0) || (65...70).contains($0) || (97...102).contains($0) }) - else { return nil } - let short = digits.count < 6 - let expanded = short ? digits.dropFirst().flatMap { [$0, $0] } : Array(digits.dropFirst()) - guard let value = UInt32(String(decoding: expanded, as: UTF8.self), radix: 16) else { return nil } - let rgba = expanded.count == 6 ? (value << 8) | 255 : value - red = Double((rgba >> 24) & 255) / 255 - green = Double((rgba >> 16) & 255) / 255 - blue = Double((rgba >> 8) & 255) / 255 - alpha = Double(rgba & 255) / 255 - } - - var hex: String { - String(format: "#%02X%02X%02X%02X", Int((red * 255).rounded()), - Int((green * 255).rounded()), Int((blue * 255).rounded()), Int((alpha * 255).rounded())) - } -} - -struct ImportedTheme: Equatable { - let name: String - let colors: [ThemeColorRole: ThemeRGBA] -} - -enum ThemeImportError: Error, Equatable { - case tooLarge, invalidJSON, invalidColors, noColors, invalidColor(String), unreadable -} - -enum ThemeImportSupport { - static let maximumBytes = 256 * 1024 - - static func parse(_ data: Data) throws -> ImportedTheme { - guard data.count <= maximumBytes else { throw ThemeImportError.tooLarge } - guard String(data: data, encoding: .utf8) != nil else { throw ThemeImportError.invalidJSON } - let json = try normalizedJSONC(data) - guard let object = try? JSONSerialization.jsonObject(with: json), - let root = object as? [String: Any] else { throw ThemeImportError.invalidJSON } - guard let colors = root["colors"] as? [String: Any] else { throw ThemeImportError.invalidColors } - var mapped: [ThemeColorRole: ThemeRGBA] = [:] - for role in ThemeColorRole.allCases { - for key in role.sourceKeys { - guard let value = colors[key] else { continue } - guard let text = value as? String, let color = ThemeRGBA(hex: text) - else { throw ThemeImportError.invalidColor(key) } - if mapped[role] == nil { mapped[role] = color } - } - } - guard !mapped.isEmpty else { throw ThemeImportError.noColors } - let rawName = root["name"] as? String ?? "" - let name = String(rawName.unicodeScalars.filter { !CharacterSet.controlCharacters.contains($0) }.prefix(80)) - .trimmingCharacters(in: .whitespacesAndNewlines) - return ImportedTheme(name: name, colors: mapped) - } - - static func savedData(_ theme: ImportedTheme) throws -> Data { - let colors = Dictionary(uniqueKeysWithValues: theme.colors.map { ($0.key.sourceKeys[0], $0.value.hex) }) - let data = try JSONSerialization.data(withJSONObject: ["name": theme.name, "colors": colors], options: [.sortedKeys]) - _ = try parse(data) - return data - } - - static func read(_ url: URL) throws -> ImportedTheme { - guard url.isFileURL else { throw ThemeImportError.unreadable } - do { - guard try url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == true else { - throw ThemeImportError.unreadable - } - let handle = try FileHandle(forReadingFrom: url) - defer { try? handle.close() } - let data = try handle.read(upToCount: maximumBytes + 1) ?? Data() - return try parse(data) - } catch let error as ThemeImportError { - throw error - } catch { - throw ThemeImportError.unreadable - } - } - - // JSONC adds comments and trailing commas, not JSON5's unquoted keys or single quotes. - private static func normalizedJSONC(_ data: Data) throws -> Data { - var bytes = Array(data) - var index = 0 - var inString = false - var escaped = false - while index < bytes.count { - let byte = bytes[index] - if inString { - if escaped { escaped = false } - else if byte == 92 { escaped = true } - else if byte == 34 { inString = false } - index += 1 - continue - } - if byte == 34 { inString = true; index += 1; continue } - if byte == 47, index + 1 < bytes.count { - let next = bytes[index + 1] - if next == 47 { - bytes[index] = 32; bytes[index + 1] = 32; index += 2 - while index < bytes.count, bytes[index] != 10, bytes[index] != 13 { - bytes[index] = 32; index += 1 - } - continue - } - if next == 42 { - bytes[index] = 32; bytes[index + 1] = 32; index += 2 - var closed = false - while index < bytes.count { - if bytes[index] == 42, index + 1 < bytes.count, bytes[index + 1] == 47 { - bytes[index] = 32; bytes[index + 1] = 32; index += 2; closed = true; break - } - if bytes[index] != 10, bytes[index] != 13 { bytes[index] = 32 } - index += 1 - } - guard closed else { throw ThemeImportError.invalidJSON } - continue - } - } - index += 1 - } - guard !inString else { throw ThemeImportError.invalidJSON } - inString = false; escaped = false - for position in bytes.indices { - let byte = bytes[position] - if inString { - if escaped { escaped = false } - else if byte == 92 { escaped = true } - else if byte == 34 { inString = false } - } else if byte == 34 { - inString = true - } else if byte == 44 { - var previous = position - 1 - while previous >= 0, [9, 10, 13, 32].contains(bytes[previous]) { previous -= 1 } - guard previous >= 0, ![44, 91, 123].contains(bytes[previous]) else { throw ThemeImportError.invalidJSON } - var next = position + 1 - while next < bytes.count, [9, 10, 13, 32].contains(bytes[next]) { next += 1 } - if next < bytes.count, bytes[next] == 93 || bytes[next] == 125 { bytes[position] = 32 } - } - } - return Data(bytes) - } -} diff --git a/Sources/Vorssaint/Core/ThemeLinkImportSupport.swift b/Sources/Vorssaint/Core/ThemeLinkImportSupport.swift deleted file mode 100644 index 2149f8f..0000000 --- a/Sources/Vorssaint/Core/ThemeLinkImportSupport.swift +++ /dev/null @@ -1,58 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// Copyright (C) 2026 Vorssaint - -import Foundation - -enum ThemeLinkImportError: Error { - case invalidLink, downloadFailed, tooLarge, invalidArchive, themeNotFound, ambiguousTheme, invalidTheme -} - -enum ThemeLinkImportSupport { - struct Link { - let publisher: String - let extensionName: String - let slug: String - var downloadURL: URL { - URL(string: "https://\(publisher).gallery.vsassets.io/_apis/public/gallery/publisher/\(publisher)/extension/\(extensionName)/latest/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage")! - } - } - - static func parse(_ input: String) throws -> Link { - guard let url = URLComponents(string: input.trimmingCharacters(in: .whitespacesAndNewlines)), - url.scheme?.lowercased() == "https", url.host?.lowercased() == "vscodethemes.com", - url.user == nil, url.password == nil, url.port == nil, - url.query == nil, url.fragment == nil else { throw ThemeLinkImportError.invalidLink } - let parts = url.path.split(separator: "/", omittingEmptySubsequences: false) - guard parts.count == 4, parts[0].isEmpty, parts[1] == "e" else { throw ThemeLinkImportError.invalidLink } - let identifier = parts[2].split(separator: ".", omittingEmptySubsequences: false) - func valid(_ value: Substring) -> Bool { - !value.isEmpty && value.count <= 128 && value.utf8.allSatisfy { - (48...57).contains($0) || (65...90).contains($0) || (97...122).contains($0) || $0 == 45 - } - } - guard identifier.count == 2, identifier.allSatisfy(valid), valid(parts[3]) else { throw ThemeLinkImportError.invalidLink } - return Link(publisher: String(identifier[0]), extensionName: String(identifier[1]), slug: String(parts[3]).lowercased()) - } - - static func selection(manifest: Data, slug: String) throws -> (path: String, name: String) { - guard let object = try? JSONSerialization.jsonObject(with: manifest) as? [String: Any], - let contributes = object["contributes"] as? [String: Any], - let themes = contributes["themes"] as? [[String: Any]] else { throw ThemeLinkImportError.invalidArchive } - func normalized(_ value: String) -> String { - value.lowercased().components(separatedBy: CharacterSet.alphanumerics.inverted) - .filter { !$0.isEmpty }.joined(separator: "-") - } - let matches = themes.filter { theme in - [theme["label"], theme["id"]].compactMap { $0 as? String }.contains { normalized($0) == slug } - } - guard !matches.isEmpty else { throw ThemeLinkImportError.themeNotFound } - guard matches.count == 1 else { throw ThemeLinkImportError.ambiguousTheme } - guard var path = matches[0]["path"] as? String else { throw ThemeLinkImportError.invalidArchive } - if path.hasPrefix("./") { path.removeFirst(2) } - guard !path.isEmpty, !path.hasPrefix("/"), !path.contains("\\"), - !path.contains("*"), !path.contains("?"), !path.contains("["), - path.split(separator: "/", omittingEmptySubsequences: false).allSatisfy({ !$0.isEmpty && $0 != "." && $0 != ".." }) - else { throw ThemeLinkImportError.invalidArchive } - return ("extension/" + path, (matches[0]["label"] as? String) ?? (matches[0]["id"] as? String) ?? slug) - } -} diff --git a/Sources/Vorssaint/Core/ThemeSettingsStrings.swift b/Sources/Vorssaint/Core/ThemeSettingsStrings.swift deleted file mode 100644 index ac22869..0000000 --- a/Sources/Vorssaint/Core/ThemeSettingsStrings.swift +++ /dev/null @@ -1,215 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// Copyright (C) 2026 Vorssaint - -struct ThemeSettingsText { - let language: AppLanguage - - private func localized(_ en: String, _ zh: String, _ de: String, _ fr: String, _ es: String, _ ja: String) -> String { - switch language { - case .zhHans: return zh - case .de: return de - case .fr: return fr - case .es: return es - case .ja: return ja - default: return en - } - } - var title: String { localized("Interface colors", - "界面配色", - "Oberflächenfarben", - "Couleurs de l’interface", - "Colores de la interfaz", - "インターフェイスの配色") } - var defaultName: String { localized("kururu default greyscale", - "kururu 默认灰阶", - "kururu-Standardgraustufen", - "Niveaux de gris par défaut de kururu", - "Escala de grises predeterminada de kururu", - "kururu 標準のグレースケール") } - var imported: String { localized("Imported colors", - "导入的配色", - "Importierte Farben", - "Couleurs importées", - "Colores importados", - "読み込んだ配色") } - var scope: String { localized("Choose colors on the theme website, then paste the theme page link to preview. Only interface colors are used; no extension is installed.", - "从主题网站挑选配色,粘贴主题页面链接预览。只使用界面配色,不安装主题扩展。", - "Wähle Farben auf der Theme-Website aus und füge den Link zur Theme-Seite für eine Vorschau ein. Es werden nur Oberflächenfarben verwendet; keine Erweiterung wird installiert.", - "Choisissez des couleurs sur le site de thèmes, puis collez le lien de la page du thème pour les prévisualiser. Seules les couleurs de l’interface sont utilisées ; aucune extension n’est installée.", - "Elige colores en el sitio de temas y pega el enlace de la página del tema para previsualizarlos. Solo se usan los colores de la interfaz; no se instala ninguna extensión.", - "テーマサイトで配色を選び、テーマページのリンクを貼り付けてプレビューします。インターフェイスの配色だけを使用し、拡張機能はインストールしません。") } - var browseButton: String { localized("Browse themes", - "浏览主题网站", - "Themes durchsuchen", - "Parcourir les thèmes", - "Explorar temas", - "テーマを見る") } - var linkPlaceholder: String { "https://vscodethemes.com/e/…" } - var linkLabel: String { localized("Theme page link", - "主题页面链接", - "Link zur Theme-Seite", - "Lien de la page du thème", - "Enlace de la página del tema", - "テーマページのリンク") } - var cancelLoading: String { localized("Cancel loading", - "取消读取", - "Laden abbrechen", - "Annuler le chargement", - "Cancelar carga", - "読み込みをキャンセル") } - var reading: String { localized("Loading colors…", - "正在读取配色…", - "Farben werden geladen…", - "Chargement des couleurs…", - "Cargando colores…", - "配色を読み込み中…") } - var importButton: String { localized("Import from file…", - "从文件导入…", - "Aus Datei importieren…", - "Importer un fichier…", - "Importar desde archivo…", - "ファイルから読み込む…") } - var restoreButton: String { localized("Restore default", - "恢复默认", - "Standard wiederherstellen", - "Rétablir les valeurs par défaut", - "Restaurar valores predeterminados", - "標準に戻す") } - var previewTitle: String { localized("Preview", - "预览", - "Vorschau", - "Aperçu", - "Vista previa", - "プレビュー") } - var previewCaption: String { localized("Preview leaves your current colors unchanged. Missing colors use defaults; check that text remains legible. Warning colors stay unchanged.", - "预览不更改当前配色。缺少的颜色沿用默认值;请检查文字与背景是否清楚。警告状态颜色保持不变。", - "Die Vorschau ändert die aktuellen Farben nicht. Fehlende Farben verwenden Standardwerte; prüfe die Lesbarkeit des Textes. Warnfarben bleiben unverändert.", - "L’aperçu ne modifie pas vos couleurs actuelles. Les couleurs manquantes utilisent les valeurs par défaut ; vérifiez la lisibilité du texte. Les couleurs d’avertissement restent inchangées.", - "La vista previa no cambia los colores actuales. Los colores que falten usan los valores predeterminados; comprueba que el texto sea legible. Los colores de advertencia no cambian.", - "プレビューでは現在の配色を変更しません。未指定の色には標準値を使用します。文字が読みやすいか確認してください。警告色は変更しません。") } - var cancelButton: String { localized("Cancel preview", - "取消预览", - "Vorschau abbrechen", - "Annuler l’aperçu", - "Cancelar vista previa", - "プレビューをキャンセル") } - var applyButton: String { localized("Apply colors", - "应用配色", - "Farben anwenden", - "Appliquer les couleurs", - "Aplicar colores", - "配色を適用") } - var settingsSample: String { localized("Settings sample", - "设置示例", - "Einstellungsbeispiel", - "Exemple de réglages", - "Ejemplo de ajustes", - "設定の表示例") } - var secondarySample: String { localized("Secondary description", - "次要说明文字", - "Ergänzende Beschreibung", - "Description secondaire", - "Descripción secundaria", - "補足説明") } - var normalSample: String { localized("Monitor sample · Normal", - "监控示例 · 正常", - "Monitorbeispiel · Normal", - "Exemple de suivi · Normal", - "Ejemplo de monitorización · Normal", - "モニターの表示例 · 正常") } - func message(for error: Error) -> String { - if let error = error as? ThemeImportError { return message(for: error) } - guard let error = error as? ThemeLinkImportError else { - return localized("Could not load colors. Try again later. Your current colors were kept.", - "无法读取配色,请稍后重试。当前配色已保留。", - "Farben konnten nicht geladen werden. Versuche es später erneut. Deine aktuellen Farben wurden beibehalten.", - "Impossible de charger les couleurs. Réessayez plus tard. Vos couleurs actuelles ont été conservées.", - "No se pudieron cargar los colores. Inténtalo de nuevo más tarde. Se conservaron los colores actuales.", - "配色を読み込めませんでした。しばらくしてから再試行してください。現在の配色は保持されています。") - } - switch error { - case .invalidLink: return localized("Paste the full page link for a theme on VS Code Themes.", - "请粘贴 VS Code Themes 中某个主题的完整页面链接。", - "Füge den vollständigen Seitenlink eines Themes von VS Code Themes ein.", - "Collez le lien complet de la page d’un thème sur VS Code Themes.", - "Pega el enlace completo de la página de un tema en VS Code Themes.", - "VS Code Themes のテーマページの完全なリンクを貼り付けてください。") - case .downloadFailed: return localized("The download did not finish. Check your connection and try again.", - "下载未完成,请检查网络后重试。", - "Der Download wurde nicht abgeschlossen. Prüfe deine Verbindung und versuche es erneut.", - "Le téléchargement n’a pas abouti. Vérifiez votre connexion et réessayez.", - "La descarga no se completó. Comprueba la conexión e inténtalo de nuevo.", - "ダウンロードが完了しませんでした。接続を確認して再試行してください。") - case .tooLarge: return localized("This theme is too large. Choose another theme.", - "主题文件过大,请选择另一个主题。", - "Dieses Theme ist zu groß. Wähle ein anderes Theme.", - "Ce thème est trop volumineux. Choisissez-en un autre.", - "Este tema es demasiado grande. Elige otro.", - "このテーマは大きすぎます。別のテーマを選んでください。") - case .invalidArchive: return localized("This theme package could not be read. Choose another theme.", - "无法读取这个主题包,请选择另一个主题。", - "Dieses Theme-Paket konnte nicht gelesen werden. Wähle ein anderes Theme.", - "Impossible de lire ce paquet de thème. Choisissez un autre thème.", - "No se pudo leer este paquete de tema. Elige otro.", - "このテーマパッケージを読み取れませんでした。別のテーマを選んでください。") - case .themeNotFound: return localized("The linked colors were not found in the package. Choose the theme page again.", - "主题包中找不到链接指定的配色,请重新选择主题页面。", - "Die verlinkten Farben wurden im Paket nicht gefunden. Wähle die Theme-Seite erneut aus.", - "Les couleurs indiquées par le lien sont introuvables dans le paquet. Sélectionnez à nouveau la page du thème.", - "No se encontraron los colores del enlace en el paquete. Vuelve a elegir la página del tema.", - "リンクで指定された配色がパッケージ内に見つかりませんでした。テーマページを選び直してください。") - case .ambiguousTheme: return localized("This link matches more than one theme. Choose a specific theme page.", - "这个链接对应多个配色,无法确定要使用哪一个。请选择具体主题页面。", - "Dieser Link passt zu mehreren Themes. Wähle eine bestimmte Theme-Seite.", - "Ce lien correspond à plusieurs thèmes. Choisissez la page d’un thème précis.", - "Este enlace corresponde a varios temas. Elige la página de un tema específico.", - "このリンクには複数のテーマが該当します。特定のテーマページを選んでください。") - case .invalidTheme: return localized("This theme has no readable interface colors. Choose another theme.", - "这个主题没有可读取的界面配色,请选择另一个主题。", - "Dieses Theme enthält keine lesbaren Oberflächenfarben. Wähle ein anderes Theme.", - "Ce thème ne contient aucune couleur d’interface lisible. Choisissez-en un autre.", - "Este tema no contiene colores de interfaz que se puedan leer. Elige otro.", - "このテーマには読み取り可能なインターフェイスの配色がありません。別のテーマを選んでください。") - } - } - func message(for error: ThemeImportError) -> String { - switch error { - case .tooLarge: return localized("The file exceeds 256 KiB. Choose a smaller theme file.", - "文件超过 256 KiB,请选择较小的主题文件。", - "Die Datei ist größer als 256 KiB. Wähle eine kleinere Theme-Datei.", - "Le fichier dépasse 256 KiB. Choisissez un fichier de thème plus petit.", - "El archivo supera los 256 KiB. Elige un archivo de tema más pequeño.", - "ファイルが 256 KiB を超えています。より小さいテーマファイルを選んでください。") - case .invalidJSON: return localized("The file is not valid UTF-8 JSON / JSONC.", - "文件不是有效的 UTF-8 JSON / JSONC。", - "Die Datei ist kein gültiges UTF-8-JSON / JSONC.", - "Le fichier n’est pas un document JSON / JSONC UTF-8 valide.", - "El archivo no es JSON / JSONC válido en UTF-8.", - "ファイルは有効な UTF-8 JSON / JSONC ではありません。") - case .invalidColors: return localized("The theme needs a colors object.", - "主题缺少 colors 颜色对象。", - "Das Theme benötigt ein colors-Objekt.", - "Le thème doit contenir un objet colors.", - "El tema necesita un objeto colors.", - "テーマには colors オブジェクトが必要です。") - case .noColors: return localized("The file has no supported interface colors.", - "文件没有可用于界面的颜色。", - "Die Datei enthält keine unterstützten Oberflächenfarben.", - "Le fichier ne contient aucune couleur d’interface prise en charge.", - "El archivo no contiene colores de interfaz compatibles.", - "ファイルには対応しているインターフェイスの配色がありません。") - case .invalidColor(let key): return localized("Invalid color for \(key). Use a hexadecimal color.", - "颜色字段 \(key) 格式无效,请使用十六进制颜色。", - "Ungültige Farbe für \(key). Verwende eine hexadezimale Farbe.", - "Couleur invalide pour \(key). Utilisez une couleur hexadécimale.", - "Color no válido para \(key). Usa un color hexadecimal.", - "\(key) の色が無効です。16進数の色を使用してください。") - case .unreadable: return localized("The theme could not be read or applied. Your current colors were kept.", - "无法读取或应用主题文件,当前配色已保留。", - "Das Theme konnte nicht gelesen oder angewendet werden. Deine aktuellen Farben wurden beibehalten.", - "Le thème n’a pas pu être lu ou appliqué. Vos couleurs actuelles ont été conservées.", - "No se pudo leer o aplicar el tema. Se conservaron los colores actuales.", - "テーマを読み取るか適用することができませんでした。現在の配色は保持されています。") - } - } -} diff --git a/Sources/Vorssaint/Services/BoundedProcessRunner.swift b/Sources/Vorssaint/Services/BoundedProcessRunner.swift index 0d91cdd..cb50f3b 100644 --- a/Sources/Vorssaint/Services/BoundedProcessRunner.swift +++ b/Sources/Vorssaint/Services/BoundedProcessRunner.swift @@ -27,17 +27,64 @@ private final class BoundedProcessOutput: @unchecked Sendable { } } +/// Cancellation owns one process launch and closes the gap between a queued +/// request being cancelled and that request launching its child. +final class BoundedProcessCancellation: @unchecked Sendable { + private let lock = NSLock() + private var cancelled = false + private var process: Process? + + var isCancelled: Bool { + lock.lock() + defer { lock.unlock() } + return cancelled + } + + func cancel() { + lock.lock() + guard !cancelled else { + lock.unlock() + return + } + cancelled = true + let child = process + lock.unlock() + + guard let child, child.isRunning else { return } + child.terminate() + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.5) { + if child.isRunning { kill(child.processIdentifier, SIGKILL) } + } + } + + fileprivate func launch(_ child: Process) throws { + lock.lock() + defer { lock.unlock() } + guard !cancelled, process == nil else { throw CancellationError() } + try child.run() + process = child + } + + fileprivate func release(_ child: Process) { + lock.lock() + defer { lock.unlock() } + if process === child { process = nil } + } +} + enum BoundedProcessRunner { struct Result { let status: Int32 let output: Data let timedOut: Bool + let cancelled: Bool } static func run(_ path: String, _ arguments: [String], timeout: TimeInterval, - maxOutputBytes: Int) -> Result { + maxOutputBytes: Int, + cancellation: BoundedProcessCancellation? = nil) -> Result { let process = Process() process.executableURL = URL(fileURLWithPath: path) process.arguments = arguments @@ -71,17 +118,20 @@ enum BoundedProcessRunner { let finished = DispatchSemaphore(value: 0) process.terminationHandler = { _ in finished.signal() } + defer { cancellation?.release(process) } do { - try process.run() + if let cancellation { try cancellation.launch(process) } + else { try process.run() } } catch { reader.readabilityHandler = nil try? reader.close() - return Result(status: -1, output: Data(), timedOut: false) + return Result(status: -1, output: Data(), timedOut: false, + cancelled: cancellation?.isCancelled == true) } var didFinish = finished.wait(timeout: .now() + max(0, timeout)) == .success - let timedOut = !didFinish - if timedOut { + let timedOut = !didFinish && cancellation?.isCancelled != true + if !didFinish { process.terminate() didFinish = finished.wait(timeout: .now() + 0.5) == .success if !didFinish { @@ -97,8 +147,10 @@ enum BoundedProcessRunner { reader.readabilityHandler = nil try? reader.close() - return Result(status: timedOut ? -1 : process.terminationStatus, + let cancelled = !timedOut && cancellation?.isCancelled == true + return Result(status: timedOut || cancelled ? -1 : process.terminationStatus, output: output.value(), - timedOut: timedOut) + timedOut: timedOut, + cancelled: cancelled) } } diff --git a/Sources/Vorssaint/Services/CommandBar/CommandBarCatalog.swift b/Sources/Vorssaint/Services/CommandBar/CommandBarCatalog.swift index cff42ca..31e69b5 100644 --- a/Sources/Vorssaint/Services/CommandBar/CommandBarCatalog.swift +++ b/Sources/Vorssaint/Services/CommandBar/CommandBarCatalog.swift @@ -484,21 +484,15 @@ enum CommandBarCatalog { } if AppFeature.brightness.isAvailable { - let enabled = UserDefaults.standard.bool(forKey: DefaultsKey.brightnessControlEnabled) entries.append(CommandBarEntry( id: "action.brightness", title: bar.brightnessTitle, - subtitle: enabled - ? String(format: bar.argumentRangeFormat, 0, 100) - : area(.brightness), + subtitle: String(format: bar.argumentRangeFormat, 0, 100), // The Displays page name doubles as a synonym, so the words // of both surfaces land here. keywords: FeatureStrings.brightness(language).pageTitle, icon: .symbol("sun.max"), - trouble: enabled ? nil - : .needsSetup(featureTitle: AppFeature.brightness.name(s, language: language), - page: .brightness), - numericRange: enabled ? 0...100 : nil, + numericRange: 0...100, run: { value in guard let value else { return } applyBrightness(percent: value) diff --git a/Sources/Vorssaint/Services/CommandBar/CommandBarExecutor.swift b/Sources/Vorssaint/Services/CommandBar/CommandBarExecutor.swift new file mode 100644 index 0000000..b3288e0 --- /dev/null +++ b/Sources/Vorssaint/Services/CommandBar/CommandBarExecutor.swift @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import Foundation + +/// Execution state is independent of ranking and panel construction. All calls, +/// including the injected launch completion, run on the main thread. +final class CommandBarExecutor { + struct Context { + let presentationID: UUID + let query: String + let isVisible: Bool + } + private(set) var queryWhenRun = "" + private(set) var selectionWhenRun = "" + private var requests = CommandBarDestinationRequests() + private let context: () -> Context? + private let exists: (String) -> Bool + private let open: (URL) -> Bool + private let launch: (URL, @escaping (Bool) -> Void) -> Void + private let completed: (CommandBarDestinationOpening.Result, String) -> Void + + init(context: @escaping () -> Context?, exists: @escaping (String) -> Bool, + open: @escaping (URL) -> Bool, launch: @escaping (URL, @escaping (Bool) -> Void) -> Void, + completed: @escaping (CommandBarDestinationOpening.Result, String) -> Void) { + self.context = context + self.exists = exists + self.open = open + self.launch = launch + self.completed = completed + } + func resetInput() { queryWhenRun = ""; selectionWhenRun = "" } + func execute(query: String, selection: String, value: Int?, keepsBarOpen: Bool, + waitsForOpenResult: Bool, dismiss: () -> Void, run: (Int?) -> Void) { + queryWhenRun = query + selectionWhenRun = selection + if !keepsBarOpen && !waitsForOpenResult { dismiss() } + run(value) + } + func begin() -> CommandBarDestinationAttempt? { + guard let context = context() else { return nil } + return requests.begin(presentationID: context.presentationID, query: context.query, isVisible: context.isVisible) + } + func accepts(_ attempt: CommandBarDestinationAttempt) -> Bool { + guard let context = context() else { return false } + return requests.accepts(attempt, presentationID: context.presentationID, + query: context.query, isVisible: context.isVisible) + } + func invalidate() { requests.invalidate() } + func openDestination(_ url: URL?, title: String, attempt: CommandBarDestinationAttempt) { + guard accepts(attempt) else { return } + complete(attempt, result: CommandBarDestinationOpening.open(url: url, exists: exists, open: open), title: title) + } + func openApplication(at url: URL, title: String, attempt: CommandBarDestinationAttempt) { + guard accepts(attempt) else { return } + launch(url) { [weak self] succeeded in + self?.complete(attempt, result: succeeded ? .opened : .failed, title: title) + } + } + func complete(_ attempt: CommandBarDestinationAttempt, + result: CommandBarDestinationOpening.Result, title: String) { + guard accepts(attempt) else { return } + requests.invalidate() + completed(result, title) + } +} diff --git a/Sources/Vorssaint/Services/CommandBar/CommandBarPresentation.swift b/Sources/Vorssaint/Services/CommandBar/CommandBarPresentation.swift new file mode 100644 index 0000000..179d796 --- /dev/null +++ b/Sources/Vorssaint/Services/CommandBar/CommandBarPresentation.swift @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import AppKit +import SwiftUI + +/// Owns the reusable window and every native monitor attached while it is +/// visible. Search state and command semantics arrive through callbacks. +final class CommandBarPresentation { + var onKeyDown: ((NSEvent, NSPanel) -> NSEvent?)? + var onDismiss: (() -> Void)? + var onCommandHeldChanged: ((Bool) -> Void)? + private var panel: NSPanel? + private var panelScreen: NSRect? + private var keyMonitor: Any? + private var outsideClickMonitor: Any? + private var localClickMonitor: Any? + private var flagsMonitor: Any? + private var activationObserver: NSObjectProtocol? + + var isVisible: Bool { panel?.isVisible == true } + var hasCustomPosition: Bool { positionOffset != .zero } + func prepare() { _ = ensurePanel() } + func show() { + let panel = ensurePanel() + position(panel) + installMonitors(for: panel) + panel.alphaValue = 1 + panel.orderFrontRegardless() + panel.makeKey() + } + func hide() { + removeMonitors() + panel?.orderOut(nil) + } + func release() { + hide() + panel = nil + } + + /// Re-fits the panel to its content as the result list grows and + /// shrinks, keeping the top edge and horizontal center still so the + /// field itself never jumps under the caret. + func refreshLayout() { + guard let panel, panel.isVisible else { return } + DispatchQueue.main.async { [weak self] in + guard let self, let panel = self.panel, panel.isVisible else { return } + panel.contentViewController?.view.layoutSubtreeIfNeeded() + let size = panel.contentViewController?.view.fittingSize ?? panel.frame.size + let screen = self.panelScreen ?? NSScreen.pointerVisibleFrame + var frame = panel.frame + frame.origin.x = frame.midX - size.width / 2 + frame.origin.y = frame.maxY - size.height + frame.size = size + // Growing downward must stop at the screen edge; the list scrolls + // instead of hiding its own footer below the bezel. + frame.origin.y = max(frame.origin.y, screen.minY + 16) + panel.setFrame(frame, display: true) + } + } + + /// Borderless panels refuse key status by default, and the bar's field + /// needs it for typing while the target app stays active. + private final class KeyableBarPanel: NSPanel { + override var canBecomeKey: Bool { true } + } + + private func ensurePanel() -> NSPanel { + if let panel { return panel } + let panel = KeyableBarPanel(contentRect: NSRect(x: 0, y: 0, width: 560, height: 380), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false) + panel.title = AppInfo.name + panel.isReleasedWhenClosed = false + panel.isMovableByWindowBackground = false + panel.hidesOnDeactivate = false + panel.level = .floating + panel.backgroundColor = .clear + panel.isOpaque = false + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle] + let host = NSHostingController(rootView: CommandBarView()) + host.sizingOptions = .preferredContentSize + panel.contentViewController = host + self.panel = panel + return panel + } + + /// Centered, a bit above the middle of the screen the pointer is on: + /// where the eye already is, and where the system's own search field + /// puts itself. Anchored by the top edge so the list can grow and shrink + /// below a field that never moves. Wherever the person dragged the bar + /// away from that spot is added on, so the choice survives the close. + private func position(_ panel: NSPanel, animated: Bool = false) { + panel.contentViewController?.view.layoutSubtreeIfNeeded() + let size = panel.contentViewController?.view.fittingSize ?? NSSize(width: 560, height: 380) + // Decided once, here: moving the pointer to another display while + // typing must not clamp the panel against a screen it is not on. + let screen = NSScreen.pointerVisibleFrame + panelScreen = screen + let offset = positionOffset + let origin = CommandBarPreferences.clampedPanelOrigin( + size: size, in: screen, offset: offset) + panel.setFrame(NSRect(origin: origin, size: size), + display: true, + animate: animated) + } + + /// How far the person dragged the bar from the spot it would otherwise + /// open on. + private var positionOffset: CGSize { + CommandBarPreferences.decodePositionOffset( + UserDefaults.standard.string(forKey: DefaultsKey.commandBarPositionOffset) ?? "") + } + + // MARK: - Moving the bar + + /// Clamps and saves only after the person's drag has ended. Programmatic + /// positioning and content-driven resizing never rewrite this preference. + func finishDrag() { + guard let panel else { return } + let screen = panel.screen?.visibleFrame ?? panelScreen ?? NSScreen.pointerVisibleFrame + panelScreen = screen + let draggedOffset = CGSize( + width: panel.frame.midX - screen.midX, + height: panel.frame.maxY - (screen.minY + screen.height * 0.72)) + let origin = CommandBarPreferences.clampedPanelOrigin( + size: panel.frame.size, in: screen, offset: draggedOffset) + if panel.frame.origin != origin { panel.setFrameOrigin(origin) } + let offset = CGSize(width: panel.frame.midX - screen.midX, + height: panel.frame.maxY - (screen.minY + screen.height * 0.72)) + let encoded = CommandBarPreferences.encodePositionOffset(offset) + if encoded.isEmpty { + UserDefaults.standard.removeObject(forKey: DefaultsKey.commandBarPositionOffset) + } else { + UserDefaults.standard.set(encoded, forKey: DefaultsKey.commandBarPositionOffset) + } + } + + /// The way back: a double-click on the mark, or the button in Settings, + /// returns the bar to the spot it opens on by default, with the same + /// short slide it took on the way there. + func resetPosition() { + UserDefaults.standard.removeObject(forKey: DefaultsKey.commandBarPositionOffset) + guard let panel, panel.isVisible else { return } + position(panel, animated: true) + } + + private func installMonitors(for panel: NSPanel) { + removeMonitors() + keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self, weak panel] event in + guard let self, let panel, event.window === panel else { return event } + // Input-method composition owns Return and navigation keys. + if self.fieldIsComposing(in: panel) { return event } + guard let onKeyDown = self.onKeyDown else { return event } + return onKeyDown(event, panel) + } + flagsMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in + guard let self else { return event } + let held = event.modifierFlags.contains(.command) + self.onCommandHeldChanged?(held) + return event + } + let mouseEvents: NSEvent.EventTypeMask = [.leftMouseDown, .rightMouseDown, .otherMouseDown] + localClickMonitor = NSEvent.addLocalMonitorForEvents(matching: mouseEvents) { [weak self, weak panel] event in + guard let self, let panel, panel.isVisible else { return event } + if event.window !== panel, !Self.mouseIsInside(panel) { + self.onDismiss?() + } + return event + } + outsideClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: mouseEvents) { [weak self, weak panel] event in + guard let self, let panel, panel.isVisible else { return } + if event.windowNumber != panel.windowNumber, !Self.mouseIsInside(panel), + // Every key on the Accessibility Keyboard is a click outside this + // panel. Dismissing on those makes the panel impossible to type into. + !AssistiveKeyboard.ownsCocoaPoint(NSEvent.mouseLocation) { + self.onDismiss?() + } + } + activationObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didActivateApplicationNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self, + let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + app.bundleIdentifier != Bundle.main.bundleIdentifier, + app.bundleIdentifier != AssistiveKeyboard.bundleID + else { return } + self.onDismiss?() + } + } + + private func removeMonitors() { + if let keyMonitor { + NSEvent.removeMonitor(keyMonitor) + self.keyMonitor = nil + } + if let localClickMonitor { + NSEvent.removeMonitor(localClickMonitor) + self.localClickMonitor = nil + } + if let outsideClickMonitor { + NSEvent.removeMonitor(outsideClickMonitor) + self.outsideClickMonitor = nil + } + if let flagsMonitor { + NSEvent.removeMonitor(flagsMonitor) + self.flagsMonitor = nil + } + onCommandHeldChanged?(false) + if let activationObserver { + NSWorkspace.shared.notificationCenter.removeObserver(activationObserver) + self.activationObserver = nil + } + } + + /// True while an input method is still composing in the field. The panel + /// edits through a field editor, so the marked range lives there. + private func fieldIsComposing(in panel: NSPanel) -> Bool { + guard let responder = panel.firstResponder as? NSTextView else { return false } + return responder.hasMarkedText() + } + + private static func mouseIsInside(_ panel: NSPanel) -> Bool { + panel.frame.insetBy(dx: -2, dy: -2).contains(NSEvent.mouseLocation) + } + +} diff --git a/Sources/Vorssaint/Services/CommandBar/CommandBarService.swift b/Sources/Vorssaint/Services/CommandBar/CommandBarService.swift index 304bd8f..464e224 100644 --- a/Sources/Vorssaint/Services/CommandBar/CommandBarService.swift +++ b/Sources/Vorssaint/Services/CommandBar/CommandBarService.swift @@ -4,7 +4,6 @@ import AppKit import Carbon.HIToolbox import Combine -import SwiftUI /// The command bar: one floating field, summoned by a global shortcut, that /// finds and runs everything the app can do. The panel never activates, so @@ -72,7 +71,15 @@ final class CommandBarService: ObservableObject { } @Published private(set) var argumentSubmission = CommandBarArgumentSubmission() @Published private(set) var destinationFailure: CommandBarDestinationFailure? - private var destinationRequests = CommandBarDestinationRequests() + private lazy var executor = CommandBarExecutor( + context: { [weak self] in + guard let self else { return nil } + return .init(presentationID: self.presentationID, query: self.query, isVisible: self.isVisible) + }, + exists: { FileManager.default.fileExists(atPath: $0) }, + open: { NSWorkspace.shared.open($0) }, + launch: { CommandBarService.launchApplication(at: $0, completion: $1) }, + completed: { [weak self] result, title in self?.showDestinationResult(result, title: title) }) @Published private(set) var presentationID = UUID() @Published private(set) var shortcutRegistrationFailed = false /// Rows whose own combination the system refused, because another app got @@ -103,12 +110,19 @@ final class CommandBarService: ObservableObject { private let hotkey = QuickToolHotkey(id: 20) private var rowHotkeys: [QuickToolHotkey] = [] - private var panel: NSPanel? - private var keyMonitor: Any? - private var outsideClickMonitor: Any? - private var localClickMonitor: Any? - private var flagsMonitor: Any? - private var activationObserver: NSObjectProtocol? + private lazy var presentation: CommandBarPresentation = { + let presentation = CommandBarPresentation() + presentation.onKeyDown = { [weak self] event, panel in + guard let self else { return event } + return self.handleKeyDown(event, in: panel) + } + presentation.onDismiss = { [weak self] in self?.hide() } + presentation.onCommandHeldChanged = { [weak self] held in + guard let self, held != self.commandIsHeld else { return } + self.commandIsHeld = held + } + return presentation + }() private var micStateSubscription: AnyCancellable? private var micCatalogRefreshQueued = false @@ -173,7 +187,6 @@ final class CommandBarService: ObservableObject { /// Where the pointer sat when the bar opened. A row under a pointer that /// has not moved must not steal the selection from the keyboard. private var lastPointerLocation = NSPoint.zero - private var panelScreen: NSRect? /// The selected row's id, so a rebuilt list keeps the selection on the /// same command instead of on the same position. private var selectedID: String? @@ -214,12 +227,12 @@ final class CommandBarService: ObservableObject { // asks to see it for the first time. DispatchQueue.main.async { [weak self] in guard AppFeature.commandBar.isAvailable, let self else { return } - _ = self.ensurePanel() + self.presentation.prepare() } } if !available { hide() - panel = nil + presentation.release() // Uninstalled means uninstalled: the catalog, the app scan and the // window list all go, not just the window. catalog = [] @@ -255,7 +268,7 @@ final class CommandBarService: ObservableObject { } var isVisible: Bool { - panel?.isVisible == true + presentation.isVisible } func toggle() { @@ -268,7 +281,7 @@ final class CommandBarService: ObservableObject { private func show(promptingFor stableKey: String?) { guard AppFeature.commandBar.isAvailable else { return } - let panel = ensurePanel() + presentation.prepare() if AppFeature.textSnippets.isAvailable { TextSnippetService.shared.setCommandBarVisible(true) } @@ -284,7 +297,8 @@ final class CommandBarService: ObservableObject { self.refreshResults() } } - present(panel) + observeLiveCatalog() + presentation.show() // Ordering the prepared panel is the keystroke path. Home is filled on // the next main-loop turn, when a close or newer opening can supersede it. DispatchQueue.main.async { [weak self] in @@ -313,8 +327,7 @@ final class CommandBarService: ObservableObject { savedQuery = "" queryBeforeCompletion = nil completedQuery = nil - queryWhenRun = "" - selectionWhenRun = "" + executor.resetInput() lastPointerLocation = NSEvent.mouseLocation selectedID = nil lastRankedQuery = nil @@ -355,14 +368,6 @@ final class CommandBarService: ObservableObject { loadKillProcessEntries(for: id) } - private func present(_ panel: NSPanel) { - position(panel) - installMonitors(for: panel) - panel.alphaValue = 1 - panel.orderFrontRegardless() - panel.makeKey() - } - func hide() { invalidateDestinationAttempt() if AppFeature.textSnippets.isAvailable { @@ -382,8 +387,10 @@ final class CommandBarService: ObservableObject { rows = [] sectionTitles = [:] } - removeMonitors() - panel?.orderOut(nil) + micStateSubscription?.cancel() + micStateSubscription = nil + micCatalogRefreshQueued = false + presentation.hide() mode = .search // A selection belongs to the moment the bar was opened. Keeping it // would offer to act on text the person may have replaced since. @@ -402,26 +409,7 @@ final class CommandBarService: ObservableObject { clearIndex() } - /// Re-fits the panel to its content as the result list grows and - /// shrinks, keeping the top edge and horizontal center still so the - /// field itself never jumps under the caret. - func refreshPanelLayout() { - guard let panel, panel.isVisible else { return } - DispatchQueue.main.async { [weak self] in - guard let self, let panel = self.panel, panel.isVisible else { return } - panel.contentViewController?.view.layoutSubtreeIfNeeded() - let size = panel.contentViewController?.view.fittingSize ?? panel.frame.size - let screen = self.panelScreen ?? NSScreen.pointerVisibleFrame - var frame = panel.frame - frame.origin.x = frame.midX - size.width / 2 - frame.origin.y = frame.maxY - size.height - frame.size = size - // Growing downward must stop at the screen edge; the list scrolls - // instead of hiding its own footer below the bezel. - frame.origin.y = max(frame.origin.y, screen.minY + 16) - panel.setFrame(frame, display: true) - } - } + func refreshPanelLayout() { presentation.refreshLayout() } // MARK: - Results @@ -558,8 +546,8 @@ final class CommandBarService: ObservableObject { /// Closing the bar wipes both before the row's own closure gets to work, /// so they are handed over here instead of being read back from a panel /// that is already gone. - private(set) var queryWhenRun = "" - private(set) var selectionWhenRun = "" + var queryWhenRun: String { executor.queryWhenRun } + var selectionWhenRun: String { executor.selectionWhenRun } /// The text the person had selected when the bar opened, for the rows and /// the saved destinations that act on it. @@ -772,7 +760,7 @@ final class CommandBarService: ObservableObject { UserDefaults.standard.string(forKey: DefaultsKey.commandBarQueryHabits)) shortcutCache = rowShortcuts compactMode = UserDefaults.standard.bool(forKey: DefaultsKey.commandBarCompactMode) - hasCustomPosition = positionOffset != .zero + hasCustomPosition = presentation.hasCustomPosition reloadFileSearchCaches() } @@ -1814,7 +1802,7 @@ final class CommandBarService: ObservableObject { if case .actions = mode { leaveActions() } let attempt = beginDestinationAttempt() guard FileManager.default.fileExists(atPath: path) else { - completeDestinationAttempt(attempt, result: .unavailable, title: entry.title) + executor.complete(attempt, result: .unavailable, title: entry.title) return } hide() @@ -1823,34 +1811,26 @@ final class CommandBarService: ObservableObject { func beginDestinationAttempt() -> CommandBarDestinationAttempt { invalidateDestinationAttempt() - return destinationRequests.begin(presentationID: presentationID, query: query, isVisible: isVisible) + return executor.begin()! } func acceptsDestinationAttempt(_ attempt: CommandBarDestinationAttempt) -> Bool { - destinationRequests.accepts(attempt, presentationID: presentationID, query: query, isVisible: isVisible) + executor.accepts(attempt) } private func invalidateDestinationAttempt() { - destinationRequests.invalidate() + executor.invalidate() guard destinationFailure != nil else { return } destinationFailure = nil refreshPanelLayout() } func openDestination(_ url: URL?, title: String, attempt: CommandBarDestinationAttempt? = nil) { - let attempt = attempt ?? beginDestinationAttempt() - guard acceptsDestinationAttempt(attempt) else { return } - let result = CommandBarDestinationOpening.open(url: url, - exists: { FileManager.default.fileExists(atPath: $0) }, - open: { NSWorkspace.shared.open($0) }) - completeDestinationAttempt(attempt, result: result, title: title) + executor.openDestination(url, title: title, attempt: attempt ?? beginDestinationAttempt()) } func openApplicationDestination(at url: URL, title: String) { - let attempt = beginDestinationAttempt() - Self.launchApplication(at: url) { [weak self] succeeded in - self?.completeDestinationAttempt(attempt, result: succeeded ? .opened : .failed, title: title) - } + executor.openApplication(at: url, title: title, attempt: beginDestinationAttempt()) } /// AppKit delivers this completion on a concurrent queue. Both search @@ -1862,10 +1842,7 @@ final class CommandBarService: ObservableObject { } } - private func completeDestinationAttempt(_ attempt: CommandBarDestinationAttempt, - result: CommandBarDestinationOpening.Result, title: String) { - guard acceptsDestinationAttempt(attempt) else { return } - destinationRequests.invalidate() + private func showDestinationResult(_ result: CommandBarDestinationOpening.Result, title: String) { let text = FeatureStrings.commandBar(L10n.shared.language) let message: String switch result { @@ -2308,15 +2285,9 @@ final class CommandBarService: ObservableObject { forKey: DefaultsKey.commandBarQueryHabits) } } - // Handed over before hiding, which wipes the field and the selection. - queryWhenRun = query - selectionWhenRun = selectedText - guard !entry.keepsBarOpen, !entry.waitsForOpenResult else { - entry.run(value) - return - } - hide() - entry.run(value) + executor.execute(query: query, selection: selectedText, value: value, + keepsBarOpen: entry.keepsBarOpen, waitsForOpenResult: entry.waitsForOpenResult, + dismiss: { self.hide() }, run: entry.run) } // MARK: - Clipboard paste @@ -2744,99 +2715,19 @@ final class CommandBarService: ObservableObject { // MARK: - Panel - /// Borderless panels refuse key status by default, and the bar's field - /// needs it for typing while the target app stays active. - private final class KeyableBarPanel: NSPanel { - override var canBecomeKey: Bool { true } - } - - private func ensurePanel() -> NSPanel { - if let panel { return panel } - let panel = KeyableBarPanel(contentRect: NSRect(x: 0, y: 0, width: 560, height: 380), - styleMask: [.borderless, .nonactivatingPanel], - backing: .buffered, - defer: false) - panel.title = AppInfo.name - panel.isReleasedWhenClosed = false - panel.isMovableByWindowBackground = false - panel.hidesOnDeactivate = false - panel.level = .floating - panel.backgroundColor = .clear - panel.isOpaque = false - panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle] - let host = NSHostingController(rootView: CommandBarView()) - host.sizingOptions = .preferredContentSize - panel.contentViewController = host - self.panel = panel - return panel - } - - /// Centered, a bit above the middle of the screen the pointer is on: - /// where the eye already is, and where the system's own search field - /// puts itself. Anchored by the top edge so the list can grow and shrink - /// below a field that never moves. Wherever the person dragged the bar - /// away from that spot is added on, so the choice survives the close. - private func position(_ panel: NSPanel, animated: Bool = false) { - panel.contentViewController?.view.layoutSubtreeIfNeeded() - let size = panel.contentViewController?.view.fittingSize ?? NSSize(width: 560, height: 380) - // Decided once, here: moving the pointer to another display while - // typing must not clamp the panel against a screen it is not on. - let screen = NSScreen.pointerVisibleFrame - panelScreen = screen - let offset = positionOffset - let origin = CommandBarPreferences.clampedPanelOrigin( - size: size, in: screen, offset: offset) - panel.setFrame(NSRect(origin: origin, size: size), - display: true, - animate: animated) - } - - /// How far the person dragged the bar from the spot it would otherwise - /// open on. - private var positionOffset: CGSize { - CommandBarPreferences.decodePositionOffset( - UserDefaults.standard.string(forKey: DefaultsKey.commandBarPositionOffset) ?? "") - } - - // MARK: - Moving the bar - - /// Clamps and saves only after the person's drag has ended. Programmatic - /// positioning and content-driven resizing never rewrite this preference. func finishPanelDrag() { - guard let panel else { return } - let screen = panel.screen?.visibleFrame ?? panelScreen ?? NSScreen.pointerVisibleFrame - panelScreen = screen - let draggedOffset = CGSize( - width: panel.frame.midX - screen.midX, - height: panel.frame.maxY - (screen.minY + screen.height * 0.72)) - let origin = CommandBarPreferences.clampedPanelOrigin( - size: panel.frame.size, in: screen, offset: draggedOffset) - if panel.frame.origin != origin { panel.setFrameOrigin(origin) } - let offset = CGSize(width: panel.frame.midX - screen.midX, - height: panel.frame.maxY - (screen.minY + screen.height * 0.72)) - let encoded = CommandBarPreferences.encodePositionOffset(offset) - if encoded.isEmpty { - UserDefaults.standard.removeObject(forKey: DefaultsKey.commandBarPositionOffset) - } else { - UserDefaults.standard.set(encoded, forKey: DefaultsKey.commandBarPositionOffset) - } - hasCustomPosition = !encoded.isEmpty + presentation.finishDrag() + hasCustomPosition = presentation.hasCustomPosition } - /// The way back: a double-click on the mark, or the button in Settings, - /// returns the bar to the spot it opens on by default, with the same - /// short slide it took on the way there. func resetPanelPosition() { - UserDefaults.standard.removeObject(forKey: DefaultsKey.commandBarPositionOffset) - hasCustomPosition = false - guard let panel, panel.isVisible else { return } - position(panel, animated: true) + presentation.resetPosition() + hasCustomPosition = presentation.hasCustomPosition } - // MARK: - Monitors - - private func installMonitors(for panel: NSPanel) { - removeMonitors() + private func observeLiveCatalog() { + micStateSubscription?.cancel() + micCatalogRefreshQueued = false let micPresentationID = presentationID micStateSubscription = MicMuteService.shared.objectWillChange.sink { [weak self] _ in guard let self, !self.micCatalogRefreshQueued else { return } @@ -2852,216 +2743,135 @@ final class CommandBarService: ObservableObject { self.refreshResults() } } - keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self, weak panel] event in - guard let self, let panel, event.window === panel else { return event } - - // While a language is composing a character (Japanese, Korean, - // Chinese, and dead keys for accents) Return confirms the - // candidate and the arrows walk it. Taking those keys here would - // make the field unusable in five of the languages the app - // speaks, so composition always wins. - if self.fieldIsComposing(in: panel) { return event } - - // Listening for a combination: every key belongs to the person, - // except the two that mean "never mind" and "take it off". The - // recording tap is the primary path; this is the fallback when - // that tap cannot exist. - if case .capturingShortcut = self.mode { - self.handleCaptureKey( - keyCode: Int64(event.keyCode), - modifiers: GlobalShortcutModifiers(eventFlags: event.modifierFlags)) - return nil - } + } - let navigationModifiers = event.modifierFlags - .intersection([.command, .option, .shift, .control]) - if event.modifierFlags.contains(.command) { - // `characters` is the Command-aware key macOS resolves: it - // follows remapped Latin layouts and supplies the positional - // Latin equivalent when the active layout is non-Latin. Option - // rewrites it into the alternate glyph, and the app's own menu - // owns ⌥⌘H, so while Option is held the unmodified reading is - // the one that still names the key to swallow. - let key = (event.modifierFlags.contains(.option) - ? event.charactersIgnoringModifiers - : event.characters)?.lowercased() - switch key { - case "q", "w", "m", "h": - // The app's menu owns these combinations and the panel is - // key, so they would quit, close or hide Vorssaint while - // the person believes they are acting on the app the bar - // is floating over. - return nil - case ",": - self.hide() - SettingsRouter.shared.page = .commandBar - appDelegate()?.openSettingsWindow() - return nil - case "k": - self.openActions() - return nil - case "p": - if let entry = self.selectedEntry, !entry.isAnswer, - CommandBarPreferences.acceptsPin(rowID: entry.id) { - self.togglePin(entry) - } - return nil - case "a" where navigationModifiers == [.command]: - return NSApp.sendAction(#selector(NSText.selectAll(_:)), to: nil, from: panel) ? nil : event - case "c" where navigationModifiers == [.command]: - return NSApp.sendAction(#selector(NSText.copy(_:)), to: nil, from: panel) ? nil : event - case "x" where navigationModifiers == [.command]: - return NSApp.sendAction(#selector(NSText.cut(_:)), to: nil, from: panel) ? nil : event - case "v" where navigationModifiers == [.command]: - return NSApp.sendAction(#selector(NSText.paste(_:)), to: nil, from: panel) ? nil : event - default: - break - } - } - // ⌘Return shows the selected row where it lives. Guarded by the - // row's own rule, so a row with nowhere to go hands the keys back - // and Return goes on meaning what it always did. - if event.modifierFlags.contains(.command), - Int(event.keyCode) == kVK_Return || Int(event.keyCode) == kVK_ANSI_KeypadEnter { - if case .search = self.mode, let entry = self.selectedEntry, - entry.canRevealInFinder { - self.revealInFinder(entry) - return nil - } - } - switch Int(event.keyCode) { - case kVK_Escape: - self.stepBack() + private func handleKeyDown(_ event: NSEvent, in panel: NSPanel) -> NSEvent? { + // Listening for a combination: every key belongs to the person, + // except the two that mean "never mind" and "take it off". The + // recording tap is the primary path; this is the fallback when + // that tap cannot exist. + if case .capturingShortcut = self.mode { + self.handleCaptureKey( + keyCode: Int64(event.keyCode), + modifiers: GlobalShortcutModifiers(eventFlags: event.modifierFlags)) + return nil + } + + let navigationModifiers = event.modifierFlags + .intersection([.command, .option, .shift, .control]) + if event.modifierFlags.contains(.command) { + // `characters` is the Command-aware key macOS resolves: it + // follows remapped Latin layouts and supplies the positional + // Latin equivalent when the active layout is non-Latin. Option + // rewrites it into the alternate glyph, and the app's own menu + // owns ⌥⌘H, so while Option is held the unmodified reading is + // the one that still names the key to swallow. + let key = (event.modifierFlags.contains(.option) + ? event.charactersIgnoringModifiers + : event.characters)?.lowercased() + switch key { + case "q", "w", "m", "h": + // The app's menu owns these combinations and the panel is + // key, so they would quit, close or hide Vorssaint while + // the person believes they are acting on the app the bar + // is floating over. return nil - case kVK_Return, kVK_ANSI_KeypadEnter: - self.runSelected() + case ",": + self.hide() + SettingsRouter.shared.page = .commandBar + appDelegate()?.openSettingsWindow() return nil - case kVK_UpArrow: - if case .actions = self.mode { self.moveActionSelection(-1) } else { self.moveSelection(-1) } + case "k": + self.openActions() return nil - case kVK_DownArrow: - if case .actions = self.mode { - self.moveActionSelection(1) - } else if !self.peekHome() { - self.moveSelection(1) + case "p": + if let entry = self.selectedEntry, !entry.isAnswer, + CommandBarPreferences.acceptsPin(rowID: entry.id) { + self.togglePin(entry) } return nil - case kVK_LeftArrow: - // Handed back untouched when the field has text in it. - return self.moveCategory(-1) ? nil : event - case kVK_RightArrow: - return self.moveCategory(1) ? nil : event - case kVK_Tab: - self.completeSelection() - return nil + case "a" where navigationModifiers == [.command]: + return NSApp.sendAction(#selector(NSText.selectAll(_:)), to: nil, from: panel) ? nil : event + case "c" where navigationModifiers == [.command]: + return NSApp.sendAction(#selector(NSText.copy(_:)), to: nil, from: panel) ? nil : event + case "x" where navigationModifiers == [.command]: + return NSApp.sendAction(#selector(NSText.cut(_:)), to: nil, from: panel) ? nil : event + case "v" where navigationModifiers == [.command]: + return NSApp.sendAction(#selector(NSText.paste(_:)), to: nil, from: panel) ? nil : event default: - // ⌘1…⌘9 run by position; plain digits belong to the field. - if event.modifierFlags.contains(.command), - let index = Self.digitIndex(for: event.keyCode) { - self.run(at: index) - return nil - } - if navigationModifiers == [.control], - let key = event.charactersIgnoringModifiers?.lowercased() { - // Match the typed letter so alternate keyboard layouts - // follow the keys the person sees. - switch key { - case "n": - // Same ↓ in the footer's own hint. - if case .actions = self.mode { - self.moveActionSelection(1) - } else if !self.peekHome() { - self.moveSelection(1) - } - return nil - case "p": - if case .actions = self.mode { self.moveActionSelection(-1) } else { self.moveSelection(-1) } - return nil - default: - break - } - } - // Typing while a confirmation is up takes the confirmation - // down. Otherwise a destructive Return stays armed behind - // what looks like an ordinary search. - if case .confirm = self.mode { self.stepBack() } - if case .naming = self.mode { self.aliasWarning = nil } - return event + break } } - flagsMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in - guard let self else { return event } - let held = event.modifierFlags.contains(.command) - if held != self.commandIsHeld { self.commandIsHeld = held } - return event - } - let mouseEvents: NSEvent.EventTypeMask = [.leftMouseDown, .rightMouseDown, .otherMouseDown] - localClickMonitor = NSEvent.addLocalMonitorForEvents(matching: mouseEvents) { [weak self, weak panel] event in - guard let self, let panel, panel.isVisible else { return event } - if event.window !== panel, !Self.mouseIsInside(panel) { - self.hide() + // ⌘Return shows the selected row where it lives. Guarded by the + // row's own rule, so a row with nowhere to go hands the keys back + // and Return goes on meaning what it always did. + if event.modifierFlags.contains(.command), + Int(event.keyCode) == kVK_Return || Int(event.keyCode) == kVK_ANSI_KeypadEnter { + if case .search = self.mode, let entry = self.selectedEntry, + entry.canRevealInFinder { + self.revealInFinder(entry) + return nil } - return event } - outsideClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: mouseEvents) { [weak self, weak panel] event in - guard let self, let panel, panel.isVisible else { return } - if event.windowNumber != panel.windowNumber, !Self.mouseIsInside(panel), - // Every key on the Accessibility Keyboard is a click outside this - // panel. Dismissing on those makes the panel impossible to type into. - !AssistiveKeyboard.ownsCocoaPoint(NSEvent.mouseLocation) { - self.hide() + switch Int(event.keyCode) { + case kVK_Escape: + self.stepBack() + return nil + case kVK_Return, kVK_ANSI_KeypadEnter: + self.runSelected() + return nil + case kVK_UpArrow: + if case .actions = self.mode { self.moveActionSelection(-1) } else { self.moveSelection(-1) } + return nil + case kVK_DownArrow: + if case .actions = self.mode { + self.moveActionSelection(1) + } else if !self.peekHome() { + self.moveSelection(1) } + return nil + case kVK_LeftArrow: + // Handed back untouched when the field has text in it. + return self.moveCategory(-1) ? nil : event + case kVK_RightArrow: + return self.moveCategory(1) ? nil : event + case kVK_Tab: + self.completeSelection() + return nil + default: + // ⌘1…⌘9 run by position; plain digits belong to the field. + if event.modifierFlags.contains(.command), + let index = Self.digitIndex(for: event.keyCode) { + self.run(at: index) + return nil + } + if navigationModifiers == [.control], + let key = event.charactersIgnoringModifiers?.lowercased() { + // Match the typed letter so alternate keyboard layouts + // follow the keys the person sees. + switch key { + case "n": + // Same ↓ in the footer's own hint. + if case .actions = self.mode { + self.moveActionSelection(1) + } else if !self.peekHome() { + self.moveSelection(1) + } + return nil + case "p": + if case .actions = self.mode { self.moveActionSelection(-1) } else { self.moveSelection(-1) } + return nil + default: + break + } + } + // Typing while a confirmation is up takes the confirmation + // down. Otherwise a destructive Return stays armed behind + // what looks like an ordinary search. + if case .confirm = self.mode { self.stepBack() } + if case .naming = self.mode { self.aliasWarning = nil } + return event } - activationObserver = NSWorkspace.shared.notificationCenter.addObserver( - forName: NSWorkspace.didActivateApplicationNotification, - object: nil, - queue: .main - ) { [weak self] notification in - guard let self, - let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, - app.bundleIdentifier != Bundle.main.bundleIdentifier, - app.bundleIdentifier != AssistiveKeyboard.bundleID - else { return } - self.hide() - } - } - - private func removeMonitors() { - micStateSubscription?.cancel() - micStateSubscription = nil - micCatalogRefreshQueued = false - if let keyMonitor { - NSEvent.removeMonitor(keyMonitor) - self.keyMonitor = nil - } - if let localClickMonitor { - NSEvent.removeMonitor(localClickMonitor) - self.localClickMonitor = nil - } - if let outsideClickMonitor { - NSEvent.removeMonitor(outsideClickMonitor) - self.outsideClickMonitor = nil - } - if let flagsMonitor { - NSEvent.removeMonitor(flagsMonitor) - self.flagsMonitor = nil - } - commandIsHeld = false - if let activationObserver { - NSWorkspace.shared.notificationCenter.removeObserver(activationObserver) - self.activationObserver = nil - } - } - - /// True while an input method is still composing in the field. The panel - /// edits through a field editor, so the marked range lives there. - private func fieldIsComposing(in panel: NSPanel) -> Bool { - guard let responder = panel.firstResponder as? NSTextView else { return false } - return responder.hasMarkedText() - } - - private static func mouseIsInside(_ panel: NSPanel) -> Bool { - panel.frame.insetBy(dx: -2, dy: -2).contains(NSEvent.mouseLocation) } private static func digitIndex(for keyCode: UInt16) -> Int? { diff --git a/Sources/Vorssaint/Services/Display/BrightnessService.swift b/Sources/Vorssaint/Services/Display/BrightnessService.swift index 8dea492..f4f5c0c 100644 --- a/Sources/Vorssaint/Services/Display/BrightnessService.swift +++ b/Sources/Vorssaint/Services/Display/BrightnessService.swift @@ -33,6 +33,13 @@ struct BrightnessDisplay: Identifiable, Equatable { var readable: Bool /// Unknown slider positions are input affordances, never measured percentages. var hasKnownBrightness = true + var fingerprint = "" + var state = BrightnessState() + var observedBrightness: Double? { state.observed } + var requestedBrightness: Double? { state.requested } + var observedAt: Date? { state.observedAt } + var status: BrightnessState.Status { state.status } + var failure: BrightnessSupport.DDCFailure? { state.failure } } /// Brightness sliders for every display, built-in and external. The built-in @@ -44,8 +51,8 @@ struct BrightnessDisplay: Identifiable, Equatable { /// I2C traffic. Keyboard light state is read when Quick toggles opens or one /// of its global shortcuts is pressed. /// While display control is on, the standing resources are one screen change -/// observer and a pair of wake observers, and no timers; everything else -/// happens when a slider moves, a panel opens or the Mac wakes. All I2C work +/// observer and a pair of wake observers. Visible controls also refresh at a +/// bounded interval; hidden controls do not poll. All I2C work /// runs on a serial queue with the pacing displays need, and slider drags /// coalesce to the newest value per display. final class BrightnessService: ObservableObject { @@ -90,6 +97,34 @@ final class BrightnessService: ObservableObject { var ddcReadable = false } + private var refreshObservers = Set() + private var visibleRefreshTimer: Timer? + + func beginVisibleRefresh(_ owner: UUID) { + refreshObservers.insert(owner) + updateVisibleRefresh() + refresh() + } + + func endVisibleRefresh(_ owner: UUID) { + refreshObservers.remove(owner) + updateVisibleRefresh() + } + + private func updateVisibleRefresh() { + visibleRefreshTimer?.invalidate() + visibleRefreshTimer = nil + guard running, !refreshObservers.isEmpty else { return } + visibleRefreshTimer = Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { [weak self] _ in + guard let self else { return } + self.stateLock.lock() + let busy = !self.latestBrightnessWrites.isEmpty || self.rebuildingTopology != nil + self.stateLock.unlock() + if !busy { self.refresh() } + } + visibleRefreshTimer?.tolerance = 0.5 + } + private var screenObserver: NSObjectProtocol? private var rebuildDebounce: DispatchWorkItem? private var wakeObservers: [NSObjectProtocol] = [] @@ -133,9 +168,12 @@ final class BrightnessService: ObservableObject { let value: Double let showOSD: Bool let sequence: UInt64 + let fingerprint: String + let method: BrightnessDisplay.Method } private var pendingLevels: [CGDirectDisplayID: PendingWrite] = [:] private var writeSequence: UInt64 = 0 + private var displayWriteVersions: [CGDirectDisplayID: UInt64] = [:] private var latestBrightnessWrites: [CGDirectDisplayID: UInt64] = [:] /// Keeps fast system-key repeats based on the newest requested value while /// DisplayServices is still applying the previous asynchronous write. @@ -150,18 +188,19 @@ final class BrightnessService: ObservableObject { } private var ddcPendingSteps: [CGDirectDisplayID: DDCReadSteps] = [:] private var drainScheduled = false - /// Session memory for write-only monitors, so their slider does not jump - /// back to a placeholder between panel openings. Each value remembers + /// Read-confirmed session values and successful software dim factors. + /// Each value remembers /// which physical monitor it belonged to: display numbers are handed out /// again after a reconnection, and a level saved for one monitor must /// never dim whatever inherits its number. private struct RememberedLevel { var value: Double var fingerprint: String + var method: BrightnessDisplay.Method } private var lastApplied: [CGDirectDisplayID: RememberedLevel] = [:] - /// When each display's remembered level was last known to match the - /// monitor, so a step can tell a value it just wrote from one that has + /// When each display's remembered level was read back from the + /// monitor, so a step can tell a fresh observation from one that has /// been sitting around while the monitor moved on without it. private var levelKnownAt: [CGDirectDisplayID: Date] = [:] /// How long a remembered level is taken at face value. Long enough that @@ -170,6 +209,7 @@ final class BrightnessService: ObservableObject { private static let levelTrustWindow: TimeInterval = 3 /// When the previous DDC command to each display finished, so the next /// one keeps the standard's spacing. Touched only on the work queue. + private var ddcReadFailures: [CGDirectDisplayID: BrightnessSupport.DDCFailure] = [:] private var ddcCommandEnds: [CGDirectDisplayID: UInt64] = [:] /// The unmodified gamma curve of each software-dimmed display, captured /// before the first change so restoring is exact. Touched only on the @@ -198,13 +238,17 @@ final class BrightnessService: ObservableObject { /// Identifies the physical monitor behind a display number. private static func displayFingerprint(_ id: CGDirectDisplayID) -> String { - "\(CGDisplayVendorNumber(id)):\(CGDisplayModelNumber(id)):\(CGDisplaySerialNumber(id))" + let uuid = CGDisplayCreateUUIDFromDisplayID(id).map { + CFUUIDCreateString(nil, $0.takeRetainedValue()) as String + } + return BrightnessSupport.displayIdentity(uuid: uuid, vendor: CGDisplayVendorNumber(id), + model: CGDisplayModelNumber(id), serial: CGDisplaySerialNumber(id)) ?? "" } /// A remembered level, only if it was saved for the monitor currently /// behind this display number. Callers hold the state lock. private func rememberedLevel(for id: CGDirectDisplayID) -> Double? { - guard let remembered = lastApplied[id], + guard let remembered = lastApplied[id], !remembered.fingerprint.isEmpty, remembered.fingerprint == Self.displayFingerprint(id) else { return nil } return remembered.value } @@ -226,6 +270,7 @@ final class BrightnessService: ObservableObject { private let displayBrightnessDecreaseHotkey = QuickToolHotkey(id: 59) private let displayBrightnessIncreaseHotkey = QuickToolHotkey(id: 60) /// Stale rebuilds (an unplug mid-scan) must not overwrite fresh state. + private var lifecycleGeneration = 0 private var rebuildGeneration = 0 /// The topology a queued or running rebuild already covers. Opening the /// panel must not put the same slow monitor probe behind itself again. @@ -307,7 +352,6 @@ final class BrightnessService: ObservableObject { func syncWithPreferences() { let wanted = AppFeature.brightness.isAvailable - && UserDefaults.standard.bool(forKey: DefaultsKey.brightnessControlEnabled) if wanted { start() } else { stop() } syncKeyTap() syncKeyboardBrightnessHotkeys() @@ -332,7 +376,6 @@ final class BrightnessService: ObservableObject { private func syncDisplayBrightnessHotkeys() { let enabled = AppFeature.brightness.isAvailable - && UserDefaults.standard.bool(forKey: DefaultsKey.brightnessControlEnabled) && UserDefaults.standard.bool(forKey: BrightnessShortcutPreferenceKey.enabled) let decrease = GlobalShortcutRole.displayBrightnessDecrease.configuredShortcut() let increase = GlobalShortcutRole.displayBrightnessIncrease.configuredShortcut() @@ -377,6 +420,7 @@ final class BrightnessService: ObservableObject { private func start() { guard !running else { return } running = true + updateVisibleRefresh() screenObserver = NotificationCenter.default.addObserver( forName: NSApplication.didChangeScreenParametersNotification, object: nil, queue: .main) { [weak self] _ in @@ -389,6 +433,7 @@ final class BrightnessService: ObservableObject { func stop() { guard running else { return } running = false + updateVisibleRefresh() removeKeyTap() removeFunctionKeyTap() if let screenObserver { NotificationCenter.default.removeObserver(screenObserver) } @@ -398,12 +443,11 @@ final class BrightnessService: ObservableObject { rebuildDebounce = nil stateLock.lock() rebuildGeneration += 1 + lifecycleGeneration += 1 rebuildingTopology = nil routes = [:] - // An optimistic slider request is not a successful hardware setting. - for id in latestBrightnessWrites.keys { lastApplied.removeValue(forKey: id) } pendingLevels = [:] - writeSequence = 0 + displayWriteVersions = [:] latestBrightnessWrites = [:] systemWritesInFlight = [] levelKnownAt = [:] @@ -454,12 +498,13 @@ final class BrightnessService: ObservableObject { } rebuildGeneration += 1 let generation = rebuildGeneration + let versions = displayWriteVersions rebuildingTopology = topology stateLock.unlock() let screenNames = Self.localizedScreenNames() workQueue.async { [weak self] in self?.rebuild(generation: generation, previousDisplays: previousDisplays, - screenNames: screenNames) + screenNames: screenNames, versions: versions) } } @@ -482,21 +527,27 @@ final class BrightnessService: ObservableObject { /// queue, and a drag folds into one write of the newest value. func setBrightness(_ value: Double, for id: CGDirectDisplayID, showOSD: Bool = false) { + guard running, value.isFinite, + let method = displays.first(where: { $0.id == id && $0.isActive })?.method else { return } + let fingerprint = Self.displayFingerprint(id) + guard !fingerprint.isEmpty else { return } let clamped = min(max(value, 0), 1) brightnessWriteFailures.removeValue(forKey: id) if let index = displays.firstIndex(where: { $0.id == id }) { displays[index].brightness = clamped - displays[index].hasKnownBrightness = true + displays[index].hasKnownBrightness = false + displays[index].readable = false + displays[index].state.request(clamped) } stateLock.lock() writeSequence &+= 1 latestBrightnessWrites[id] = writeSequence + displayWriteVersions[id] = writeSequence pendingLevels[id] = PendingWrite(value: clamped, showOSD: showOSD, - sequence: writeSequence) - lastApplied[id] = RememberedLevel(value: clamped, - fingerprint: Self.displayFingerprint(id)) - levelKnownAt[id] = Date() + sequence: writeSequence, + fingerprint: fingerprint, method: method) + levelKnownAt.removeValue(forKey: id) let schedule = !drainScheduled if schedule { drainScheduled = true } stateLock.unlock() @@ -515,7 +566,8 @@ final class BrightnessService: ObservableObject { } func canToggleDisplay(_ display: BrightnessDisplay) -> Bool { - guard displaySwitchingAvailable, !isDisplayPending(display.id) else { return false } + guard displaySwitchingAvailable, !display.fingerprint.isEmpty, + !isDisplayPending(display.id) else { return false } guard display.isActive else { return true } return BrightnessSupport.canDisableDisplay(drawableDisplayIDs: drawableDisplays, target: display.id) @@ -525,7 +577,10 @@ final class BrightnessService: ObservableObject { /// system arrangement. The transaction is app-only and never overwrites /// the user's saved display configuration. func toggleDisplay(_ display: BrightnessDisplay) { - guard !isDisplayPending(display.id) else { return } + guard running, !isDisplayPending(display.id), !display.fingerprint.isEmpty else { return } + stateLock.lock() + let generation = lifecycleGeneration + stateLock.unlock() guard displaySwitchingAvailable else { displayControlFailure = .unavailable return @@ -548,6 +603,13 @@ final class BrightnessService: ObservableObject { pendingDisplayIDs.insert(display.id) workQueue.async { [weak self] in guard let self else { return } + self.stateLock.lock() + let current = self.lifecycleGeneration == generation + self.stateLock.unlock() + guard current, self.matchesToggleTarget(display) else { + self.finishDisplayToggle(id: display.id, enabled: targetEnabled, failure: .unavailable, generation: generation) + return + } if !targetEnabled { let topology = Self.currentTopology() let drawable = Self.drawableDisplayIDs(online: topology.online, @@ -555,7 +617,7 @@ final class BrightnessService: ObservableObject { guard BrightnessSupport.canDisableDisplay(drawableDisplayIDs: drawable, target: display.id) else { self.finishDisplayToggle(id: display.id, enabled: targetEnabled, - failure: .lastActive) + failure: .lastActive, generation: generation) return } // A software-dimmed screen should return with its clean gamma @@ -571,18 +633,41 @@ final class BrightnessService: ObservableObject { // The gamma curve above is work-queue state; the reconfiguration // itself belongs to the main thread (see `configureDisplay`). DispatchQueue.main.async { [weak self] in - self?.commitDisplayToggle(display, enabled: targetEnabled) + self?.commitDisplayToggle(display, enabled: targetEnabled, generation: generation) } } } /// Second half of `toggleDisplay`, on the main thread: the display /// reconfiguration and the bookkeeping that follows it. - private func commitDisplayToggle(_ display: BrightnessDisplay, enabled: Bool) { + private func matchesToggleTarget(_ display: BrightnessDisplay) -> Bool { + let current = Self.displayFingerprint(display.id) + if !current.isEmpty { return current == display.fingerprint } + stateLock.lock() + let disabled = managedDisabledDisplays[display.id] + stateLock.unlock() + return !display.isActive && CGDisplayIsOnline(display.id) == 0 + && disabled?.fingerprint == display.fingerprint && !display.fingerprint.isEmpty + } + + private func commitDisplayToggle(_ display: BrightnessDisplay, enabled: Bool, generation: Int) { + guard running, lifecycleGeneration == generation, matchesToggleTarget(display) else { + finishDisplayToggle(id: display.id, enabled: enabled, failure: .unavailable, generation: generation) + return + } + if !enabled { + let topology = Self.currentTopology() + guard BrightnessSupport.canDisableDisplay( + drawableDisplayIDs: Self.drawableDisplayIDs(online: topology.online, active: topology.active), + target: display.id) else { + finishDisplayToggle(id: display.id, enabled: enabled, failure: .lastActive, generation: generation) + return + } + } if !enabled { Self.rememberDisplaySwitchedOff(display.id) } guard Self.configureDisplay(display.id, enabled: enabled) else { if !enabled { Self.forgetDisplaySwitchedOff(display.id) } - finishDisplayToggle(id: display.id, enabled: enabled, failure: .failed) + finishDisplayToggle(id: display.id, enabled: enabled, failure: .failed, generation: generation) return } @@ -611,13 +696,13 @@ final class BrightnessService: ObservableObject { // happening today, and the thread this runs on is not something the // lock should have to depend on. if enabled { Self.forgetDisplaySwitchedOff(display.id) } - finishDisplayToggle(id: display.id, enabled: enabled, failure: nil) + finishDisplayToggle(id: display.id, enabled: enabled, failure: nil, generation: generation) } private func finishDisplayToggle(id: CGDirectDisplayID, enabled: Bool, - failure: DisplayControlFailure?) { + failure: DisplayControlFailure?, generation: Int) { DispatchQueue.main.async { [weak self] in - guard let self else { return } + guard let self, self.running, self.lifecycleGeneration == generation else { return } self.pendingDisplayIDs.remove(id) self.displayControlFailure = failure if failure == nil, let index = self.displays.firstIndex(where: { $0.id == id }) { @@ -699,9 +784,11 @@ final class BrightnessService: ObservableObject { private func restoreManagedDisplays() { stateLock.lock() let ids = managedDisabledIDs + let snapshots = managedDisabledDisplays stateLock.unlock() for id in ids { - guard Self.configureDisplay(id, enabled: true) else { continue } + guard let snapshot = snapshots[id], matchesToggleTarget(snapshot), + Self.configureDisplay(id, enabled: true) else { continue } stateLock.lock() managedDisabledIDs.remove(id) managedDisabledDisplays.removeValue(forKey: id) @@ -714,7 +801,7 @@ final class BrightnessService: ObservableObject { /// now belongs to a different monitor. Runs on the work queue. private func restoreAllGamma() { for (id, baseline) in gammaBaselines - where Self.displayFingerprint(id) == baseline.fingerprint { + where !baseline.fingerprint.isEmpty && Self.displayFingerprint(id) == baseline.fingerprint { CGSetDisplayTransferByTable(id, baseline.count, baseline.red, baseline.green, baseline.blue) Self.log.log("restored gamma baseline for display \(id)") @@ -1020,7 +1107,7 @@ final class BrightnessService: ObservableObject { delta: Double, showOSD: Bool) { let snapshot = displays.first(where: { $0.id == displayID }) - let cached = snapshot?.hasKnownBrightness == true ? snapshot?.brightness : nil + let cached = snapshot?.requestedBrightness ?? (snapshot?.hasKnownBrightness == true ? snapshot?.brightness : nil) if method == .system { guard let current = currentSystemBrightness(for: displayID, fallback: cached) else { return } commitStep(from: current, delta: delta, to: displayID, method: method, showOSD: showOSD) @@ -1030,13 +1117,13 @@ final class BrightnessService: ObservableObject { let known = levelKnownAt[displayID] let route = routes[displayID] let readGeneration = rebuildGeneration - let readWriteSequence = writeSequence + let readWriteSequence = displayWriteVersions[displayID] stateLock.unlock() let fresh = BrightnessSupport.trustsRememberedLevel(lastKnownAt: known, now: Date(), window: Self.levelTrustWindow) // Gamma dimming is this app's own doing, so the remembered value is // the truth by construction and there is nothing to ask. - guard method == .ddc, !fresh, + guard method == .ddc, !fresh, snapshot?.status != .pending, let route, let service = route.service else { guard let current = cached else { return } commitStep(from: current, delta: delta, to: displayID, method: method, showOSD: showOSD) @@ -1062,10 +1149,10 @@ final class BrightnessService: ObservableObject { let queued = self.ddcPendingSteps.removeValue(forKey: displayID)?.delta ?? 0 self.stateLock.lock() let currentRequest = self.running && self.rebuildGeneration == readGeneration - && self.writeSequence == readWriteSequence + && self.displayWriteVersions[displayID] == readWriteSequence self.stateLock.unlock() guard currentRequest else { return } - var current = cached + var current: Double? if case let .replied(value, maximum) = probe { let level = BrightnessSupport.normalized( current: value, maximum: BrightnessSupport.sanitizedMaximum(maximum)) @@ -1075,6 +1162,7 @@ final class BrightnessService: ObservableObject { self.displays[index].brightness = level self.displays[index].hasKnownBrightness = true self.displays[index].readable = true + self.displays[index].state.observe(level) } } else if let index = self.displays.firstIndex(where: { $0.id == displayID }) { self.displays[index].readable = false @@ -1190,10 +1278,10 @@ final class BrightnessService: ObservableObject { fallback: Double?) -> Double? { stateLock.lock() let queued = pendingLevels[id]?.value - let requested = systemWritesInFlight.contains(id) ? rememberedLevel(for: id) : nil + let writing = systemWritesInFlight.contains(id) stateLock.unlock() if let queued { return queued } - if let requested { return requested } + if writing, let requested = displays.first(where: { $0.id == id })?.requestedBrightness { return requested } // Same reason as the rebuild: a sleeping panel answers a number it // cannot honour, so the last value known good is the better base. var live: Float = -1 @@ -1322,7 +1410,8 @@ final class BrightnessService: ObservableObject { // MARK: - Rebuild (work queue) private func rebuild(generation: Int, previousDisplays: [BrightnessDisplay], - screenNames: [CGDirectDisplayID: String]) { + screenNames: [CGDirectDisplayID: String], + versions: [CGDirectDisplayID: UInt64]) { var ids = [CGDirectDisplayID](repeating: 0, count: 16) var count: UInt32 = 0 CGGetOnlineDisplayList(16, &ids, &count) @@ -1385,11 +1474,6 @@ final class BrightnessService: ObservableObject { method: .system, isActive: true, brightness: trusted, readable: !asleep)) newRoutes[id] = Route(method: .system, service: nil, maximum: 100) - if !asleep { - stateLock.lock() - levelKnownAt[id] = Date() - stateLock.unlock() - } continue } guard !isBuiltIn else { continue } @@ -1397,7 +1481,7 @@ final class BrightnessService: ObservableObject { // Placeholder; the DDC pass below fills brightness and route. built.append(BrightnessDisplay(id: id, name: name, isBuiltIn: false, method: .ddc, isActive: true, - brightness: 0.5, readable: false, hasKnownBrightness: false)) + brightness: 0, readable: false, hasKnownBrightness: false)) } let drawableIDs = BrightnessSupport.drawableDisplayIDs( @@ -1432,16 +1516,16 @@ final class BrightnessService: ObservableObject { if pending.isActive { pending.method = nil } return pending } - return BrightnessDisplay(id: display.id, name: display.name, - isBuiltIn: display.isBuiltIn, - method: previous.method, isActive: true, - brightness: previous.brightness, - readable: previous.readable, - hasKnownBrightness: previous.hasKnownBrightness) + guard previous.fingerprint == Self.displayFingerprint(display.id) else { return display } + return previous } DispatchQueue.main.async { [weak self] in guard let self, self.running, generation == self.rebuildGeneration else { return } - if self.displays != discovered { self.displays = discovered } + let published = discovered.map { item in + self.displays.first(where: { $0.id == item.id && $0.isActive == item.isActive + && $0.fingerprint == Self.displayFingerprint(item.id) }) ?? item + } + if self.displays != published { self.displays = published } if self.drawableDisplays != drawableIDs { self.drawableDisplays = drawableIDs } } } @@ -1489,22 +1573,20 @@ final class BrightnessService: ObservableObject { readable: true) newRoutes[id] = Route(method: .ddc, service: matched.service, maximum: ceiling, ddcReadable: true) - stateLock.lock() - levelKnownAt[id] = Date() - stateLock.unlock() softwareIndices.remove(candidate.index) case .writeOnly: // Reads fail on some monitors whose writes still work: // keep the slider, seeded from this session's last value. stateLock.lock() - let seed = rememberedLevel(for: id) + let seed = lastApplied[id]?.method == .ddc ? rememberedLevel(for: id) : nil stateLock.unlock() built[candidate.index] = BrightnessDisplay( id: id, name: built[candidate.index].name, isBuiltIn: false, - method: .ddc, isActive: true, brightness: seed ?? 0.5, readable: false, - hasKnownBrightness: seed != nil) + method: .ddc, isActive: true, brightness: seed ?? 0, readable: false, + hasKnownBrightness: false) newRoutes[id] = Route(method: .ddc, service: matched.service, maximum: 100) + built[candidate.index].state.unreadable(ddcReadFailures[id] ?? .readFailed) softwareIndices.remove(candidate.index) case .dead: // The channel rejects every write (typically an HDMI @@ -1531,7 +1613,7 @@ final class BrightnessService: ObservableObject { let id = built[index].id stateLock.lock() var value = BrightnessSupport.softwareDimToRestore( - remembered: rememberedLevel(for: id), + remembered: lastApplied[id]?.method == .software ? rememberedLevel(for: id) : nil, appliedByApp: dimmedDisplays.contains(id)) stateLock.unlock() captureGammaBaselineIfNeeded(id) @@ -1550,7 +1632,11 @@ final class BrightnessService: ObservableObject { id: id, name: built[index].name, isBuiltIn: false, method: .software, isActive: true, brightness: value, readable: true) newRoutes[id] = Route(method: .software, service: nil, maximum: 100) - if value < 0.999 { _ = applySoftwareDim(id, value: value) } + if value < 0.999, !applySoftwareDim(id, value: value) { + built[index].readable = false + built[index].hasKnownBrightness = false + built[index].state.unreadable(.writeFailed) + } } var resolved: [BrightnessDisplay] = [] var supportsBrightnessOSD = false @@ -1569,11 +1655,18 @@ final class BrightnessService: ObservableObject { return display } for index in resolved.indices { - let id = resolved[index].id - if pendingLevels[id] != nil { resolved[index].hasKnownBrightness = true } - resolved[index].brightness = BrightnessSupport.brightnessAfterRebuild( - probed: resolved[index].brightness, - pending: pendingLevels[id]?.value) + if resolved[index].isActive || resolved[index].fingerprint.isEmpty { + resolved[index].fingerprint = Self.displayFingerprint(resolved[index].id) + } + if resolved[index].fingerprint.isEmpty { + newRoutes.removeValue(forKey: resolved[index].id) + resolved[index].method = nil + resolved[index].readable = false + resolved[index].hasKnownBrightness = false + resolved[index].state.unreadable(.identityUnavailable) + } else if resolved[index].readable { + resolved[index].state.observe(resolved[index].brightness) + } } supportsBrightnessOSD = resolved.contains { display in guard display.isActive, newRoutes[display.id] != nil else { return false } @@ -1586,8 +1679,10 @@ final class BrightnessService: ObservableObject { return false } } + latestBrightnessWrites = latestBrightnessWrites.filter { + newRoutes[$0.key] != nil && routes[$0.key]?.method == newRoutes[$0.key]?.method + } routes = newRoutes - latestBrightnessWrites = latestBrightnessWrites.filter { pendingLevels[$0.key] != nil } knownTopology = seenTopology knownActiveTopology = activeTopology rebuildingTopology = nil @@ -1595,10 +1690,13 @@ final class BrightnessService: ObservableObject { for id in activeTopology { managedDisabledDisplays.removeValue(forKey: id) } - for display in resolved where display.method != nil && display.hasKnownBrightness { + for display in resolved where display.method != nil && display.readable + && BrightnessSupport.acceptsObservation(startedVersion: versions[display.id], + currentVersion: displayWriteVersions[display.id]) { + levelKnownAt[display.id] = Date() lastApplied[display.id] = RememberedLevel( value: display.brightness, - fingerprint: Self.displayFingerprint(display.id)) + fingerprint: Self.displayFingerprint(display.id), method: display.method!) } } stateLock.unlock() @@ -1615,7 +1713,18 @@ final class BrightnessService: ObservableObject { self.brightnessWriteFailures = self.brightnessWriteFailures.filter { resolvedFingerprints[$0.key] == $0.value } - if self.displays != resolved { self.displays = resolved } + self.stateLock.lock() + let currentVersions = self.displayWriteVersions + self.stateLock.unlock() + let published = resolved.map { display in + guard !BrightnessSupport.acceptsObservation( + startedVersion: versions[display.id], currentVersion: currentVersions[display.id]), + let current = self.displays.first(where: { $0.id == display.id }), + resolvedFingerprints[display.id] == Self.displayFingerprint(display.id), + current.method == display.method, current.fingerprint == display.fingerprint else { return display } + return current + } + if self.displays != published { self.displays = published } if self.drawableDisplays != drawableIDs { self.drawableDisplays = drawableIDs } if self.brightnessOSDSupported != supportsBrightnessOSD { self.brightnessOSDSupported = supportsBrightnessOSD @@ -1639,10 +1748,20 @@ final class BrightnessService: ObservableObject { let value = pending.value let osdLevel = pending.showOSD ? value : nil let route = routes[id] - let writeGeneration = rebuildGeneration + let writeGeneration = lifecycleGeneration if route?.method == .system { systemWritesInFlight.insert(id) } stateLock.unlock() - guard let route else { continue } + guard let route, route.method == pending.method, + pending.fingerprint == Self.displayFingerprint(id), + CGDisplayIsOnline(id) != 0 else { + stateLock.lock() + if latestBrightnessWrites[id] == pending.sequence { + latestBrightnessWrites.removeValue(forKey: id) + } + systemWritesInFlight.remove(id) + stateLock.unlock() + continue + } var writeSucceeded = false switch route.method { case .system: @@ -1664,25 +1783,52 @@ final class BrightnessService: ObservableObject { if pendingLevels[id] == nil { systemWritesInFlight.remove(id) } stateLock.unlock() } + var observed: Double? + var failure: BrightnessSupport.DDCFailure? = writeSucceeded ? nil : .writeFailed + if writeSucceeded { + switch route.method { + case .ddc: + if let service = route.service { + let probe = ddcProbeLuminance(for: id, service: service, classifyingChannel: true) + if case let .replied(current, maximum) = probe { + observed = BrightnessSupport.normalized(current: current, maximum: maximum) + if current != BrightnessSupport.deviceValue(for: value, maximum: maximum) { + failure = .mismatch + } + } else { failure = ddcReadFailures[id] ?? .readFailed } + } + case .system: + var actual: Float = -1 + if BrightnessBridge.getBrightness?(id, &actual) == 0, actual >= 0, actual <= 1 { + observed = Double(actual) + if abs(Double(actual) - value) > 0.01 { failure = .mismatch } + } else { failure = .readFailed } + case .software: observed = value + } + } + let confirmed = observed + let readFailure = failure let succeeded = writeSucceeded DispatchQueue.main.async { [weak self] in self?.finishBrightnessWrite(id: id, sequence: pending.sequence, - generation: writeGeneration, succeeded: succeeded) + generation: writeGeneration, fingerprint: pending.fingerprint, + method: route.method, succeeded: succeeded, + observed: confirmed, failure: readFailure) } if route.method == .ddc, !writeSucceeded, route.ddcReadable == false { DispatchQueue.main.async { [weak self] in self?.refresh(force: true) } } - if writeSucceeded, let osdLevel { + if writeSucceeded, readFailure == nil, let osdLevel { DispatchQueue.main.async { [weak self] in guard let self, self.running, UserDefaults.standard.bool( forKey: DefaultsKey.brightnessOSDEnabled ) else { return } self.stateLock.lock() - let current = self.rebuildGeneration - let latestWrite = self.writeSequence + let current = self.lifecycleGeneration + let latestWrite = self.displayWriteVersions[id] self.stateLock.unlock() guard current == writeGeneration, latestWrite == pending.sequence else { return } @@ -1697,28 +1843,37 @@ final class BrightnessService: ObservableObject { /// newer request. Failed targets remain visible for retry but are not a /// known hardware level for later key presses or reconnection recovery. private func finishBrightnessWrite(id: CGDirectDisplayID, sequence: UInt64, - generation: Int, succeeded: Bool) { + generation: Int, fingerprint: String, method: BrightnessDisplay.Method, succeeded: Bool, + observed: Double?, failure: BrightnessSupport.DDCFailure?) { guard running else { return } stateLock.lock() - guard rebuildGeneration == generation, latestBrightnessWrites[id] == sequence else { + guard latestBrightnessWrites[id] == sequence else { stateLock.unlock() return } - latestBrightnessWrites.removeValue(forKey: id) - let fingerprint = lastApplied[id]?.fingerprint ?? Self.displayFingerprint(id) - if !succeeded { - lastApplied.removeValue(forKey: id) - levelKnownAt.removeValue(forKey: id) + guard lifecycleGeneration == generation, + fingerprint == Self.displayFingerprint(id), CGDisplayIsOnline(id) != 0, + routes[id]?.method == method else { + // A rejected result still retires its own busy marker, otherwise + // visible refresh waits forever for a request that already ended. + latestBrightnessWrites.removeValue(forKey: id) + if pendingLevels[id]?.sequence == sequence { pendingLevels.removeValue(forKey: id) } + stateLock.unlock() + return } + latestBrightnessWrites.removeValue(forKey: id) + if let observed { + lastApplied[id] = RememberedLevel(value: observed, fingerprint: fingerprint, method: method) + levelKnownAt[id] = Date() + } else { levelKnownAt.removeValue(forKey: id) } stateLock.unlock() - if succeeded { - brightnessWriteFailures.removeValue(forKey: id) - } else { - brightnessWriteFailures[id] = fingerprint - if let index = displays.firstIndex(where: { $0.id == id }) { - displays[index].readable = false - displays[index].hasKnownBrightness = false - } + if !succeeded || failure == .mismatch { brightnessWriteFailures[id] = fingerprint } + else { brightnessWriteFailures.removeValue(forKey: id) } + if let index = displays.firstIndex(where: { $0.id == id }) { + displays[index].state.complete(succeeded: succeeded, observed: observed, failure: failure) + displays[index].readable = observed != nil + displays[index].hasKnownBrightness = observed != nil + if let observed { displays[index].brightness = observed } } } @@ -1729,6 +1884,7 @@ final class BrightnessService: ObservableObject { /// is no way to tell a scaled copy from the real thing. private func captureGammaBaselineIfNeeded(_ id: CGDirectDisplayID) { let fingerprint = Self.displayFingerprint(id) + guard !fingerprint.isEmpty else { return } // A curve is only read while the screen is still showing its own. Once // a dim is applied the live curve is a scaled copy, and reading that // back would take the dim as the new normal and darken the screen @@ -1836,25 +1992,37 @@ final class BrightnessService: ObservableObject { let writeCycles = BrightnessSupport.ddcProbeWriteCycles( classifyingChannel: classifyingChannel) for attempt in 0.. String? { + if let uuid, !uuid.isEmpty { return "uuid:" + uuid } + guard serial != 0 else { return nil } + return "serial:\(vendor):\(model):\(serial)" + } + + static func acceptsObservation(startedVersion: UInt64?, currentVersion: UInt64?) -> Bool { + startedVersion == currentVersion + } + static func brightnessAfterRebuild(probed: Double, pending: Double?) -> Double { pending ?? probed } @@ -42,16 +52,13 @@ enum BrightnessSupport { static let retryAttempts = 4 static let replyLength = 11 - /// Discovery keeps the normal number of reply chances but sends only one - /// request before each read. The read and retry pauses put every request - /// more than 50ms apart instead of sending pairs 10ms apart. + /// Some displays answer NULL until GET is sent twice, as in MonitorControl + /// and m1ddc. Mi Monitor read-only probes reproduce this with a 10ms gap. static func ddcProbeAttempts() -> Int { retryAttempts + 1 } - static func ddcProbeWriteCycles(classifyingChannel: Bool) -> Int { - classifyingChannel ? 1 : writeCycles - } + static func ddcProbeWriteCycles(classifyingChannel: Bool) -> Int { writeCycles } static let defaultKeyboardLightLevel: Float = 0.5 static let keyboardLightStep: Float = 1.0 / 16.0 @@ -108,16 +115,41 @@ enum BrightnessSupport { packet(payload: [code]) } - /// Parses a Get VCP Feature reply: checksum first (seeded with the host - /// address the display answers to), then the big-endian maximum and - /// current values. Anything malformed reads as no reply. - static func parseReply(_ reply: [UInt8]) -> (current: UInt16, maximum: UInt16)? { - guard reply.count >= replyLength else { return nil } - let checksum = reply[0..<(reply.count - 1)].reduce(UInt8(0x50)) { $0 ^ $1 } - guard checksum == reply[reply.count - 1] else { return nil } + enum DDCFailure: String, Error, Equatable { + case length, checksum, source, header, opcode, unsupported, feature, valueType, range, nullReply + case unavailable, transport, readFailed, writeFailed, mismatch, identityUnavailable + } + + struct Reading: Equatable { + let current: UInt16 + let maximum: UInt16 + } + + static func validateReply(_ reply: [UInt8]) -> Result { + // NULL is a complete three-byte response; a fixed-length IO buffer may + // retain unrelated bytes after its checksum. Those bytes are not VCP data. + if reply.count >= 3, reply[0] == 0x6e, reply[1] == 0x80, reply[2] == 0xbe { + return .failure(.nullReply) + } + guard reply.count == replyLength else { return .failure(.length) } + guard reply.dropLast().reduce(UInt8(0x50), ^) == reply.last else { + return .failure(.checksum) + } + guard reply[0] == 0x6e else { return .failure(.source) } + guard reply[1] == 0x88 else { return .failure(.header) } + guard reply[2] == 0x02 else { return .failure(.opcode) } + guard reply[3] == 0 else { return .failure(.unsupported) } + guard reply[4] == luminanceCode else { return .failure(.feature) } + guard reply[5] == 0 else { return .failure(.valueType) } let maximum = UInt16(reply[6]) << 8 | UInt16(reply[7]) let current = UInt16(reply[8]) << 8 | UInt16(reply[9]) - return (current, maximum) + guard maximum > 0, current <= maximum else { return .failure(.range) } + return .success(Reading(current: current, maximum: maximum)) + } + + static func parseReply(_ reply: [UInt8]) -> (current: UInt16, maximum: UInt16)? { + guard case let .success(value) = validateReply(reply) else { return nil } + return (value.current, value.maximum) } /// A display that reports no range still accepts writes; treat it as the @@ -462,3 +494,41 @@ enum BrightnessSupport { return assignment } } + +/// A requested value never becomes a hardware observation without a read. +struct BrightnessState: Equatable { + enum Status: Equatable { case unknown, confirmed, pending, sentUnconfirmed, failed } + private(set) var observed: Double? + private(set) var requested: Double? + private(set) var observedAt: Date? + private(set) var status = Status.unknown + private(set) var failure: BrightnessSupport.DDCFailure? + + mutating func observe(_ value: Double, at date: Date = Date()) { + observed = value + observedAt = date + requested = nil + failure = nil + status = .confirmed + } + + mutating func request(_ value: Double) { + requested = value + status = .pending + failure = nil + } + + mutating func complete(succeeded: Bool, observed value: Double?, + failure reason: BrightnessSupport.DDCFailure?) { + if let value { observed = value; observedAt = Date() } + failure = reason + if !succeeded || reason == .mismatch { status = .failed } + else if value == nil { status = .sentUnconfirmed } + else { status = .confirmed; requested = nil } + } + + mutating func unreadable(_ reason: BrightnessSupport.DDCFailure) { + failure = reason + status = .unknown + } +} diff --git a/Sources/Vorssaint/Services/Environment/EnvironmentInspector.swift b/Sources/Vorssaint/Services/Environment/EnvironmentInspector.swift index 08c880d..ece55f9 100644 --- a/Sources/Vorssaint/Services/Environment/EnvironmentInspector.swift +++ b/Sources/Vorssaint/Services/Environment/EnvironmentInspector.swift @@ -67,31 +67,53 @@ final class EnvironmentInspector: ObservableObject { @Published private(set) var isLoading = false private let workQueue = DispatchQueue(label: "com.vorssaint.environment", qos: .userInitiated) + private var cancellation: BoundedProcessCancellation? + private var generation = 0 private init() {} func refresh(completion: ((EnvironmentReport) -> Void)? = nil) { guard !isLoading else { return } isLoading = true + generation += 1 + let generation = generation + let cancellation = BoundedProcessCancellation() + self.cancellation = cancellation workQueue.async { [weak self] in - let report = Self.inspect() + let report = Self.inspect(cancellation: cancellation) DispatchQueue.main.async { - self?.report = report - self?.isLoading = false + guard let self, self.generation == generation, !cancellation.isCancelled else { return } + self.cancellation = nil + self.report = report + self.isLoading = false completion?(report) } } } + func cancel() { + generation += 1 + cancellation?.cancel() + cancellation = nil + isLoading = false + } + // MARK: - Reading - static func inspect() -> EnvironmentReport { + static func inspect(cancellation: BoundedProcessCancellation? = nil) -> EnvironmentReport { var report = EnvironmentReport() - let shellPath = loginShellPath() + guard cancellation?.isCancelled != true else { return report } + let shellPath = loginShellPath(cancellation: cancellation) report.readLoginShell = shellPath != nil report.terminalPath = shellPath ?? systemPath() - report.guiPath = guiPath() - report.tools = inspectedCommands.map { tool(named: $0, in: report.terminalPath) } + guard cancellation?.isCancelled != true else { return report } + report.guiPath = guiPath(cancellation: cancellation) + for command in inspectedCommands { + guard cancellation?.isCancelled != true else { return report } + report.tools.append(tool(named: command, in: report.terminalPath, + cancellation: cancellation)) + } + guard cancellation?.isCancelled != true else { return report } report.caches = caches() return report } @@ -99,7 +121,7 @@ final class EnvironmentInspector: ObservableObject { /// Asks the user's own login shell what PATH it ends up with. Only the /// shell can answer: PATH is assembled by its startup files, and this app /// must never read or edit those. - static func loginShellPath() -> [String]? { + static func loginShellPath(cancellation: BoundedProcessCancellation? = nil) -> [String]? { guard let shell = loginShell(), FileManager.default.isExecutableFile(atPath: shell) else { return nil } // Login *and* interactive, because that is what a terminal window runs @@ -108,8 +130,9 @@ final class EnvironmentInspector: ObservableObject { ["-l", "-c", "printf %s \"$PATH\""]] { let result = BoundedProcessRunner.run(shell, arguments, timeout: shellTimeout, - maxOutputBytes: maxOutputBytes) - guard result.status == 0, !result.timedOut, + maxOutputBytes: maxOutputBytes, + cancellation: cancellation) + guard result.status == 0, !result.timedOut, !result.cancelled, let text = String(data: result.output, encoding: .utf8) else { continue } let entries = splitPath(text) if !entries.isEmpty { return entries } @@ -143,11 +166,12 @@ final class EnvironmentInspector: ObservableObject { /// What a Finder- or Dock-launched app gets. `launchctl getenv PATH` is /// normally unset, and then launchd's own default is the honest answer — /// not this process's PATH, which depends on how the app was started. - static func guiPath() -> [String] { + static func guiPath(cancellation: BoundedProcessCancellation? = nil) -> [String] { let result = BoundedProcessRunner.run("/bin/launchctl", ["getenv", "PATH"], timeout: commandTimeout, - maxOutputBytes: maxOutputBytes) - if result.status == 0, !result.timedOut, + maxOutputBytes: maxOutputBytes, + cancellation: cancellation) + if result.status == 0, !result.timedOut, !result.cancelled, let text = String(data: result.output, encoding: .utf8) { let entries = splitPath(text) if !entries.isEmpty { return entries } @@ -155,7 +179,8 @@ final class EnvironmentInspector: ObservableObject { return launchdDefaultPath } - static func tool(named command: String, in path: [String]) -> EnvironmentTool { + static func tool(named command: String, in path: [String], + cancellation: BoundedProcessCancellation? = nil) -> EnvironmentTool { let matches = path .map { ($0 as NSString).appendingPathComponent(command) } .filter { FileManager.default.isExecutableFile(atPath: $0) } @@ -167,7 +192,7 @@ final class EnvironmentInspector: ObservableObject { let shim = shimTarget(of: first, command: command) return EnvironmentTool(command: command, path: first, - version: version(of: first), + version: version(of: first, cancellation: cancellation), shimTarget: shim, shadowedPaths: Array(winner.dropFirst()), source: installationSource(of: first, command: command, isWrapper: shim != nil)) @@ -191,13 +216,15 @@ final class EnvironmentInspector: ObservableObject { home: NSHomeDirectory(), isWrapper: isWrapper || head == Data("#!".utf8)) } - static func version(of path: String) -> String? { + static func version(of path: String, + cancellation: BoundedProcessCancellation? = nil) -> String? { let result = BoundedProcessRunner.run(path, ["--version"], timeout: commandTimeout, - maxOutputBytes: maxOutputBytes) + maxOutputBytes: maxOutputBytes, + cancellation: cancellation) // A wrapper that refuses `--version` still prints something and exits // non-zero; printing its complaint as a version would be a lie. - guard result.status == 0, !result.timedOut, + guard result.status == 0, !result.timedOut, !result.cancelled, let text = String(data: result.output, encoding: .utf8) else { return nil } let line = text.split(whereSeparator: \.isNewline).first.map(String.init) ?? "" let trimmed = line.trimmingCharacters(in: .whitespaces) diff --git a/Sources/Vorssaint/Services/Environment/EnvironmentUpdateChecker.swift b/Sources/Vorssaint/Services/Environment/EnvironmentUpdateChecker.swift index a6796be..d0a6f9d 100644 --- a/Sources/Vorssaint/Services/Environment/EnvironmentUpdateChecker.swift +++ b/Sources/Vorssaint/Services/Environment/EnvironmentUpdateChecker.swift @@ -12,6 +12,7 @@ final class EnvironmentUpdateChecker: ObservableObject { private var generation = 0 private var operationObservation: AnyCancellable? private var observedOperation = false + private var acceptsAutomaticRefresh = true private init() { let manager = HomebrewManager.shared @@ -28,6 +29,7 @@ final class EnvironmentUpdateChecker: ObservableObject { // including the catalog refresh that follows a completed operation. DispatchQueue.main.async { [weak self] in guard let self, self.observedOperation, + self.acceptsAutomaticRefresh, !HomebrewManager.shared.isBusy, !EnvironmentInspector.shared.isLoading, !self.isChecking else { return } self.observedOperation = false @@ -38,6 +40,7 @@ final class EnvironmentUpdateChecker: ObservableObject { func refreshAndCheck() { guard !isChecking, !EnvironmentInspector.shared.isLoading else { return } + acceptsAutomaticRefresh = true reset() isChecking = true let generation = generation @@ -56,8 +59,17 @@ final class EnvironmentUpdateChecker: ObservableObject { isChecking = false } + func cancel() { + acceptsAutomaticRefresh = false + observedOperation = false + reset() + EnvironmentInspector.shared.cancel() + HomebrewManager.shared.cancelEnvironmentUpdateCheck() + } + func check(_ tools: [EnvironmentTool]) { guard !isChecking else { return } + acceptsAutomaticRefresh = true reset() isChecking = true let generation = generation diff --git a/Sources/Vorssaint/Services/Homebrew/HomebrewEnvironmentCheckSettlement.swift b/Sources/Vorssaint/Services/Homebrew/HomebrewEnvironmentCheckSettlement.swift new file mode 100644 index 0000000..1851e52 --- /dev/null +++ b/Sources/Vorssaint/Services/Homebrew/HomebrewEnvironmentCheckSettlement.swift @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// Delivers one Environment snapshot result on the main thread. Cancellation +/// settles immediately, so a queued second brew read cannot retain its caller. +final class HomebrewEnvironmentCheckSettlement: @unchecked Sendable { + private let lock = NSLock() + private var settled = false + private let completion: (EnvironmentHomebrewSnapshot?) -> Void + + init(completion: @escaping (EnvironmentHomebrewSnapshot?) -> Void) { + self.completion = completion + } + + func settle(_ snapshot: EnvironmentHomebrewSnapshot?) { + if Thread.isMainThread { + settleOnMain(snapshot) + } else { + DispatchQueue.main.async { [self] in settleOnMain(snapshot) } + } + } + + private func settleOnMain(_ snapshot: EnvironmentHomebrewSnapshot?) { + lock.lock() + guard !settled else { + lock.unlock() + return + } + settled = true + lock.unlock() + completion(snapshot) + } +} diff --git a/Sources/Vorssaint/Services/Homebrew/HomebrewManager.swift b/Sources/Vorssaint/Services/Homebrew/HomebrewManager.swift index e3d6075..d090936 100644 --- a/Sources/Vorssaint/Services/Homebrew/HomebrewManager.swift +++ b/Sources/Vorssaint/Services/Homebrew/HomebrewManager.swift @@ -37,6 +37,9 @@ final class HomebrewManager: ObservableObject { private var installedCaskRecordsFetchedAt: Date? private var ownershipLoads: [String: [(HomebrewPackage?) -> Void]] = [:] private var completedOperationCleanup: DispatchWorkItem? + private var environmentCheckCancellation: BoundedProcessCancellation? + private var environmentCheckSettlement: HomebrewEnvironmentCheckSettlement? + private var environmentCheckGeneration = 0 var isBusy: Bool { isLoadingInstalled || isCheckingEnvironment || operation != nil @@ -140,30 +143,59 @@ final class HomebrewManager: ObservableObject { completion(nil) return } + environmentCheckGeneration += 1 + let generation = environmentCheckGeneration + let cancellation = BoundedProcessCancellation() + environmentCheckCancellation = cancellation isCheckingEnvironment = true - let finish: (EnvironmentHomebrewSnapshot?) -> Void = { [weak self] snapshot in - DispatchQueue.main.async { - self?.isCheckingEnvironment = false - completion(snapshot) + let settlement = HomebrewEnvironmentCheckSettlement { [weak self] snapshot in + guard let self else { + completion(nil) + return + } + let isCurrent = self.environmentCheckGeneration == generation + && self.environmentCheckCancellation === cancellation + && !cancellation.isCancelled + if isCurrent { + self.environmentCheckCancellation = nil + self.environmentCheckSettlement = nil + self.isCheckingEnvironment = false } + completion(isCurrent ? snapshot : nil) } - run(HomebrewCommandBuilder.installed(brewPath: brewPath)) { [weak self] status, output in + environmentCheckSettlement = settlement + runEnvironmentRead(HomebrewCommandBuilder.installed(brewPath: brewPath), + cancellation: cancellation) { [weak self] status, output in guard let self, status == 0, let packages = try? HomebrewParser.parseInfoCommandOutput(output) else { - finish(nil) + settlement.settle(nil) return } - self.run(HomebrewCommandBuilder.outdated(brewPath: brewPath)) { status, output in + guard !cancellation.isCancelled else { return } + self.runEnvironmentRead(HomebrewCommandBuilder.outdated(brewPath: brewPath), + cancellation: cancellation) { status, output in guard status == 0, let updates = try? HomebrewParser.parseOutdatedCommandOutput(output) else { - finish(nil) + settlement.settle(nil) return } - finish(EnvironmentHomebrewSnapshot(packages: packages, updates: updates)) + settlement.settle(EnvironmentHomebrewSnapshot(packages: packages, updates: updates)) } } } + /// Stops only the read-only snapshot requested by Environment. Package + /// installs, upgrades and removals are user operations with their own + /// explicit cancellation control. + func cancelEnvironmentUpdateCheck() { + environmentCheckGeneration += 1 + environmentCheckCancellation?.cancel() + environmentCheckSettlement?.settle(nil) + environmentCheckCancellation = nil + environmentCheckSettlement = nil + isCheckingEnvironment = false + } + /// Looks up whether Homebrew owns this exact app bundle. The cached /// installed catalog is reused when fresh; otherwise one read-only command /// answers every concurrent request for the same path. @@ -531,6 +563,7 @@ final class HomebrewManager: ObservableObject { /// These are normally fast, but a hung NFS share or locked database can /// make them stall indefinitely. private static let brewReadTimeout: TimeInterval = 30 + private static let environmentReadMaxOutputBytes = 16 * 1_024 * 1_024 /// Install/upgrade/uninstall run on the same serial queue as every read, so a /// hung one must end eventually. A big download, a source build and a copy into /// Applications are all slow but never silent, so the bound is on silence: a cap @@ -602,6 +635,23 @@ final class HomebrewManager: ObservableObject { } } + private func runEnvironmentRead( + _ command: HomebrewCommand, + cancellation: BoundedProcessCancellation, + completion: @escaping (_ status: Int32, _ output: String) -> Void + ) { + workQueue.async { + let result = BoundedProcessRunner.run( + command.executable, + command.arguments, + timeout: Self.brewReadTimeout, + maxOutputBytes: Self.environmentReadMaxOutputBytes, + cancellation: cancellation + ) + completion(result.status, String(decoding: result.output, as: UTF8.self)) + } + } + private func runStreaming(_ command: HomebrewCommand, onOutput: @escaping (String) -> Void, completion: @escaping (_ status: Int32, _ output: String) -> Void) { diff --git a/Sources/Vorssaint/Services/Shelf/ShelfImportStore.swift b/Sources/Vorssaint/Services/Shelf/ShelfImportStore.swift new file mode 100644 index 0000000..f4ef96d --- /dev/null +++ b/Sources/Vorssaint/Services/Shelf/ShelfImportStore.swift @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import Foundation + +enum ShelfImportServiceError: Error { case unavailable, changed, managedReference(String) } + +/// Owns staged imports on the shelf persistence queue. Live UI revisions and +/// item conversion remain the caller's gate before committing any transaction. +final class ShelfImportStore { + private let directory: URL + private var pending: [URL: (prepared: ShelfImportPrepared, source: URL)] = [:] + + init(directory: URL) { self.directory = directory } + + func prepare(selected: [ShelfPersistedItem], current: [ShelfPersistedItem], + mappings: [ShelfImportMapping], sourceURL: URL, + managedRoots: [URL]) throws -> ShelfImportPrepared { + let access = sourceURL.startAccessingSecurityScopedResource() + let scoped = mappings.map { $0.selectedDirectory.startAccessingSecurityScopedResource() } + defer { + if access { sourceURL.stopAccessingSecurityScopedResource() } + for (mapping, acquired) in zip(mappings, scoped) where acquired { + mapping.selectedDirectory.stopAccessingSecurityScopedResource() + } + } + try validateSource(sourceURL) + // Reject capacity before reading attachments, preserving all-or-nothing import. + let merged = try ShelfImportSupport.merging(selected: selected, current: current) + let copies = Array(merged.dropFirst(current.count)) + let assets = try ShelfImportAssets.prepare(items: copies, mappings: mappings, + destinationDirectory: directory) + let copiedPaths = Set(assets.files.keys.map { + directory.appendingPathComponent("ShelfFiles").appendingPathComponent($0).path + }) + let roots = managedRoots.map { $0.standardizedFileURL.resolvingSymlinksInPath().path + "/" } + func validateReferences(_ items: [ShelfPersistedItem]) throws { + for item in items { + if let path = item.path, !copiedPaths.contains(path) { + let canonical = URL(fileURLWithPath: path).standardizedFileURL.resolvingSymlinksInPath().path + guard !roots.contains(where: { canonical.hasPrefix($0) || canonical + "/" == $0 }) else { + throw ShelfImportServiceError.managedReference(path) + } + } + try validateReferences(item.children ?? []) + } + } + try validateReferences(assets.items) + let prepared = try ShelfImportTransaction.prepare(items: current + assets.items, + files: assets.files, in: directory) + pending[prepared.stagingURL] = (prepared, sourceURL) + return prepared + } + + func commit(_ prepared: ShelfImportPrepared) throws { + guard let transaction = pending[prepared.stagingURL], transaction.prepared === prepared else { + throw ShelfImportServiceError.unavailable + } + defer { discard(prepared) } + let access = transaction.source.startAccessingSecurityScopedResource() + defer { if access { transaction.source.stopAccessingSecurityScopedResource() } } + try validateSource(transaction.source) + try ShelfImportTransaction.commit(prepared, to: directory) + } + + func discard(_ prepared: ShelfImportPrepared) { + guard pending[prepared.stagingURL]?.prepared === prepared else { return } + pending.removeValue(forKey: prepared.stagingURL) + ShelfImportTransaction.discard(prepared) + } + + func discardAll() { + let transactions = pending.values.map(\.prepared) + for transaction in transactions { discard(transaction) } + } + + private func validateSource(_ source: URL) throws { + try ShelfImportAssets.validateSource(source: source, + currentIndex: directory.appendingPathComponent("ShelfItems.json")) + } +} diff --git a/Sources/Vorssaint/Services/Shelf/ShelfService.swift b/Sources/Vorssaint/Services/Shelf/ShelfService.swift index a294ef1..fea81ea 100644 --- a/Sources/Vorssaint/Services/Shelf/ShelfService.swift +++ b/Sources/Vorssaint/Services/Shelf/ShelfService.swift @@ -8,7 +8,6 @@ import SwiftUI import UniformTypeIdentifiers enum ShelfPersistenceIssue { case unreadable, saveFailed, tooLarge } -enum ShelfImportServiceError: Error { case unavailable, changed, managedReference(String) } /// A floating "shelf" that holds files, images, text and links you drop on it, /// to drag back out into any app later. It's summoned at the cursor by a global @@ -323,6 +322,7 @@ final class ShelfService: ObservableObject { private let tempDir: URL private let containerDirectory: URL? private let indexStore: ShelfIndexStore? + private let importStore: ShelfImportStore? private let fixtureMode: Bool /// Payload files for pasted images and GIFs, next to the clipboard images: @@ -343,8 +343,6 @@ final class ShelfService: ObservableObject { @Published private(set) var isImporting = false private var importRequest: UUID? private var publishingImport = false - // Owned by persistQueue until the main-thread commit or termination drain. - private var preparedImports: [URL: ShelfImportPrepared] = [:] /// Gates persistence until the restore has landed, so an early mutation /// cannot overwrite the saved shelf with a partial list. private var restoreCompleted = false @@ -367,6 +365,7 @@ final class ShelfService: ObservableObject { tempDir = temporary fixtureMode = fixture indexStore = directory.map { ShelfIndexStore(directory: $0, legacyDefaults: defaults) } + importStore = directory.map(ShelfImportStore.init(directory:)) if !fixture { automaticExclusions = Defaults.sanitizedBundleIdentifierList( defaults.stringArray(forKey: DefaultsKey.shelfAutomaticExclusions) ?? []) @@ -2292,7 +2291,7 @@ final class ShelfService: ObservableObject { completion: @escaping (Result) -> Void) -> UUID? { finishRestore() guard !isImporting, !isTerminating, restoreCompleted, storeWritable, - fixtureMode || AppFeature.shelf.isAvailable, let destination = containerDirectory else { + fixtureMode || AppFeature.shelf.isAvailable, let importStore else { completion(.failure(ShelfImportServiceError.unavailable)); return nil } guard !selected.isEmpty else { completion(.failure(ShelfImportError.emptySelection)); return nil } @@ -2313,71 +2312,36 @@ final class ShelfService: ObservableObject { } let current = self.items.map(Self.persistedItem(from:)) let revision = self.contentRevision - let managedRoots = self.payloadRoots.map { $0.standardizedFileURL.resolvingSymlinksInPath().path + "/" } + let managedRoots = self.payloadRoots self.persistQueue.async { - let access = sourceURL.startAccessingSecurityScopedResource() - let scoped = mappings.map { $0.selectedDirectory.startAccessingSecurityScopedResource() } - let result = Result { () throws -> ShelfImportPrepared in - try ShelfImportAssets.validateSource(source: sourceURL, - currentIndex: destination.appendingPathComponent("ShelfItems.json")) - // Capacity is checked before reading attachments; no partial import. - let merged = try ShelfImportSupport.merging(selected: selected, current: current) - let copies = Array(merged.dropFirst(current.count)) - let assets = try ShelfImportAssets.prepare(items: copies, mappings: mappings, - destinationDirectory: destination) - let copiedPaths = Set(assets.files.keys.map { destination.appendingPathComponent("ShelfFiles").appendingPathComponent($0).path }) - func validateReferences(_ items: [ShelfPersistedItem]) throws { - for item in items { - if let path = item.path, !copiedPaths.contains(path) { - let canonical = URL(fileURLWithPath: path).standardizedFileURL.resolvingSymlinksInPath().path - guard !managedRoots.contains(where: { canonical.hasPrefix($0) || canonical + "/" == $0 }) else { - throw ShelfImportServiceError.managedReference(path) - } - } - try validateReferences(item.children ?? []) - } - } - try validateReferences(assets.items) - let prepared = try ShelfImportTransaction.prepare(items: current + assets.items, - files: assets.files, in: destination) - self.preparedImports[prepared.stagingURL] = prepared - return prepared - } - if access { sourceURL.stopAccessingSecurityScopedResource() } - for (mapping, acquired) in zip(mappings, scoped) where acquired { - mapping.selectedDirectory.stopAccessingSecurityScopedResource() + let result = Result { + try importStore.prepare(selected: selected, current: current, mappings: mappings, + sourceURL: sourceURL, managedRoots: managedRoots) } DispatchQueue.main.async { - if case let .success(prepared) = result { - self.persistQueue.async { self.preparedImports.removeValue(forKey: prepared.stagingURL) } - } guard self.importRequest == request, !self.isTerminating else { - if case let .success(prepared) = result { ShelfImportTransaction.discard(prepared) } + if case let .success(prepared) = result { self.persistQueue.sync { importStore.discard(prepared) } } return } switch result { case let .failure(error): finish(.failure(error)) case let .success(prepared): guard self.storeWritable, self.fixtureMode || AppFeature.shelf.isAvailable else { - ShelfImportTransaction.discard(prepared) + self.persistQueue.sync { importStore.discard(prepared) } finish(.failure(ShelfImportServiceError.unavailable)); return } guard self.contentRevision == revision else { - ShelfImportTransaction.discard(prepared) + self.persistQueue.sync { importStore.discard(prepared) } if attempt < 2 { prepare(attempt: attempt + 1) } else { finish(.failure(ShelfImportServiceError.changed)) } return } do { - let access = sourceURL.startAccessingSecurityScopedResource() - defer { if access { sourceURL.stopAccessingSecurityScopedResource() } } - try ShelfImportAssets.validateSource(source: sourceURL, - currentIndex: destination.appendingPathComponent("ShelfItems.json")) // Validate every live conversion before the filesystem commit. let additions = Array(prepared.items.dropFirst(current.count)).compactMap { self.restoredItem(from: $0) } guard additions.count == prepared.items.count - current.count else { throw ShelfImportError.invalidDocument } try self.persistQueue.sync { - try ShelfImportTransaction.commit(prepared, to: destination) + try importStore.commit(prepared) self.indexStore?.didCommitImportedStore() } self.publishingImport = true @@ -2391,7 +2355,7 @@ final class ShelfService: ObservableObject { if !self.fixtureMode { self.startContentThumbnails(for: additions) } finish(.success(additions.reduce(0) { $0 + $1.leafCount })) } catch { - ShelfImportTransaction.discard(prepared) + self.persistQueue.sync { importStore.discard(prepared) } finish(.failure(error)) } } @@ -2411,10 +2375,7 @@ final class ShelfService: ObservableObject { private func stopImports() { importRequest = nil isImporting = false - persistQueue.sync { - for prepared in preparedImports.values { ShelfImportTransaction.discard(prepared) } - preparedImports.removeAll() - } + persistQueue.sync { importStore?.discardAll() } } private func restoreItems() { diff --git a/Sources/Vorssaint/Services/ThemeLinkImportService.swift b/Sources/Vorssaint/Services/ThemeLinkImportService.swift deleted file mode 100644 index f450c3b..0000000 --- a/Sources/Vorssaint/Services/ThemeLinkImportService.swift +++ /dev/null @@ -1,125 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// Copyright (C) 2026 Vorssaint - -import Foundation -import Darwin - -/// Downloads theme data only; no extension is installed or executed. -enum ThemeLinkImportService { - static let maximumDownloadBytes = 20 * 1_024 * 1_024 - - static func fetch(_ input: String) async throws -> ImportedTheme { - let link = try ThemeLinkImportSupport.parse(input) - try Task.checkCancellation() - let configuration = URLSessionConfiguration.ephemeral - configuration.timeoutIntervalForRequest = 15 - configuration.timeoutIntervalForResource = 60 - configuration.httpShouldSetCookies = false - configuration.urlCache = nil - let session = URLSession(configuration: configuration, delegate: RedirectPolicy(), delegateQueue: nil) - defer { session.invalidateAndCancel() } - let data: Data - do { - let (bytes, response) = try await session.bytes(from: link.downloadURL) - guard let response = response as? HTTPURLResponse, response.statusCode == 200 else { - throw ThemeLinkImportError.downloadFailed - } - guard response.expectedContentLength <= maximumDownloadBytes else { throw ThemeLinkImportError.tooLarge } - var received = Data() - for try await byte in bytes { - try Task.checkCancellation() - guard received.count < maximumDownloadBytes else { throw ThemeLinkImportError.tooLarge } - received.append(byte) - } - data = received - } catch { - if Task.isCancelled { throw CancellationError() } - throw (error as? ThemeLinkImportError) ?? .downloadFailed - } - let worker = Task.detached(priority: .userInitiated) { - try extract(data, slug: link.slug) - } - return try await withTaskCancellationHandler { - let theme = try await worker.value - try Task.checkCancellation() - return theme - } onCancel: { worker.cancel() } - } - - static func extract(_ archive: Data, slug: String) throws -> ImportedTheme { - guard archive.count <= maximumDownloadBytes else { throw ThemeLinkImportError.tooLarge } - try Task.checkCancellation() - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, - attributes: [.posixPermissions: 0o700]) - defer { try? FileManager.default.removeItem(at: directory) } - let file = directory.appendingPathComponent("theme.vsix") - try archive.write(to: file, options: .atomic) - let summary = try runUnzip(["-Z", "-t", file.path], limit: 4096) - guard let text = String(data: summary, encoding: .utf8) else { throw ThemeLinkImportError.invalidArchive } - let fields = text.split(separator: ",") - guard fields.count >= 2, - let count = fields[0].split(separator: " ").first.flatMap({ Int($0) }), - let expanded = fields[1].split(separator: " ").first.flatMap({ Int($0) }), - count <= 10_000, expanded <= 100 * 1_024 * 1_024 else { throw ThemeLinkImportError.tooLarge } - let manifest = try readEntry("extension/package.json", archive: file, limit: 256 * 1_024) - let selection = try ThemeLinkImportSupport.selection(manifest: manifest, slug: slug) - let theme = try readEntry(selection.path, archive: file, limit: ThemeImportSupport.maximumBytes) - try Task.checkCancellation() - do { - let parsed = try ThemeImportSupport.parse(theme) - return ImportedTheme(name: parsed.name.isEmpty ? selection.name : parsed.name, colors: parsed.colors) - } - catch { throw ThemeLinkImportError.invalidTheme } - } - - private static func readEntry(_ path: String, archive: URL, limit: Int) throws -> Data { - try runUnzip(["-p", archive.path, path], limit: limit) - } - - private static func runUnzip(_ arguments: [String], limit: Int) throws -> Data { - // Only bounded stdout is read; no archive member is written to disk. - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") - process.arguments = arguments - process.environment = ["LC_ALL": "C", "LANG": "C"] - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = FileHandle.nullDevice - try Task.checkCancellation() - try process.run() - let deadline = DispatchWorkItem { if process.isRunning { kill(process.processIdentifier, SIGKILL) } } - DispatchQueue.global().asyncAfter(deadline: .now() + 10, execute: deadline) - defer { - deadline.cancel() - if process.isRunning { kill(process.processIdentifier, SIGKILL) } - process.waitUntilExit() - try? pipe.fileHandleForReading.close() - } - var data = Data() - while true { - try Task.checkCancellation() - let chunk = pipe.fileHandleForReading.readData(ofLength: 16 * 1_024) - if chunk.isEmpty { break } - guard data.count + chunk.count <= limit else { throw ThemeLinkImportError.tooLarge } - data.append(chunk) - } - process.waitUntilExit() - guard process.terminationStatus == 0 else { throw ThemeLinkImportError.invalidArchive } - return data - } - - private final class RedirectPolicy: NSObject, URLSessionTaskDelegate { - func urlSession(_ session: URLSession, task: URLSessionTask, - willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, - completionHandler: @escaping (URLRequest?) -> Void) { - guard let url = request.url, url.scheme == "https", url.user == nil, url.password == nil, - url.port == nil, let host = url.host?.lowercased(), - host.hasSuffix(".gallery.vsassets.io") || host.hasSuffix(".gallerycdn.vsassets.io") else { - completionHandler(nil) - return - } - completionHandler(request) - } - } -} diff --git a/Sources/Vorssaint/Services/ThemePreferences.swift b/Sources/Vorssaint/Services/ThemePreferences.swift deleted file mode 100644 index 3fefa75..0000000 --- a/Sources/Vorssaint/Services/ThemePreferences.swift +++ /dev/null @@ -1,29 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// Copyright (C) 2026 Vorssaint - -import Foundation -import Combine - -final class ThemePreferences: ObservableObject { - static let shared = ThemePreferences(defaults: .standard) - static let storageKey = DefaultsKey.importedInterfaceTheme - @Published private(set) var applied: ImportedTheme? - private let defaults: UserDefaults - - init(defaults: UserDefaults) { - self.defaults = defaults - applied = defaults.data(forKey: Self.storageKey).flatMap { try? ThemeImportSupport.parse($0) } - } - - func apply(_ theme: ImportedTheme) throws { - let data = try ThemeImportSupport.savedData(theme) - let validated = try ThemeImportSupport.parse(data) - defaults.set(data, forKey: Self.storageKey) - applied = validated - } - - func restoreDefault() { - defaults.removeObject(forKey: Self.storageKey) - applied = nil - } -} diff --git a/Sources/Vorssaint/UI/MenuPanel/BrightnessSection.swift b/Sources/Vorssaint/UI/MenuPanel/BrightnessSection.swift index aa0d9fc..733bcd3 100644 --- a/Sources/Vorssaint/UI/MenuPanel/BrightnessSection.swift +++ b/Sources/Vorssaint/UI/MenuPanel/BrightnessSection.swift @@ -12,6 +12,7 @@ struct BrightnessSection: View { @ObservedObject private var permissions = Permissions.shared @AppStorage(DefaultsKey.brightnessOSDEnabled) private var brightnessOSDEnabled = false var collapsible = true + @State private var refreshOwner = UUID() private var strings: BrightnessFeatureStrings { FeatureStrings.brightness(l10n.language) } @@ -46,7 +47,8 @@ struct BrightnessSection: View { } } .panelCard() - .onAppear { service.refresh() } + .onAppear { service.beginVisibleRefresh(refreshOwner) } + .onDisappear { service.endVisibleRefresh(refreshOwner) } } } @@ -62,8 +64,8 @@ struct BrightnessSection: View { .lineLimit(1) .truncationMode(.middle) Spacer(minLength: 4) - if !display.isBuiltIn, display.isActive, display.method != nil { - Text(display.hasKnownBrightness ? "\(Int((display.brightness * 100).rounded()))%" : "—") + if display.isActive, display.method != nil { + Text(display.observedBrightness.map { "\(Int(($0 * 100).rounded()))%" } ?? "—") .font(.system(size: 10.5, weight: .semibold).monospacedDigit()) .foregroundStyle(.secondary) } else if !display.isActive { @@ -73,12 +75,8 @@ struct BrightnessSection: View { } DisplayPowerButton(display: display, compact: true) } - if !display.isBuiltIn, display.isActive, display.method != nil { - Slider(value: brightnessBinding(display), in: 0...1) - .controlSize(.small) - .disabled(service.isDisplayPending(display.id)) - .accessibilityLabel(display.name) - .accessibilityValue(display.hasKnownBrightness ? "\(Int((display.brightness * 100).rounded()))%" : FeatureStrings.brightness(l10n.language).brightnessUnknownNote) + if display.isActive, display.method != nil { + DisplayBrightnessControl(display: display, showOSD: brightnessOSDEnabled) } if let explanation = brightnessControlExplanation(display, strings: strings) { Text(explanation) @@ -157,8 +155,23 @@ func displayControlFailureText(_ failure: BrightnessService.DisplayControlFailur /// Describes the displayed value without treating missing readback as a failed write. func brightnessControlExplanation(_ display: BrightnessDisplay, strings: BrightnessFeatureStrings) -> String? { - guard !display.isBuiltIn, display.isActive, let method = display.method else { return nil } + guard display.isActive else { return nil } + if display.failure == .identityUnavailable { + return FeatureBehaviorStrings(language: L10n.shared.language).identityUnavailable + } + guard let method = display.method else { return nil } if method == .software { return strings.softwareDimmingNote } + let statusText = FeatureBehaviorStrings(language: L10n.shared.language) + switch display.status { + case .pending: return statusText.pending + requestedLevelText(display) + case .sentUnconfirmed: return statusText.unconfirmed + requestedLevelText(display) + case .failed, .unknown: return statusText.invalidRead + case .confirmed: break + } if !display.hasKnownBrightness { return strings.brightnessUnknownNote } return display.readable ? nil : strings.brightnessReadbackNote } + +private func requestedLevelText(_ display: BrightnessDisplay) -> String { + display.requestedBrightness.map { " (\(Int(($0 * 100).rounded()))%)" } ?? "" +} diff --git a/Sources/Vorssaint/UI/MenuPanel/CPUCoreMatrix.swift b/Sources/Vorssaint/UI/MenuPanel/CPUCoreMatrix.swift index 7a04193..3c7d2f4 100644 --- a/Sources/Vorssaint/UI/MenuPanel/CPUCoreMatrix.swift +++ b/Sources/Vorssaint/UI/MenuPanel/CPUCoreMatrix.swift @@ -9,7 +9,6 @@ struct CPUCoreMatrix: View { @Environment(\.colorSchemeContrast) private var contrast @State private var width: CGFloat = 280 let usage: [Double?] - var palette: ImportedTheme? = nil var increasedContrast: Bool? = nil private var usesIncreasedContrast: Bool { increasedContrast ?? (contrast == .increased) } var coreGroups: [CPUCoreGroup]? = nil @@ -28,8 +27,8 @@ struct CPUCoreMatrix: View { chipRows .padding(8) .frame(maxWidth: .infinity, alignment: .leading) - .background((Theme.color(.primaryText, in: palette) ?? Color.primary).opacity(0.025), in: RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Theme.color(.border, in: palette) ?? Color.primary.opacity(0.12), lineWidth: 1)) + .background(Color.primary.opacity(0.025), in: RoundedRectangle(cornerRadius: 5)) + .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color.primary.opacity(0.12), lineWidth: 1)) .onAppear { width = proxy.size.width } .onChange(of: proxy.size.width) { _, value in width = value } } @@ -66,7 +65,7 @@ struct CPUCoreMatrix: View { } Text("\(CPUCoreStrings.groupName(segment.group.name, language: l10n.language)) ×\(segment.group.indices.count)") .font(PanelTypography.meta) - .foregroundStyle(Theme.color(.secondaryText, in: palette) ?? Color.secondary) + .foregroundStyle(Color.secondary) .lineLimit(2) .minimumScaleFactor(0.8) .frame(height: 22, alignment: .topLeading) @@ -78,20 +77,20 @@ struct CPUCoreMatrix: View { let value = usage.indices.contains(index) ? usage[index].flatMap { $0.isFinite ? min(1, max(0, $0)) : nil } : nil return GeometryReader { proxy in ZStack(alignment: .bottom) { - Rectangle().fill((Theme.color(.primaryText, in: palette) ?? Color.primary).opacity(0.035)) + Rectangle().fill(Color.primary.opacity(0.035)) if let value { Rectangle() - .fill((Theme.color(.primaryText, in: palette) ?? Color.primary).opacity(0.78)) + .fill(Color.primary.opacity(0.78)) .frame(height: proxy.size.height * value) } else { - Text("–").font(PanelTypography.meta).foregroundStyle(Theme.color(.secondaryText, in: palette) ?? Color.secondary) + Text("–").font(PanelTypography.meta).foregroundStyle(Color.secondary) .frame(maxHeight: .infinity) } } .clipShape(RoundedRectangle(cornerRadius: 2)) .overlay { RoundedRectangle(cornerRadius: 2) - .strokeBorder(usesIncreasedContrast ? Color.primary.opacity(0.65) : (Theme.color(.border, in: palette) ?? Color.primary.opacity(0.25)), + .strokeBorder(usesIncreasedContrast ? Color.primary.opacity(0.65) : Color.primary.opacity(0.25), style: StrokeStyle(lineWidth: usesIncreasedContrast ? 1.5 : 0.75, dash: value == nil ? [2, 2] : [])) } diff --git a/Sources/Vorssaint/UI/MenuPanel/DisplayBrightnessControl.swift b/Sources/Vorssaint/UI/MenuPanel/DisplayBrightnessControl.swift new file mode 100644 index 0000000..da9d477 --- /dev/null +++ b/Sources/Vorssaint/UI/MenuPanel/DisplayBrightnessControl.swift @@ -0,0 +1,46 @@ +import SwiftUI + +/// Unknown hardware readings have no slider thumb; setting an absolute target +/// remains possible without presenting an invented midpoint as a measurement. +struct DisplayBrightnessControl: View { + @ObservedObject private var service = BrightnessService.shared + @ObservedObject private var l10n = L10n.shared + let display: BrightnessDisplay + let showOSD: Bool + @State private var target = "" + + private var text: FeatureBehaviorStrings { .init(language: l10n.language) } + private var parsedTarget: Double? { + guard let value = Double(target), value.isFinite, (0...100).contains(value) else { return nil } + return value / 100 + } + + var body: some View { + Group { + if display.observedBrightness != nil || display.requestedBrightness != nil { + Slider(value: Binding(get: { display.requestedBrightness ?? display.observedBrightness ?? display.brightness }, + set: { service.setBrightness($0, for: display.id, showOSD: showOSD) }), + in: 0...1) + .controlSize(.small) + .accessibilityLabel(display.name) + } else { + HStack { + TextField(text.level, text: $target) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 150) + .accessibilityLabel(text.level) + .onSubmit { submit() } + Button(text.setLevel) { submit() } + .disabled(parsedTarget == nil) + .controlSize(.small) + } + } + } + .disabled(service.isDisplayPending(display.id)) + } + + private func submit() { + guard let parsedTarget else { return } + service.setBrightness(parsedTarget, for: display.id, showOSD: showOSD) + } +} diff --git a/Sources/Vorssaint/UI/MenuPanel/MenuPanelView.swift b/Sources/Vorssaint/UI/MenuPanel/MenuPanelView.swift index 87ff05c..032a294 100644 --- a/Sources/Vorssaint/UI/MenuPanel/MenuPanelView.swift +++ b/Sources/Vorssaint/UI/MenuPanel/MenuPanelView.swift @@ -60,7 +60,6 @@ final class MenuPanelFocus: ObservableObject { /// Content of the menu bar popover: keep-awake controls, the volume mixer and /// the system monitor. struct MenuPanelView: View { - @ObservedObject private var themePreferences = ThemePreferences.shared @ObservedObject private var l10n = L10n.shared @ObservedObject private var updates = UpdateService.shared @ObservedObject private var panelFocus = MenuPanelFocus.shared @@ -74,7 +73,6 @@ struct MenuPanelView: View { @AppStorage(DefaultsKey.panelShowFanControl) private var showFanControl = true @AppStorage(DefaultsKey.panelShowKeepAwake) private var showKeepAwake = true @AppStorage(DefaultsKey.panelShowBrightness) private var showBrightness = true - @AppStorage(DefaultsKey.brightnessControlEnabled) private var brightnessEnabled = false @AppStorage(DefaultsKey.panelShowUtilities) private var showUtilities = true @AppStorage(DefaultsKey.panelShowControls) private var showControls = true @AppStorage(DefaultsKey.panelSectionOrder) private var sectionOrderRaw = "" @@ -102,7 +100,7 @@ struct MenuPanelView: View { navigablePanel } } - .kururuTheme() + .tint(SettingsVisualStyle.preview?.accent) .onAppear { applyFocus(panelFocus.request) KeepAwakeManager.shared.refreshPasswordlessStatus() @@ -179,11 +177,10 @@ struct MenuPanelView: View { } UpdateBanner().reportHeight($updateBannerHeight) HStack(alignment: .top, spacing: 16) { - ScrollView(.vertical) { + PanelNavigationScrollView(height: navigableScrollHeight) { sectionNavigation } - .scrollIndicators(.hidden) - .frame(width: 52, height: navigableScrollHeight) + .frame(width: 52) OverlayScrollView(measuredHeight: $navigableContentHeight) { Group { @@ -346,7 +343,7 @@ struct MenuPanelView: View { case .keepAwake: return showKeepAwake // The section only earns its navigation tab while the feature is on; // it is switched on in Settings, not from an empty panel screen. - case .brightness: return showBrightness && brightnessEnabled + case .brightness: return showBrightness case .mixer: return showMixer case .system: return showSystem case .network: return showNetwork @@ -373,10 +370,10 @@ struct MenuPanelView: View { } .buttonStyle(.plain) .focused($focusedSection, equals: id) - .foregroundStyle(isActive ? Color(nsColor: .windowBackgroundColor) : Color.secondary) + .foregroundStyle(isActive ? Color.primary : Color.secondary) .background { if isActive { - RoundedRectangle(cornerRadius: 12, style: .continuous).fill(Color.primary) + RoundedRectangle(cornerRadius: 10, style: .continuous).fill(Color.primary.opacity(0.10)) } } .accessibilityLabel(id.title(l10n.s)) @@ -385,7 +382,6 @@ struct MenuPanelView: View { } } .padding(4) - .panelNavigationSurface() } private func metricNavigationHeader(_ kind: MetricDetailKind) -> some View { @@ -2325,8 +2321,9 @@ struct KeepAwakeCard: View { HStack { statusLine Spacer() - Toggle("", isOn: activeBinding) + Toggle(AppFeature.keepAwake.name(l10n.s, language: l10n.language), isOn: activeBinding) .toggleStyle(.switch) + .controlSize(.small) .labelsHidden() } @@ -2565,8 +2562,8 @@ struct KeepAwakeCard: View { Text(l10n.s.keepAwakeNormalRules) } } - .font(.system(size: 11)) - .foregroundStyle(.secondary) + .font(PanelTypography.body) + .foregroundStyle(.primary) } private var automationStrings: KeepAwakeAutomationStrings { diff --git a/Sources/Vorssaint/UI/MenuPanel/MetricDetailView.swift b/Sources/Vorssaint/UI/MenuPanel/MetricDetailView.swift index e07e79e..95e755d 100644 --- a/Sources/Vorssaint/UI/MenuPanel/MetricDetailView.swift +++ b/Sources/Vorssaint/UI/MenuPanel/MetricDetailView.swift @@ -141,7 +141,6 @@ struct ActivityMonitorButton: View { struct MetricDetailView: View { @ObservedObject private var l10n = L10n.shared @ObservedObject private var monitor = SystemMonitor.shared - @ObservedObject private var themePreferences = ThemePreferences.shared @ObservedObject private var speed = SpeedTest.shared @Environment(\.colorScheme) private var colorScheme @AppStorage(DefaultsKey.temperatureUnit) private var temperatureUnit = TemperatureUnit.celsius.rawValue @@ -223,7 +222,7 @@ struct MetricDetailView: View { case .memory, .disk, .battery: MetricScale(fraction: summaryFraction) case .cpu: - CPUCoreMatrix(usage: monitor.snapshot.cpuCoreUsage, palette: themePreferences.applied) + CPUCoreMatrix(usage: monitor.snapshot.cpuCoreUsage) case .gpu, .network, .power, .fan: EmptyView() } diff --git a/Sources/Vorssaint/UI/MenuPanel/MixerSection.swift b/Sources/Vorssaint/UI/MenuPanel/MixerSection.swift index 671eec0..9c5176f 100644 --- a/Sources/Vorssaint/UI/MenuPanel/MixerSection.swift +++ b/Sources/Vorssaint/UI/MenuPanel/MixerSection.swift @@ -457,21 +457,6 @@ struct MixerSection: View { @ViewBuilder private var mixerRows: some View { -#if compiler(>=6.2) - if #available(macOS 26.0, *), LiquidGlassSupport.isEnabled() { - GlassEffectContainer(spacing: 8) { - rowList - } - } else { - rowList - } -#else - rowList -#endif - } - - @ViewBuilder - private var rowList: some View { ForEach(visibleApps) { app in MixerRow(app: app, normalTint: normalSliderTint, @@ -945,25 +930,9 @@ private struct MixerVolumeSlider: View { private var percentage: Int { Int((value * 100).rounded()) } var body: some View { - Group { -#if compiler(>=6.2) - if #available(macOS 26.0, *), LiquidGlassSupport.isEnabled() { - LiquidGlassMixerSlider(value: $value, - tint: activeTint, - isBoosting: isBoosting, - maximum: maximum, - accessibilityLabel: accessibilityLabel) - } else { - nativeSlider - .accessibilityLabel(accessibilityLabel) - .accessibilityValue("\(percentage)%") - } -#else - nativeSlider - .accessibilityLabel(accessibilityLabel) - .accessibilityValue("\(percentage)%") -#endif - } + nativeSlider + .accessibilityLabel(accessibilityLabel) + .accessibilityValue("\(percentage)%") } private var nativeSlider: some View { @@ -977,111 +946,6 @@ private struct MixerVolumeSlider: View { } } -#if compiler(>=6.2) -@available(macOS 26.0, *) -private struct LiquidGlassMixerSlider: View { - @Binding var value: Double - let tint: Color - let isBoosting: Bool - let maximum: Double - let accessibilityLabel: String - @Environment(\.accessibilityReduceTransparency) private var reduceTransparency - @Environment(\.colorScheme) private var colorScheme - - private let knobWidth: CGFloat = 24 - private let knobHeight: CGFloat = 15 - private let trackHeight: CGFloat = 5 - - private var progress: CGFloat { - let clamped = min(max(value, 0), maximum) - return CGFloat(clamped / maximum) - } - - var body: some View { - GeometryReader { proxy in - let width = max(proxy.size.width, knobWidth) - let amount = progress - ZStack(alignment: .leading) { - Capsule() - .fill(Color.primary.opacity(trackOpacity)) - .frame(height: trackHeight) - - Capsule() - .fill(tint) - .frame(width: max(trackHeight, width * amount), height: trackHeight) - .shadow(color: tint.opacity(0.18), radius: 3) - - knob - .frame(width: knobWidth, height: knobHeight) - .offset(x: (width - knobWidth) * amount) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - .animation(.easeOut(duration: 0.16), value: amount) - .contentShape(Rectangle()) - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { gesture in - updateValue(at: gesture.location.x, width: width) - } - ) - } - .frame(height: knobHeight) - .accessibilityLabel(accessibilityLabel) - .accessibilityValue("\(Int((value * 100).rounded()))%") - .accessibilityAdjustableAction { direction in - switch direction { - case .increment: - value = min(maximum, value + 0.05) - case .decrement: - value = max(0, value - 0.05) - @unknown default: - break - } - } - } - - private var trackOpacity: Double { - colorScheme == .light ? 0.11 : 0.16 - } - - private var knob: some View { - ZStack { - knobFill - Capsule() - .strokeBorder(tint.opacity(isBoosting ? 0.55 : 0.36), lineWidth: isBoosting ? 1.1 : 0.8) - Capsule() - .fill( - LinearGradient(colors: [ - Color.white.opacity(colorScheme == .light ? 0.48 : 0.28), - Color.white.opacity(0.06) - ], startPoint: .top, endPoint: .bottom) - ) - .blendMode(.screen) - .padding(1) - } - .shadow(color: tint.opacity(isBoosting ? 0.24 : 0.16), radius: 3, x: 0, y: 0) - .shadow(color: Color.black.opacity(colorScheme == .light ? 0.08 : 0.18), radius: 2, x: 0, y: 1) - } - - @ViewBuilder - private var knobFill: some View { - if reduceTransparency { - Capsule() - .fill(Color(nsColor: .controlBackgroundColor)) - .overlay(Capsule().fill(tint.opacity(colorScheme == .light ? 0.10 : 0.16))) - } else { - Color.clear - .glassEffect(.regular.tint(tint.opacity(isBoosting ? 0.18 : 0.10)).interactive(), in: Capsule()) - } - } - - private func updateValue(at x: CGFloat, width: CGFloat) { - let travel = max(width - knobWidth, 1) - let normalized = min(max((x - knobWidth / 2) / travel, 0), 1) - value = Double(normalized) * maximum - } -} -#endif /// The same persisted list scope is available beside the rows and in Settings. struct MixerAppScopePicker: View { diff --git a/Sources/Vorssaint/UI/MenuPanel/MonitorTrendPlot.swift b/Sources/Vorssaint/UI/MenuPanel/MonitorTrendPlot.swift index b965f49..6797403 100644 --- a/Sources/Vorssaint/UI/MenuPanel/MonitorTrendPlot.swift +++ b/Sources/Vorssaint/UI/MenuPanel/MonitorTrendPlot.swift @@ -2,7 +2,7 @@ import SwiftUI import Charts -/// Shared production and preview chart. All data and colors are explicit inputs. +/// Monitor history with explicit samples and system semantic colors. struct MonitorTrendPlot: View { struct Sample: Identifiable { let id: Int @@ -15,7 +15,6 @@ struct MonitorTrendPlot: View { let window: Int let ymax: Double var hovered: Sample? = nil - var palette: ImportedTheme? = nil var nowLabel = "Now" var axisFormat: (Double) -> String = { String(format: "%.0f", locale: Locale.current, $0) } private var segmentCounts: [Int: Int] { Dictionary(grouping: samples, by: \.segment).mapValues(\.count) } @@ -26,18 +25,18 @@ struct MonitorTrendPlot: View { series: .value("Interval", sample.segment)) .interpolationMethod(.linear) .lineStyle(StrokeStyle(lineWidth: 1.6, lineCap: .round)) - .foregroundStyle((Theme.color(.primaryText, in: palette) ?? Color.primary).opacity(0.78)) + .foregroundStyle(Color.primary.opacity(0.78)) } ForEach(samples.filter { segmentCounts[$0.segment] == 1 }) { sample in PointMark(x: .value("Time", sample.time - now), y: .value("Value", sample.value)) - .foregroundStyle((Theme.color(.primaryText, in: palette) ?? Color.primary).opacity(0.78)).symbolSize(14) + .foregroundStyle(Color.primary.opacity(0.78)).symbolSize(14) } if let hovered { RuleMark(x: .value("Time", hovered.time - now)) - .foregroundStyle((Theme.color(.secondaryText, in: palette) ?? Color.secondary).opacity(0.5)) + .foregroundStyle(Color.secondary.opacity(0.5)) .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) PointMark(x: .value("Time", hovered.time - now), y: .value("Value", hovered.value)) - .foregroundStyle((Theme.color(.primaryText, in: palette) ?? Color.primary).opacity(0.78)).symbolSize(20) + .foregroundStyle(Color.primary.opacity(0.78)).symbolSize(20) } } .chartXScale(domain: -Double(window * 60)...0) @@ -47,16 +46,16 @@ struct MonitorTrendPlot: View { if let seconds = axis.as(Double.self) { AxisValueLabel(anchor: seconds == 0 ? .topTrailing : (seconds == -Double(window * 60) ? .topLeading : .top), collisionResolution: .disabled) { - Text(seconds == 0 ? nowLabel : relativeTime(seconds)).foregroundStyle(Theme.color(.secondaryText, in: palette) ?? Color.secondary) + Text(seconds == 0 ? nowLabel : relativeTime(seconds)).foregroundStyle(Color.secondary) } } } } .chartYAxis { AxisMarks(position: .trailing, values: [0, ymax]) { axis in - AxisGridLine().foregroundStyle((Theme.color(.secondaryText, in: palette) ?? Color.secondary).opacity(0.12)) + AxisGridLine().foregroundStyle(Color.secondary.opacity(0.12)) AxisValueLabel { - if let v = axis.as(Double.self) { Text(axisFormat(v)).foregroundStyle(Theme.color(.secondaryText, in: palette) ?? Color.secondary) } + if let v = axis.as(Double.self) { Text(axisFormat(v)).foregroundStyle(Color.secondary) } } } } diff --git a/Sources/Vorssaint/UI/MenuPanel/MonitorTrendView.swift b/Sources/Vorssaint/UI/MenuPanel/MonitorTrendView.swift index 7668a6e..b4fcacb 100644 --- a/Sources/Vorssaint/UI/MenuPanel/MonitorTrendView.swift +++ b/Sources/Vorssaint/UI/MenuPanel/MonitorTrendView.swift @@ -15,7 +15,6 @@ struct MonitorTrendView: View { } private struct MonitorMetricTrend: View { - @ObservedObject private var palettePreferences = ThemePreferences.shared @ObservedObject private var monitor = SystemMonitor.shared @ObservedObject private var l10n = L10n.shared @AppStorage("monitorHistoryMinutes") private var minutes = 1 @@ -74,7 +73,7 @@ private struct MonitorMetricTrend: View { samples: samples.enumerated().map { .init(id: $0.offset, time: $0.element.time, value: value($0.element.value), segment: $0.element.segment) }, now: now, window: window, ymax: ymax, hovered: hovered.map { .init(id: -1, time: $0.time, value: value($0.value), segment: $0.segment) }, - palette: palettePreferences.applied, nowLabel: text.now, axisFormat: axisFormat) + nowLabel: text.now, axisFormat: axisFormat) .chartOverlay { proxy in GeometryReader { geometry in Color.clear.contentShape(Rectangle()) diff --git a/Sources/Vorssaint/UI/MenuPanel/PanelNavigationScrollView.swift b/Sources/Vorssaint/UI/MenuPanel/PanelNavigationScrollView.swift new file mode 100644 index 0000000..97642dd --- /dev/null +++ b/Sources/Vorssaint/UI/MenuPanel/PanelNavigationScrollView.swift @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import SwiftUI + +/// The rail stays fixed while its entries scroll inside the rounded viewport. +struct PanelNavigationScrollView: View { + let height: CGFloat + @ViewBuilder let content: Content + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + @State private var contentFrame = CGRect.zero + @Namespace private var viewport + + var body: some View { + ScrollView(.vertical) { + content + .onGeometryChange(for: CGRect.self) { $0.frame(in: .named(viewport)) } action: { + contentFrame = $0 + } + } + .coordinateSpace(name: viewport) + .scrollIndicators(.hidden) + .frame(height: height) + .mask { + VStack(spacing: 0) { + LinearGradient(colors: [contentFrame.minY < -1 ? .clear : .black, .black], + startPoint: .top, endPoint: .bottom) + .frame(height: 12) + Rectangle() + LinearGradient(colors: [.black, contentFrame.maxY > height + 1 ? .clear : .black], + startPoint: .top, endPoint: .bottom) + .frame(height: 12) + } + } + .background { + let shape = RoundedRectangle(cornerRadius: 12, style: .continuous) + if reduceTransparency { + shape.fill(Color(nsColor: .controlBackgroundColor)) + } else { + shape.fill(.regularMaterial) + } + } + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } +} diff --git a/Sources/Vorssaint/UI/MenuPanel/SystemSection.swift b/Sources/Vorssaint/UI/MenuPanel/SystemSection.swift index d824aaf..377c38a 100644 --- a/Sources/Vorssaint/UI/MenuPanel/SystemSection.swift +++ b/Sources/Vorssaint/UI/MenuPanel/SystemSection.swift @@ -23,7 +23,6 @@ enum BreakdownKind { struct SystemSection: View { @ObservedObject private var l10n = L10n.shared @ObservedObject private var monitor = SystemMonitor.shared - @ObservedObject private var themePreferences = ThemePreferences.shared @Environment(\.colorScheme) private var colorScheme var collapsible = true @State private var expanded: BreakdownKind? @@ -355,7 +354,7 @@ struct SystemSection: View { if editing { PanelInlineHideButton(isVisible: visible) } } if kind == .cpu { - CPUCoreMatrix(usage: monitor.snapshot.cpuCoreUsage, palette: themePreferences.applied) + CPUCoreMatrix(usage: monitor.snapshot.cpuCoreUsage) } if !editing { MonitorTrendView(metrics: [kind == .cpu ? .cpu : .gpu], embedded: true) diff --git a/Sources/Vorssaint/UI/RadialMenu/RadialMenuView.swift b/Sources/Vorssaint/UI/RadialMenu/RadialMenuView.swift index 1bbdf1c..b3380f1 100644 --- a/Sources/Vorssaint/UI/RadialMenu/RadialMenuView.swift +++ b/Sources/Vorssaint/UI/RadialMenu/RadialMenuView.swift @@ -11,8 +11,6 @@ struct RadialMenuView: View { @ObservedObject private var l10n = L10n.shared @Environment(\.colorScheme) private var colorScheme @Environment(\.accessibilityReduceMotion) private var reduceMotion - @Environment(\.accessibilityReduceTransparency) private var reduceTransparency - @AppStorage(DefaultsKey.liquidGlassEnabled) private var liquidGlassEnabled = false /// Where the highlight sits right now. Kept unwrapped past a full turn, so /// the step from the last slice to the first is one step onward and not a @@ -124,22 +122,9 @@ struct RadialMenuView: View { @ViewBuilder private var discMaterial: some View { -#if compiler(>=6.2) - if #available(macOS 26.0, *), liquidGlassEnabled, !reduceTransparency { - Circle() - .fill(Color.clear) - .glassEffect(.regular, in: Circle()) - .overlay(Circle().fill(PanelSurface.baseFill(for: colorScheme).opacity(colorScheme == .light ? 0.35 : 0.45))) - } else { - Circle() - .fill(.regularMaterial) - .overlay(Circle().fill(PanelSurface.baseFill(for: colorScheme))) - } -#else Circle() .fill(.regularMaterial) .overlay(Circle().fill(PanelSurface.baseFill(for: colorScheme))) -#endif } /// A hairline between one slice and the next. Almost invisible on its own, diff --git a/Sources/Vorssaint/UI/Settings/AdvancedSettings.swift b/Sources/Vorssaint/UI/Settings/AdvancedSettings.swift index 2b92a30..c1f07f0 100644 --- a/Sources/Vorssaint/UI/Settings/AdvancedSettings.swift +++ b/Sources/Vorssaint/UI/Settings/AdvancedSettings.swift @@ -63,12 +63,6 @@ struct AdvancedSettings: View { } } .pickerStyle(.segmented) -#if compiler(>=6.2) - if #available(macOS 26.0, *) { - Toggle(appearanceStrings.liquidGlass, isOn: $appearance.liquidGlassEnabled) - } -#endif - ThemeSettings() } SettingsSection(hierarchy.maintenance) { VStack(alignment: .leading, spacing: 12) { diff --git a/Sources/Vorssaint/UI/Settings/BrightnessSettings.swift b/Sources/Vorssaint/UI/Settings/BrightnessSettings.swift index b2296a6..393160a 100644 --- a/Sources/Vorssaint/UI/Settings/BrightnessSettings.swift +++ b/Sources/Vorssaint/UI/Settings/BrightnessSettings.swift @@ -8,20 +8,24 @@ struct BrightnessSettings: View { @ObservedObject private var features = FeatureRuntime.shared @ObservedObject private var permissions = Permissions.shared @ObservedObject private var brightness = BrightnessService.shared - @AppStorage(DefaultsKey.brightnessControlEnabled) private var brightnessEnabled = false @AppStorage(DefaultsKey.brightnessKeysEnabled) private var brightnessKeysEnabled = false @AppStorage(DefaultsKey.brightnessOSDEnabled) private var brightnessOSDEnabled = false @AppStorage(BrightnessShortcutPreferenceKey.enabled) private var displayBrightnessShortcutsEnabled = false + @State private var refreshOwner = UUID() + var body: some View { SettingsForm { if AppFeature.brightness.isAvailable { let strings = FeatureStrings.brightness(l10n.language) SettingsSection(title: UXEntryStrings(l10n.language).displayDevices, systemImage: "display") { - FeatureSwitchRow(feature: .brightness) - SettingsCaptionText(strings.enableCaption) - if brightnessEnabled { + HStack { + Text(strings.enable) + .font(SettingsTypography.body) + SettingsHelpButton(title: UXEntryStrings(l10n.language).displayDevices, + text: strings.enableCaption + "\n\n" + strings.externalCaption) + } if brightness.displays.isEmpty { SettingsCaptionText(strings.noDisplays) } else { @@ -39,29 +43,29 @@ struct BrightnessSettings: View { SettingsCaptionText(displayControlFailureText(failure, strings: strings)) .foregroundStyle(.red) } - } - SettingsCaptionText(strings.externalCaption) } .settingsSectionAnchor(.brightness) SettingsSection(title: UXEntryStrings(l10n.language).keyboardControl, systemImage: "keyboard") { SettingsToggleWithCaption(title: strings.keysToggle, caption: strings.keysCaption, + showsCaptionInline: false, isOn: $brightnessKeysEnabled) .onChange(of: brightnessKeysEnabled) { _, isOn in - if isOn && brightnessEnabled { Permissions.shared.requestAccessibility() } + if isOn { Permissions.shared.requestAccessibility() } BrightnessService.shared.syncWithPreferences() } if brightness.brightnessOSDSupported { SettingsToggleWithCaption(title: strings.osdToggle, caption: strings.osdCaption, + showsCaptionInline: false, isOn: $brightnessOSDEnabled) .onChange(of: brightnessOSDEnabled) { _, isOn in - if isOn && brightnessEnabled { Permissions.shared.requestAccessibility() } + if isOn { Permissions.shared.requestAccessibility() } BrightnessService.shared.syncWithPreferences() } } - if brightnessEnabled, (brightnessKeysEnabled || brightnessOSDEnabled), + if (brightnessKeysEnabled || brightnessOSDEnabled), !permissions.accessibility { PermissionRow(kind: .accessibility) } @@ -71,16 +75,16 @@ struct BrightnessSettings: View { } } .formStyle(.grouped) - .onAppear { - BrightnessService.shared.refresh() - } + .onAppear { brightness.beginVisibleRefresh(refreshOwner) } + .onDisappear { brightness.endVisibleRefresh(refreshOwner) } } private var displayBrightnessShortcutControls: some View { let text = BrightnessShortcutStrings.localized(l10n.language) return Group { - SettingsCaptionText(text.caption) - Toggle(text.toggle, isOn: $displayBrightnessShortcutsEnabled) + SettingsToggleWithCaption(title: text.toggle, caption: text.caption, + showsCaptionInline: false, + isOn: $displayBrightnessShortcutsEnabled) .onChange(of: displayBrightnessShortcutsEnabled) { _, _ in BrightnessService.shared.syncWithPreferences() } @@ -115,16 +119,9 @@ struct BrightnessSettings: View { Text(display.name) .lineLimit(1) .truncationMode(.middle) - if !display.isBuiltIn, display.isActive, display.method != nil { - Slider(value: Binding(get: { display.brightness }, - set: { BrightnessService.shared.setBrightness( - $0, for: display.id, - showOSD: brightnessOSDEnabled) }), - in: 0...1) - .disabled(brightness.isDisplayPending(display.id)) - .accessibilityLabel(display.name) - .accessibilityValue(display.hasKnownBrightness ? "\(Int((display.brightness * 100).rounded()))%" : FeatureStrings.brightness(l10n.language).brightnessUnknownNote) - Text(display.hasKnownBrightness ? "\(Int((display.brightness * 100).rounded()))%" : "—") + if display.isActive, display.method != nil { + DisplayBrightnessControl(display: display, showOSD: brightnessOSDEnabled) + Text(display.observedBrightness.map { "\(Int(($0 * 100).rounded()))%" } ?? "—") .font(.system(.body, design: .monospaced)) .foregroundStyle(.secondary) .frame(width: 52, alignment: .trailing) diff --git a/Sources/Vorssaint/UI/Settings/ClipboardSettings.swift b/Sources/Vorssaint/UI/Settings/ClipboardSettings.swift index 1c07312..5debf53 100644 --- a/Sources/Vorssaint/UI/Settings/ClipboardSettings.swift +++ b/Sources/Vorssaint/UI/Settings/ClipboardSettings.swift @@ -52,9 +52,8 @@ struct ClipboardSettings: View { .settingsSectionAnchor(.clipboardHistory) SettingsSection(title: flowText.automaticCapture, systemImage: "tray.and.arrow.down") { - FeatureSwitchRow(feature: .clipboardHistory, title: flowText.recordCopiedContent) - SettingsExplanation(flowText.automaticCaptureCaption) - SettingsInfo(text: text.localNote, systemImage: "lock.shield") + FeatureSwitchRow(feature: .clipboardHistory, title: flowText.recordCopiedContent, + help: flowText.automaticCaptureCaption + "\n\n" + text.localNote) if enabled, history.isRunning { Label(flowText.captureActive, systemImage: "checkmark.circle.fill") .font(SettingsTypography.caption) @@ -66,6 +65,7 @@ struct ClipboardSettings: View { } SettingsToggleWithCaption(title: text.includeImagesFiles, caption: text.includeImagesFilesCaption, + showsCaptionInline: false, isOn: $includeImagesFiles) SettingsToggleWithCaption(title: text.skipSensitive, caption: text.skipSensitiveCaption, @@ -88,8 +88,7 @@ struct ClipboardSettings: View { if AppFeature.pastePlain.isAvailable { SettingsSection { - FeatureSwitchRow(feature: .pastePlain) - SettingsExplanation(l10n.s.pastePlainCaption) + FeatureSwitchRow(feature: .pastePlain, help: l10n.s.pastePlainCaption) ShortcutPreferenceRow(role: .pastePlain, isEnabled: pastePlainEnabled) { PastePlainService.shared.syncWithPreferences() } diff --git a/Sources/Vorssaint/UI/Settings/CommandBarSettings.swift b/Sources/Vorssaint/UI/Settings/CommandBarSettings.swift index 701550a..298c4e2 100644 --- a/Sources/Vorssaint/UI/Settings/CommandBarSettings.swift +++ b/Sources/Vorssaint/UI/Settings/CommandBarSettings.swift @@ -55,23 +55,15 @@ struct CommandBarSettings: View { Label(text.resetPositionButton, systemImage: "viewfinder") } .disabled(!service.hasCustomPosition) + SettingsHelpButton(title: text.openButton, + text: text.positionCaption + "\n\n" + text.settingsCaption) } - // One row for the whole explanation. As separate rows the form - // drew a divider between every sentence, cutting one paragraph - // about one feature into four cards that looked like settings. VStack(alignment: .leading, spacing: 6) { - Text(text.positionCaption) - Text(text.settingsCaption) Label(text.privacyNote, systemImage: "lock.laptopcomputer") - HStack(spacing: 6) { - Text(text.tryTheseLabel) - .foregroundStyle(.tertiary) + DisclosureGroup(text.tryTheseLabel) { ForEach(examples, id: \.self) { example in Text(example) - .font(.system(size: 10.5, design: .rounded)) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(Capsule().fill(Color.primary.opacity(0.06))) + .font(SettingsTypography.caption.monospaced()) } } .padding(.top, 2) @@ -83,7 +75,8 @@ struct CommandBarSettings: View { // screen with a stale value between them. Divider() SettingsToggleWithCaption(title: text.compactModeToggle, - caption: text.compactModeCaption, isOn: $compactMode) + caption: text.compactModeCaption, + showsCaptionInline: false, isOn: $compactMode) // Not the shared "Global shortcut" label the other feature // pages use: this page already has an "open the bar" button at // the top, so the toggle has to say which of the two it arms. diff --git a/Sources/Vorssaint/UI/Settings/CutPasteSettings.swift b/Sources/Vorssaint/UI/Settings/CutPasteSettings.swift index 81c1c90..78c51ff 100644 --- a/Sources/Vorssaint/UI/Settings/CutPasteSettings.swift +++ b/Sources/Vorssaint/UI/Settings/CutPasteSettings.swift @@ -79,8 +79,7 @@ struct CutPasteSettings: View { if AppFeature.finderRename.isAvailable { SettingsSection { VStack(alignment: .leading, spacing: 4) { - FeatureSwitchRow(feature: .finderRename) - SettingsCaptionText(renameText.caption) + FeatureSwitchRow(feature: .finderRename, help: renameText.caption) } SettingsControlRow(title: renameText.shortcutLabel, systemImage: "keyboard") { ShortcutRecorderButton( diff --git a/Sources/Vorssaint/UI/Settings/DockSettings.swift b/Sources/Vorssaint/UI/Settings/DockSettings.swift index 2bb2a37..46b2674 100644 --- a/Sources/Vorssaint/UI/Settings/DockSettings.swift +++ b/Sources/Vorssaint/UI/Settings/DockSettings.swift @@ -27,7 +27,7 @@ struct DockSettings: View { systemImage: dockPreviewWarning ? "exclamationmark.triangle" : "cursorarrow.motionlines", warning: dockPreviewWarning) Divider() - SettingsControlRow(title: text.openDelay, systemImage: "timer", caption: text.openDelayCaption) { + SettingsControlRow(title: text.openDelay, systemImage: "timer", help: text.openDelayCaption) { HStack(spacing: 6) { TextField(text.openDelay, value: dockPreviewOpenDelayBinding, formatter: Self.dockPreviewOpenDelayFormatter) @@ -38,7 +38,7 @@ struct DockSettings: View { Text(verbatim: "ms").foregroundStyle(.secondary) } } - SettingsControlRow(title: text.backgroundOpacity, systemImage: "square.on.square", caption: text.backgroundOpacityCaption) { + SettingsControlRow(title: text.backgroundOpacity, systemImage: "square.on.square", help: text.backgroundOpacityCaption) { HStack(spacing: 8) { Slider(value: dockPreviewBackgroundOpacityBinding, in: DockPreviewSupport.backgroundOpacityRange, step: 0.05) diff --git a/Sources/Vorssaint/UI/Settings/FeatureBehaviorStrings.swift b/Sources/Vorssaint/UI/Settings/FeatureBehaviorStrings.swift new file mode 100644 index 0000000..1bef671 --- /dev/null +++ b/Sources/Vorssaint/UI/Settings/FeatureBehaviorStrings.swift @@ -0,0 +1,74 @@ +import Foundation + +struct FeatureBehaviorStrings { + var triggers = "Enable triggers" + var setLevel = "Set brightness" + var level = "Target brightness (%)" + var pending = "Confirming display brightness…" + var unconfirmed = "Target sent. The display has not returned a reliable reading." + var invalidRead = "Brightness could not be read reliably. Enter a target to set it, or retry later." + var shortcut = "Enable shortcut" + var identityUnavailable = "This display cannot be identified reliably. Reconnect it before using display controls." + var shelfInteraction = "Shelf interaction and triggers" + var shelfInteractionHelp = "Pauses opening, shortcuts, automatic triggers and incoming drops. Existing items and backup import remain available." + + init(language: AppLanguage) { + switch language { + case .zhHans: + triggers = "启用触发方式"; setLevel = "设置亮度"; level = "目标亮度 (%)" + pending = "正在确认显示器亮度…" + unconfirmed = "已发送目标值,显示器尚未返回可靠读数。" + invalidRead = "无法可靠读取亮度。可输入目标值设置,或稍后重试。" + shortcut = "启用快捷键" + identityUnavailable = "无法可靠识别这台显示器,请重新连接后再使用显示器控制。" + shelfInteraction = "暂存架交互与触发" + shelfInteractionHelp = "关闭后暂停打开、快捷键、自动唤出和接收拖入,保留已有内容与备份导入。" + case .zhTW, .zhHK: + triggers = "啟用觸發方式"; setLevel = "設定亮度"; level = "目標亮度 (%)" + pending = "正在確認顯示器亮度…" + unconfirmed = "已傳送目標值,顯示器尚未傳回可靠讀數。" + invalidRead = "無法可靠讀取亮度。可輸入目標值設定,或稍後重試。" + shortcut = "啟用快捷鍵" + identityUnavailable = "無法可靠識別這台顯示器,請重新連接後再使用顯示器控制。" + shelfInteraction = "暫存架互動與觸發" + shelfInteractionHelp = "關閉後暫停開啟、快捷鍵、自動喚出與接收拖入,保留已有內容與備份匯入。" + case .de: + triggers = "Auslöser aktivieren"; setLevel = "Helligkeit einstellen"; level = "Zielhelligkeit (%)" + pending = "Displayhelligkeit wird bestätigt…" + unconfirmed = "Zielwert gesendet. Das Display hat noch keinen zuverlässigen Messwert geliefert." + invalidRead = "Helligkeit konnte nicht zuverlässig gelesen werden. Zielwert eingeben oder später erneut versuchen." + shortcut = "Kurzbefehl aktivieren" + identityUnavailable = "Das Display kann nicht zuverlässig identifiziert werden. Verbinde es erneut." + shelfInteraction = "Ablageinteraktion und Auslöser" + shelfInteractionHelp = "Pausiert Öffnen, Kurzbefehle, automatische Auslöser und Ablegen. Vorhandene Inhalte und Sicherungsimport bleiben verfügbar." + case .fr: + triggers = "Activer les déclencheurs"; setLevel = "Régler la luminosité"; level = "Luminosité cible (%)" + pending = "Confirmation de la luminosité…" + unconfirmed = "Valeur envoyée. L’écran n’a pas encore renvoyé de mesure fiable." + invalidRead = "Lecture de luminosité non fiable. Saisissez une valeur cible ou réessayez plus tard." + shortcut = "Activer le raccourci" + identityUnavailable = "Impossible d’identifier cet écran de manière fiable. Reconnectez-le." + shelfInteraction = "Interactions et déclencheurs de l’étagère" + shelfInteractionHelp = "Suspend l’ouverture, les raccourcis, les déclencheurs et les dépôts. Les éléments existants et l’import de sauvegardes restent disponibles." + case .es: + triggers = "Activar los disparadores"; setLevel = "Ajustar brillo"; level = "Brillo objetivo (%)" + pending = "Confirmando el brillo de la pantalla…" + unconfirmed = "Valor enviado. La pantalla aún no ha devuelto una lectura fiable." + invalidRead = "No se pudo leer el brillo de forma fiable. Introduce un valor o inténtalo más tarde." + shortcut = "Activar atajo" + identityUnavailable = "No se puede identificar esta pantalla de forma fiable. Vuelve a conectarla." + shelfInteraction = "Interacción y activación del estante" + shelfInteractionHelp = "Pausa la apertura, los atajos, la activación automática y la recepción de archivos. Se conservan los elementos y la importación de copias." + case .ja: + triggers = "起動方法を有効にする"; setLevel = "明るさを設定"; level = "目標の明るさ (%)" + pending = "ディスプレイの明るさを確認中…" + unconfirmed = "目標値を送信しました。信頼できる測定値はまだ返されていません。" + invalidRead = "明るさを正しく読み取れません。目標値を入力するか、後で再試行してください。" + shortcut = "ショートカットを有効にする" + identityUnavailable = "ディスプレイを正しく識別できません。接続し直してください。" + shelfInteraction = "シェルフの操作と起動" + shelfInteractionHelp = "表示、ショートカット、自動起動、ドロップの受け取りを停止します。既存の項目とバックアップの読み込みは保持されます。" + default: break + } + } +} diff --git a/Sources/Vorssaint/UI/Settings/FeatureHubSettings.swift b/Sources/Vorssaint/UI/Settings/FeatureHubSettings.swift index 9c0ecfe..fec1382 100644 --- a/Sources/Vorssaint/UI/Settings/FeatureHubSettings.swift +++ b/Sources/Vorssaint/UI/Settings/FeatureHubSettings.swift @@ -36,14 +36,16 @@ struct FeatureHubSettings: View { private var content: some View { SettingsForm { SettingsSection { - Picker("", selection: $tab) { - Text(hub.tabFeatures).tag(Tab.features) - Text(hub.tabPermissions).tag(Tab.permissions) + HStack(spacing: 8) { + Picker("", selection: $tab) { + Text(hub.tabFeatures).tag(Tab.features) + Text(hub.tabPermissions).tag(Tab.permissions) + } + .pickerStyle(.segmented) + .labelsHidden() + SettingsHelpButton(title: tab == .features ? hub.tabFeatures : hub.tabPermissions, + text: tab == .features ? workspace.intro + "\n\n" + workspace.footer : hub.permissionsIntro) } - .pickerStyle(.segmented) - .labelsHidden() - Text(tab == .features ? workspace.intro : hub.permissionsIntro) - .fixedSize(horizontal: false, vertical: true) if tab == .features { HStack(spacing: 8) { Text(String(format: workspace.activeFormat, @@ -65,14 +67,14 @@ struct FeatureHubSettings: View { Button { FeatureRuntime.shared.setAllAvailable(true) } label: { - Label(workspace.addAll, systemImage: "plus.circle") + Text(workspace.addAll) } .settingsAction(.primary) .disabled(features.availableCount == features.installableCount) Button { FeatureRuntime.shared.setAllAvailable(false) } label: { - Label(workspace.removeAll, systemImage: "pause.circle") + Text(workspace.removeAll) } .settingsAction(.secondary) .disabled(features.availableCount == 0) @@ -160,10 +162,6 @@ struct FeatureHubSettings: View { Text(group.title(hub)) } } - SettingsSection { - Text(workspace.footer) - .fixedSize(horizontal: false, vertical: true) - } } } @@ -220,13 +218,13 @@ private struct FeatureHubRow: View { .accessibilityLabel(accessibilitySummary) .saturation(unsupportedReason == nil ? 1 : 0) } + SettingsHelpButton(title: title, text: (energyLabels + [hub.energyHelp]).joined(separator: "\n\n")) HStack(spacing: 0) { Button { flip(to: !installed) } label: { ZStack { - Label(workspace.remove, systemImage: "pause.circle").hidden() - Label(workspace.add, systemImage: "plus.circle").hidden() - Label(installed ? workspace.remove : workspace.add, - systemImage: installed ? "pause.circle" : "plus.circle") + Text(workspace.remove).hidden() + Text(workspace.add).hidden() + Text(installed ? workspace.remove : workspace.add) } } .settingsAction(installed ? .secondary : .primary) @@ -258,9 +256,16 @@ private struct FeatureHubRow: View { VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { Text(title) - .font(SettingsTypography.body.weight(.semibold)) + .font(SettingsTypography.body.weight(.medium)) .foregroundStyle(.primary) .layoutPriority(1) + ForEach(unit.permissions, id: \.self) { permission in + Image(systemName: permission.symbolName) + .font(SettingsTypography.smallIcon) + .foregroundStyle(.secondary) + .help(permission.name(hub)) + .accessibilityHidden(true) + } if unit.isBeta { Text(l10n.s.betaBadge) .font(SettingsTypography.caption) @@ -275,25 +280,6 @@ private struct FeatureHubRow: View { .font(SettingsTypography.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) - HStack(spacing: 6) { - ForEach(unit.permissions, id: \.self) { permission in - Image(systemName: permission.symbolName) - .font(SettingsTypography.smallIcon) - .foregroundStyle(.tertiary) - .help(permission.name(hub)) - .accessibilityHidden(true) - } - ForEach(energyLabels, id: \.self) { label in - Text(label) - .font(SettingsTypography.caption) - .foregroundStyle(.secondary) - .padding(.horizontal, 5) - .padding(.vertical, 1) - .background(Capsule().fill(Color.secondary.opacity(0.12))) - .help(hub.energyHelp) - .accessibilityHidden(true) - } - } } Spacer(minLength: 8) if showsChevron { @@ -660,6 +646,7 @@ struct FeatureSwitchRow: View { @ObservedObject private var features = FeatureRuntime.shared let feature: AppFeature var title: String? = nil + var help: String? = nil /// The mouse page asked for Accessibility the moment one of these /// went on; the other members show a permission row instead. @@ -687,11 +674,17 @@ struct FeatureSwitchRow: View { HStack(spacing: 12) { SettingsSymbol(systemImage: feature.symbolName) Text(name).font(SettingsTypography.body.weight(.semibold)) + ForEach(feature.permissions, id: \.self) { permission in + Image(systemName: permission.symbolName) + .foregroundStyle(.secondary) + .help(permission.name(FeatureStrings.hub(l10n.language))) + } + if let help { SettingsHelpButton(title: name, text: help) } Spacer(minLength: 8) } .frame(maxWidth: .infinity, alignment: .leading) } - .toggleStyle(.switch) + .toggleStyle(.switch).controlSize(.small) .disabled(blocked != nil) } else { HStack(spacing: 12) { diff --git a/Sources/Vorssaint/UI/Settings/KeepAwakeSettings.swift b/Sources/Vorssaint/UI/Settings/KeepAwakeSettings.swift index f5b84d4..6cccff8 100644 --- a/Sources/Vorssaint/UI/Settings/KeepAwakeSettings.swift +++ b/Sources/Vorssaint/UI/Settings/KeepAwakeSettings.swift @@ -30,7 +30,7 @@ struct KeepAwakeSettings: View { caption: l10n.s.keepAwakeAutoStartCaption, isOn: $keepAwakeAutoStart) SettingsToggleWithCaption(title: l10n.s.keepAwakeRightClickToggle, - caption: l10n.s.keepAwakeRightClickToggleCaption, + caption: l10n.s.keepAwakeRightClickToggleCaption, showsCaptionInline: false, isOn: $keepAwakeRightClickToggle) Toggle(l10n.s.hotkeyToggle, isOn: $hotkeyEnabled) .onChange(of: hotkeyEnabled) { _, enabled in @@ -106,10 +106,18 @@ struct KeepAwakeSettings: View { } } } - SettingsSection(title: UXEntryStrings(l10n.language).menuBarDisplay, systemImage: "menubar.rectangle") { - KeepAwakeIconPicker(iconValue: $keepAwakeActiveIcon, - tintValue: $keepAwakeIconTint) - Toggle(l10n.s.showCountdown, isOn: $showCountdown) + SettingsSection { + DisclosureGroup { + VStack(alignment: .leading, spacing: SettingsVisualStyle.current.contentSpacing) { + KeepAwakeIconPicker(iconValue: $keepAwakeActiveIcon, + tintValue: $keepAwakeIconTint) + Toggle(l10n.s.showCountdown, isOn: $showCountdown) + } + .padding(.top, 10) + } label: { + SettingsSectionHeading(title: UXEntryStrings(l10n.language).menuBarDisplay, + systemImage: "menubar.rectangle") + } } SettingsSection(title: l10n.s.clamshellSection, systemImage: "laptopcomputer") { Toggle(l10n.s.clamshellTitle, isOn: $awake.clamshellPreferred) diff --git a/Sources/Vorssaint/UI/Settings/MixerSettings.swift b/Sources/Vorssaint/UI/Settings/MixerSettings.swift index 1766763..e533614 100644 --- a/Sources/Vorssaint/UI/Settings/MixerSettings.swift +++ b/Sources/Vorssaint/UI/Settings/MixerSettings.swift @@ -54,8 +54,9 @@ struct MixerSettings: View { if AppFeature.soundOutputSwitcher.isAvailable { SettingsSection { - FeatureSwitchRow(feature: .soundOutputSwitcher) - SettingsCaptionText(switcherText.caption) + FeatureSwitchRow(feature: .soundOutputSwitcher, + title: FeatureBehaviorStrings(language: l10n.language).shortcut, + help: switcherText.caption) SoundOutputSwitcherDevicePicker() ShortcutPreferenceRow(role: .soundOutputSwitcher, isEnabled: soundOutputSwitcherEnabled) { diff --git a/Sources/Vorssaint/UI/Settings/MonitorPanelConfig.swift b/Sources/Vorssaint/UI/Settings/MonitorPanelConfig.swift index 51fae72..27e58ce 100644 --- a/Sources/Vorssaint/UI/Settings/MonitorPanelConfig.swift +++ b/Sources/Vorssaint/UI/Settings/MonitorPanelConfig.swift @@ -161,7 +161,7 @@ struct MonitorPanelConfig: View { .disabled(!master.wrappedValue) } Toggle(l10n.s.monitorShowInPanel, isOn: master) - .toggleStyle(.switch).controlSize(.regular) + .toggleStyle(.switch).controlSize(.small) .labelsHidden() .accessibilityLabel("\(id.title(l10n.s)): \(l10n.s.monitorShowInPanel)") } @@ -188,7 +188,7 @@ struct MonitorPanelConfig: View { .disabled(!master.wrappedValue) } Toggle(l10n.s.monitorShowInPanel, isOn: master) - .toggleStyle(.switch).controlSize(.regular) + .toggleStyle(.switch).controlSize(.small) .labelsHidden() .accessibilityLabel("\(title): \(l10n.s.monitorShowInPanel)") } @@ -279,7 +279,7 @@ private struct MonitorGraphVisibilityRow: View { .disabled(!visible || !sectionVisible) } Toggle(l10n.s.monitorShowInPanel, isOn: $visible) - .toggleStyle(.switch).controlSize(.regular).labelsHidden() + .toggleStyle(.switch).controlSize(.small).labelsHidden() .accessibilityLabel("\(title): \(l10n.s.monitorShowInPanel)") } } diff --git a/Sources/Vorssaint/UI/Settings/MonitorSettings.swift b/Sources/Vorssaint/UI/Settings/MonitorSettings.swift index 83cf5bf..646a352 100644 --- a/Sources/Vorssaint/UI/Settings/MonitorSettings.swift +++ b/Sources/Vorssaint/UI/Settings/MonitorSettings.swift @@ -25,7 +25,7 @@ struct MonitorSettings: View { SettingsSection(title: UXEntryStrings(l10n.language).samplingAndUnits, systemImage: "gauge.with.dots.needle.33percent") { SettingsControlRow(title: l10n.s.monitorIntervalLabel, systemImage: "timer", - caption: MonitorHistoryStrings.text(l10n.language).intervalHint) { + help: MonitorHistoryStrings.text(l10n.language).intervalHint) { Picker(l10n.s.monitorIntervalLabel, selection: $interval) { ForEach(1...5, id: \.self) { seconds in Text(intervalTitle(seconds)).tag(seconds) diff --git a/Sources/Vorssaint/UI/Settings/MusicBlockSettings.swift b/Sources/Vorssaint/UI/Settings/MusicBlockSettings.swift index efd3e7b..750a9fc 100644 --- a/Sources/Vorssaint/UI/Settings/MusicBlockSettings.swift +++ b/Sources/Vorssaint/UI/Settings/MusicBlockSettings.swift @@ -21,7 +21,9 @@ struct MusicBlockSettings: View { SettingsForm { if AppFeature.musicBlock.isAvailable { SettingsSection(musicBlockText.section) { - Toggle(musicBlockText.title, isOn: $musicBlockEnabled) + SettingsToggleWithCaption(title: musicBlockText.title, + caption: musicBlockText.caption, + isOn: $musicBlockEnabled) .onChange(of: musicBlockEnabled) { _, _ in MusicLaunchBlocker.shared.syncWithPreferences() } @@ -57,7 +59,6 @@ struct MusicBlockSettings: View { .font(SettingsTypography.caption) .foregroundStyle(.orange) } - SettingsCaptionText(musicBlockText.caption) } .settingsSectionAnchor(.musicBlocking) } diff --git a/Sources/Vorssaint/UI/Settings/RadialMenuSettings.swift b/Sources/Vorssaint/UI/Settings/RadialMenuSettings.swift index 069b6ef..01bbd5b 100644 --- a/Sources/Vorssaint/UI/Settings/RadialMenuSettings.swift +++ b/Sources/Vorssaint/UI/Settings/RadialMenuSettings.swift @@ -69,13 +69,13 @@ struct RadialMenuSettings: View { var body: some View { SettingsForm { SettingsSection { - SettingsControlRow(title: text.enableLabel, systemImage: "circle.hexagongrid", - caption: text.hubDescription) { + SettingsControlRow(title: FeatureBehaviorStrings(language: l10n.language).triggers, + systemImage: "circle.hexagongrid", help: text.hubDescription) { Button(text.tryButton) { RadialMenuService.shared.presentPreview(for: selectedProfile) } .settingsAction(.primary) - Toggle(text.enableLabel, isOn: $enabled).labelsHidden().toggleStyle(.switch) + Toggle(text.enableLabel, isOn: $enabled).labelsHidden().toggleStyle(.switch).controlSize(.small) } if service.registrationFailed { SettingsInfo(text: l10n.s.shortcutInvalid, @@ -165,7 +165,7 @@ struct RadialMenuSettings: View { SettingsSection(title: UXEntryStrings(l10n.language).radialOpeningBehavior, systemImage: "cursorarrow.rays") { SettingsControlRow(title: text.activationModeLabel, systemImage: "hand.tap", - caption: text.activationModeCaption) { + help: text.activationModeCaption) { Picker(text.activationModeLabel, selection: $activationModeRaw) { Text(text.activationModePressOrHold) .tag(RadialMenuActivationMode.pressOrHold.rawValue) diff --git a/Sources/Vorssaint/UI/Settings/ScreenshotSettings.swift b/Sources/Vorssaint/UI/Settings/ScreenshotSettings.swift index c186785..9195b24 100644 --- a/Sources/Vorssaint/UI/Settings/ScreenshotSettings.swift +++ b/Sources/Vorssaint/UI/Settings/ScreenshotSettings.swift @@ -237,7 +237,7 @@ struct ScreenshotCaptureSettings: View { private var subfolderRow: some View { SettingsControlRow(title: strings.subfolderLabel, systemImage: "folder.badge.gearshape", - caption: strings.subfolderCaption) { + help: strings.subfolderCaption) { VStack(alignment: .leading, spacing: 4) { TextField(strings.subfolderLabel, text: $saveSubfolder) .labelsHidden() @@ -258,7 +258,7 @@ struct ScreenshotCaptureSettings: View { private var fileNameRow: some View { VStack(alignment: .leading, spacing: 12) { SettingsControlRow(title: strings.fileNamePatternLabel, systemImage: "doc.text", - caption: strings.fileNamePatternCaption) { + help: strings.fileNamePatternCaption) { VStack(alignment: .leading, spacing: 4) { TextField(strings.fileNamePatternLabel, text: $fileNamePattern) .labelsHidden() diff --git a/Sources/Vorssaint/UI/Settings/SettingsSection.swift b/Sources/Vorssaint/UI/Settings/SettingsSection.swift index 8fce227..7d1f971 100644 --- a/Sources/Vorssaint/UI/Settings/SettingsSection.swift +++ b/Sources/Vorssaint/UI/Settings/SettingsSection.swift @@ -33,7 +33,7 @@ struct SettingsSection: View { VStack(alignment: .leading, spacing: SettingsVisualStyle.current.contentSpacing) { content .font(SettingsTypography.body) - .buttonStyle(SettingsActionStyle()) + .buttonStyle(.bordered) footer.font(SettingsTypography.caption).foregroundStyle(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Sources/Vorssaint/UI/Settings/SettingsView.swift b/Sources/Vorssaint/UI/Settings/SettingsView.swift index 4feb24f..2249692 100644 --- a/Sources/Vorssaint/UI/Settings/SettingsView.swift +++ b/Sources/Vorssaint/UI/Settings/SettingsView.swift @@ -8,7 +8,6 @@ import SwiftUI /// page on the right. Scales cleanly as features are added, and gives each /// feature a page of its own with room for examples and advanced options. struct SettingsView: View { - @ObservedObject private var themePreferences = ThemePreferences.shared @ObservedObject private var l10n = L10n.shared @ObservedObject private var router = SettingsRouter.shared @ObservedObject private var features = FeatureRuntime.shared @@ -98,7 +97,7 @@ struct SettingsView: View { } } .navigationSplitViewStyle(.balanced) - .kururuTheme() + .tint(SettingsVisualStyle.preview?.accent) .frame(minWidth: 772, maxWidth: .infinity, minHeight: 528, maxHeight: .infinity) .onChange(of: searchResults, initial: true) { previous, current in updateSearchSelection(previous: previous, current: current) @@ -856,7 +855,7 @@ struct SwitcherSettings: View { Spacer(minLength: 8) } } - .toggleStyle(.switch) + .toggleStyle(.switch).controlSize(.small) .onChange(of: switcherEnabled) { _, _ in AppSwitcher.shared.syncWithPreferences() } @@ -890,22 +889,22 @@ struct SwitcherSettings: View { } SettingsSection(title: UXEntryStrings(l10n.language).appearanceAndPreviews, systemImage: "macwindow") { - SettingsToggleWithCaption(title: l10n.s.switcherSimpleMode, caption: l10n.s.switcherSimpleModeCaption, isOn: $switcherSimpleMode) + SettingsToggleWithCaption(title: l10n.s.switcherSimpleMode, caption: l10n.s.switcherSimpleModeCaption, showsCaptionInline: false, isOn: $switcherSimpleMode) .onChange(of: switcherSimpleMode) { _, _ in AppSwitcher.shared.syncWithPreferences() } - SettingsToggleWithCaption(title: String(format: l10n.s.switcherIconRowMode, switcherShortcutDisplayString), caption: l10n.s.switcherIconRowModeCaption, isOn: $switcherIconRowMode) + SettingsToggleWithCaption(title: String(format: l10n.s.switcherIconRowMode, switcherShortcutDisplayString), caption: l10n.s.switcherIconRowModeCaption, showsCaptionInline: false, isOn: $switcherIconRowMode) .disabled(switcherSimpleMode) .onChange(of: switcherIconRowMode) { _, _ in AppSwitcher.shared.syncWithPreferences() } if switcherSimpleMode || switcherIconRowMode { - SettingsToggleWithCaption(title: l10n.s.switcherShowShortcutHints, caption: l10n.s.switcherShowShortcutHintsCaption, isOn: $switcherShowShortcutHints) + SettingsToggleWithCaption(title: l10n.s.switcherShowShortcutHints, caption: l10n.s.switcherShowShortcutHintsCaption, showsCaptionInline: false, isOn: $switcherShowShortcutHints) } - SettingsControlRow(title: l10n.s.switcherScreenPlacementLabel, systemImage: "display", caption: l10n.s.switcherScreenPlacementCaption) { + SettingsControlRow(title: l10n.s.switcherScreenPlacementLabel, systemImage: "display", help: l10n.s.switcherScreenPlacementCaption) { Picker(l10n.s.switcherScreenPlacementLabel, selection: $switcherScreenPlacement) { Text(l10n.s.switcherScreenPlacementPointer).tag(SwitcherScreenPlacement.pointer.rawValue) Text(l10n.s.switcherScreenPlacementMenuBar).tag(SwitcherScreenPlacement.menuBar.rawValue) @@ -957,7 +956,7 @@ struct SwitcherSettings: View { SettingsSection(title: UXEntryStrings(l10n.language).interaction, systemImage: "cursorarrow.click") { SettingsControlRow(title: l10n.s.switcherAppearanceDelay, systemImage: "timer", - caption: l10n.s.switcherAppearanceDelayCaption) { + help: l10n.s.switcherAppearanceDelayCaption) { Slider(value: switcherAppearanceDelayBinding, in: Double(SwitcherSupport.appearanceDelayMillisecondsRange.lowerBound) ... Double(SwitcherSupport.appearanceDelayMillisecondsRange.upperBound), @@ -970,7 +969,7 @@ struct SwitcherSettings: View { .frame(width: 64, alignment: .trailing) } SettingsToggleWithCaption(title: l10n.s.switcherSearchPin, - caption: l10n.s.switcherSearchPinCaption, + caption: l10n.s.switcherSearchPinCaption, showsCaptionInline: false, isOn: $switcherSearchPinEnabled) } } @@ -1237,15 +1236,19 @@ struct SettingsCaptionText: View { struct SettingsToggleWithCaption: View { let title: String let caption: String + var showsCaptionInline = true @Binding var isOn: Bool var body: some View { - Toggle(isOn: $isOn) { - VStack(alignment: .leading, spacing: 4) { - Text(title).font(SettingsTypography.body.weight(.medium)) - SettingsCaptionText(caption) + HStack(spacing: 6) { + Toggle(isOn: $isOn) { + VStack(alignment: .leading, spacing: 4) { + Text(title).font(SettingsTypography.body.weight(.medium)) + if showsCaptionInline { SettingsCaptionText(caption) } + } + .frame(maxWidth: .infinity, alignment: .leading) } - .frame(maxWidth: .infinity, alignment: .leading) + if !showsCaptionInline { SettingsHelpButton(title: title, text: caption) } } } } diff --git a/Sources/Vorssaint/UI/Settings/SettingsVisualStyle.swift b/Sources/Vorssaint/UI/Settings/SettingsVisualStyle.swift index 90b87d0..ff71695 100644 --- a/Sources/Vorssaint/UI/Settings/SettingsVisualStyle.swift +++ b/Sources/Vorssaint/UI/Settings/SettingsVisualStyle.swift @@ -40,6 +40,7 @@ enum SettingsVisualStyle: String, CaseIterable { var cornerRadius: CGFloat { self == .cards ? 16 : self == .columns ? 12 : 8 } var titleFont: Font { .system(size: self == .paper ? 29 : self == .compact ? 22 : 24, weight: .semibold) } var accent: Color { + guard Self.isPreview else { return .accentColor } switch self { case .paper: return Color(red: 0.16, green: 0.40, blue: 0.43) case .cards: return Color(red: 0.31, green: 0.34, blue: 0.75) @@ -47,13 +48,18 @@ enum SettingsVisualStyle: String, CaseIterable { case .compact: return Color(red: 0.45, green: 0.34, blue: 0.56) } } - func canvas(_ scheme: ColorScheme) -> Color { - if scheme == .dark { return Color(nsColor: .windowBackgroundColor) } - switch self { - case .paper: return Color(red: 0.985, green: 0.982, blue: 0.972) - case .cards: return Color(red: 0.949, green: 0.954, blue: 0.971) - case .columns: return Color(nsColor: .textBackgroundColor) - case .compact: return Color(nsColor: .windowBackgroundColor) + @ViewBuilder + func canvas(_ scheme: ColorScheme) -> some View { + if !Self.isPreview || scheme == .dark { + Color(nsColor: .windowBackgroundColor) + .overlay(Color.primary.opacity(scheme == .light ? 0.04 : 0)) + } else { + switch self { + case .paper: Color(red: 0.985, green: 0.982, blue: 0.972) + case .cards: Color(red: 0.949, green: 0.954, blue: 0.971) + case .columns: Color(nsColor: .textBackgroundColor) + case .compact: Color(nsColor: .windowBackgroundColor) + } } } } @@ -72,7 +78,7 @@ struct SettingsForm: View { } .font(SettingsTypography.body) .toggleStyle(.checkbox) - .buttonStyle(SettingsActionStyle()) + .buttonStyle(.bordered) .controlSize(SettingsVisualStyle.current == .compact ? .small : .regular) } } @@ -84,7 +90,6 @@ extension View { private struct SettingsSurfaceModifier: ViewModifier { let item: Bool - @ObservedObject private var theme = ThemePreferences.shared @Environment(\.colorScheme) private var scheme @Environment(\.colorSchemeContrast) private var contrast private var style: SettingsVisualStyle { .current } @@ -95,94 +100,43 @@ private struct SettingsSurfaceModifier: ViewModifier { .background { if !item && style != .paper { RoundedRectangle(cornerRadius: style.cornerRadius, style: .continuous) - .fill(style == .cards ? (Theme.color(.card, in: theme.applied) ?? (scheme == .dark ? Color.white.opacity(0.055) : Color(nsColor: .controlBackgroundColor))) : Color.primary.opacity(scheme == .dark ? 0.045 : 0.025)) + .fill(style == .cards ? (scheme == .dark ? Color.white.opacity(0.055) : Color(nsColor: .controlBackgroundColor)) : Color.primary.opacity(scheme == .dark ? 0.045 : 0.025)) } } .overlay { - if contrast == .increased && !item { - RoundedRectangle(cornerRadius: style.cornerRadius) - .strokeBorder(Color.primary.opacity(0.35), lineWidth: 1) + if !item && style != .paper { + RoundedRectangle(cornerRadius: style.cornerRadius, style: .continuous) + .strokeBorder(Color.primary.opacity(contrast == .increased ? 0.35 : 0.06), + lineWidth: contrast == .increased ? 1 : 0.5) } } } } -/// Settings-only interaction roles. Native buttons retain their actions, keyboard -/// shortcuts and accessibility labels while sharing geometry and feedback. +/// Explicit action roles keep native button rendering and keyboard behavior. enum SettingsActionRole { case primary, secondary } -struct SettingsActionStyle: ButtonStyle { - var role: SettingsActionRole = .secondary - - func makeBody(configuration: Configuration) -> some View { - SettingsActionBody(configuration: configuration, role: role) - } -} - -private struct SettingsActionBody: View { - let configuration: ButtonStyleConfiguration - let role: SettingsActionRole - @Environment(\.isEnabled) private var isEnabled - @Environment(\.isFocused) private var isFocused - @Environment(\.colorScheme) private var scheme - @Environment(\.colorSchemeContrast) private var contrast - @ObservedObject private var theme = ThemePreferences.shared - @State private var hovered = false - - private var accent: Color { SettingsVisualStyle.current.controlAccent(scheme, theme: theme.applied) } - private var emphasized: Bool { role == .primary || configuration.role == .destructive } - private var ink: Color { configuration.role == .destructive ? .red : role == .primary ? accent : .primary } - - var body: some View { - configuration.label - .font(SettingsTypography.body.weight(.medium)) - .symbolRenderingMode(.hierarchical) - .padding(.horizontal, 11) - .padding(.vertical, 6) - .frame(minHeight: 30) - .foregroundStyle(ink) - .background { - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(emphasized ? ink.opacity(configuration.isPressed ? 0.25 : hovered ? 0.19 : 0.12) - : Color.primary.opacity(configuration.isPressed ? 0.13 : hovered ? 0.09 : scheme == .dark ? 0.06 : 0.035)) - } - .overlay { - RoundedRectangle(cornerRadius: 8, style: .continuous) - .strokeBorder(isFocused ? accent : (emphasized ? ink.opacity(0.30) : Color.primary.opacity(contrast == .increased ? 0.4 : 0.12)), lineWidth: isFocused ? 2 : 1) - } - .opacity(isEnabled ? 1 : 0.42) - .contentShape(RoundedRectangle(cornerRadius: 8)) - .onHover { hovered = $0 } - } -} - -extension SettingsVisualStyle { - func controlAccent(_ scheme: ColorScheme, theme: ImportedTheme?) -> Color { - Theme.color(.accent, in: theme) ?? (scheme == .dark - ? Color(red: 0.70, green: 0.73, blue: 1.0) : accent) - } -} - extension View { + @ViewBuilder func settingsAction(_ role: SettingsActionRole = .secondary) -> some View { - buttonStyle(SettingsActionStyle(role: role)) + if role == .primary { + buttonStyle(.borderedProminent) + } else { + buttonStyle(.bordered) + } } } struct SettingsSymbol: View { let systemImage: String @Environment(\.isEnabled) private var isEnabled - @Environment(\.colorScheme) private var scheme - @ObservedObject private var theme = ThemePreferences.shared - var body: some View { - let accent = isEnabled ? SettingsVisualStyle.current.controlAccent(scheme, theme: theme.applied) : Color.secondary + let accent = isEnabled ? SettingsVisualStyle.current.accent : Color.secondary Image(systemName: systemImage) .font(.system(size: 15, weight: .medium)) .symbolRenderingMode(.hierarchical) .foregroundStyle(accent) .frame(width: 30, height: 30) - .background(accent.opacity(0.10), in: RoundedRectangle(cornerRadius: 8)) .accessibilityHidden(true) } } @@ -193,6 +147,7 @@ struct SettingsControlRow: View { let title: String let systemImage: String var caption: String? = nil + var help: String? = nil @ViewBuilder let control: Control private var label: some View { @@ -200,7 +155,8 @@ struct SettingsControlRow: View { SettingsSymbol(systemImage: systemImage) Text(title).font(SettingsTypography.body.weight(.medium)) .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) + if let help { SettingsHelpButton(title: title, text: help) } + Spacer(minLength: 0) } } @@ -233,6 +189,37 @@ struct SettingsControlRow: View { } } +/// Supplemental explanations are available by keyboard or click, not only hover. +struct SettingsHelpButton: View { + let title: String + let text: String + @State private var presented = false + + var body: some View { + Button { presented.toggle() } label: { + Image(systemName: "questionmark.circle") + .font(SettingsTypography.body) + .foregroundStyle(.secondary) + .frame(width: 22, height: 22) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(title) + .accessibilityHint(text) + .help(text) + .popover(isPresented: $presented) { + VStack(alignment: .leading, spacing: 8) { + Text(title).font(SettingsTypography.sectionTitle) + Text(text).font(SettingsTypography.body) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + } + .padding(16) + .frame(width: 320, alignment: .leading) + } + } +} + struct SettingsInfo: View { let text: String var systemImage: String = "info.circle" diff --git a/Sources/Vorssaint/UI/Settings/ShelfSettings.swift b/Sources/Vorssaint/UI/Settings/ShelfSettings.swift index 746701b..16557be 100644 --- a/Sources/Vorssaint/UI/Settings/ShelfSettings.swift +++ b/Sources/Vorssaint/UI/Settings/ShelfSettings.swift @@ -27,16 +27,12 @@ struct ShelfSettings: View { } } SettingsSection { - Toggle(l10n.s.shelfEnable, isOn: $enabled) + SettingsToggleWithCaption(title: FeatureBehaviorStrings(language: l10n.language).shelfInteraction, + caption: FeatureBehaviorStrings(language: l10n.language).shelfInteractionHelp, + isOn: $enabled) .onChange(of: enabled) { _, _ in ShelfService.shared.syncWithPreferences() } - Text(l10n.s.shelfEnableCaption) - .font(SettingsTypography.caption) - .foregroundStyle(.secondary) - Label(l10n.s.shelfNoPermission, systemImage: "checkmark.shield") - .font(SettingsTypography.caption) - .foregroundStyle(.secondary) Button { ShelfService.shared.summon() } label: { @@ -44,12 +40,16 @@ struct ShelfSettings: View { } .settingsAction(.primary) .disabled(!enabled) + SettingsHelpButton(title: l10n.s.shelfEnable, + text: l10n.s.shelfEnableCaption + "\n\n" + l10n.s.shelfNoPermission) } SettingsSection(l10n.s.shelfHowTitle) { - bullet("1", l10n.s.shelfStep1) - bullet("2", l10n.s.shelfStep2) - bullet("3", l10n.s.shelfStep3) + DisclosureGroup(l10n.s.shelfHowTitle) { + bullet("1", l10n.s.shelfStep1) + bullet("2", l10n.s.shelfStep2) + bullet("3", l10n.s.shelfStep3) + } } Group { diff --git a/Sources/Vorssaint/UI/Settings/TextSnippetsSections.swift b/Sources/Vorssaint/UI/Settings/TextSnippetsSections.swift index e641b1b..92bbae5 100644 --- a/Sources/Vorssaint/UI/Settings/TextSnippetsSections.swift +++ b/Sources/Vorssaint/UI/Settings/TextSnippetsSections.swift @@ -175,7 +175,7 @@ private struct SnippetRow: View { Toggle("", isOn: Binding(get: { snippet.enabled }, set: toggle)) .labelsHidden() .toggleStyle(.switch) - .controlSize(.regular) + .controlSize(.small) } .padding(.vertical, 1) } diff --git a/Sources/Vorssaint/UI/Settings/ThemeSettings.swift b/Sources/Vorssaint/UI/Settings/ThemeSettings.swift deleted file mode 100644 index 4bd7b60..0000000 --- a/Sources/Vorssaint/UI/Settings/ThemeSettings.swift +++ /dev/null @@ -1,217 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// Copyright (C) 2026 Vorssaint - -import SwiftUI -import UniformTypeIdentifiers - -struct ThemeSettings: View { - @ObservedObject private var preferences = ThemePreferences.shared - @ObservedObject private var l10n = L10n.shared - @State private var preview: ImportedTheme? - @State private var errorMessage: String? - @State private var isReading = false - @State private var isChoosingFile = false - @State private var filePanel: NSOpenPanel? - @State private var themeLink = "" - @State private var requestID = UUID() - @State private var importTask: Task? - - private var text: ThemeSettingsText { ThemeSettingsText(language: l10n.language) } - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text(text.title).font(.headline) - Text(preferences.applied.map { $0.name.isEmpty ? text.imported : $0.name } ?? text.defaultName) - .font(.callout) - .foregroundStyle(.secondary) - SettingsExplanation(text.scope) - Link(text.browseButton, destination: URL(string: "https://vscodethemes.com/")!) - .buttonStyle(.link) - .foregroundStyle(Color(nsColor: .linkColor)) - HStack { - TextField("", text: $themeLink, prompt: Text(text.linkPlaceholder)) - .textFieldStyle(.roundedBorder) - .labelsHidden() - .accessibilityLabel(text.linkLabel) - .disabled(isReading || isChoosingFile) - .onSubmit { loadLink() } - Button(text.previewTitle, action: loadLink) - .disabled(isReading || isChoosingFile || themeLink.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - .fixedSize() - } - if isReading { - HStack { - ProgressView().controlSize(.small) - Text(text.reading).font(SettingsTypography.caption) - Spacer() - Button(text.cancelLoading, action: cancelImport) - } - } - HStack { - Button(text.importButton, action: chooseFile).disabled(isReading || isChoosingFile) - Spacer() - Button(text.restoreButton) { - preferences.restoreDefault() - preview = nil - errorMessage = nil - } - .disabled(preferences.applied == nil || isReading || isChoosingFile) - } - if let errorMessage { - Label(errorMessage, systemImage: "exclamationmark.triangle") - .font(SettingsTypography.caption) - .foregroundStyle(Color(nsColor: .labelColor)) - .accessibilityLabel(errorMessage) - } - if let preview { - Text(text.previewTitle).font(.headline) - ThemePreviewCard(palette: preview, text: text) - SettingsExplanation(text.previewCaption) - HStack { - Button(text.cancelButton) { self.preview = nil; errorMessage = nil } - Spacer() - Button(text.applyButton) { - do { - try preferences.apply(preview) - self.preview = nil - errorMessage = nil - } catch { - errorMessage = text.message(for: (error as? ThemeImportError) ?? .unreadable) - } - } - } - .disabled(isReading) - } - } - // Keep recovery controls independent of imported accent colors. - .tint(.primary) - .buttonStyle(.bordered) - .onDisappear(perform: cancelImport) - } - - private func cancelImport() { - requestID = UUID() - filePanel?.cancel(nil) - filePanel = nil - isChoosingFile = false - importTask?.cancel() - importTask = nil - isReading = false - } - - private func loadLink() { - guard !isReading, !isChoosingFile, !themeLink.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } - let id = UUID() - requestID = id - isReading = true - errorMessage = nil - let link = themeLink - importTask = Task { @MainActor in - do { - let theme = try await ThemeLinkImportService.fetch(link) - guard !Task.isCancelled, requestID == id else { return } - preview = theme - } catch is CancellationError { - } catch { - guard !Task.isCancelled, requestID == id else { return } - errorMessage = text.message(for: error) - } - guard requestID == id else { return } - isReading = false - importTask = nil - } - } - - private func chooseFile() { - guard !isReading, !isChoosingFile else { return } - isChoosingFile = true - let id = UUID() - requestID = id - let panel = NSOpenPanel() - filePanel = panel - panel.allowedContentTypes = [.json, UTType(filenameExtension: "jsonc") ?? .plainText] - panel.allowsMultipleSelection = false - panel.canChooseDirectories = false - panel.canChooseFiles = true - panel.prompt = text.previewTitle - let completion: (NSApplication.ModalResponse) -> Void = { response in - guard requestID == id else { return } - isChoosingFile = false - filePanel = nil - guard response == .OK, let url = panel.url else { return } - isReading = true - errorMessage = nil - DispatchQueue.global(qos: .userInitiated).async { - let result = Result { try ThemeImportSupport.read(url) } - DispatchQueue.main.async { - guard requestID == id else { return } - isReading = false - switch result { - case .success(let theme): preview = theme - case .failure(let failure): errorMessage = text.message(for: failure) - } - } - } - } - if let window = NSApp.keyWindow { - panel.beginSheetModal(for: window, completionHandler: completion) - } else { - panel.begin(completionHandler: completion) - } - } -} - -private struct ThemePreviewCard: View { - let palette: ImportedTheme - let text: ThemeSettingsText - - private func color(_ role: ThemeColorRole, fallback: NSColor) -> Color { - Theme.color(role, in: palette) ?? Color(nsColor: fallback) - } - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text(palette.name.isEmpty ? text.imported : palette.name) - .font(.headline) - .foregroundStyle(color(.primaryText, fallback: .labelColor)) - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 6) { - Text(text.settingsSample).font(.subheadline) - Text(text.secondarySample) - .font(SettingsTypography.caption) - .foregroundStyle(color(.secondaryText, fallback: .secondaryLabelColor)) - RoundedRectangle(cornerRadius: 3) - .fill(color(.accent, fallback: .controlAccentColor)) - .frame(width: 60, height: 6) - } - Spacer() - VStack(alignment: .trailing, spacing: 6) { - Text("CPU 24%") - .font(PanelTypography.metric) - Text(text.normalSample) - .font(SettingsTypography.caption) - .foregroundStyle(color(.secondaryText, fallback: .secondaryLabelColor)) - } - } - .foregroundStyle(color(.primaryText, fallback: .labelColor)) - .padding(12) - .background(color(.card, fallback: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10)) - .overlay(RoundedRectangle(cornerRadius: 10).stroke(color(.border, fallback: .separatorColor), lineWidth: 1)) - CPUCoreMatrix(usage: [0, 0.45, 1, nil], palette: palette, - coreGroups: [CPUCoreGroup(name: "CPU", indices: [0, 1, 2, 3])]) - MonitorTrendPlot(samples: [ - .init(id: 0, time: 0, value: 15, segment: 0), - .init(id: 1, time: 15, value: 55, segment: 0), - .init(id: 2, time: 30, value: 24, segment: 0), - .init(id: 3, time: 50, value: 70, segment: 1), - ], now: 60, window: 1, ymax: 100, palette: palette) - .frame(height: 90) - .padding(12) - .background(color(.card, fallback: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10)) - .overlay(RoundedRectangle(cornerRadius: 10).stroke(color(.border, fallback: .separatorColor), lineWidth: 1)) - } - .padding(12) - .background(color(.background, fallback: .windowBackgroundColor), in: RoundedRectangle(cornerRadius: 12)) - .accessibilityElement(children: .contain) - } -} diff --git a/Sources/Vorssaint/UI/Settings/TrackpadSettings.swift b/Sources/Vorssaint/UI/Settings/TrackpadSettings.swift index 05b868f..3368d02 100644 --- a/Sources/Vorssaint/UI/Settings/TrackpadSettings.swift +++ b/Sources/Vorssaint/UI/Settings/TrackpadSettings.swift @@ -122,7 +122,6 @@ struct TrackpadSettings: View { if !value.isEmpty { permissions.requestAccessibility() } } SettingsCaptionText(tuningText.spreadHint) - FeatureSwitchRow(feature: .radialMenu) Button(AppFeature.radialMenu.name(l10n.s, language: l10n.language)) { SettingsRouter.shared.request(AppFeature.radialMenu.settingsDestination) } diff --git a/Sources/Vorssaint/UI/SharedUI.swift b/Sources/Vorssaint/UI/SharedUI.swift index 5bf1e1c..1e3f881 100644 --- a/Sources/Vorssaint/UI/SharedUI.swift +++ b/Sources/Vorssaint/UI/SharedUI.swift @@ -146,7 +146,6 @@ struct HUDBackdrop: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.accessibilityReduceTransparency) private var reduceTransparency - @AppStorage(DefaultsKey.liquidGlassEnabled) private var liquidGlassEnabled = false private var materialOpacity: Double { reduceTransparency ? 1 : min(max(opacity, 0), 1) @@ -166,30 +165,6 @@ struct HUDBackdrop: View { } var body: some View { -#if compiler(>=6.2) - if #available(macOS 26.0, *), liquidGlassEnabled, !reduceTransparency { - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .fill(Color.clear) - .glassEffect(.regular, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .fill(colorScheme == .dark ? Color.black : Color.white) - .opacity(plateOpacity) - ) - .overlay( - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .strokeBorder(colorScheme == .dark ? Color.white.opacity(0.12) : Color.black.opacity(0.08), lineWidth: 0.8) - ) - } else { - classicBackdrop - } -#else - classicBackdrop -#endif - } - - @ViewBuilder - private var classicBackdrop: some View { HUDBackdropMaterial(cornerRadius: cornerRadius, opacity: materialOpacity) .overlay( RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) diff --git a/Sources/Vorssaint/UI/Theme.swift b/Sources/Vorssaint/UI/Theme.swift index 07257f8..251d072 100644 --- a/Sources/Vorssaint/UI/Theme.swift +++ b/Sources/Vorssaint/UI/Theme.swift @@ -5,22 +5,6 @@ import SwiftUI /// Shared look & feel: brand colors, card styling and the brand mark. enum Theme { - static func color(_ role: ThemeColorRole, in palette: ImportedTheme? = ThemePreferences.shared.applied) -> Color? { - palette?.colors[role].map { Color(.sRGB, red: $0.red, green: $0.green, blue: $0.blue, opacity: $0.alpha) } - } - - static func colorScheme(in palette: ImportedTheme?, fallback: ColorScheme) -> ColorScheme? { - guard let background = palette?.colors[.background] else { return nil } - let base = fallback == .dark ? 0.0 : 1.0 - func linear(_ component: Double) -> Double { - let value = component * background.alpha + base * (1 - background.alpha) - return value <= 0.04045 ? value / 12.92 : pow((value + 0.055) / 1.055, 2.4) - } - let luminance = 0.2126 * linear(background.red) - + 0.7152 * linear(background.green) + 0.0722 * linear(background.blue) - return luminance > 0.179 ? .light : .dark - } - /// Near-black background behind the brand mark. Neutral greys into black, no /// colour cast, with just a hint of depth so the badge does not read as flat. static let spaceGradient = LinearGradient( @@ -55,7 +39,7 @@ struct MetricSymbol: View { } enum PanelMetricColor { - static var data: Color { (Theme.color(.primaryText) ?? Color.primary).opacity(0.78) } + static var data: Color { Color.primary.opacity(0.78) } static func green(for scheme: ColorScheme) -> Color { scheme == .light ? Color(red: 0.00, green: 0.44, blue: 0.18) : .green @@ -88,15 +72,15 @@ enum PanelMetricColor { enum PanelSurface { static func baseFill(for scheme: ColorScheme) -> Color { - Theme.color(.background) ?? (scheme == .light ? Color.white.opacity(0.68) : Color.black.opacity(0.42)) + scheme == .light ? Color.white.opacity(0.68) : Color.black.opacity(0.42) } static func cardFill(for scheme: ColorScheme) -> Color { - Theme.color(.card) ?? (scheme == .light ? Color.white.opacity(0.38) : Color.white.opacity(0.075)) + scheme == .light ? Color.white.opacity(0.38) : Color.white.opacity(0.075) } static func controlFill(for scheme: ColorScheme) -> Color { - Theme.color(.card) ?? (scheme == .light ? Color.black.opacity(0.055) : Color.white.opacity(0.085)) + scheme == .light ? Color.black.opacity(0.055) : Color.white.opacity(0.085) } /// Raised contrast is asked for by someone who cannot see a hairline at a @@ -105,7 +89,6 @@ enum PanelSurface { /// which is when a change to this setting shows. static func border(for scheme: ColorScheme) -> Color { let raised = NSWorkspace.shared.accessibilityDisplayShouldIncreaseContrast - if !raised, let imported = Theme.color(.border) { return imported } return scheme == .light ? Color.black.opacity(raised ? 0.24 : 0.09) : Color.white.opacity(raised ? 0.28 : 0.11) @@ -115,12 +98,11 @@ enum PanelSurface { /// own round toggles read as physical because they are lighter than what /// is behind them and carry their own shadow. static func raisedFill(for scheme: ColorScheme) -> Color { - Theme.color(.card) ?? (scheme == .light ? Color.white.opacity(0.88) : Color.white.opacity(0.14)) + scheme == .light ? Color.white.opacity(0.88) : Color.white.opacity(0.14) } static func raisedBorder(for scheme: ColorScheme) -> Color { let raised = NSWorkspace.shared.accessibilityDisplayShouldIncreaseContrast - if !raised, let imported = Theme.color(.border) { return imported } return scheme == .light ? Color.black.opacity(raised ? 0.22 : 0.07) : Color.white.opacity(raised ? 0.32 : 0.16) @@ -147,10 +129,6 @@ func sectionTitle(_ text: String) -> some View { } extension View { - func kururuTheme() -> some View { - modifier(KururuThemeModifier()) - } - /// The rounded card background used by every panel section. func panelCard() -> some View { modifier(PanelCardModifier()) @@ -160,31 +138,15 @@ extension View { modifier(PanelGlassModifier(cornerRadius: cornerRadius)) } - func panelNavigationSurface() -> some View { - background(PanelNavigationSurface()) - } -} - -private struct KururuThemeModifier: ViewModifier { - @ObservedObject private var theme = ThemePreferences.shared - @Environment(\.colorScheme) private var colorScheme - - func body(content: Content) -> some View { - content - .tint(Theme.color(.accent, in: theme.applied) ?? SettingsVisualStyle.preview?.accent) - .background(Theme.color(.background, in: theme.applied)) - .preferredColorScheme(Theme.colorScheme(in: theme.applied, fallback: colorScheme)) - } } private struct PanelGlassModifier: ViewModifier { - @ObservedObject private var theme = ThemePreferences.shared let cornerRadius: CGFloat func body(content: Content) -> some View { content.background { RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .fill(Theme.color(.background, in: theme.applied) ?? Color(nsColor: .windowBackgroundColor)) + .fill(Color(nsColor: .windowBackgroundColor)) .overlay { RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .fill(Color.primary.opacity(0.03)) @@ -194,7 +156,6 @@ private struct PanelGlassModifier: ViewModifier { } private struct PanelCardModifier: ViewModifier { - @ObservedObject private var theme = ThemePreferences.shared @Environment(\.colorScheme) private var colorScheme @Environment(\.colorSchemeContrast) private var contrast @@ -207,10 +168,10 @@ private struct PanelCardModifier: ViewModifier { .padding(12) .background { RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Theme.color(.card, in: theme.applied) ?? Color(nsColor: .controlBackgroundColor)) + .fill(Color(nsColor: .controlBackgroundColor)) } .overlay { - if contrast == .increased || theme.applied?.colors[.border] != nil { + if contrast == .increased { RoundedRectangle(cornerRadius: 16, style: .continuous) .strokeBorder(PanelSurface.border(for: colorScheme), lineWidth: 1) } @@ -219,38 +180,6 @@ private struct PanelCardModifier: ViewModifier { } } -private struct PanelNavigationSurface: View { - @ObservedObject private var theme = ThemePreferences.shared - @Environment(\.accessibilityReduceTransparency) private var reduceTransparency - @AppStorage(DefaultsKey.liquidGlassEnabled) private var liquidGlassEnabled = false - - var body: some View { - let shape = RoundedRectangle(cornerRadius: 12, style: .continuous) - if let card = Theme.color(.card, in: theme.applied) { - shape.fill(card) - } else { -#if compiler(>=6.2) - if #available(macOS 26.0, *), liquidGlassEnabled, !reduceTransparency { - shape.fill(Color.clear).glassEffect(.regular, in: shape) - } else { - standardSurface(shape) - } -#else - standardSurface(shape) -#endif - } - } - - @ViewBuilder - private func standardSurface(_ shape: RoundedRectangle) -> some View { - if reduceTransparency { - shape.fill(Color(nsColor: .controlBackgroundColor)) - } else { - shape.fill(.regularMaterial) - } - } -} - func appDelegate() -> AppDelegate? { NSApp.delegate as? AppDelegate } diff --git a/Tests/BoundedProcessCancellationTests.swift b/Tests/BoundedProcessCancellationTests.swift new file mode 100644 index 0000000..c20583b --- /dev/null +++ b/Tests/BoundedProcessCancellationTests.swift @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +@main +struct BoundedProcessCancellationTests { + static func main() { + var failures: [String] = [] + + func expect(_ condition: Bool, _ message: String) { + if !condition { failures.append(message) } + } + + let prelaunch = BoundedProcessCancellation() + prelaunch.cancel() + let marker = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + .appendingPathComponent(".build/prelaunch-\(UUID().uuidString)") + let prelaunchResult = BoundedProcessRunner.run( + "/usr/bin/touch", [marker.path], timeout: 1, maxOutputBytes: 1_024, + cancellation: prelaunch) + expect(prelaunchResult.cancelled && !prelaunchResult.timedOut && prelaunchResult.status == -1, + "pre-launch cancellation is distinct from timeout and launch failure") + expect(!FileManager.default.fileExists(atPath: marker.path), + "pre-launch cancellation prevents the child side effect") + + let running = BoundedProcessCancellation() + let started = Date() + DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.05) { + running.cancel() + } + let runningResult = BoundedProcessRunner.run( + "/bin/sleep", ["5"], timeout: 3, maxOutputBytes: 1_024, + cancellation: running) + expect(runningResult.cancelled && !runningResult.timedOut && runningResult.status == -1, + "running cancellation is reported explicitly") + expect(Date().timeIntervalSince(started) < 1.5, + "running cancellation terminates the child promptly") + + let timeout = BoundedProcessRunner.run( + "/bin/sleep", ["5"], timeout: 0.05, maxOutputBytes: 1_024) + expect(timeout.timedOut && !timeout.cancelled && timeout.status == -1, + "timeout remains distinct from cancellation") + + let capped = BoundedProcessRunner.run( + "/bin/sh", ["-c", "/usr/bin/yes x | /usr/bin/head -c 200000"], + timeout: 2, maxOutputBytes: 1_024) + expect(capped.status == 0 && !capped.cancelled && capped.output.count == 1_024, + "output stays bounded without changing a successful result") + + let sequence = BoundedProcessCancellation() + let secondMarker = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + .appendingPathComponent(".build/second-launch-\(UUID().uuidString)") + DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.05) { + sequence.cancel() + } + let first = BoundedProcessRunner.run( + "/bin/sleep", ["5"], timeout: 3, maxOutputBytes: 1_024, + cancellation: sequence) + let second = BoundedProcessRunner.run( + "/usr/bin/touch", [secondMarker.path], timeout: 1, maxOutputBytes: 1_024, + cancellation: sequence) + expect(first.cancelled && second.cancelled + && !FileManager.default.fileExists(atPath: secondMarker.path), + "one token cancels the active read and prevents the next sequential read") + + if failures.isEmpty { + print("5 process cancellation checks passed") + } else { + failures.forEach { fputs("FAIL: \($0)\n", stderr) } + exit(1) + } + } +} diff --git a/Tests/BrightnessModuleMigrationTests.swift b/Tests/BrightnessModuleMigrationTests.swift new file mode 100644 index 0000000..c6e5cd3 --- /dev/null +++ b/Tests/BrightnessModuleMigrationTests.swift @@ -0,0 +1,46 @@ +import Foundation + +enum BrightnessModuleMigrationTests { + static func run(_ expect: (Bool, String) -> Void) { + let name = "com.vorssaint.tests.brightness-migration.\(UUID().uuidString)" + let store = UserDefaults(suiteName: name)! + defer { store.removePersistentDomain(forName: name) } + for available in [false, true] { + for enabled in [false, true] { + store.removePersistentDomain(forName: name) + store.set(available, forKey: FeatureUnit.brightness.availabilityKey) + store.set(enabled, forKey: DefaultsKey.brightnessControlEnabled) + Defaults.migrateBrightnessModuleGate(in: store) + expect(AppFeature.brightness.isAvailable(in: store) == (available && enabled), + "brightness migration preserves both legacy gates") + expect(store.object(forKey: DefaultsKey.brightnessControlEnabled) == nil, + "brightness migration consumes retired gate") + store.set(true, forKey: FeatureUnit.brightness.availabilityKey) + Defaults.migrateBrightnessModuleGate(in: store) + expect(AppFeature.brightness.isAvailable(in: store), + "later module enable survives repeated registration") + } + } + store.removePersistentDomain(forName: name) + store.set(true, forKey: FeatureUnit.brightness.availabilityKey) + Defaults.migrateBrightnessModuleGate(in: store) + expect(!AppFeature.brightness.isAvailable(in: store), + "legacy absent gate preserves former off default") + for version in [1, 2] { + let settings: [String: Any] = [FeatureUnit.brightness.availabilityKey: true, + DefaultsKey.featureAvailable(AppFeature.brightness.rawValue): false, + DefaultsKey.brightnessControlEnabled: false] + let payload: [String: Any] = [SettingsBackupSupport.formatVersionKey: version, + SettingsBackupSupport.settingsKey: settings] + let sanitized = SettingsBackupSupport.sanitizedSettings(from: payload)! + SettingsBackupSupport.replaceExportedSettings(sanitized, in: store) + Defaults.migrateBrightnessModuleGate(in: store) + expect(AppFeature.brightness.isAvailable(in: store) == (version == 2), + "backup version distinguishes retired gate from current module intent") + } + expect(!SettingsBackupSupport.exportKeys().contains(DefaultsKey.brightnessControlEnabled), + "new backups exclude retired brightness gate") + expect(AppFeature.brightness.pageSwitchKey == nil, + "brightness has a single module gate") + } +} diff --git a/Tests/BrightnessPipelineTests.swift b/Tests/BrightnessPipelineTests.swift new file mode 100644 index 0000000..912a7f4 --- /dev/null +++ b/Tests/BrightnessPipelineTests.swift @@ -0,0 +1,43 @@ +import Foundation + +enum BrightnessPipelineTests { + static func run(_ expect: (Bool, String) -> Void) { + func frame(_ body: [UInt8]) -> [UInt8] { body + [body.reduce(UInt8(0x50), ^)] } + expect(!BrightnessSupport.acceptsObservation(startedVersion: nil, currentVersion: 1), "old first read cannot overwrite new write") + expect(!BrightnessSupport.acceptsObservation(startedVersion: 1, currentVersion: 2), "old80 cannot overwrite newer70") + expect(BrightnessSupport.acceptsObservation(startedVersion: 2, currentVersion: 2), "same display version remains valid when another display writes") + expect(BrightnessSupport.displayIdentity(uuid: nil, vendor: 1, model: 2, serial: 0) == nil, "ambiguous serial0 display is not cacheable") + expect(BrightnessSupport.displayIdentity(uuid: "first", vendor: 1, model: 2, serial: 0) + != BrightnessSupport.displayIdentity(uuid: "second", vendor: 1, model: 2, serial: 0), "identical models use display UUID identity") + var unavailable = BrightnessState() + unavailable.unreadable(.identityUnavailable) + expect(unavailable.status == .unknown && unavailable.failure == .identityUnavailable + && unavailable.observed == nil, "unidentified display exposes actionable unavailable state without a value") + var state = BrightnessState() + expect(state.observed == nil && state.requested == nil, "unknown state never invents50") + state.observe(0.8, at: Date(timeIntervalSince1970: 10)) + state.request(0.7) + expect(state.observed == 0.8 && state.requested == 0.7 && state.status == .pending, "request preserves actual reading") + state.complete(succeeded: true, observed: nil, failure: .checksum) + expect(state.observed == 0.8 && state.status == .sentUnconfirmed && state.failure == .checksum, "accepted write is unconfirmed on invalid reply") + state.request(0.6) + state.complete(succeeded: false, observed: nil, failure: .writeFailed) + expect(state.observed == 0.8 && state.status == .failed, "failed write retains last actual reading") + state.request(0.7) + state.complete(succeeded: true, observed: 0.8, failure: .mismatch) + expect(state.status == .failed && state.observed == 0.8 && state.requested == 0.7, "readback mismatch remains explicit") + state.complete(succeeded: true, observed: 0.7, failure: nil) + expect(state.status == .confirmed && state.requested == nil && state.observed == 0.7, "matching readback confirms") + let valid: [UInt8] = [0x6e, 0x88, 0x02, 0, 0x10, 0, 0, 100, 0, 80] + expect(BrightnessSupport.parseReply(frame(valid))?.current == 80, "valid luminance reply reads80") + for (index, value): (Int, UInt8) in [(0, 0x60), (1, 0x80), (2, 0xbe), (3, 1), (4, 0x12), (5, 2), (7, 0), (9, 101)] { + var bad = valid; bad[index] = value + expect(BrightnessSupport.parseReply(frame(bad)) == nil, "reject malformed field \(index) even with valid checksum") + } + let nullReply: [UInt8] = [0x6e,0x80,0xbe,0,0x10,0,0,100,0,80,0x90] + expect(BrightnessSupport.validateReply(nullReply) == .failure(.nullReply), "real monitor NULL reply ignores trailing stale80") + expect(BrightnessSupport.validateReply([0x6e,0x80,0xbe]) == .failure(.nullReply), "three byte NULL is classified without fabricating a reading") + expect(BrightnessSupport.parseReply(nullReply) == nil, "NULL never produces luminance") + expect(BrightnessSupport.parseReply(frame(valid) + [0]) == nil, "reject trailing bytes") + } +} diff --git a/Tests/BrightnessReadbackRegression.py b/Tests/BrightnessReadbackRegression.py index 3ef2f9c..d247a20 100644 --- a/Tests/BrightnessReadbackRegression.py +++ b/Tests/BrightnessReadbackRegression.py @@ -1,116 +1,12 @@ #!/usr/bin/env python3 -"""Generate an offline Swift regression from actual brightness lifecycle blocks. -Run from repo root: - python3 Tests/BrightnessReadbackRegression.py - swiftc Sources/Vorssaint/Services/Display/BrightnessSupport.swift .build/brightness-readback/regression/main.swift -o .build/brightness-readback/regression/test - .build/brightness-readback/regression/test -Optional args: alternate service source, output subdirectory (for mutation checks). -Only external IOKit/display/defaults dependencies are fixture substitutes; the -stop, probe selection, result handling and remembered-value commit blocks run -from the selected production source. The complete step method is also compiled -unchanged, including its asynchronous closure scope; fixture callback parameters -must not hide missing locals in that method. This does not test hardware timing/UI. +"""Compatibility entry for the production brightness contract fixture. +The new harness runs actual request, completion, step and DDC probe functions. +Protocol/state contracts also run in BrightnessPipelineTests.swift. """ from pathlib import Path +import subprocess import sys -base=Path(sys.argv[1]) if len(sys.argv)>1 else Path("Sources/Vorssaint/Services/Display/BrightnessService.swift") -suffix=sys.argv[2] if len(sys.argv)>2 else "regression" -s=base.read_text() -def block(a,b):return s[s.index(a):s.index(b,s.index(a))] -stop=block(" stateLock.lock()\n rebuildGeneration += 1", " ddcPendingSteps = [:]") -read=block(" let rememberedWriteOnly =" if "let rememberedWriteOnly" in s else " // A timeout describes", " switch probe {") -read="\n".join(line for line in read.splitlines() if "Self.log.log(" not in line) -switch=block(" switch probe {", "\n }\n }\n\n // Software route") -callbackStart = " guard self.ddcPendingSteps[displayID]?.token" if "guard self.ddcPendingSteps[displayID]?.token" in s else " let queued = self.ddcPendingSteps.removeValue" -readCompletion = block(callbackStart, " var current = cached") -stepGate=block(" let fresh = BrightnessSupport.trustsRememberedLevel", " // A press that lands") -completeStep=block(" private func step(", " private func commitStep(") -finish=block(" private func finishBrightnessWrite", " // MARK: - Software dimming") -remember=block(" for display in resolved where", "\n }\n stateLock.unlock()\n guard !stale else") -# Same-member schema shim allows the old implementation to compile against the new unknown-value assertions. -head="""import Foundation -import os -typealias CGDirectDisplayID = UInt32 -struct BrightnessDisplay { - enum Method { case ddc, system, software } - let id: UInt32; let name: String; let isBuiltIn: Bool - var method: Method?; var isActive: Bool; var brightness: Double; var readable: Bool - var hasKnownBrightness = true -} -final class Probe { - struct RememberedLevel {var value: Double; var fingerprint: String} - struct Route {var method: BrightnessDisplay.Method;var service: Int?;var maximum: UInt16; var ddcReadable = false;var ddcPathKey: String?} - enum DDCProbe {case replied(UInt16,UInt16),writeOnly,dead} - let stateLock=NSLock(); var lastApplied:[UInt32:RememberedLevel]=[:] - var levelKnownAt:[UInt32:Date]=[:]; var routes:[UInt32:Route]=[:] - var pendingLevels:[UInt32:Double]=[:];var writeSequence=0;var latestBrightnessWrites:[UInt32:UInt64]=[:] - var systemWritesInFlight:Set=[];var knownTopology:Set=[];var knownActiveTopology:Set=[] - struct DDCReadSteps: ExpressibleByIntegerLiteral {let token:UUID;var delta:Double;init(token:UUID,delta:Double){self.token=token;self.delta=delta};init(integerLiteral:Int){token=UUID();delta=Double(integerLiteral)}} - var ddcPendingSteps:[UInt32:DDCReadSteps]=[:];var acceptedReads=0 - var running=true;var brightnessWriteFailures:[UInt32:String]=[:];var displays:[BrightnessDisplay]=[] - static let levelTrustWindow:TimeInterval=3 - static let log=Logger(subsystem:"brightness-offline-test",category:"scope") - let workQueue=DispatchQueue(label:"brightness-offline-test") - func currentSystemBrightness(for id:UInt32,fallback:Double?)->Double?{fallback} - var attemptedRead=false; var stepped:Double? - func commitStep(from:Double,delta:Double,to:UInt32,method:BrightnessDisplay.Method,showOSD:Bool){stepped=from+delta} - var rebuildGeneration=0;var rebuildingTopology:Int?;var fingerprint="monitor-A" - var stored:Set=[];var nextProbe:DDCProbe = .writeOnly;var readCalls=0 - func rememberedLevel(for id:UInt32)->Double? { guard let v=lastApplied[id],v.fingerprint==fingerprint else{return nil};return v.value } - func writeOnlyDDCPaths()->Set{stored} - func rememberWriteOnlyDDCPath(_ p:String?){if let p{stored.insert(p)}} - func forgetWriteOnlyDDCPath(_ p:String?){if let p{stored.remove(p)}} - func ddcProbeLuminance(for id:UInt32,service:Int,classifyingChannel:Bool=false)->DDCProbe{readCalls+=1;return nextProbe} - static var activeFingerprint="monitor-A" - static func displayFingerprint(_ id:UInt32)->String{activeFingerprint} - func stop(){ -""" -body=head+stop+"\n}\nfunc refresh(_ input:DDCProbe)->BrightnessDisplay {\nnextProbe=input; Self.activeFingerprint=fingerprint\nlet id:UInt32=1;let pathKey:String?=\"monitor|port\";let candidate=(index:0, unused:0); let matched=(service:1, unused:0)\nvar built=[BrightnessDisplay(id:id,name:\"Fixture\",isBuiltIn:false,method:.ddc,isActive:true,brightness:0.5,readable:false,hasKnownBrightness:false)]\nvar newRoutes:[UInt32:Route]=[:];var softwareIndices:Set=[0]\n"+read+"\n"+switch+"\nlet resolved=built\n"+remember+"\nreturn built[0]\n}\n}\n" -body=body.rsplit("}\n",1)[0]+completeStep+finish.replace("private func", "func")+"\nfunc stepGate(_ cached:Double?) { let displayID:UInt32=1;let method=BrightnessDisplay.Method.ddc; let delta=0.05;let showOSD=false;let known:Date?=nil;let route:Route?=Route(method:.ddc,service:1,maximum:100,ddcReadable:false)\n"+stepGate+"\nattemptedRead=true\n}\nfunc finishRead(token readToken:UUID, generation readGeneration:Int, sequence readWriteSequence:Int) {let displayID:UInt32=1\n"+readCompletion+"\n_ = queued;acceptedReads+=1\n}\n}\n" -body+=""" -var checks=0;var failures=0 -func expect(_ value:Bool,_ message:String){checks+=1;if !value{failures+=1;print("FAIL: \(message)")}} -let p=Probe() -let first=p.refresh(.replied(80,100));expect(first.brightness==0.8 && first.readable,"first real reply is 80%") -p.stop() -let missed=p.refresh(.writeOnly);expect(missed.brightness==0.8 && missed.hasKnownBrightness && !missed.readable,"stop/start timeout retains last80 but marks unreadable") -let recovered=p.refresh(.replied(90,100));expect(recovered.brightness==0.9 && recovered.readable,"later refresh retries and recovers90") -let q=Probe();q.stored=["monitor|port"] -let legacy=q.refresh(.replied(80,100));expect(legacy.brightness==0.8 && q.readCalls==1,"legacy persisted write-only cache cannot block readback") -let unknown=Probe();let noReply=unknown.refresh(.writeOnly) -expect(!noReply.hasKnownBrightness,"first unavailable read does not invent50") -expect(unknown.lastApplied.isEmpty,"unknown placeholder is never remembered as real value") -p.stop();p.fingerprint="monitor-B";let different=p.refresh(.writeOnly) -expect(!different.hasKnownBrightness,"reused display number cannot inherit another monitor brightness") -let pending=Probe();_ = pending.refresh(.replied(80,100)) -pending.lastApplied[1]=Probe.RememberedLevel(value:0.3,fingerprint:"monitor-A") -pending.latestBrightnessWrites[1]=1;pending.stop() -let afterPending=pending.refresh(.writeOnly) -expect(!afterPending.hasKnownBrightness,"stop discards an unconfirmed optimistic write before failed readback") -let g=Probe();g.stepGate(nil) -expect(g.attemptedRead && g.stepped==nil,"unknown write-only route requests a fresh read before any step") -let stale=Probe();stale.stepGate(0.8) -expect(stale.attemptedRead && stale.stepped==nil,"previously unreadable route retries before stepping stale80") -let failed=Probe();failed.displays=[first];failed.latestBrightnessWrites[1]=1 -failed.finishBrightnessWrite(id:1,sequence:1,generation:0,succeeded:false) -expect(!failed.displays[0].readable && !failed.displays[0].hasKnownBrightness,"failed requested value is not presented as current brightness") -let token=UUID();let later=UUID() -let readNewerWrite=Probe();readNewerWrite.ddcPendingSteps[1] = .init(token:token,delta:0.1);readNewerWrite.writeSequence=2 -readNewerWrite.finishRead(token:token,generation:0,sequence:1) -expect(readNewerWrite.acceptedReads==0,"late read cannot overwrite a newer slider request") -let readStopped=Probe();readStopped.ddcPendingSteps[1] = .init(token:token,delta:0.1);readStopped.running=false -readStopped.finishRead(token:token,generation:0,sequence:0) -expect(readStopped.acceptedReads==0,"late read cannot commit after stop") -let readNewSession=Probe();readNewSession.ddcPendingSteps[1] = .init(token:later,delta:0.2);readNewSession.rebuildGeneration=1 -readNewSession.finishRead(token:token,generation:0,sequence:0) -expect(readNewSession.acceptedReads==0 && readNewSession.ddcPendingSteps[1]?.token==later,"old callback leaves the new session read and queued steps intact") -let readValid=Probe();readValid.ddcPendingSteps[1] = .init(token:token,delta:0.1) -readValid.finishRead(token:token,generation:0,sequence:0) -expect(readValid.acceptedReads==1 && readValid.ddcPendingSteps.isEmpty,"current read completes exactly once and consumes its queued steps") -readValid.finishRead(token:token,generation:0,sequence:0) -expect(readValid.acceptedReads==1,"completed callback cannot commit a second time") -print("Lifecycle: \(checks) checks, \(failures) failures") -exit(failures==0 ? 0:1) -""" -Path('.build/brightness-readback/'+suffix+'/main.swift').parent.mkdir(parents=True, exist_ok=True) -Path('.build/brightness-readback/'+suffix+'/main.swift').write_text(body) +source = sys.argv[1] if len(sys.argv) > 1 else "Sources/Vorssaint/Services/Display/BrightnessService.swift" +suffix = sys.argv[2] if len(sys.argv) > 2 else "regression" +output = Path(".build/brightness-readback") / suffix / "main.swift" +subprocess.run([sys.executable, "Tests/BrightnessServiceContract.py", source, str(output)], check=True) diff --git a/Tests/BrightnessServiceContract.py b/Tests/BrightnessServiceContract.py new file mode 100644 index 0000000..fbfaa6a --- /dev/null +++ b/Tests/BrightnessServiceContract.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Compile production request/completion and DDC probing against deterministic IO fixtures.""" +from pathlib import Path +import sys +s = Path(sys.argv[1] if len(sys.argv) > 1 else "Sources/Vorssaint/Services/Display/BrightnessService.swift").read_text() +def block(start, end): + a = s.index(start) + return s[a:s.index(end, a)] +model = block("struct BrightnessDisplay:", "/// Brightness sliders") +request = block(" func setBrightness(_ value:", " // MARK: - Display power") +finish = block(" private func finishBrightnessWrite", " // MARK: - Software dimming").replace("private func", "func") +step = block(" private func step(", " /// Routes a handled brightness key").replace("private func", "func") +probe = block(" private enum DDCProbe", " // MARK: - Display identity").replace("private enum", "enum").replace("private func", "func") +fixture = r'''import Foundation +import CoreFoundation +import os +import Darwin +typealias CGDirectDisplayID = UInt32 +let KERN_SUCCESS: Int32 = 0 +var connected = true +func CGDisplayIsOnline(_ id: UInt32) -> Int32 { connected ? 1 : 0 } +enum BrightnessBridge { + static var payload: [UInt8] = [] + static var reads = 0 + static var writesSinceRead = 0 + static var requiresRepeatedGET = false + static var writeI2C: ((CFTypeRef, UInt32, UInt32, UnsafeMutableRawPointer, UInt32) -> Int32)? = { _,_,_,_,_ in writesSinceRead += 1; return 0 } + static var readI2C: ((CFTypeRef, UInt32, UInt32, UnsafeMutableRawPointer, UInt32) -> Int32)? = { _,_,_,buffer,count in + reads += 1 + let response = requiresRepeatedGET && writesSinceRead < 2 + ? [UInt8](arrayLiteral: 0x6e,0x80,0xbe,0,0x10,0,0,100,0,80,0x90) : payload + writesSinceRead = 0 + let bytes = buffer.assumingMemoryBound(to: UInt8.self) + for i in 0.. Double? { fallback } + static let log = Logger(subsystem: "brightness-contract", category: "test") + static var identity = "screenA" + static func displayFingerprint(_ id: UInt32) -> String { identity } + var running = true + var displays: [BrightnessDisplay] = [] + var brightnessWriteFailures: [UInt32: String] = [:] + let stateLock = NSLock() + var writeSequence: UInt64 = 0 + var displayWriteVersions: [UInt32: UInt64] = [:] + var latestBrightnessWrites: [UInt32: UInt64] = [:] + var pendingLevels: [UInt32: PendingWrite] = [:] + var lastApplied: [UInt32: RememberedLevel] = [:] + var levelKnownAt: [UInt32: Date] = [:] + var routes: [UInt32: Route] = [1: Route(method: .ddc)] + var lifecycleGeneration = 0 + var drainScheduled = true + let workQueue = DispatchQueue(label: "test") + var ddcReadFailures: [UInt32: BrightnessSupport.DDCFailure] = [:] + func drainPendingLevels() {} + func paceDDCCommand(for id: UInt32) {} + func recordDDCCommandEnd(for id: UInt32) {} +''' + request + finish + probe + step + "}\n" +fixture += r''' +var checks = 0 +var failures = 0 +func expect(_ condition: Bool, _ name: String) { checks += 1; if !condition { failures += 1; print("FAIL: \(name)") } } +let p = Fixture() +var display = BrightnessDisplay(id: 1, name: "Fixture", isBuiltIn: false, method: .ddc, isActive: true, brightness: 0.8, readable: true) +display.state.observe(0.8) +p.displays = [display] +p.setBrightness(0.7, for: 1) +expect(p.displays[0].requestedBrightness == 0.7 && p.displays[0].observedBrightness == 0.8, "actual request keeps observed80 separate from requested70") +expect(!p.displays[0].hasKnownBrightness && p.lastApplied.isEmpty, "request is never persisted as confirmed") +p.finishBrightnessWrite(id: 1, sequence: 1, generation: 0, fingerprint: "screenA", method: .ddc, succeeded: true, observed: nil, failure: .checksum) +expect(p.displays[0].status == .sentUnconfirmed && p.lastApplied.isEmpty, "accepted write with bad readback is unconfirmed") +p.setBrightness(0.6, for: 1) +p.setBrightness(0.5, for: 1) +p.finishBrightnessWrite(id: 1, sequence: 2, generation: 0, fingerprint: "screenA", method: .ddc, succeeded: true, observed: 0.6, failure: nil) +expect(p.displays[0].requestedBrightness == 0.5 && p.latestBrightnessWrites[1] == 3, "late write completion cannot overwrite latest target or clear its owner") +p.finishBrightnessWrite(id: 1, sequence: 3, generation: 0, fingerprint: "screenA", method: .ddc, succeeded: false, observed: nil, failure: .writeFailed) +expect(p.displays[0].status == .failed && p.displays[0].observedBrightness == 0.8, "write failure retains last observation") +p.setBrightness(0.4, for: 1) +p.lifecycleGeneration = 1 +p.finishBrightnessWrite(id: 1, sequence: 4, generation: 0, fingerprint: "screenA", method: .ddc, succeeded: true, observed: 0.4, failure: nil) +expect(p.displays[0].status == .pending, "completion from stopped lifecycle is discarded") +p.finishBrightnessWrite(id: 1, sequence: 4, generation: 1, fingerprint: "screenB", method: .ddc, succeeded: true, observed: 0.4, failure: nil) +expect(p.displays[0].status == .pending, "reused ID cannot consume previous display completion") +p.setBrightness(0.4, for: 1) +p.finishBrightnessWrite(id: 1, sequence: 5, generation: 1, fingerprint: "screenA", method: .ddc, succeeded: true, observed: 0.4, failure: nil) +expect(p.displays[0].status == .confirmed && p.lastApplied[1]?.value == 0.4, "valid current write readback is committed") +p.setBrightness(0.3, for: 1) +p.routes[1] = Fixture.Route(method: .software) +p.finishBrightnessWrite(id: 1, sequence: 6, generation: 1, fingerprint: "screenA", method: .ddc, succeeded: true, observed: 0.3, failure: nil) +expect(p.lastApplied[1]?.value == 0.4 && p.lastApplied[1]?.method == .ddc, "route switch cannot relabel completed DDC reading as software factor") +expect(p.displays[0].status == .pending, "route switch rejects stale hardware completion publication") +BrightnessBridge.payload = [0x6e,0x80,0xbe,0,0x10,0,0,100,0,80,0x90] +let failed = p.ddcProbeLuminance(for: 1, service: "test" as NSString, classifyingChannel: true) +if case .writeOnly = failed {} else { expect(false, "invalid frame never yields luminance") } +expect(p.ddcReadFailures[1] == .nullReply, "NULL response is retained without consuming stale tail") +expect(BrightnessBridge.reads == BrightnessSupport.ddcProbeAttempts(), "read retry count is bounded") +var valid: [UInt8] = [0x6e,0x88,2,0,0x10,0,0,100,0,80] +valid.append(valid.reduce(UInt8(0x50), ^)) +BrightnessBridge.payload = valid + [1] +BrightnessBridge.requiresRepeatedGET = true +for discovery in [true, false] { + BrightnessBridge.writesSinceRead = 0 + let result = p.ddcProbeLuminance(for: 1, service: "test" as NSString, classifyingChannel: discovery) + if case .replied(80,100) = result { + expect(true, "repeated GET recovers real80 for discovery=\(discovery)") + } else { expect(false, "repeated GET recovers real80 for discovery=\(discovery)") } +} +BrightnessBridge.requiresRepeatedGET = false +BrightnessBridge.payload = valid +let recovered = p.ddcProbeLuminance(for: 1, service: "test" as NSString, classifyingChannel: true) +if case .replied(80,100) = recovered {} else { expect(false, "valid later reply recovers80") } +expect(p.ddcReadFailures[1] == nil, "successful read clears stale failure") +let reused = Fixture(); reused.displays = [display] +reused.setBrightness(0.7, for: 1) +Fixture.identity = "screenB" +reused.finishBrightnessWrite(id: 1, sequence: 1, generation: 0, fingerprint: "screenA", method: .ddc, succeeded: true, observed: 0.7, failure: nil) +expect(reused.latestBrightnessWrites.isEmpty, "identity-mismatched completion clears its pending owner so visible refresh resumes") +expect(reused.pendingLevels.isEmpty && reused.lastApplied.isEmpty, "retired owner cannot leave a queued write or publish into replacement display") +Fixture.identity = "screenA" +let readCount = BrightnessBridge.reads +BrightnessBridge.writeI2C = { _,_,_,_,_ in -1 } +let dead = p.ddcProbeLuminance(for: 1, service: "test" as NSString) +if case .dead = dead {} else { expect(false, "rejected GET cannot certify cached valid reply") } +expect(BrightnessBridge.reads == readCount && p.ddcReadFailures[1] == .transport, "rejected GET never consumes stale reply buffer") +BrightnessBridge.writeI2C = { _,_,_,_,_ in 0 } +let unknown = Fixture() +unknown.displays = [BrightnessDisplay(id: 1, name: "Unknown", isBuiltIn: false, method: .ddc, isActive: true, brightness: 0, readable: false, hasKnownBrightness: false)] +BrightnessBridge.payload = [0x6e,0x80,0xbe,0,0x10,0,0,100,0,80,0x90] +func drain(_ fixture: Fixture) { + fixture.workQueue.sync {} + RunLoop.current.run(until: Date().addingTimeInterval(0.02)) +} +unknown.step(1, method: .ddc, delta: 0.05, showOSD: false) +drain(unknown) +expect(unknown.displays[0].requestedBrightness == nil && unknown.ddcPendingSteps.isEmpty, "unknown failed read cannot step from invented50") +BrightnessBridge.payload = valid +unknown.step(1, method: .ddc, delta: 0.05, showOSD: false) +drain(unknown) +expect(abs((unknown.displays[0].requestedBrightness ?? 0) - 0.85) < 0.001, "later valid80 read recovers and steps85") +let staleRead = Fixture(); staleRead.displays = [display] +staleRead.step(1, method: .ddc, delta: 0.05, showOSD: false) +staleRead.setBrightness(0.7, for: 1) +drain(staleRead) +expect(staleRead.displays[0].requestedBrightness == 0.7, "late step read cannot replace newer slider70") +let stoppedRead = Fixture(); stoppedRead.displays = [display] +stoppedRead.step(1, method: .ddc, delta: 0.05, showOSD: false) +stoppedRead.running = false +drain(stoppedRead) +expect(stoppedRead.displays[0].requestedBrightness == nil, "step read cannot commit after stop") +print("Brightness service contract: \(checks) checks, \(failures) failures") +exit(failures == 0 ? 0 : 1) +''' +out = Path(sys.argv[2] if len(sys.argv) > 2 else ".build/backend-refactor/brightness-contract/main.swift") +out.parent.mkdir(parents=True, exist_ok=True) +out.write_text(fixture) diff --git a/Tests/CommandBarExecutorTests.swift b/Tests/CommandBarExecutorTests.swift new file mode 100644 index 0000000..d46efa4 --- /dev/null +++ b/Tests/CommandBarExecutorTests.swift @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import Foundation + +enum CommandBarExecutorTests { + static func run(_ expect: (Bool, String) -> Void) { + var context: CommandBarExecutor.Context? = .init(presentationID: UUID(), query: "original", isVisible: true) + var launchCompletions: [(Bool) -> Void] = [] + var opened: [URL] = [] + var results: [(CommandBarDestinationOpening.Result, String)] = [] + let executor = CommandBarExecutor(context: { context }, exists: { _ in false }, + open: { opened.append($0); return true }, + launch: { _, completion in launchCompletions.append(completion) }, + completed: { results.append(($0, $1)) }) + var events: [String] = [] + executor.execute(query: "typed", selection: "selected", value: 42, + keepsBarOpen: false, waitsForOpenResult: false, + dismiss: { + events.append("hide") + expect(executor.queryWhenRun == "typed" && executor.selectionWhenRun == "selected", + "action input is captured before dismissal clears the search") + }, run: { + events.append("run") + expect($0 == 42, "execution preserves the validated numeric argument") + }) + expect(events == ["hide", "run"], "ordinary actions run after the panel dismisses") + for (keep, wait) in [(true, false), (false, true), (true, true)] { + events = [] + executor.execute(query: "next", selection: "", value: nil, keepsBarOpen: keep, + waitsForOpenResult: wait, dismiss: { events.append("hide") }, + run: { _ in events.append("run") }) + expect(events == ["run"], "keep-open and pending-destination actions retain the panel") + } + executor.resetInput() + expect(executor.queryWhenRun.isEmpty && executor.selectionWhenRun.isEmpty, + "a new presentation discards previous execution input") + let missing = executor.begin()! + executor.openDestination(URL(fileURLWithPath: "/missing"), title: "file", attempt: missing) + expect(results.last?.0 == .unavailable && opened.isEmpty, + "missing destinations never reach the platform opener") + expect(!executor.accepts(missing), "completion consumes the request once") + let app = URL(fileURLWithPath: "/Applications/Test.app") + let first = executor.begin()! + executor.openApplication(at: app, title: "old", attempt: first) + let second = executor.begin()! + executor.openApplication(at: app, title: "new", attempt: second) + expect(launchCompletions.count == 2, "application launches use the asynchronous boundary") + // Keep the remaining tests runnable against the intentionally incomplete implementation. + guard launchCompletions.count == 2 else { return } + let before = results.count + launchCompletions[0](false) + expect(results.count == before, "an older launch failure cannot replace a newer attempt") + launchCompletions[1](true) + expect(results.last?.0 == .opened && results.last?.1 == "new", "the current launch completion is reported") + launchCompletions[1](false) + expect(results.count == before + 1, "duplicate completions cannot overwrite the consumed result") + for change in 0..<4 { + context = .init(presentationID: UUID(), query: "same", isVisible: true) + let attempt = executor.begin()! + executor.openApplication(at: app, title: "late", attempt: attempt) + let original = context! + switch change { + case 0: context = .init(presentationID: original.presentationID, query: "changed", isVisible: true) + case 1: context = .init(presentationID: original.presentationID, query: "same", isVisible: false) + case 2: context = .init(presentationID: UUID(), query: "same", isVisible: true) + default: executor.invalidate() + } + let count = results.count + launchCompletions.last!(false) + expect(results.count == count, "changed query, hide, reopen, and cancel reject late results: \(change)") + } + context = .init(presentationID: UUID(), query: "", isVisible: false) + let hidden = executor.begin()! + executor.openApplication(at: app, title: "shortcut", attempt: hidden) + launchCompletions.last!(false) + expect(results.last?.0 == .failed && results.last?.1 == "shortcut", + "hidden shortcut failures remain reportable without opening a panel") + context = nil + expect(executor.begin() == nil, "a released search cannot start a destination request") + } +} diff --git a/Tests/DisplayBrightnessShortcutTests.swift b/Tests/DisplayBrightnessShortcutTests.swift index 8772f10..ddc38d6 100644 --- a/Tests/DisplayBrightnessShortcutTests.swift +++ b/Tests/DisplayBrightnessShortcutTests.swift @@ -13,8 +13,7 @@ enum DisplayBrightnessShortcutTests { BrightnessShortcutPreferenceKey.increase].allSatisfy(backupKeys.contains), "display brightness shortcut choice and assigned directions follow settings backups") let enabled: (String) -> Bool = { key in - key == DefaultsKey.brightnessControlEnabled - || key == BrightnessShortcutPreferenceKey.enabled + key == BrightnessShortcutPreferenceKey.enabled } let assigned = GlobalShortcut(keyCode: 12, modifiers: [.control, .option]) let onlyDecrease: (String) -> String? = { key in diff --git a/Tests/FeatureLifecycleTests.swift b/Tests/FeatureLifecycleTests.swift new file mode 100644 index 0000000..35c29e5 --- /dev/null +++ b/Tests/FeatureLifecycleTests.swift @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +enum FeatureLifecycleTests { + static func run(_ expect: (Bool, String) -> Void) { + enum Feature: Hashable { case brightness, environment, screenshot, recorder } + final class Service { + var running = false + var syncs = 0 + var stops = 0 + var terminations = 0 + func sync(enabled: Bool) { + syncs += 1 + if running && !enabled { stops += 1 } + running = enabled + } + func terminate() { running = false; terminations += 1 } + } + var available: Set = [.brightness, .screenshot, .recorder] + var accessibilityAllowed = true + let brightness = Service(), environment = Service(), capture = Service() + let lifecycle = FeatureLifecycle([ + .init(members: [.brightness], permissions: [.accessibility], + synchronize: { brightness.sync(enabled: available.contains(.brightness) && accessibilityAllowed) }, + terminate: { brightness.terminate() }), + .init(members: [.environment], + synchronize: { environment.sync(enabled: available.contains(.environment)) }, + terminate: { environment.terminate() }), + .init(members: [.screenshot, .recorder], permissions: [.screenRecording], + synchronize: { capture.sync(enabled: !available.isDisjoint(with: [.screenshot, .recorder])) }, + terminate: { capture.terminate() }) + ]) + lifecycle.sync(changed: available, available: available) + expect(brightness.running && capture.running, "available services start") + expect(capture.syncs == 1, "shared members synchronize their owner once at launch") + expect(environment.syncs == 0, "unavailable on-demand service is not created") + lifecycle.permissionChanged(.accessibility, available: available) + expect(brightness.syncs == 2 && capture.syncs == 1, "permission change reaches only its registered owners") + accessibilityAllowed = false + lifecycle.permissionChanged(.accessibility, available: available) + expect(!brightness.running && brightness.stops == 1, "revoking permission suspends dependent work") + accessibilityAllowed = true + lifecycle.permissionChanged(.accessibility, available: available) + expect(brightness.running, "granting permission restores enabled work") + lifecycle.sync(changed: [.environment], available: available) + expect(brightness.syncs == 4 && environment.syncs == 0, + "an unrelated inactive owner cannot trigger service work") + available.remove(.screenshot) + lifecycle.sync(changed: [.screenshot], available: available) + expect(capture.running && capture.stops == 0, "removing one member preserves a shared service") + available.remove(.recorder) + lifecycle.sync(changed: [.recorder], available: available) + expect(!capture.running && capture.stops == 1, "removing the last member stops the shared service") + lifecycle.permissionChanged(.screenRecording, available: available) + expect(capture.syncs == 3, "permission callbacks do not revive disabled resources") + lifecycle.sync(changed: [.recorder], available: available) + expect(capture.syncs == 3, "repeated stop does not instantiate an inactive resource") + available.insert(.environment) + lifecycle.sync(changed: [.environment], available: available) + expect(environment.running, "installing an on-demand owner registers its lifecycle") + available.remove(.environment) + lifecycle.sync(changed: [.environment], available: available) + expect(environment.stops == 1, "uninstall cancels on-demand work") + available.insert(.recorder) + lifecycle.sync(changed: [.recorder], available: available) + expect(capture.running && capture.syncs == 4, "a shared resource can be reenabled") + lifecycle.terminate() + expect(!brightness.running && !capture.running, "termination stops running owners") + expect(environment.terminations == 1, "previously loaded owners retain their final flush hook") + expect(capture.terminations == 1, "shared termination runs once") + lifecycle.terminate() + lifecycle.sync(changed: available, available: available) + lifecycle.permissionChanged(.accessibility, available: available) + expect(brightness.terminations == 1 && brightness.syncs == 4, + "termination is idempotent and later callbacks cannot restart work") + let neverLoaded = Service() + let empty = FeatureLifecycle([ + .init(members: [.environment], synchronize: { neverLoaded.sync(enabled: false) }, + terminate: { neverLoaded.terminate() }) + ]) + empty.sync(changed: [.environment], available: []) + empty.terminate() + expect(neverLoaded.syncs == 0 && neverLoaded.terminations == 0, + "disabled-at-launch owners are never constructed even on exit") + } +} diff --git a/Tests/HomebrewEnvironmentCheckSettlementTests.swift b/Tests/HomebrewEnvironmentCheckSettlementTests.swift new file mode 100644 index 0000000..70c14fa --- /dev/null +++ b/Tests/HomebrewEnvironmentCheckSettlementTests.swift @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +@main +struct HomebrewEnvironmentCheckSettlementTests { + static func main() { + var failures: [String] = [] + func expect(_ condition: Bool, _ message: String) { + if !condition { failures.append(message) } + } + + var cancellations = 0 + let cancelled = HomebrewEnvironmentCheckSettlement { snapshot in + cancellations += 1 + expect(snapshot == nil, "cancellation settles with no snapshot") + } + cancelled.settle(nil) + cancelled.settle(EnvironmentHomebrewSnapshot(packages: [], updates: [:])) + expect(cancellations == 1, "a late read cannot settle a cancelled check twice") + + var successes = 0 + let successful = HomebrewEnvironmentCheckSettlement { snapshot in + successes += 1 + expect(snapshot != nil, "a current check delivers its snapshot") + } + successful.settle(EnvironmentHomebrewSnapshot(packages: [], updates: [:])) + successful.settle(nil) + expect(successes == 1, "a completed check ignores later cancellation") + + var racedValue: EnvironmentHomebrewSnapshot?? + let raced = HomebrewEnvironmentCheckSettlement { racedValue = $0 } + let queued = DispatchSemaphore(value: 0) + DispatchQueue.global(qos: .userInitiated).async { + raced.settle(EnvironmentHomebrewSnapshot(packages: [], updates: [:])) + queued.signal() + } + _ = queued.wait(timeout: .now() + 1) + raced.settle(nil) + RunLoop.main.run(until: Date().addingTimeInterval(0.05)) + expect(racedValue != nil, "cancel settles while a worker result waits for the main thread") + expect(racedValue! == nil, "cancel wins over an already queued late worker result") + + if failures.isEmpty { + print("6 Homebrew settlement checks passed") + } else { + failures.forEach { fputs("FAIL: \($0)\n", stderr) } + exit(1) + } + } +} diff --git a/Tests/MetricsTests.swift b/Tests/MetricsTests.swift index 2a3cb28..fe45f09 100644 --- a/Tests/MetricsTests.swift +++ b/Tests/MetricsTests.swift @@ -67,6 +67,7 @@ struct MetricsTests { LocalPortTests.run { expect($0, $1) } CleanerPackageCacheTests.run { expect($0, $1) } BrightnessNativeBoundaryTests.run { expect($0, $1) } + BrightnessPipelineTests.run { expect($0, $1) } OnboardingFeatureSelectionTests.run { expect($0, $1) } FeatureSwitchRetirementTests.run { expect($0, $1) } ShelfDockPlacementTests.run { expect($0, $1) } @@ -83,7 +84,6 @@ struct MetricsTests { ClipboardImportTransactionTests.run { expect($0, $1) } ScratchpadImportStoreTests.run { expect($0, $1) } ShelfDockBackupTests.run { expect($0, $1) } - ThemeImportTests.run { expect($0, $1) } ClipboardJSONPreviewTests.run { expect($0, $1) } URLAutomaticCleaningTests.run { expect($0, $1) } URLRuleImportTests.run { expect($0, $1) } @@ -100,6 +100,10 @@ struct MetricsTests { FinderArrangementTests.run { expect($0, $1) } FinderTargetAcquisitionTests.run { expect($0, $1) } DisplayBrightnessShortcutTests.run { expect($0, $1) } + BrightnessModuleMigrationTests.run { expect($0, $1) } + FeatureLifecycleTests.run { expect($0, $1) } + ShelfImportStoreTests.run { expect($0, $1) } + CommandBarExecutorTests.run { expect($0, $1) } MediaPDFTests.run { expect($0, $1) } MediaPDFCompressionTests.run { expect($0, $1) } MediaPDFCompressionSelectionTests.run { expect($0, $1) } @@ -1225,7 +1229,8 @@ UninstallerSelectionTests.run { expect($0, $1) } contentsOfFile: "Sources/Vorssaint/App/FeatureRuntime.swift", encoding: .utf8)) ?? "" expect(featureRuntimeSource.contains( - ".mouseClickDebounce: { MouseClickDebounceService.shared.syncWithPreferences() }" + ".init(members: [.mouseClickDebounce], permissions: [.accessibility], synchronize:" + ) && featureRuntimeSource.contains("MouseClickDebounceService.shared.syncWithPreferences()" ), "the Features hub owns the click debounce runtime lifecycle") expect(ScrollWheelSupport.isMouseWheel( @@ -2198,10 +2203,6 @@ UninstallerSelectionTests.run { expect($0, $1) } "monitor memory metric is included in settings backups") expect(registeredDefaults[DefaultsKey.appearance] as? String == AppAppearance.system.rawValue, "the app follows the system appearance until the user picks a side") - expect(registeredDefaults[DefaultsKey.liquidGlassEnabled] as? Bool == false, - "liquid glass appearance is opt-in") - expect(SettingsBackupSupport.exportKeys().contains(DefaultsKey.liquidGlassEnabled), - "liquid glass appearance follows settings backups") expect(AppAppearance.sanitized(nil) == .system && AppAppearance.sanitized("nonsense") == .system, "an unknown stored appearance falls back to the system one") @@ -4090,7 +4091,7 @@ UninstallerSelectionTests.run { expect($0, $1) } "Keep Awake panel section is shown by default") expect(registeredDefaults[DefaultsKey.panelShowBrightness] as? Bool == true, "brightness panel section is shown by default once the feature is on") - expect(registeredDefaults[DefaultsKey.brightnessControlEnabled] as? Bool == false, + expect(registeredDefaults[DefaultsKey.brightnessControlEnabled] == nil, "brightness control arrives switched off") expect(registeredDefaults[DefaultsKey.brightnessKeysEnabled] as? Bool == false, "pointer-following brightness keys arrive switched off") @@ -13453,9 +13454,9 @@ UninstallerSelectionTests.run { expect($0, $1) } "availability key derives from the unit's raw value") expect(FeatureUnit.availabilityDefaults.count == FeatureUnit.allCases.count && FeatureUnit.allCases.allSatisfy { - (FeatureUnit.availabilityDefaults[$0.availabilityKey] as? Bool) == true + (FeatureUnit.availabilityDefaults[$0.availabilityKey] as? Bool) == ($0 != .brightness) }, - "every unit ships available, an opt-in member keeping its switch off instead") + "display control remains opt-in at module level; other modules preserve existing defaults") expect((Defaults.registeredDefaults[DefaultsKey.fanControlEnabled] as? Bool) == false && AppFeature.allCases.filter { $0 != .fanControl && $0.enabledKeys.isEmpty } .compactMap(\.switchKey) @@ -13971,13 +13972,11 @@ UninstallerSelectionTests.run { expect($0, $1) } "keep awake uses accessibility only with the mouse jiggle on") expect(!activeSet(.accessibility).contains(.keepAwake), "keep awake without jiggle does not use accessibility") - expect(activeSet(.accessibility, on: [DefaultsKey.brightnessControlEnabled, - DefaultsKey.brightnessKeysEnabled]).contains(.brightness), + expect(activeSet(.accessibility, on: [DefaultsKey.brightnessKeysEnabled]).contains(.brightness), "brightness uses accessibility only for the key option") - expect(activeSet(.accessibility, on: [DefaultsKey.brightnessControlEnabled, - DefaultsKey.brightnessOSDEnabled]).contains(.brightness), + expect(activeSet(.accessibility, on: [DefaultsKey.brightnessOSDEnabled]).contains(.brightness), "brightness uses accessibility for the adjustment overlay") - expect(!activeSet(.accessibility, on: [DefaultsKey.brightnessControlEnabled]) + expect(!activeSet(.accessibility) .contains(.brightness), "brightness sliders alone never use accessibility") expect(activeSet(.accessibility).contains(.screenRecorder), @@ -14347,8 +14346,8 @@ UninstallerSelectionTests.run { expect($0, $1) } "shortcut editor strings keep their format and avoid em-dashes (\(language.rawValue))") let appearanceValues = Mirror(reflecting: FeatureStrings.appearance(language)).children .compactMap { $0.value as? String } - expect(appearanceValues.count == 5 && appearanceValues.allSatisfy { !$0.isEmpty }, - "every appearance string is set for \(language.rawValue)") + expect(appearanceValues.count == 4 && appearanceValues.allSatisfy { !$0.isEmpty }, + "every appearance string is set for \(language.rawValue), found \(appearanceValues.count)") expect(appearanceValues.allSatisfy { !$0.contains("—") }, "no em-dash in visible appearance strings (\(language.rawValue))") let screenshotValues = Mirror(reflecting: FeatureStrings.screenshot(language)).children @@ -14773,13 +14772,13 @@ UninstallerSelectionTests.run { expect($0, $1) } "rejected writes mean no DDC reaches the display (HDMI conversion)") expect(BrightnessSupport.ddcProbeAttempts() == BrightnessSupport.retryAttempts + 1 - && BrightnessSupport.ddcProbeWriteCycles(classifyingChannel: true) == 1, - "channel discovery keeps its reply chances but sends one spaced write each") + && BrightnessSupport.ddcProbeWriteCycles(classifyingChannel: true) == 2, + "channel discovery repeats GET for displays requiring a second request") expect(BrightnessSupport.ddcProbeAttempts() == BrightnessSupport.retryAttempts + 1 && BrightnessSupport.ddcProbeWriteCycles(classifyingChannel: false) - == BrightnessSupport.writeCycles, - "answering channels retain their field-proven read and write retries") + == 2, + "brightness refresh repeats GET while retaining bounded retries") expect(!SettingsBackupSupport.exportKeys().contains( DefaultsKey.brightnessDDCWriteOnlyPaths), "per-monitor DDC capability never travels in a settings backup") @@ -16660,7 +16659,8 @@ UninstallerSelectionTests.run { expect($0, $1) } && recentCaptureServiceSource.contains( "hotkey.onPress = { [weak self] in self?.showHistoryWindow() }") && featureRuntimeSource.components(separatedBy: - "RecentCaptureService.shared.syncWithPreferences()").count == 3, + "RecentCaptureService.shared.syncWithPreferences()").count == 2 + && featureRuntimeSource.contains("members: [.screenshot, .screenRecorder]"), "the history shortcut opens its window and follows both capture producers") expect(!ScreenshotSupport.captureAvailabilityChanged( activeTools: [.screenshot, .recording], @@ -22482,8 +22482,8 @@ UninstallerSelectionTests.run { expect($0, $1) } .filter { !$0.trimmingCharacters(in: .whitespaces).hasPrefix("//") } .joined(separator: "\n") let monitorParts = (commandBarCode - .components(separatedBy: "private func installMonitors(for panel: NSPanel)") - .last ?? "").components(separatedBy: "\n private func ") + .components(separatedBy: "private func handleKeyDown(_ event: NSEvent, in panel: NSPanel)") + .last ?? "").components(separatedBy: "\n private ") let monitor = monitorParts.first ?? "" expect(monitorParts.count > 1 && monitor.contains("? event.charactersIgnoringModifiers") @@ -23501,12 +23501,14 @@ UninstallerSelectionTests.run { expect($0, $1) } let mouseTapAppDelegateSource = (try? String( contentsOfFile: "Sources/Vorssaint/App/AppDelegate.swift", encoding: .utf8)) ?? "" - expect(mouseTapAppDelegateSource.contains("MouseButtonShortcutService.shared.suspend()"), + expect(featureRuntimeSource.contains("MouseButtonShortcutService.shared.suspend()") + && mouseTapAppDelegateSource.contains("FeatureRuntime.shared.terminate()"), "normal termination releases mouse-button tap state instead of waiting for a future Up") let accessibilitySink = mouseTapAppDelegateSource .components(separatedBy: "Permissions.shared.$accessibility") .dropFirst().first?.components(separatedBy: "Permissions.shared.$screenRecording").first ?? "" - expect(accessibilitySink.contains(".quitWindowProtection"), + expect(accessibilitySink.contains("permissionChanged(.accessibility)") + && featureRuntimeSource.contains("members: [.quitWindowProtection], permissions: [.accessibility]"), "granting Accessibility starts quit protection without a relaunch") let smoothSchedulerSource = (try? String( contentsOfFile: "Sources/Vorssaint/Services/SmoothScrollService.swift", @@ -24886,7 +24888,7 @@ UninstallerSelectionTests.run { expect($0, $1) } $0.switchKey == nil && $0.pageSwitchKey == nil && $0.enabledKeys.count > 1 }, "multi-action groups expose concrete actions without an extra availability switch") - expect(AppFeature.brightness.pageSwitchKey == DefaultsKey.brightnessControlEnabled + expect(AppFeature.brightness.pageSwitchKey == nil && AppFeature.fanControl.pageSwitchKey == DefaultsKey.fanControlEnabled && AppFeature.monitorCPU.pageSwitchKey == nil && AppFeature.dockClick.pageSwitchKey == nil diff --git a/Tests/MonitorThemeRenderingTests.swift b/Tests/MonitorThemeRenderingTests.swift index 9262527..a294096 100644 --- a/Tests/MonitorThemeRenderingTests.swift +++ b/Tests/MonitorThemeRenderingTests.swift @@ -4,13 +4,13 @@ import AppKit enum MonitorThemeRenderingTests { @MainActor static func run(_ expect: (Bool, String) -> Void) { - func render(_ palette: ImportedTheme?, contrast: ColorSchemeContrast = .standard) -> Data? { + func render(contrast: ColorSchemeContrast = .standard) -> Data? { let view = VStack(spacing: 16) { - CPUCoreMatrix(usage: [0, 0.45, 1, nil], palette: palette, increasedContrast: contrast == .increased, + CPUCoreMatrix(usage: [0, 0.45, 1, nil], increasedContrast: contrast == .increased, coreGroups: [CPUCoreGroup(name: "CPU", indices: [0, 1, 2, 3])]) MonitorTrendPlot(samples: [.init(id: 0, time: 0, value: 15, segment: 0), .init(id: 1, time: 30, value: 55, segment: 0)], - now: 60, window: 1, ymax: 100, palette: palette) + now: 60, window: 1, ymax: 100) .frame(height: 90) }.padding(20).frame(width: 420).background(.white) .environment(\.colorScheme, .light) @@ -19,34 +19,9 @@ enum MonitorThemeRenderingTests { guard let image = renderer.cgImage else { return nil } return NSBitmapImageRep(cgImage: image).representation(using: .png, properties: [:]) } - let primary = ImportedTheme(name: "primary", colors: [.primaryText: ThemeRGBA(hex: "#008040")!]) - let border = ImportedTheme(name: "border", colors: [.border: ThemeRGBA(hex: "#D00080")!]) - let secondary = ImportedTheme(name: "secondary", colors: [.secondaryText: ThemeRGBA(hex: "#D00080")!]) - let unused = ImportedTheme(name: "accent only", colors: [.accent: ThemeRGBA(hex: "#008040")!]) - let suite = "kururu.theme-render-test." + UUID().uuidString - let defaults = UserDefaults(suiteName: suite)! - defer { defaults.removePersistentDomain(forName: suite) } - let preferences = ThemePreferences(defaults: defaults) - do { - try preferences.apply(primary) - let saved = defaults.data(forKey: ThemePreferences.storageKey) - _ = render(border) - expect(preferences.applied == primary && defaults.data(forKey: ThemePreferences.storageKey) == saved, - "rendering candidate preserves applied theme and persistent bytes") - try preferences.apply(border) - expect(preferences.applied == border, "explicit application accepts candidate") - preferences.restoreDefault() - expect(preferences.applied == nil && defaults.data(forKey: ThemePreferences.storageKey) == nil, - "restore returns to default and clears saved theme") - } catch { expect(false, "theme preference fixture failed: \(error)") } - let baseline = render(nil) + let baseline = render() expect(baseline != nil, "production monitor views render offscreen") - expect(render(nil) == baseline, "fixed samples render deterministically") - expect(render(unused) == baseline, "missing monitor roles preserve exact default output") - expect(render(primary) != baseline, "primary theme color changes real core and curve rendering") - expect(render(border) != baseline, "border theme color changes real core rendering") - expect(render(secondary) != baseline, "secondary theme color changes core group and missing label rendering") - expect(render(nil, contrast: .increased) != baseline, "increased contrast retains distinct core boundaries") - expect(render(primary) == render(primary), "explicit candidate rendering is repeatable") + expect(render() == baseline, "fixed samples render deterministically") + expect(render(contrast: .increased) != baseline, "increased contrast retains distinct core boundaries") } } diff --git a/Tests/SettingsBackupIdentityTests.swift b/Tests/SettingsBackupIdentityTests.swift index 2ffeea4..f950a2d 100644 --- a/Tests/SettingsBackupIdentityTests.swift +++ b/Tests/SettingsBackupIdentityTests.swift @@ -4,6 +4,25 @@ import Foundation enum SettingsBackupIdentityTests { static func run(_ expect: (Bool, String) -> Void) { + let retiredAppearance = ["liquidGlassEnabled", "importedInterfaceTheme"] + let oldAppearance: [String: Any] = [ + "liquidGlassEnabled": true, + "importedInterfaceTheme": Data("obsolete theme".utf8), + DefaultsKey.appearance: "dark" + ] + let appearancePayload = SettingsBackupSupport.payload(appVersion: "test") { oldAppearance[$0] } + let appearanceExport = appearancePayload[SettingsBackupSupport.settingsKey] as? [String: Any] + let appearanceImport = SettingsBackupSupport.sanitizedSettings(from: [ + SettingsBackupSupport.formatVersionKey: 1, + SettingsBackupSupport.settingsKey: oldAppearance + ]) + for key in retiredAppearance { + expect(Defaults.registeredDefaults[key] == nil, "retired appearance preference is not registered: \(key)") + expect(appearanceExport?[key] == nil, "retired appearance preference is not exported: \(key)") + expect(appearanceImport?[key] == nil, "retired appearance preference is ignored during import: \(key)") + } + expect(appearanceImport?[DefaultsKey.appearance] as? String == "dark", + "old theme bytes do not prevent importing the supported light/dark choice") let machine: [String: Any] = [ DefaultsKey.launchAtLoginWanted: true, DefaultsKey.bluetoothSleepRestorePending: true, diff --git a/Tests/ShelfImportStoreTests.swift b/Tests/ShelfImportStoreTests.swift new file mode 100644 index 0000000..6c460e4 --- /dev/null +++ b/Tests/ShelfImportStoreTests.swift @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import Foundation + +enum ShelfImportStoreTests { + static func run(_ expect: (Bool, String) -> Void) { + let fm = FileManager.default + let root = fm.temporaryDirectory.appendingPathComponent("kururu-import-store-\(UUID())") + defer { try? fm.removeItem(at: root) } + do { + try fm.createDirectory(at: root, withIntermediateDirectories: true) + let source = root.appendingPathComponent("source.json") + try Data("[]".utf8).write(to: source) + let destination = root.appendingPathComponent("store") + let store = ShelfImportStore(directory: destination) + let selected = [ShelfPersistedItem(id: UUID(), kind: .text, title: "Title", text: "Payload")] + let first = try store.prepare(selected: selected, current: [], mappings: [], sourceURL: source, managedRoots: []) + expect(!fm.fileExists(atPath: destination.appendingPathComponent("ShelfItems.json").path), "preparation never publishes live index") + store.discard(first) + expect(!fm.fileExists(atPath: first.stagingURL.path), "cancel discards owned staged data") + do { try store.commit(first); expect(false, "cancelled request cannot commit") } catch { expect(true, "cancelled request cannot commit") } + let pending = try store.prepare(selected: selected, current: [], mappings: [], sourceURL: source, managedRoots: []) + store.discardAll() + expect(!fm.fileExists(atPath: pending.stagingURL.path), "shutdown discards all prepared data") + let success = try store.prepare(selected: selected, current: [], mappings: [], sourceURL: source, managedRoots: []) + try store.commit(success) + let saved = try JSONDecoder().decode([ShelfPersistedItem].self, from: Data(contentsOf: destination.appendingPathComponent("ShelfItems.json"))) + expect(saved == success.items && saved[0].title == "Title", "commit preserves transaction contents and group metadata") + do { try store.commit(success); expect(false, "transaction is consumed once") } catch { expect(true, "transaction is consumed once") } + let changedSource = try store.prepare(selected: selected, current: saved, mappings: [], sourceURL: source, managedRoots: []) + try fm.removeItem(at: source) + try fm.linkItem(at: destination.appendingPathComponent("ShelfItems.json"), to: source) + do { try store.commit(changedSource); expect(false, "source becoming current index before commit rejects") } catch { expect(true, "source becoming current index before commit rejects") } + expect(!fm.fileExists(atPath: changedSource.stagingURL.path), "failed validation cleans prepared transaction") + let afterFailure = try JSONDecoder().decode([ShelfPersistedItem].self, from: Data(contentsOf: destination.appendingPathComponent("ShelfItems.json"))) + expect(afterFailure == saved, "failed validation leaves existing index intact") + try fm.removeItem(at: source) + try Data("[]".utf8).write(to: source) + let managed = root.appendingPathComponent("managed") + try fm.createDirectory(at: managed, withIntermediateDirectories: true) + let asset = managed.appendingPathComponent("owned.txt") + try Data("owned payload".utf8).write(to: asset) + let file = [ShelfPersistedItem(id: UUID(), kind: .file, title: "Attachment", path: "/legacy/owned.txt")] + do { + _ = try store.prepare(selected: file, current: saved, + mappings: [.init(originalDirectory: "/legacy", selectedDirectory: managed, copyFiles: false)], + sourceURL: source, managedRoots: [managed]) + expect(false, "managed attachment cannot be imported as external reference") + } catch ShelfImportServiceError.managedReference { expect(true, "managed attachment cannot be imported as external reference") } + let copied = try store.prepare(selected: file, current: saved, + mappings: [.init(originalDirectory: "/legacy", selectedDirectory: managed, copyFiles: true)], + sourceURL: source, managedRoots: [managed]) + try store.commit(copied) + let copiedURL = URL(fileURLWithPath: copied.items.last!.path!) + expect(try Data(contentsOf: copiedURL) == Data("owned payload".utf8), "explicit copy imports managed attachment into new owned payload") + expect(fm.fileExists(atPath: asset.path), "import preserves source attachment") + } catch { expect(false, "import store fixture: \(error)") } + } +} diff --git a/Tests/ThemeImportTests.swift b/Tests/ThemeImportTests.swift deleted file mode 100644 index 25a28c0..0000000 --- a/Tests/ThemeImportTests.swift +++ /dev/null @@ -1,169 +0,0 @@ -import Foundation - -enum ThemeImportTests { - static func run(_ expect: (Bool, String) -> Void) { - func parse(_ text: String) -> ImportedTheme? { try? ThemeImportSupport.parse(Data(text.utf8)) } - runLinks(expect) - let plain = ##"{"name":"Local","colors":{"editor.background":"#123","foreground":"#1234","descriptionForeground":"#11223344","focusBorder":"#abcdef","sideBar.background":"#456789","panel.border":"#AABBCC"}}"## - let theme = parse(plain) - expect(theme?.colors.count == 6, "six UI roles are imported") - expect(theme?.colors[.background]?.hex == "#112233FF", "short RGB expands with opaque alpha") - expect(theme?.colors[.primaryText]?.hex == "#11223344", "short RGBA expands all channels") - expect(theme?.colors[.secondaryText]?.alpha == 68.0 / 255, "eight-digit alpha is preserved") - expect(parse(##"{"colors":{"editor.background":"#123",},}//tail"##)?.colors[.background]?.hex == "#112233FF", "JSONC line comments and trailing commas") - expect(parse(##"{/* x */"colors":{"editor.background":"#123"/* y */},}"##) != nil, "JSONC block comments") - expect(parse(##"{"name":"https://example.test/*x*/","colors":{"foreground":"#fff"}}"##)?.name == "https://example.test/*x*/", "comment markers inside strings are preserved") - expect(parse(##"{"name":"a\"//b","colors":{"foreground":"#fff"}}"##)?.name == "a\"//b", "escaped string quote does not open a comment") - expect(parse(##"{"include":"/missing/never-read.json","colors":{"foreground":"#fff"}}"##) != nil, "include never loads files") - expect(parse(##"{"tokenColors":[{"settings":{"foreground":"#fff"}}]}"##) == nil, "syntax colors cannot become UI colors") - expect(parse(##"{"colors":{"editor.foreground":"#123","foreground":"#456"}}"##)?.colors[.primaryText]?.hex == "#112233FF", "specific UI role precedes fallback") - expect(parse(##"{"colors":{"foreground":"#fff","unused.role":"not a color"}}"##) != nil, "unknown roles are ignored") - for text in [ - ##"{'colors': {'foreground': '#fff'}}"##, - ##"{colors: {foreground: '#fff'}}"##, - ##"{"colors":{"foreground":"rgb(0,0,0)"}}"##, - ##"{"colors":{"foreground":"#ggg"}}"##, - ##"{"colors":{"foreground":"#12345"}}"##, - ##"{"colors":{"foreground":12}}"##, - ##"{"colors":{"foreground":null}}"##, - ##"{"colors":{"foreground":"#fff","editor.foreground":"bad"}}"##, - ##"{"colors":{"foreground":"#fff"}}/* never closed"##, - ##"{"colors":{"foreground":"#fff",,}}"##, - ##"{"colors":[]}"##, ##"[]"##, ##"{}"## - ] { expect(parse(text) == nil, "invalid theme rejected: \(text)") } - expect((try? ThemeImportSupport.parse(Data(repeating: 32, count: ThemeImportSupport.maximumBytes + 1))) == nil, "oversize input rejected") - expect((try? ThemeImportSupport.parse(Data([0xFF, 0xFE]))) == nil, "non-UTF8 input rejected") - if let theme { - let saved = try? ThemeImportSupport.savedData(theme) - expect(saved.flatMap { try? ThemeImportSupport.parse($0) } == theme, "normalized persistence round trips") - } - let directory = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) - .appendingPathComponent("build/theme-read-tests-\(UUID().uuidString)", isDirectory: true) - do { - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - let file = directory.appendingPathComponent("配色.jsonc") - try Data(plain.utf8).write(to: file) - expect((try? ThemeImportSupport.read(file)) == theme, "selected local file imports, including Unicode path") - try Data(repeating: 32, count: ThemeImportSupport.maximumBytes + 1).write(to: file) - do { - _ = try ThemeImportSupport.read(file) - expect(false, "oversized file throws") - } catch { - expect(error as? ThemeImportError == .tooLarge, "oversized file reports the size limit") - } - expect((try? ThemeImportSupport.read(directory)) == nil, "directories are not theme files") - expect((try? ThemeImportSupport.read(URL(string: "https://example.test/theme.json")!)) == nil, "remote URLs are never read") - } catch { expect(false, "file fixture setup succeeds: \(error)") } - let suite = "com.vorssaint.tests.theme.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suite)! - defer { defaults.removePersistentDomain(forName: suite) } - defaults.set("keep", forKey: "unrelated") - let preferences = ThemePreferences(defaults: defaults) - expect(preferences.applied == nil, "no saved theme means default") - if let theme { - do { try preferences.apply(theme) } catch { expect(false, "valid apply succeeds") } - expect(ThemePreferences(defaults: defaults).applied == theme, "applied theme survives a new preferences instance") - let before = defaults.data(forKey: ThemePreferences.storageKey) - _ = parse("invalid") - do { - try preferences.apply(ImportedTheme(name: "Broken", colors: [:])) - expect(false, "invalid apply throws") - } catch { expect(true, "invalid apply throws") } - expect(defaults.data(forKey: ThemePreferences.storageKey) == before && preferences.applied == theme, "invalid preview leaves active theme unchanged") - preferences.restoreDefault() - expect(preferences.applied == nil && defaults.object(forKey: ThemePreferences.storageKey) == nil, "restore removes only theme state") - expect(defaults.string(forKey: "unrelated") == "keep", "restore preserves other preferences") - } - let registrationBeforeBackup = UserDefaults.standard.volatileDomain(forName: UserDefaults.registrationDomain) - runBackup(expect) - let registrationAfterBackup = UserDefaults.standard.volatileDomain(forName: UserDefaults.registrationDomain) - expect(NSDictionary(dictionary: registrationBeforeBackup).isEqual(to: registrationAfterBackup), - "theme backup tests restore the shared registration domain exactly") - defaults.set(Data("broken".utf8), forKey: ThemePreferences.storageKey) - expect(ThemePreferences(defaults: defaults).applied == nil, "corrupt saved data falls back without crashing") - } - private static func runLinks(_ expect: (Bool, String) -> Void) { - let valid = "https://vscodethemes.com/e/teabyii.ayu/ayu-dark-bordered" - let link = try? ThemeLinkImportSupport.parse(" " + valid + "\n") - expect(link?.publisher == "teabyii" && link?.extensionName == "ayu" && link?.slug == "ayu-dark-bordered", - "theme page identifies the exact extension and variant") - expect(link?.downloadURL.host == "teabyii.gallery.vsassets.io", "theme download uses extension publisher asset host") - for invalid in ["", "http://vscodethemes.com/e/a.b/dark", "https://example.com/e/a.b/dark", - "https://vscodethemes.com.evil.test/e/a.b/dark", "https://user@vscodethemes.com/e/a.b/dark", - "https://vscodethemes.com:443/e/a.b/dark", "https://vscodethemes.com/e/a.b", - "https://vscodethemes.com/e/a.b/dark/extra", valid + "?x=1", valid + "#fragment", - "https://vscodethemes.com/e/a.b/../dark", "https://vscodethemes.com/e/a.b/a%2Fb"] { - expect((try? ThemeLinkImportSupport.parse(invalid)) == nil, "theme link rejects unsupported destination or path: \(invalid)") - } - func manifest(_ themes: [[String: String]]) -> Data { - try! JSONSerialization.data(withJSONObject: ["contributes": ["themes": themes]]) - } - let data = manifest([["label": "Ayu Dark", "path": "./themes/dark.json"], - ["label": "Ayu Dark Bordered", "path": "./themes/dark-bordered.json"]]) - let selected = try? ThemeLinkImportSupport.selection(manifest: data, slug: "ayu-dark-bordered") - expect(selected?.path == "extension/themes/dark-bordered.json" && selected?.name == "Ayu Dark Bordered", - "theme variant matches a whole label rather than first or path substring") - expect((try? ThemeLinkImportSupport.selection(manifest: data, slug: "dark")) == nil, - "theme selection rejects partial labels") - let ambiguous = manifest([["label": "Dark", "path": "a.json"], ["id": "dark", "path": "b.json"]]) - expect((try? ThemeLinkImportSupport.selection(manifest: ambiguous, slug: "dark")) == nil, - "ambiguous theme selection never silently picks first") - for path in ["/theme.json", "../theme.json", "themes/../../theme.json", "themes//a.json", - "themes/*.json", "themes/a?.json", "themes/[ab].json", "themes\\a.json"] { - expect((try? ThemeLinkImportSupport.selection(manifest: manifest([["label": "Dark", "path": path]]), slug: "dark")) == nil, - "theme archive member rejects traversal and extraction wildcards: \(path)") - } - } - - private static func runBackup(_ expect: (Bool, String) -> Void) { - let registration = UserDefaults.standard.volatileDomain(forName: UserDefaults.registrationDomain) - defer { UserDefaults.standard.setVolatileDomain(registration, forName: UserDefaults.registrationDomain) } - let key = ThemePreferences.storageKey - let suite = "com.vorssaint.tests.theme-backup.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suite)! - defer { defaults.removePersistentDomain(forName: suite) } - defaults.register(defaults: Defaults.registeredDefaults) - defaults.set("unchanged", forKey: "unrelated") - func envelope(_ value: Any?) -> [String: Any] { - [SettingsBackupSupport.formatVersionKey: SettingsBackupSupport.formatVersion, - SettingsBackupSupport.settingsKey: value.map { [key: $0] } ?? [:]] - } - do { - let theme = try ThemeImportSupport.parse(Data(##"{"name":"Portable","colors":{"editor.background":"#234","foreground":"#fff"}}"##.utf8)) - try ThemePreferences(defaults: defaults).apply(theme) - expect(Defaults.registeredDefaults[key] as? Data == Data(), "theme registers an explicit built-in default") - let payload = SettingsBackupSupport.payload(appVersion: "theme-test") { defaults.object(forKey: $0) } - let encoded = try PropertyListSerialization.data(fromPropertyList: payload, format: .xml, options: 0) - let decoded = try PropertyListSerialization.propertyList(from: encoded, options: [], format: nil) as! [String: Any] - let sanitized = SettingsBackupSupport.sanitizedSettings(from: decoded) - expect(sanitized?[key] as? Data != nil, "a real plist backup includes imported colors") - defaults.removeObject(forKey: key) - if let sanitized { SettingsBackupSupport.replaceExportedSettings(sanitized, in: defaults) } - expect(ThemePreferences(defaults: defaults).applied == theme, "backup restores the applied theme through the production replacement seam") - let before = defaults.persistentDomain(forName: suite)! as NSDictionary - for invalid: Any in ["not data", 42, Data("broken".utf8), Data(repeating: 32, count: ThemeImportSupport.maximumBytes + 1)] { - let settings = SettingsBackupSupport.sanitizedSettings(from: envelope(invalid)) - expect(settings == nil, "malformed or oversized theme rejects the backup before clearing settings") - if let settings { SettingsBackupSupport.replaceExportedSettings(settings, in: defaults) } - expect((defaults.persistentDomain(forName: suite)! as NSDictionary).isEqual(to: before as! [AnyHashable: Any]), "failed restore leaves all existing preferences unchanged") - let export = SettingsBackupSupport.payload(appVersion: "test") { $0 == key ? invalid : nil } - expect((export[SettingsBackupSupport.settingsKey] as? [String: Any])?[key] == nil, - "invalid local theme cache is not exported") - } - let old = SettingsBackupSupport.sanitizedSettings(from: envelope(nil)) - expect(old != nil && old?[key] == nil, "older backups without a theme remain valid") - if let old { SettingsBackupSupport.replaceExportedSettings(old, in: defaults) } - expect(ThemePreferences(defaults: defaults).applied == nil, "an older backup restores built-in colors") - expect(defaults.string(forKey: "unrelated") == "unchanged", "theme backup restore preserves non-exported state") - let defaultBackup = SettingsBackupSupport.sanitizedSettings(from: envelope(Data())) - expect(defaultBackup?[key] as? Data == Data(), "explicit built-in default is a valid backup value") - let extra = Data(##"{"include":"/private/theme.json","tokenColors":[{"scope":"secret"}],"colors":{"foreground":"#abc"}}"##.utf8) - let portable = SettingsBackupSupport.sanitizedSettings(from: envelope(extra))?[key] as? Data - let normalized = portable.flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } - expect(normalized?["include"] == nil && normalized?["tokenColors"] == nil && portable != nil, - "incoming themes retain only normalized portable colors") - } catch { expect(false, "theme backup fixture succeeds: \(error)") } - } - -} diff --git a/Tools/InspectEventTaps.swift b/Tools/InspectEventTaps.swift new file mode 100644 index 0000000..f3f08fc --- /dev/null +++ b/Tools/InspectEventTaps.swift @@ -0,0 +1,18 @@ +import CoreGraphics +import Foundation + +// Read-only snapshot. Pass the app PID to avoid collecting other apps' data. +guard CommandLine.arguments.count == 2, let pid = Int32(CommandLine.arguments[1]) else { + fputs("Usage: InspectEventTaps \n", stderr) + exit(2) +} +var count: UInt32 = 0 +guard CGGetEventTapList(0, nil, &count) == .success else { exit(1) } +var taps = [CGEventTapInformation](repeating: CGEventTapInformation(), count: Int(count) + 32) +let capacity = UInt32(taps.count) +guard CGGetEventTapList(capacity, &taps, &count) == .success else { exit(1) } +let owned = taps.prefix(min(Int(count), taps.count)).filter { $0.tappingProcess == pid } +print("PID \(pid): \(owned.count) registered taps, \(owned.filter(\.enabled).count) enabled") +for tap in owned { + print("tap=\(tap.eventTapID) enabled=\(tap.enabled) point=\(tap.tapPoint.rawValue) mask=\(tap.eventsOfInterest) average_us=\(tap.avgUsecLatency) max_us=\(tap.maxUsecLatency)") +} diff --git a/build.sh b/build.sh index e08f40b..c7b7872 100755 --- a/build.sh +++ b/build.sh @@ -275,12 +275,9 @@ if (( TEST )); then Sources/Vorssaint/Services/PermissionResetSupport.swift \ Sources/Vorssaint/Services/RadialMenu/RadialMenuProfileDeletion.swift \ Tests/SettingsActionTests.swift \ - Sources/Vorssaint/Core/ThemeImportSupport.swift \ - Sources/Vorssaint/Core/ThemeLinkImportSupport.swift \ Sources/Vorssaint/Core/JSONPreviewFormatter.swift \ Sources/Vorssaint/Core/URLAutomaticCleaning.swift \ Sources/Vorssaint/Core/URLRuleImportSupport.swift \ - Sources/Vorssaint/Services/ThemePreferences.swift \ Tests/ShelfDockPlacementTests.swift \ Tests/ShelfDockVisibilityTests.swift \ Tests/ShelfIndexStoreTests.swift \ @@ -295,7 +292,6 @@ if (( TEST )); then Tests/ClipboardImportTransactionTests.swift \ Tests/ScratchpadImportStoreTests.swift \ Tests/ShelfDockBackupTests.swift \ - Tests/ThemeImportTests.swift \ Tests/ClipboardJSONPreviewTests.swift \ Tests/URLAutomaticCleaningTests.swift \ Tests/URLRuleImportTests.swift \ @@ -328,6 +324,13 @@ if (( TEST )); then Tests/FinderArrangementTests.swift \ Tests/FinderTargetAcquisitionTests.swift \ Tests/DisplayBrightnessShortcutTests.swift \ + Tests/BrightnessModuleMigrationTests.swift \ + Tests/FeatureLifecycleTests.swift \ + Tests/ShelfImportStoreTests.swift \ + Tests/CommandBarExecutorTests.swift \ + Sources/Vorssaint/Services/Shelf/ShelfImportStore.swift \ + Sources/Vorssaint/Services/CommandBar/CommandBarExecutor.swift \ + Sources/Vorssaint/App/FeatureLifecycle.swift \ Sources/Vorssaint/Core/CommandBarStrings.swift \ Sources/Vorssaint/Core/FeedbackStrings.swift \ Sources/Vorssaint/Core/FeedbackDraftSupport.swift \ @@ -509,6 +512,7 @@ if (( TEST )); then Sources/Vorssaint/Services/Cleaner/CleanerPackageCaches.swift \ Tests/CleanerPackageCacheTests.swift \ Tests/BrightnessNativeBoundaryTests.swift \ + Tests/BrightnessPipelineTests.swift \ Sources/Vorssaint/Core/OnboardingFeatureSelection.swift \ Sources/Vorssaint/Core/OnboardingFeatureStrings.swift \ Tests/OnboardingFeatureSelectionTests.swift \ @@ -544,6 +548,31 @@ if (( TEST )); then # `set -e` would end the script on a failing run before the sweep below. test_status=0 ./build/metrics-tests || test_status=$? + if python3 Tests/BrightnessServiceContract.py \ + Sources/Vorssaint/Services/Display/BrightnessService.swift \ + build/brightness-contract/main.swift && \ + swiftc -target "$TARGET" -sdk "$SDK" \ + Sources/Vorssaint/Services/Display/BrightnessSupport.swift \ + build/brightness-contract/main.swift -o build/brightness-contract-tests; then + ./build/brightness-contract-tests || test_status=1 + else + test_status=1 + fi + if swiftc Sources/Vorssaint/Services/BoundedProcessRunner.swift \ + Tests/BoundedProcessCancellationTests.swift -o build/process-cancellation-tests; then + ./build/process-cancellation-tests || test_status=1 + else + test_status=1 + fi + if swiftc Sources/Vorssaint/Services/Update/UpdateServiceSupport.swift \ + Sources/Vorssaint/Services/Homebrew/HomebrewSupport.swift \ + Sources/Vorssaint/Services/Environment/EnvironmentUpdateSupport.swift \ + Sources/Vorssaint/Services/Homebrew/HomebrewEnvironmentCheckSettlement.swift \ + Tests/HomebrewEnvironmentCheckSettlementTests.swift -o build/homebrew-settlement-tests; then + ./build/homebrew-settlement-tests || test_status=1 + else + test_status=1 + fi if swiftc -target "$TARGET" -sdk "$SDK" \ Sources/Vorssaint/UI/PlainTextEditor.swift \ Tests/PlainTextEditorLifecycleTests.swift Tests/PlainTextEditorLifecycleMain.swift \ diff --git a/docs/architecture/backend.md b/docs/architecture/backend.md new file mode 100644 index 0000000..b527340 --- /dev/null +++ b/docs/architecture/backend.md @@ -0,0 +1,75 @@ +# Backend responsibilities + +The backend runs inside the macOS app. Existing services remain compiled into +the application; disabling a feature stops its work, but does not unload code +or provide process isolation. + +## Lifecycle and task ownership + +`FeatureRuntime` registers each resource owner with `FeatureLifecycle`, including +its feature members, permission dependencies, synchronization and termination +hooks. Shared capture and monitoring resources are registered with their owners +so a preference change synchronizes each registration once. Business behavior +remains in the individual services. + +Environment inspection and Homebrew environment snapshots propagate cancellation +to bounded subprocesses. Generation checks reject stale results. Snapshot +completion settles once, including when cancellation races with a queued result. +These read-only tasks remain separate from user-initiated package mutations. + +## Presentation, execution and storage + +- `CommandBarPresentation` owns the panel, focus and input monitors. +- `CommandBarService` retains discovery, search and ranking. +- `CommandBarExecutor` captures action input and guards asynchronous destination + results against a replaced or dismissed presentation. +- `ShelfImportStore` owns staged imports, security-scoped access, reference + validation and transaction disposal. `ShelfService` retains live revisions + and publication. Existing atomic persistence and rollback remain in use. + +These boundaries allow execution and storage contracts to be tested without +constructing the entire user interface. They are not an external plugin API. +Views still access some singletons and preferences directly; the repository has +not completed a uniform dependency-injection migration. + +## Display brightness + +Display state distinguishes observed brightness, requested brightness, +confirmation status and failure. Accepted writes are read back before being +confirmed. Identity, route and request-version checks prevent stale results from +updating a replacement display or a newer request. + +Built-in displays use the existing system brightness interface. External DDC +displays use serialized, paced requests with bounded retries and strict response +validation. A NULL reply carries no brightness, even if trailing buffer bytes +resemble an earlier value. Read failures must not create a measured midpoint. + +Each GET attempt sends the request twice, with 10 ms before each write and +50 ms before reading. This matches the default repeat count in +[MonitorControl](https://github.com/MonitorControl/MonitorControl/blob/5ce1a252ad1bde8248d8e4bb69645b28ca4da3e5/MonitorControl/Support/Arm64DDC.swift#L96-L118) +and [m1ddc](https://github.com/waydabber/m1ddc/blob/04d949794102eb8df01ad3681afff6464a3eede2/sources/i2c.m). +On the tested Mi Monitor, a single request consistently returned NULL while two +returned a valid luminance response. The firmware's internal reason is unknown. + +Panel and settings controls share the same state. Visible views request periodic +refresh; in-flight work prevents overlapping refresh. The retired page-level +brightness gate is consumed during preference and backup migration, preserving +previously disabled configurations. + +## Verification and limits + +The September 2026 local validation covered lifecycle ownership, process +cancellation, exactly-once completion, command execution, shelf import and +brightness response/state contracts. Full application builds and storage +selftests passed. Hardware checks on the current Mi Monitor connection produced +100 valid reads at 80/100 and 40 matching readbacks after alternating targets +79/80/81/80, then restored 80. A separate 30-sample interval run passed; another +run recorded one rejected checksum failure out of 30, with cause undetermined. +Application controls and built-in brightness adjustment/restoration were also +checked directly. + +This does not establish compatibility with every display, adapter, sleep/wake +sequence or multi-display topology. CPU, memory and energy improvements have not +been measured under controlled before/after conditions. Audio, capture, +monitoring and input-distribution engines retain their existing architecture; +further restructuring should follow concrete failures or measured bottlenecks.