diff --git a/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift b/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift index b495251..cea98ed 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift @@ -209,6 +209,7 @@ public final class AppController: NSObject, NSApplicationDelegate { } public func applicationWillTerminate(_ notification: Notification) { + Log.app.notice("XStats 自身即将退出,PID \(ProcessInfo.processInfo.processIdentifier)") rest.prepareForTermination() widgetTimer?.invalidate() calendarMenuBar.stop() diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/UninstallerController.swift b/Packages/XStatsKit/Sources/XStatsUI/State/UninstallerController.swift index 1795549..aee6a45 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/UninstallerController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/UninstallerController.swift @@ -21,14 +21,13 @@ public final class UninstallerController { var chosen: Set = [] /// 有应用启动或退出时变化,让“正在运行”的提示跟着刷新 private var runningRevision = 0 - @ObservationIgnored private var observers: [NSObjectProtocol] = [] + @ObservationIgnored private var runningApplicationsObservation: NSKeyValueObservation? public init() { - let center = NSWorkspace.shared.notificationCenter - for name in [NSWorkspace.didLaunchApplicationNotification, NSWorkspace.didTerminateApplicationNotification] { - observers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in - MainActor.assumeIsolated { self?.runningRevision += 1 } - }) + // LSUIElement 应用(如 MacTools)不会发送普通应用的启动/退出通知。 + // 观察运行列表才能覆盖这些应用;异步回到主线程,等 AppKit 更新列表后再通知视图。 + runningApplicationsObservation = NSWorkspace.shared.observe(\.runningApplications) { [weak self] _, _ in + Task { @MainActor [weak self] in self?.runningRevision += 1 } } } @@ -95,7 +94,10 @@ public final class UninstallerController { } func quit(_ app: InstalledApp) { - NSRunningApplication.runningApplications(withBundleIdentifier: app.bundleIdentifier).forEach { $0.terminate() } + for running in NSRunningApplication.runningApplications(withBundleIdentifier: app.bundleIdentifier) { + let accepted = running.terminate() + Log.app.notice("卸载前请求退出应用 \(app.name, privacy: .public),包名 \(app.bundleIdentifier, privacy: .public),PID \(running.processIdentifier),请求已发送:\(accepted)") + } } func requestUninstall() { diff --git a/Packages/XStatsKit/Tests/XStatsUITests/UninstallerRunningApplicationTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/UninstallerRunningApplicationTests.swift new file mode 100644 index 0000000..a67593f --- /dev/null +++ b/Packages/XStatsKit/Tests/XStatsUITests/UninstallerRunningApplicationTests.swift @@ -0,0 +1,97 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + +import AppKit +import Cleaner +import Observation +import Testing +@testable import XStatsUI + +@MainActor +struct UninstallerRunningApplicationTests { + @Test(.serialized, arguments: [true, false]) + func quittingTargetRefreshesRunningStateAndKeepsWindow(accessory: Bool) async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("UninstallerQuit-\(UUID())") + defer { try? FileManager.default.removeItem(at: root) } + let identifier = "test.xstats.uninstaller.\(UUID().uuidString)" + let url = try await Task.detached { + try Self.makeTestApplication(in: root, identifier: identifier, accessory: accessory) + }.value + let controller = UninstallerController() + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = false + configuration.createsNewApplicationInstance = true + let running = try await NSWorkspace.shared.openApplication(at: url, configuration: configuration) + defer { if !running.isTerminated { running.forceTerminate() } } + let target = InstalledApp(url: url, name: "UninstallerQuitTest", bundleIdentifier: identifier, + version: nil, teamIdentifier: nil) + controller.select(target) + // openApplication 返回与运行列表更新不是同一个时刻。 + let launchDeadline = ContinuousClock.now + .seconds(5) + while ContinuousClock.now < launchDeadline, !controller.isRunning(target) { + try await Task.sleep(for: .milliseconds(20)) + } + try #require(controller.isRunning(target)) + + // 保留一个测试窗口,确保退出的只是临时目标应用;不触碰用户的应用或文件。 + let window = NSWindow(contentRect: NSRect(x: -10_000, y: -10_000, width: 100, height: 100), + styleMask: .borderless, backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.orderFrontRegardless() + defer { window.close() } + // 等启动时排队的状态刷新完成,再模拟 SwiftUI 对运行状态的依赖。 + try await Task.sleep(for: .milliseconds(100)) + let change = RunningStateChange() + withObservationTracking { + _ = controller.isRunning(target) + } onChange: { + Task { @MainActor in change.received = true } + } + + controller.quit(target) + let deadline = ContinuousClock.now + .seconds(5) + while ContinuousClock.now < deadline, !running.isTerminated || !change.received { + try await Task.sleep(for: .milliseconds(20)) + } + #expect(running.isTerminated) + #expect(change.received, "目标退出后必须通知卸载页刷新,而不只是下次读取时返回新值") + #expect(!controller.isRunning(target)) + #expect(controller.selected == target) + #expect(window.isVisible) + } + + /// 使用真正的 AppKit 应用覆盖 LSUIElement 的通知差异;编译在后台执行,不使用输出管道。 + nonisolated private static func makeTestApplication(in root: URL, identifier: String, accessory: Bool) throws -> URL { + let app = root.appendingPathComponent("Target.app") + let contents = app.appendingPathComponent("Contents") + let executable = contents.appendingPathComponent("MacOS/Target") + try FileManager.default.createDirectory(at: executable.deletingLastPathComponent(), withIntermediateDirectories: true) + let info: [String: Any] = ["CFBundleIdentifier": identifier, "CFBundleExecutable": "Target", + "CFBundlePackageType": "APPL", "LSUIElement": accessory] + try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0) + .write(to: contents.appendingPathComponent("Info.plist")) + let source = root.appendingPathComponent("main.m") + try """ + #import + int main(void) { + @autoreleasepool { [[NSApplication sharedApplication] run]; } + return 0; + } + """.write(to: source, atomically: true, encoding: .utf8) + let compiler = Process() + compiler.executableURL = URL(fileURLWithPath: "/usr/bin/clang") + compiler.arguments = [source.path, "-framework", "AppKit", "-o", executable.path] + compiler.standardInput = FileHandle.nullDevice + compiler.standardOutput = FileHandle.nullDevice + compiler.standardError = FileHandle.standardError + try compiler.run() + compiler.waitUntilExit() + try #require(compiler.terminationStatus == 0) + return app + } +} + +@MainActor +private final class RunningStateChange { + var received = false +}