From ad74b23cb6fbe24fd98059ccbb9c5a6d8eac1b44 Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Thu, 24 Sep 2026 23:51:48 +0800 Subject: [PATCH] fix(ng2): AppKit-owned workspace window opens at launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三重根因,逐一实测排除:①MenuBarExtra 菜单内容视图首次点开才挂载, launch intent 挂在内容上等于从不执行(此前 legacy 导入自动开窗同样从未 生效),改挂到启动即挂载的标签视图;②SwiftUI openWindow 在 MenuBarExtra 上下文实测不创建场景窗口,且 LSUIElement 应用无场景启动呈现机制——主窗 口改为 AppKit 直控 NSWindow(NSHostingView 承载主 workspace),关闭仅 orderOut 复用,开窗不依赖场景时序;③resyncOnLaunch 的 launchd 往返 await 挡在开窗前,改为先开窗后 resync。装配抽成 AppComposition(init 体量回到 lint 基线)。真机验证:启动即出现主窗口,进程前台形态, SwiftLint/全量测试通过。 --- ShadowsocksX-NG2/App/AppDelegate.swift | 28 ++- ShadowsocksX-NG2/App/MainApp.swift | 185 ++++++++++++------ ShadowsocksX-NG2/App/ProxyStatusMenu.swift | 20 +- .../App/WorkspaceWindowOpeningAdapter.swift | 42 ++-- 4 files changed, 188 insertions(+), 87 deletions(-) diff --git a/ShadowsocksX-NG2/App/AppDelegate.swift b/ShadowsocksX-NG2/App/AppDelegate.swift index 387c586d..c3176e80 100644 --- a/ShadowsocksX-NG2/App/AppDelegate.swift +++ b/ShadowsocksX-NG2/App/AppDelegate.swift @@ -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 diff --git a/ShadowsocksX-NG2/App/MainApp.swift b/ShadowsocksX-NG2/App/MainApp.swift index 2a784232..e6f757ba 100644 --- a/ShadowsocksX-NG2/App/MainApp.swift +++ b/ShadowsocksX-NG2/App/MainApp.swift @@ -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) - } } } diff --git a/ShadowsocksX-NG2/App/ProxyStatusMenu.swift b/ShadowsocksX-NG2/App/ProxyStatusMenu.swift index 44613da0..c3973576 100644 --- a/ShadowsocksX-NG2/App/ProxyStatusMenu.swift +++ b/ShadowsocksX-NG2/App/ProxyStatusMenu.swift @@ -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) diff --git a/ShadowsocksX-NG2/App/WorkspaceWindowOpeningAdapter.swift b/ShadowsocksX-NG2/App/WorkspaceWindowOpeningAdapter.swift index f96b1dde..25027bc9 100644 --- a/ShadowsocksX-NG2/App/WorkspaceWindowOpeningAdapter.swift +++ b/ShadowsocksX-NG2/App/WorkspaceWindowOpeningAdapter.swift @@ -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 } }