mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-28 13:13:47 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user