fix(uninstaller): 修复应用退出后运行状态未刷新

This commit is contained in:
ysicing committed 2026-09-29 23:08:40 +08:00
1 parent 578c7c7b64
commit f559af9d1d
3 files changed
+107 -7

No files matched your search

@@ -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()
@@ -21,14 +21,13 @@ public final class UninstallerController {
var chosen: Set<String> = []
/// 有应用启动或退出时变化,让“正在运行”的提示跟着刷新
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() {
@@ -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 <AppKit/AppKit.h>
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
}