mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-27 20:53:44 +00:00
feat(ui): menu-bar-only form with scene-based main window
Replace the hybrid form (LSUIElement + runtime activation-policy switching + AppKit-owned NSWindow) with a plain SwiftUI Window scene: the app stays accessory at all times, launch presents the workspace window via defaultLaunchBehavior(.presented), and the status menu reopens it after close via openWindow. Drop AppDelegate and the window-opening adapter and intents; WorkspaceRoute keeps navigation state only, with the scene id defined there. Documented in ADR 0016 and CONTEXT.md.
This commit is contained in:
+4
-4
@@ -43,8 +43,8 @@
|
||||
- **Window-local default action shortcut**: A keyboard action bound to the focused app window or form context, such as confirming a dialog; it is distinct from a system-level shortcut.
|
||||
- **Workspace destination**: A top-level location in the single 2.0 GUI workspace, such as home, servers, subscriptions, settings, or diagnostics. It is navigation state only; it does not contain a node selection, sheet, alert, or feature draft.
|
||||
- **Workspace route**: The in-process navigation choice and intent used to move between workspace destinations from the workspace or another app entry point. It does not persist user configuration or own feature-local state.
|
||||
- **Foreground form**: The app presentation while the workspace window is open: the app presents as a regular macOS app with a Dock icon and app-switcher presence. It lasts exactly as long as the workspace window is open; hiding the window does not end it.
|
||||
- **Background form**: The menu-bar-only app presentation: the status menu is available and there is no Dock icon or app-switcher presence. The app enters it by closing the workspace window and leaves it by opening the workspace window; every launch starts in the foreground form by presenting the workspace window.
|
||||
- **Workspace window**: The single main window hosting the workspace. It is presented at every launch and, once closed, can be reopened only from the status menu; opening, closing, or hiding it does not change the app's presentation.
|
||||
- **Menu bar form**: The app's one constant presentation: a menu-bar-only app with the status menu always available and never a Dock icon or app-switcher presence, whether or not the workspace window is open.
|
||||
|
||||
## Relationships and invariants
|
||||
|
||||
@@ -90,5 +90,5 @@
|
||||
- Preferences have no single restore-all-factory-defaults action. Where a reset exists, it is scoped to one configuration item, never to the whole preference set at once.
|
||||
- The proxy mode selector's choice and the rule default action persist with the settings snapshot and survive GUI restarts; a persistence failure keeps the previous mode and sub-option in force and names the reason. Rule, global, and direct mode are always available.
|
||||
- The GUI is the user-facing manager; the Shadowsocks tunnel service is an external runtime boundary rather than part of the GUI's domain model.
|
||||
- The presentation form is governed solely by the workspace window: opening it enters the foreground form, and closing it returns the app to the background form while the GUI process keeps running with the status menu available; hiding the window without closing it does not leave the foreground form.
|
||||
- Every launch of the GUI presents the workspace window at startup.
|
||||
- The app presents only in the menu bar form: it never gains a Dock icon or app-switcher presence, and its presentation does not change when the workspace window opens, closes, or hides; the GUI process keeps running with the status menu available regardless of the workspace window.
|
||||
- Every launch of the GUI presents the workspace window; after it is closed, the status menu is the only way to reopen it.
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import AppKit
|
||||
|
||||
/// 混合应用形态的 AppKit 生命周期家:LSUIElement 让进程默认纯后台
|
||||
/// (accessory),这里负责前台形态的进入与退出——任意路径使主窗口可见
|
||||
/// 时切成普通应用(regular),最后一个窗口关闭后切回纯后台。
|
||||
@MainActor
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
/// 混合形态的硬性要求:关窗不退进程,代理 GUI 仍驻留菜单栏。
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
false
|
||||
}
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
// 启动即进入前台形态(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
|
||||
/// 单 Window 场景不接管 reopen),这里补上标准语义;纯后台形态没有
|
||||
/// Dock 图标,不受影响。
|
||||
func applicationShouldHandleReopen(
|
||||
_ sender: NSApplication,
|
||||
hasVisibleWindows flag: Bool
|
||||
) -> Bool {
|
||||
guard !flag else { return true }
|
||||
for window in sender.windows where window.isMiniaturized {
|
||||
window.deminiaturize(nil)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// 关最后一个可见主窗口 → 切回纯后台(与 WorkspaceWindowOpeningAdapter
|
||||
/// 的开窗切 .regular 构成同一形态契约的两半)。不用 occlusion state:
|
||||
/// Cmd+H 隐藏或窗口被完全遮挡也会失去 .visible,会把用户困在后台;
|
||||
/// willClose 只在真关窗时发出,隐藏不算关闭。
|
||||
@objc private func windowWillClose(_ notification: Notification) {
|
||||
let closingWindow = notification.object as? NSWindow
|
||||
// willClose 发出时窗口仍可见,推迟一轮 runloop 再清点剩余窗口。
|
||||
DispatchQueue.main.async {
|
||||
// 以「可成为主窗口」普查而不是比对 workspaceWindowID:SwiftUI 不保证
|
||||
// NSWindow 与 scene id 的稳定映射;本应用唯一的主窗口就是 workspace 窗口,
|
||||
// sheet/alert 均为 panel(canBecomeMain == false),不会误判。
|
||||
let hasWindow = NSApp.windows.contains {
|
||||
$0 !== closingWindow && $0.isVisible && $0.canBecomeMain
|
||||
}
|
||||
if !hasWindow {
|
||||
NSApp.setActivationPolicy(.accessory)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import SwiftUI
|
||||
|
||||
@main
|
||||
struct ShadowsocksXNG2App: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
@StateObject private var proxyController: ProxyRuntimeController
|
||||
@StateObject private var catalogWorkflow: CatalogWorkflow
|
||||
@StateObject private var proxyControl: ProxyControlWorkflow
|
||||
@@ -12,7 +11,7 @@ struct ShadowsocksXNG2App: App {
|
||||
@StateObject private var workspaceRoute: WorkspaceRoute
|
||||
private let textClipboard: any TextClipboard
|
||||
private let diagnosticReportExporter: any DiagnosticReportExporter
|
||||
private let windowOpening: WorkspaceWindowOpeningAdapter
|
||||
private let workspaceContent: MainWindowView
|
||||
|
||||
init() {
|
||||
let composition = AppComposition.make()
|
||||
@@ -25,7 +24,7 @@ struct ShadowsocksXNG2App: App {
|
||||
_workspaceRoute = StateObject(wrappedValue: composition.workspaceRoute)
|
||||
textClipboard = composition.textClipboard
|
||||
diagnosticReportExporter = composition.diagnosticReportExporter
|
||||
windowOpening = composition.windowOpening
|
||||
workspaceContent = composition.workspaceContent
|
||||
// GUI 事件接入内存环形缓冲(spec #21 D5,issue #34):主窗口日志查看器与
|
||||
// 诊断导出的来源;wrapper 侧不注册,仍走 stderr → agent.log 收敛。
|
||||
RuntimeLog.setSink(RuntimeEventStore.shared)
|
||||
@@ -36,34 +35,37 @@ struct ShadowsocksXNG2App: App {
|
||||
ProxyStatusMenu(
|
||||
control: proxyControl,
|
||||
catalogWorkflow: catalogWorkflow,
|
||||
route: workspaceRoute,
|
||||
clipboard: textClipboard,
|
||||
windowOpening: windowOpening)
|
||||
clipboard: textClipboard)
|
||||
} label: {
|
||||
StatusMenuLabel(
|
||||
control: proxyControl,
|
||||
route: workspaceRoute,
|
||||
windowOpening: windowOpening)
|
||||
StatusMenuLabel(control: proxyControl)
|
||||
}
|
||||
.menuBarExtraStyle(.menu)
|
||||
|
||||
// 主 workspace 窗口(ADR 0016):SwiftUI `Window` scene。启动由
|
||||
// defaultLaunchBehavior(.presented) 自动呈现主窗口(scene 方式的启动
|
||||
// 开窗,LSUIElement 下实测生效);关窗后由状态菜单 ⑦ 经 openWindow 重开。
|
||||
// app 始终保持 accessory 形态,没有激活策略切换;关窗不退进程
|
||||
// (MenuBarExtra 持有进程)。
|
||||
Window("ShadowsocksX-NG2", id: WorkspaceRoute.workspaceSceneID) {
|
||||
workspaceContent
|
||||
}
|
||||
.defaultLaunchBehavior(.presented)
|
||||
.defaultSize(width: 960, height: 640)
|
||||
}
|
||||
}
|
||||
|
||||
/// 状态菜单的标签视图(状态栏图标)。挂载时机与菜单内容视图不同:内容视图
|
||||
/// 首次点开才创建,而标签在启动时即挂载——所以启动策略(resync + launch
|
||||
/// intent,含开窗)只能挂在这里,挂在菜单内容上等于从不执行。
|
||||
/// 首次点开才创建,而标签在启动时即挂载——启动 resync 只能挂在这里,挂在
|
||||
/// 菜单内容上等于从不执行。主窗口的启动呈现由 Window scene 的
|
||||
/// defaultLaunchBehavior 负责,与本任务无时序耦合。
|
||||
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-NG2")
|
||||
.task {
|
||||
guard !ApplicationDependencies.isUnitTesting else { return }
|
||||
// 先开窗再 resync:resync 可能 await launchd 往返,不能挡在开窗前面。
|
||||
route.handle(.launch, using: windowOpening)
|
||||
await control.resyncOnLaunch()
|
||||
}
|
||||
}
|
||||
@@ -72,9 +74,9 @@ private struct StatusMenuLabel: View {
|
||||
/// 组合根装配:目录提交协调器与全部生产运行时适配器只在此接线一次,各 scene
|
||||
/// 不再重复设置提交回调;目录工作流的实现依赖经 CatalogWorkflowDependencies
|
||||
/// 显式装配(workflow 不自行创建生产默认实现);Legacy 导入后的 2.0 运行时
|
||||
/// 边界同为一次性注入。主窗口由 AppKit 直控(见 WorkspaceWindowOpeningAdapter
|
||||
/// 文档);MainWindowView 值廉价且只捕获工作流对象,其 StateObject 状态与
|
||||
/// onAppear 副作用在视图首次进入窗口时才发生。
|
||||
/// 边界同为一次性注入。主窗口是 SwiftUI `Window` scene(MainApp body 声明),
|
||||
/// 内容视图在组合根构造一次;MainWindowView 值廉价且只捕获工作流对象,其
|
||||
/// StateObject 状态与 onAppear 副作用在窗口首次打开时才发生。
|
||||
@MainActor
|
||||
private struct AppComposition {
|
||||
let controller: ProxyRuntimeController
|
||||
@@ -86,7 +88,7 @@ private struct AppComposition {
|
||||
let workspaceRoute: WorkspaceRoute
|
||||
let textClipboard: any TextClipboard
|
||||
let diagnosticReportExporter: any DiagnosticReportExporter
|
||||
let windowOpening: WorkspaceWindowOpeningAdapter
|
||||
let workspaceContent: MainWindowView
|
||||
|
||||
static func make() -> AppComposition {
|
||||
let dependencies = ApplicationDependencies.make()
|
||||
@@ -136,7 +138,6 @@ private struct AppComposition {
|
||||
loginController: loginController,
|
||||
clipboard: textClipboard,
|
||||
diagnosticReportExporter: diagnosticReportExporter)
|
||||
let windowOpening = makeWindowOpening(content: workspaceContent, route: workspaceRoute)
|
||||
return AppComposition(
|
||||
controller: controller,
|
||||
catalogWorkflow: catalogWorkflow,
|
||||
@@ -147,19 +148,7 @@ private struct AppComposition {
|
||||
workspaceRoute: workspaceRoute,
|
||||
textClipboard: textClipboard,
|
||||
diagnosticReportExporter: diagnosticReportExporter,
|
||||
windowOpening: windowOpening)
|
||||
}
|
||||
|
||||
/// 主窗口开窗器装配:AppKit 直控窗口(见 adapter 文档),窗口标题跟随当前
|
||||
/// 分区(首页/服务器/订阅/设置/诊断)——组合根一次性接线,@Published 订阅
|
||||
/// 立即发出当前值,开窗前即完成标题缓存。
|
||||
private static func makeWindowOpening(
|
||||
content: MainWindowView, route: WorkspaceRoute
|
||||
) -> WorkspaceWindowOpeningAdapter {
|
||||
let windowOpening = WorkspaceWindowOpeningAdapter(
|
||||
makeContentView: { NSHostingView(rootView: content) })
|
||||
windowOpening.bindTitle(route.$destination.map(\.label).eraseToAnyPublisher())
|
||||
return windowOpening
|
||||
workspaceContent: workspaceContent)
|
||||
}
|
||||
|
||||
/// 目录工作流接线(issue #40/#41/#49):提交协调器与生产运行时适配器在此
|
||||
|
||||
@@ -19,13 +19,13 @@ private enum WorkspaceSheet: String, Identifiable {
|
||||
}
|
||||
|
||||
/// 主窗口外壳(地图 #52,票 #53):NavigationSplitView 侧栏承载五项导航与
|
||||
/// 底部常驻代理状态卡;详情区按 route destination 承载各分区视图。分区大
|
||||
/// 标题由窗口标题承担(组合根把 destination label 推给开窗器,见
|
||||
/// WorkspaceWindowOpeningAdapter.bindTitle);订阅/诊断的页级动作经 .toolbar
|
||||
/// 桥接进窗口工具栏右端(票 #56/#58 的槽位仅呈现位置变化),设置的表单级
|
||||
/// 提交动作在其视图内容顶部(见 SettingsView)。路由状态仍由
|
||||
/// WorkspaceRoute 持有;代理状态卡的开关、模式与摘要只来自代理控制工作流
|
||||
/// 的整体 snapshot(issue #47),与状态菜单同一口径。
|
||||
/// 底部常驻代理状态卡;详情区按 route destination 承载各分区视图。分区名即
|
||||
/// 窗口标题(navigationTitle 绑定 route destination,ADR 0016 的 scene 版
|
||||
/// 窗口原生呈现);订阅/诊断的页级动作经 .toolbar 桥接进窗口工具栏右端
|
||||
/// (票 #56/#58 的槽位仅呈现位置变化),设置的表单级提交动作在其视图内容
|
||||
/// 顶部(见 SettingsView)。路由状态仍由 WorkspaceRoute 持有;代理状态卡的
|
||||
/// 开关、模式与摘要只来自代理控制工作流的整体 snapshot(issue #47),与状态
|
||||
/// 菜单同一口径。
|
||||
struct MainWindowView: View {
|
||||
@ObservedObject var route: WorkspaceRoute
|
||||
@ObservedObject var workflow: CatalogWorkflow
|
||||
@@ -56,6 +56,7 @@ struct MainWindowView: View {
|
||||
} detail: {
|
||||
destinationView
|
||||
}
|
||||
.navigationTitle(route.destination.label)
|
||||
.frame(minWidth: 920, minHeight: 580)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .primaryAction) {
|
||||
|
||||
@@ -14,10 +14,11 @@ struct ProxyStatusMenu: View {
|
||||
/// 代理控制唯一 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
|
||||
/// 主窗口是 SwiftUI `Window` scene(见 MainApp):启动呈现由场景的
|
||||
/// defaultLaunchBehavior 负责,状态菜单是关窗后的重开入口(ADR 0016);
|
||||
/// scene id 经 WorkspaceRoute 单点引用。
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@StateObject private var errors = ErrorAlertPresenter()
|
||||
|
||||
var body: some View {
|
||||
@@ -112,9 +113,12 @@ struct ProxyStatusMenu: View {
|
||||
|
||||
Divider()
|
||||
|
||||
// ⑦ 打开主窗口
|
||||
// ⑦ 重开主窗口:启动已由 defaultLaunchBehavior(.presented) 呈现,本项
|
||||
// 服务关窗后的重开。accessory 形态下 SwiftUI 开窗不会自行抢焦点,先
|
||||
// 激活本 app 让窗口压过当前前台应用。
|
||||
Button("打开主窗口…") {
|
||||
route.handle(.reopen, using: windowOpening)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
openWindow(id: WorkspaceRoute.workspaceSceneID)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
@@ -12,55 +12,22 @@ enum WorkspaceDestination: String, CaseIterable, Hashable, Identifiable, Sendabl
|
||||
var id: Self { self }
|
||||
}
|
||||
|
||||
/// workspace 的跨入口意图。呈现意图会要求主 workspace 可见;workspace 内部
|
||||
/// 导航只改变 destination,不重复执行窗口 effect。
|
||||
enum WorkspaceRouteIntent: Equatable, Sendable {
|
||||
case launch
|
||||
case navigate(destination: WorkspaceDestination)
|
||||
case present(destination: WorkspaceDestination)
|
||||
case reopen
|
||||
}
|
||||
|
||||
/// 窄的开窗 seam。实现可以连接 SwiftUI `openWindow` 或 AppKit 生命周期,route
|
||||
/// module 本身不依赖任一 UI framework。
|
||||
@MainActor
|
||||
protocol WorkspaceWindowOpening {
|
||||
func ensureWorkspaceVisible()
|
||||
}
|
||||
|
||||
/// workspace route module:集中 destination、启动 policy、恢复 policy 与跨入口
|
||||
/// intent 协调。路由状态只存在于进程内,新实例始终从 home 开始。
|
||||
/// workspace route module:集中 destination 与 workspace 内部导航。路由状态
|
||||
/// 只存在于进程内,新实例始终从 home 开始。主窗口的呈现:启动由 Window
|
||||
/// scene 的 defaultLaunchBehavior(.presented) 自动呈现,关窗后由状态菜单经
|
||||
/// SwiftUI `openWindow` 重开(scene id 单点定义见 workspaceSceneID);本
|
||||
/// module 不依赖任何 UI framework,也不持有开窗 effect。
|
||||
@MainActor
|
||||
final class WorkspaceRoute: ObservableObject {
|
||||
/// 主 workspace 的 SwiftUI `Window` scene id:scene 在组合根(MainApp)
|
||||
/// 以此常量声明,状态菜单是关窗后的重开入口。字面量单点定义在此,防
|
||||
/// scene id 散落(架构测试锚定)。
|
||||
static let workspaceSceneID = "workspace"
|
||||
|
||||
@Published private(set) var destination: WorkspaceDestination = .home
|
||||
|
||||
private var didApplyLaunchPolicy = false
|
||||
|
||||
/// workspace 内部导航只改变 route destination,不请求新的窗口呈现。
|
||||
/// workspace 内部导航只改变 route destination;开窗不由 route 驱动。
|
||||
func navigate(to destination: WorkspaceDestination) {
|
||||
self.destination = destination
|
||||
}
|
||||
|
||||
/// 应用一个 route intent,并在需要时请求主 workspace 可见。
|
||||
/// destination 总是在开窗 effect 之前更新,避免窗口先呈现旧位置再纠正。
|
||||
func handle(
|
||||
_ intent: WorkspaceRouteIntent,
|
||||
using windowOpening: any WorkspaceWindowOpening
|
||||
) {
|
||||
switch intent {
|
||||
case .launch:
|
||||
// 启动策略(CONTEXT.md「Background form」不变量):每次启动都呈现
|
||||
// 主 workspace(前台形态开窗);Legacy 导入提示由已开窗口的 home 分区呈现。
|
||||
guard !didApplyLaunchPolicy else { return }
|
||||
didApplyLaunchPolicy = true
|
||||
windowOpening.ensureWorkspaceVisible()
|
||||
case .navigate(let destination):
|
||||
navigate(to: destination)
|
||||
case .present(let destination):
|
||||
self.destination = destination
|
||||
windowOpening.ensureWorkspaceVisible()
|
||||
case .reopen:
|
||||
windowOpening.ensureWorkspaceVisible()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import AppKit
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
/// 组合根持有的 workspace 开窗器:主窗口是 AppKit 拥有的 NSWindow,内容为
|
||||
/// SwiftUI 主 workspace(NSHostingView)。此前经 SwiftUI `Window` scene +
|
||||
/// `openWindow` 开窗,但该环境值在 MenuBarExtra 上下文(无论标签还是内容)
|
||||
/// 实测不产生窗口,SwiftUI 对 LSUIElement 应用也不做场景启动呈现——「每次
|
||||
/// 启动呈现主窗口」必须由 AppKit 直控,不依赖场景挂载时序。窗口关闭时只
|
||||
/// orderOut 不释放(isReleasedWhenClosed = false),重开即复用,workspace
|
||||
/// 内部导航状态得以保留。
|
||||
@MainActor
|
||||
final class WorkspaceWindowOpeningAdapter: WorkspaceWindowOpening {
|
||||
static let workspaceWindowID = NSUserInterfaceItemIdentifier("main")
|
||||
|
||||
private var window: NSWindow?
|
||||
/// 当前分区名(组合根经 bindTitle 接线):开窗前先缓存,开窗时应用。
|
||||
private var latestTitle = ""
|
||||
private var titleCancellable: AnyCancellable?
|
||||
private let makeContentView: () -> NSView
|
||||
|
||||
/// 内容视图由组合根惰性构造:主窗口内的 SwiftUI 状态只在首次开窗时创建。
|
||||
init(makeContentView: @escaping () -> NSView) {
|
||||
self.makeContentView = makeContentView
|
||||
}
|
||||
|
||||
/// 窗口标题接线(组合根一次性调用):标题跟随当前 workspace destination
|
||||
/// 的分区名,随 workspace 内导航实时变化;订阅生命周期由本 adapter 持有。
|
||||
func bindTitle(_ updates: AnyPublisher<String, Never>) {
|
||||
titleCancellable = updates.sink { [weak self] title in
|
||||
self?.latestTitle = title
|
||||
self?.window?.title = title
|
||||
}
|
||||
}
|
||||
|
||||
func ensureWorkspaceVisible() {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
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, .fullSizeContentView],
|
||||
backing: .buffered, defer: false)
|
||||
window.title = latestTitle
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.identifier = Self.workspaceWindowID
|
||||
window.isReleasedWhenClosed = false
|
||||
// SwiftUI scene 窗口的 chrome 需在此手动补齐,两件缺一不可:
|
||||
// ① 空 NSToolbar + .unified:NavigationSplitView 桥接把侧栏切换按钮与
|
||||
// toolbar item 装进真实工具栏,并给内容正确安全区。没有工具栏时桥接
|
||||
// 只装一半——玻璃背景画进内容区顶部而内容不缩进,分区大标题被糊成
|
||||
// 色块。
|
||||
// ② .fullSizeContentView + titlebarAppearsTransparent:侧栏通顶、窗口
|
||||
// 标题(当前分区名)呈现在内容区前缘(scene 版外观)。缺省标题栏会把
|
||||
// 标题挤在红绿灯旁的窄条里。
|
||||
let toolbar = NSToolbar(identifier: "workspace")
|
||||
window.toolbar = toolbar
|
||||
window.toolbarStyle = .unified
|
||||
window.contentView = makeContentView()
|
||||
window.center()
|
||||
self.window = window
|
||||
return window
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,6 @@ final class SkeletonTests: XCTestCase {
|
||||
// 以下两个 Bundle.main 断言依赖测试包以 app 为 TEST_HOST 注入(target dependency);
|
||||
// 若移除该依赖,Bundle.main 不再是 app bundle,断言会失效。
|
||||
|
||||
func testMenuBarAppDoesNotTerminateAfterLastWindowClosed() {
|
||||
let delegate = AppDelegate()
|
||||
let shouldTerminate = delegate.applicationShouldTerminateAfterLastWindowClosed(.shared)
|
||||
XCTAssertFalse(shouldTerminate)
|
||||
}
|
||||
|
||||
func testAppBundleIDIsDistinctFromLegacy() {
|
||||
XCTAssertEqual(Bundle.main.bundleIdentifier, "com.qiuyuzhou.ShadowsocksX-NG2")
|
||||
XCTAssertNotEqual(Bundle.main.bundleIdentifier, "com.qiuyuzhou.ShadowsocksX-NG")
|
||||
@@ -59,11 +53,10 @@ final class CatalogWorkflowArchitectureTests: XCTestCase {
|
||||
private static let testTargetRoot = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent().deletingLastPathComponent()
|
||||
|
||||
/// 豁免文件:组合根(生产 adapter 唯一装配点)、应用生命周期与运行时/
|
||||
/// 系统适配器(合法接触原始目录与凭据类型)。
|
||||
/// 豁免文件:组合根(生产 adapter 唯一装配点)与运行时/系统适配器(合法
|
||||
/// 接触原始目录与凭据类型)。
|
||||
private static let exemptedFiles: Set<String> = [
|
||||
"MainApp.swift",
|
||||
"AppDelegate.swift",
|
||||
"CatalogCommitCoordinator.swift",
|
||||
"ProxyRuntimeController.swift",
|
||||
// 控制器按命令面/设置与目录同步/系统代理门禁/事实投影分文件(同属运行时适配器)。
|
||||
|
||||
@@ -5,17 +5,6 @@ import XCTest
|
||||
|
||||
@MainActor
|
||||
final class WorkspaceRouteTests: XCTestCase {
|
||||
private final class RecordingWindowOpening: WorkspaceWindowOpening {
|
||||
var openCount = 0
|
||||
var destinationAtOpen: WorkspaceDestination?
|
||||
var onOpen: (() -> Void)?
|
||||
|
||||
func ensureWorkspaceVisible() {
|
||||
openCount += 1
|
||||
onOpen?()
|
||||
}
|
||||
}
|
||||
|
||||
func testFreshRouteStartsAtHomeWithoutPersistedState() {
|
||||
XCTAssertEqual(WorkspaceRoute().destination, .home)
|
||||
}
|
||||
@@ -26,95 +15,26 @@ final class WorkspaceRouteTests: XCTestCase {
|
||||
[.home, .servers, .subscriptions, .settings, .diagnostics])
|
||||
}
|
||||
|
||||
func testLaunchPresentsWorkspaceAtHome() {
|
||||
func testNavigateChangesDestinationOnly() {
|
||||
let route = WorkspaceRoute()
|
||||
let opening = RecordingWindowOpening()
|
||||
opening.onOpen = { opening.destinationAtOpen = route.destination }
|
||||
|
||||
route.handle(.launch, using: opening)
|
||||
|
||||
XCTAssertEqual(route.destination, .home)
|
||||
XCTAssertEqual(opening.openCount, 1)
|
||||
XCTAssertEqual(opening.destinationAtOpen, .home)
|
||||
}
|
||||
|
||||
func testLaunchPolicyIsAppliedOnlyOnce() {
|
||||
let route = WorkspaceRoute()
|
||||
let opening = RecordingWindowOpening()
|
||||
|
||||
route.handle(.launch, using: opening)
|
||||
route.handle(.launch, using: opening)
|
||||
|
||||
XCTAssertEqual(opening.openCount, 1)
|
||||
}
|
||||
|
||||
func testDelayedLaunchPolicyDoesNotOverwriteExplicitDestination() {
|
||||
let route = WorkspaceRoute()
|
||||
let opening = RecordingWindowOpening()
|
||||
opening.onOpen = { opening.destinationAtOpen = route.destination }
|
||||
|
||||
route.handle(.present(destination: .settings), using: opening)
|
||||
route.handle(.launch, using: opening)
|
||||
|
||||
XCTAssertEqual(route.destination, .settings)
|
||||
XCTAssertEqual(opening.openCount, 2)
|
||||
XCTAssertEqual(opening.destinationAtOpen, .settings)
|
||||
}
|
||||
|
||||
func testExplicitSettingsPresentationSelectsBeforeOpeningWorkspace() {
|
||||
let route = WorkspaceRoute()
|
||||
let opening = RecordingWindowOpening()
|
||||
opening.onOpen = { opening.destinationAtOpen = route.destination }
|
||||
|
||||
route.handle(.present(destination: .settings), using: opening)
|
||||
|
||||
XCTAssertEqual(route.destination, .settings)
|
||||
XCTAssertEqual(opening.openCount, 1)
|
||||
XCTAssertEqual(opening.destinationAtOpen, .settings)
|
||||
}
|
||||
|
||||
func testInWorkspaceNavigationChangesDestinationWithoutOpeningWindow() {
|
||||
let route = WorkspaceRoute()
|
||||
let opening = RecordingWindowOpening()
|
||||
|
||||
route.handle(.navigate(destination: .servers), using: opening)
|
||||
route.navigate(to: .servers)
|
||||
|
||||
XCTAssertEqual(route.destination, .servers)
|
||||
XCTAssertEqual(opening.openCount, 0)
|
||||
}
|
||||
|
||||
func testExplicitHomePresentationSelectsHomeBeforeOpeningWorkspace() {
|
||||
let route = WorkspaceRoute()
|
||||
let opening = RecordingWindowOpening()
|
||||
opening.onOpen = { opening.destinationAtOpen = route.destination }
|
||||
route.navigate(to: .diagnostics)
|
||||
|
||||
route.handle(.present(destination: .home), using: opening)
|
||||
|
||||
XCTAssertEqual(route.destination, .home)
|
||||
XCTAssertEqual(opening.openCount, 1)
|
||||
XCTAssertEqual(opening.destinationAtOpen, .home)
|
||||
}
|
||||
|
||||
func testPassiveReopenRetainsCurrentDestination() {
|
||||
let route = WorkspaceRoute()
|
||||
let opening = RecordingWindowOpening()
|
||||
opening.onOpen = { opening.destinationAtOpen = route.destination }
|
||||
route.handle(.navigate(destination: .diagnostics), using: opening)
|
||||
|
||||
route.handle(.reopen, using: opening)
|
||||
|
||||
XCTAssertEqual(route.destination, .diagnostics)
|
||||
XCTAssertEqual(opening.openCount, 1)
|
||||
XCTAssertEqual(opening.destinationAtOpen, .diagnostics)
|
||||
}
|
||||
|
||||
func testSceneOpeningEffectsRemainInCompositionRootOrFocusedAdapter() throws {
|
||||
/// 开窗落点纪律(ADR 0016,scene 方式):主窗口是 SwiftUI `Window` scene,
|
||||
/// `openWindow` 调用只允许出现在状态菜单(关窗后的重开入口;启动呈现由
|
||||
/// 场景 defaultLaunchBehavior 负责);scene id 字面量只允许定义在 route
|
||||
/// (单点词汇),组合根以常量声明 scene。防视图任意开窗与 scene id 散落
|
||||
/// 回归 AppKit 直控。
|
||||
func testWindowOpeningStaysInSanctionedSurfaces() throws {
|
||||
let appDirectory = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("App", isDirectory: true)
|
||||
let allowedFiles = Set(["MainApp.swift", "WorkspaceWindowOpeningAdapter.swift"])
|
||||
let openWindowAllowed = Set(["ProxyStatusMenu.swift"])
|
||||
let sceneIDLiteralAllowed = Set(["WorkspaceRoute.swift"])
|
||||
let fileManager = FileManager.default
|
||||
let urls = try XCTUnwrap(
|
||||
fileManager.enumerator(
|
||||
@@ -124,19 +44,24 @@ final class WorkspaceRouteTests: XCTestCase {
|
||||
)?.compactMap { $0 as? URL }
|
||||
.filter { $0.pathExtension == "swift" })
|
||||
|
||||
let violations = urls.compactMap { url -> String? in
|
||||
guard !allowedFiles.contains(url.lastPathComponent),
|
||||
let source = try? String(contentsOf: url, encoding: .utf8)
|
||||
else { return nil }
|
||||
guard
|
||||
source.contains("openWindow(id:") || source.contains("id: \"main\"")
|
||||
|| source.contains("id: \"settings\"")
|
||||
else { return nil }
|
||||
return url.lastPathComponent
|
||||
var openWindowViolations: [String] = []
|
||||
var sceneIDViolations: [String] = []
|
||||
for url in urls {
|
||||
guard let source = try? String(contentsOf: url, encoding: .utf8) else { continue }
|
||||
let name = url.lastPathComponent
|
||||
if !openWindowAllowed.contains(name), source.contains("openWindow(") {
|
||||
openWindowViolations.append(name)
|
||||
}
|
||||
if !sceneIDLiteralAllowed.contains(name), source.contains(#""workspace""#) {
|
||||
sceneIDViolations.append(name)
|
||||
}
|
||||
}
|
||||
|
||||
XCTAssertTrue(
|
||||
violations.isEmpty,
|
||||
"scene ID 与开窗 effect 只能位于组合根或专用 adapter:\(violations)")
|
||||
openWindowViolations.isEmpty,
|
||||
"openWindow 调用只允许在状态菜单:\(openWindowViolations)")
|
||||
XCTAssertTrue(
|
||||
sceneIDViolations.isEmpty,
|
||||
"scene id 字面量只允许定义在 WorkspaceRoute:\(sceneIDViolations)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Menu-bar-only app form; workspace window as a scene
|
||||
|
||||
The GUI is a menu-bar-only app at all times: `LSUIElement` stays in effect and the app never switches its activation policy at runtime. The main workspace window is a SwiftUI `Window` scene presented automatically at every launch (`defaultLaunchBehavior(.presented)`); the status menu's "open main window" item reopens it after it is closed. Closing the window never terminates the GUI process — the status menu (and the launchd-hosted proxy agent with it) stays available.
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-09-28). Supersedes the earlier hybrid form (2026-09-24): `LSUIElement` plus runtime accessory⇄regular activation-policy switching, launch-time auto-presentation implemented through an AppKit-owned `NSWindow`, and the window-opening intents built around it.
|
||||
|
||||
## Considered options
|
||||
|
||||
- Keep the hybrid form (activation-policy switching plus AppKit window ownership): rejected because the AppKit window adapter existed only to serve the policy dance and the launch intents; the plain scene model provides both launch presentation and reopening without it.
|
||||
- Menu-bar-only with no launch presentation (window opens only from the status menu): considered as an intermediate step on 2026-09-28 and reversed the same day — scene-based launch presentation was verified to work under `LSUIElement` (`defaultLaunchBehavior(.presented)`), so the launch-presentation behavior returns without bringing the AppKit adapter back.
|
||||
- Keep an AppKit-direct window adapter solely to reuse a retained window across close/open: rejected; the scene window survives close by being re-shown with its content state intact, so the adapter's remaining value was the activation-policy switching this decision removes.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The app never gains a Dock icon or app-switcher presence, including while the workspace window is open; launch presents the window without a Dock icon appearing.
|
||||
- Reopening from the status menu activates the app before `openWindow`, so the window lands above the frontmost app.
|
||||
- Keyboard shortcuts that rely on owning the menu bar (for example ⌘W or Edit-menu shortcuts) are unavailable while the workspace window is frontmost; the close button and mouse interaction are unaffected. If this proves unacceptable, the fix is a product decision to re-introduce a foreground form, not a partial AppKit adapter.
|
||||
- The launch intent, reopen intent, presentation intents, and the `WorkspaceWindowOpening` seam are removed; `WorkspaceRoute` is navigation state only, and the scene id is defined once there.
|
||||
- Window title follows the workspace destination via `navigationTitle` instead of an AppKit title binding.
|
||||
Reference in New Issue
Block a user