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:
Qiu Yuzhou
2026-09-28 19:18:27 +08:00
parent c9384d2de9
commit 0d0e74d59c
10 changed files with 104 additions and 345 deletions
+4 -4
View File
@@ -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.
-74
View File
@@ -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)
}
}
}
}
+23 -34
View File
@@ -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):提交协调器与生产运行时适配器在此
+8 -7
View File
@@ -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) {
+9 -5
View File
@@ -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()
+11 -44
View File
@@ -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
}
}
+2 -9
View File
@@ -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",
// 控制器按命令面/设置与目录同步/系统代理门禁/事实投影分文件(同属运行时适配器)。
+26 -101
View File
@@ -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)")
}
}
+21
View File
@@ -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.