fix(ng2): AppKit-owned workspace window opens at launch

三重根因,逐一实测排除:①MenuBarExtra 菜单内容视图首次点开才挂载,
launch intent 挂在内容上等于从不执行(此前 legacy 导入自动开窗同样从未
生效),改挂到启动即挂载的标签视图;②SwiftUI openWindow 在 MenuBarExtra
上下文实测不创建场景窗口,且 LSUIElement 应用无场景启动呈现机制——主窗
口改为 AppKit 直控 NSWindow(NSHostingView 承载主 workspace),关闭仅
orderOut 复用,开窗不依赖场景时序;③resyncOnLaunch 的 launchd 往返
await 挡在开窗前,改为先开窗后 resync。装配抽成 AppComposition(init
体量回到 lint 基线)。真机验证:启动即出现主窗口,进程前台形态,
SwiftLint/全量测试通过。
This commit is contained in:
Qiu Yuzhou
2026-09-24 23:51:48 +08:00
parent a0137651fa
commit ad74b23cb6
4 changed files with 188 additions and 87 deletions
+25 -3
View File
@@ -1,8 +1,8 @@
import AppKit
/// 混合应用形态的 AppKit 生命周期家:LSUIElement 让进程默认纯后台
/// (accessory),主 workspace 可见期间由开窗 adapter 切成普通应用
/// (regular);这里负责最后一个窗口关闭后切回纯后台。
/// (accessory),这里负责前台形态的进入与退出——任意路径使主窗口可见
/// 时切成普通应用(regular),最后一个窗口关闭后切回纯后台。
final class AppDelegate: NSObject, NSApplicationDelegate {
/// 混合形态的硬性要求:关窗不退进程,代理 GUI 仍驻留菜单栏。
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
@@ -10,9 +10,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
func applicationDidFinishLaunching(_ notification: Notification) {
NotificationCenter.default.addObserver(
// 启动即进入前台形态(CONTEXT.md「每次启动呈现主窗口」不变量):LSUIElement
// 应用里 SwiftUI 不做启动呈现(defaultLaunchBehavior 只有 suppressed 一档
// 增量),而菜单 extra 内容视图首次点开才挂载、launch intent 挂不上;切到
// regular 后交给普通应用的默认行为——启动呈现 Window scene。
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
let center = NotificationCenter.default
center.addObserver(
self, selector: #selector(windowWillClose(_:)),
name: NSWindow.willCloseNotification, object: nil)
center.addObserver(
self, selector: #selector(appOcclusionDidChange(_:)),
name: NSApplication.didChangeOcclusionStateNotification, object: nil)
}
/// 关窗回纯后台后再经菜单 ⑦ / Dock reopen 开窗的入场:任意路径让主窗口
/// 可见都进前台形态。只进不出:窗口被完全遮挡或 Cmd+H 也会短暂失去
/// .visible,但那只意味着「不该退」,退场由 willClose 普查负责;多切一次
/// .regular 是幂等的。
@objc private func appOcclusionDidChange(_ notification: Notification) {
let hasVisibleWindow = NSApp.windows.contains { $0.isVisible && $0.canBecomeMain }
guard hasVisibleWindow, NSApp.activationPolicy() != .regular else { return }
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
}
/// 前台形态下窗口被最小化时,Dock 点击只激活应用不会自动还原(SwiftUI
+130 -55
View File
@@ -12,11 +12,86 @@ struct ShadowsocksXNG2App: App {
@StateObject private var workspaceRoute: WorkspaceRoute
private let textClipboard: any TextClipboard
private let diagnosticReportExporter: any DiagnosticReportExporter
private let windowOpening: WorkspaceWindowOpeningAdapter
init() {
let composition = AppComposition.make()
_proxyController = StateObject(wrappedValue: composition.controller)
_catalogWorkflow = StateObject(wrappedValue: composition.catalogWorkflow)
_proxyControl = StateObject(wrappedValue: composition.proxyControl)
_loginController = StateObject(wrappedValue: composition.loginController)
_settingsWorkflow = StateObject(wrappedValue: composition.settingsWorkflow)
_diagnosticsWorkflow = StateObject(wrappedValue: composition.diagnosticsWorkflow)
_workspaceRoute = StateObject(wrappedValue: composition.workspaceRoute)
textClipboard = composition.textClipboard
diagnosticReportExporter = composition.diagnosticReportExporter
windowOpening = composition.windowOpening
// GUI 事件接入内存环形缓冲(spec #21 D5,issue #34):主窗口日志查看器与
// 诊断导出的来源;wrapper 侧不注册,仍走 stderr → agent.log 收敛。
RuntimeLog.setSink(RuntimeEventStore.shared)
}
var body: some Scene {
MenuBarExtra {
ProxyStatusMenu(
control: proxyControl,
catalogWorkflow: catalogWorkflow,
route: workspaceRoute,
clipboard: textClipboard,
windowOpening: windowOpening)
} label: {
StatusMenuLabel(
control: proxyControl,
route: workspaceRoute,
windowOpening: windowOpening)
}
.menuBarExtraStyle(.menu)
}
}
/// 状态菜单的标签视图(状态栏图标)。挂载时机与菜单内容视图不同:内容视图
/// 首次点开才创建,而标签在启动时即挂载——所以启动策略(resync + launch
/// intent,含开窗)只能挂在这里,挂在菜单内容上等于从不执行。
private struct StatusMenuLabel: View {
@ObservedObject var control: ProxyControlWorkflow
@ObservedObject var route: WorkspaceRoute
let windowOpening: any WorkspaceWindowOpening
var body: some View {
Image(systemName: "network")
.accessibilityLabel("ShadowsocksX-NG 2.0")
.task {
guard !ApplicationDependencies.isUnitTesting else { return }
// 先开窗再 resync:resync 可能 await launchd 往返,不能挡在开窗前面。
route.handle(.launch, using: windowOpening)
await control.resyncOnLaunch()
}
}
}
/// 组合根装配:目录提交协调器与全部生产运行时适配器只在此接线一次,各 scene
/// 不再重复设置提交回调;目录工作流的实现依赖经 CatalogWorkflowDependencies
/// 显式装配(workflow 不自行创建生产默认实现);Legacy 导入后的 2.0 运行时
/// 边界同为一次性注入。主窗口由 AppKit 直控(见 WorkspaceWindowOpeningAdapter
/// 文档);MainWindowView 值廉价且只捕获工作流对象,其 StateObject 状态与
/// onAppear 副作用在视图首次进入窗口时才发生。
@MainActor
private struct AppComposition {
let controller: ProxyRuntimeController
let catalogWorkflow: CatalogWorkflow
let proxyControl: ProxyControlWorkflow
let loginController: LaunchAtLoginController
let settingsWorkflow: SettingsWorkflow
let diagnosticsWorkflow: DiagnosticsWorkflow
let workspaceRoute: WorkspaceRoute
let textClipboard: any TextClipboard
let diagnosticReportExporter: any DiagnosticReportExporter
let windowOpening: WorkspaceWindowOpeningAdapter
static func make() -> AppComposition {
let dependencies = ApplicationDependencies.make()
textClipboard = dependencies.textClipboard
diagnosticReportExporter = dependencies.diagnosticReportExporter
let textClipboard = dependencies.textClipboard
let diagnosticReportExporter = dependencies.diagnosticReportExporter
// The catalog document is read exactly once at startup. The coordinator
// publishes later committed snapshots; the controller receives read-only
// access to this same in-process source.
@@ -31,18 +106,63 @@ struct ShadowsocksXNG2App: App {
settingsStore: dependencies.settingsStore,
settingsRestore: dependencies.settingsRestore,
agent: dependencies.launchAgent)
_proxyController = StateObject(wrappedValue: controller)
let loginController = LaunchAtLoginController(service: dependencies.loginService)
_loginController = StateObject(wrappedValue: loginController)
// 组合根(issue #40/#41/#49):目录提交协调器与全部生产运行时适配器只在
// 此接线一次,各 scene 不再重复设置提交回调;目录工作流的实现依赖经
// CatalogWorkflowDependencies 显式装配(workflow 不自行创建生产默认实现);
// Legacy 导入后的 2.0 运行时边界同为一次性注入。
let catalogWorkflow = makeCatalogWorkflow(
dependencies: dependencies,
controller: controller,
bootstrap: catalogBootstrap)
// 代理控制工作流 module(issue #47):状态菜单等 UI 表面的唯一代理控制
// seam,组合根接线一次。生产 runtime adapter 包装既有控制器(不复制运行
// 时语义);目录目标事实经窄缝从目录工作流读取。
let proxyControl = ProxyControlWorkflow(
runtime: ControllerProxyRuntimeAdapter(controller: controller),
targetFacts: catalogWorkflow)
// 设置工作流 module(Candidate 02):设置窗口的唯一 seam,组合根接线一次;
// 写入侧经窄缝 SettingsCommitting(issue #44),运行时控制器薄扩展即生产实现。
let settingsWorkflow = SettingsWorkflow(committing: controller)
// 诊断工作流 module(issue #43):诊断区唯一 seam,组合根接线一次;共享
// 实例供诊断侧栏与详情共同使用。
let diagnosticsWorkflow = DiagnosticsWorkflow(
runtimeFacts: controller,
catalogFacts: { catalogWorkflow.diagnosticCatalogFacts })
let workspaceRoute = WorkspaceRoute()
let workspaceContent = MainWindowView(
route: workspaceRoute,
workflow: catalogWorkflow,
control: proxyControl,
proxyController: controller,
diagnostics: diagnosticsWorkflow,
settingsWorkflow: settingsWorkflow,
loginController: loginController,
clipboard: textClipboard,
diagnosticReportExporter: diagnosticReportExporter)
let windowOpening = WorkspaceWindowOpeningAdapter(
makeContentView: { NSHostingView(rootView: workspaceContent) })
return AppComposition(
controller: controller,
catalogWorkflow: catalogWorkflow,
proxyControl: proxyControl,
loginController: loginController,
settingsWorkflow: settingsWorkflow,
diagnosticsWorkflow: diagnosticsWorkflow,
workspaceRoute: workspaceRoute,
textClipboard: textClipboard,
diagnosticReportExporter: diagnosticReportExporter,
windowOpening: windowOpening)
}
/// 目录工作流接线(issue #40/#41/#49):提交协调器与生产运行时适配器在此
/// 一次性装配;Legacy 导入后的 2.0 运行时边界同为一次性注入。
private static func makeCatalogWorkflow(
dependencies: ApplicationDependencies,
controller: ProxyRuntimeController,
bootstrap: CatalogCommitBootstrap
) -> CatalogWorkflow {
let coordinator = CatalogCommitCoordinator(
fileStore: dependencies.catalogFileStore,
runtime: ProxyRuntimeSyncAdapter(controller: controller),
bootstrap: catalogBootstrap)
let catalogWorkflow = CatalogWorkflow(
bootstrap: bootstrap)
return CatalogWorkflow(
dependencies: CatalogWorkflowDependencies(
coordinator: coordinator,
credentials: dependencies.credentials,
@@ -53,51 +173,6 @@ struct ShadowsocksXNG2App: App {
await controller.legacyImportDidCommit()
},
activator: controller))
_catalogWorkflow = StateObject(wrappedValue: catalogWorkflow)
// 代理控制工作流 module(issue #47):状态菜单等 UI 表面的唯一代理控制
// seam,组合根接线一次。生产 runtime adapter 包装既有控制器(不复制运行
// 时语义);目录目标事实经窄缝从目录工作流读取。
_proxyControl = StateObject(
wrappedValue: ProxyControlWorkflow(
runtime: ControllerProxyRuntimeAdapter(controller: controller),
targetFacts: catalogWorkflow))
// 设置工作流 module(Candidate 02):设置窗口的唯一 seam,组合根接线一次;
// 写入侧经窄缝 SettingsCommitting(issue #44),运行时控制器薄扩展即生产实现。
_settingsWorkflow = StateObject(wrappedValue: SettingsWorkflow(committing: controller))
// 诊断工作流 module(issue #43):诊断区唯一 seam,组合根接线一次;共享
// 实例供诊断侧栏与详情共同使用。
_diagnosticsWorkflow = StateObject(
wrappedValue: DiagnosticsWorkflow(
runtimeFacts: controller,
catalogFacts: { catalogWorkflow.diagnosticCatalogFacts }))
_workspaceRoute = StateObject(wrappedValue: WorkspaceRoute())
// GUI 事件接入内存环形缓冲(spec #21 D5,issue #34):主窗口日志查看器与
// 诊断导出的来源;wrapper 侧不注册,仍走 stderr → agent.log 收敛。
RuntimeLog.setSink(RuntimeEventStore.shared)
}
var body: some Scene {
MenuBarExtra("ShadowsocksX-NG 2.0", systemImage: "network") {
ProxyStatusMenu(
control: proxyControl,
catalogWorkflow: catalogWorkflow,
route: workspaceRoute,
clipboard: textClipboard)
}
.menuBarExtraStyle(.menu)
Window("ShadowsocksX-NG 2.0", id: WorkspaceWindowOpeningAdapter.workspaceWindowID) {
MainWindowView(
route: workspaceRoute,
workflow: catalogWorkflow,
control: proxyControl,
proxyController: proxyController,
diagnostics: diagnosticsWorkflow,
settingsWorkflow: settingsWorkflow,
loginController: loginController,
clipboard: textClipboard,
diagnosticReportExporter: diagnosticReportExporter)
}
}
}
+3 -17
View File
@@ -10,23 +10,17 @@ import SwiftUI
/// 目录树、激活与订阅动作仍走目录工作流,剪贴板写入等 AppKit 副作用留在呈现
/// 边界。
struct ProxyStatusMenu: View {
@Environment(\.openWindow) private var openWindow
/// 代理控制唯一 seam(issue #47):状态摘要与代理命令的唯一来源。
@ObservedObject var control: ProxyControlWorkflow
@ObservedObject var catalogWorkflow: CatalogWorkflow
@ObservedObject var route: WorkspaceRoute
let clipboard: any TextClipboard
/// 开窗 seam 由组合根注入(菜单 extra 上下文的 SwiftUI openWindow 实测不可用)。
let windowOpening: any WorkspaceWindowOpening
@StateObject private var errors = ErrorAlertPresenter()
var body: some View {
menuContent
.task {
guard !isUnitTesting else { return }
await control.resyncOnLaunch()
route.handle(
.launch,
using: WorkspaceWindowOpeningAdapter(openWindow: openWindow))
}
.alert(
"操作失败",
isPresented: Binding(
@@ -106,9 +100,7 @@ struct ProxyStatusMenu: View {
// ⑦ 打开主窗口
Button("打开主窗口…") {
route.handle(
.reopen,
using: WorkspaceWindowOpeningAdapter(openWindow: openWindow))
route.handle(.reopen, using: windowOpening)
}
Divider()
@@ -119,12 +111,6 @@ struct ProxyStatusMenu: View {
}
}
private var isUnitTesting: Bool {
let environment = ProcessInfo.processInfo.environment
return environment["XCTestConfigurationFilePath"] != nil
|| environment["XCTestSessionIdentifier"] != nil
}
private func copyHTTPExportLine(_ line: String) {
do {
try clipboard.write(line)
@@ -1,24 +1,42 @@
import AppKit
import SwiftUI
/// 组合根使用的 workspace 开窗 adapter。scene ID 与 AppKit activation effect
/// 只在此 focused adapter 与 MainApp scene declaration 中出现。
/// 组合根持有的 workspace 开窗器:主窗口是 AppKit 拥有的 NSWindow,内容为
/// SwiftUI 主 workspace(NSHostingView)。此前经 SwiftUI `Window` scene +
/// `openWindow` 开窗,但该环境值在 MenuBarExtra 上下文(无论标签还是内容)
/// 实测不产生窗口,SwiftUI 对 LSUIElement 应用也不做场景启动呈现——「每次
/// 启动呈现主窗口」必须由 AppKit 直控,不依赖场景挂载时序。窗口关闭时只
/// orderOut 不释放(isReleasedWhenClosed = false),重开即复用,workspace
/// 内部导航状态得以保留。
@MainActor
struct WorkspaceWindowOpeningAdapter: WorkspaceWindowOpening {
static let workspaceWindowID = "main"
final class WorkspaceWindowOpeningAdapter: WorkspaceWindowOpening {
static let workspaceWindowID = NSUserInterfaceItemIdentifier("main")
private let openWindow: OpenWindowAction
private var window: NSWindow?
private let makeContentView: () -> NSView
init(openWindow: OpenWindowAction) {
self.openWindow = openWindow
/// 内容视图由组合根惰性构造:主窗口内的 SwiftUI 状态只在首次开窗时创建。
init(makeContentView: @escaping () -> NSView) {
self.makeContentView = makeContentView
}
func ensureWorkspaceVisible() {
// 混合形态契约(CONTEXT.md「Foreground form」不变量):主 workspace 可见
// 期间以普通应用呈现,Dock 图标随窗口出现;最后一个窗口关闭后由
// AppDelegate 切回纯后台形态。LSUIElement 保证启动即静默。
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
openWindow(id: Self.workspaceWindowID)
let window = self.window ?? makeWindow()
window.makeKeyAndOrderFront(nil)
}
private func makeWindow() -> NSWindow {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 960, height: 640),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered, defer: false)
window.title = "ShadowsocksX-NG 2.0"
window.identifier = Self.workspaceWindowID
window.isReleasedWhenClosed = false
window.contentView = makeContentView()
window.center()
self.window = window
return window
}
}