mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-29 05:33:45 +00:00
feat(ng2): hybrid app form with foreground workspace window
This commit is contained in:
@@ -34,6 +34,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 default menu-bar-only app presentation: the status menu is available and there is no Dock icon or app-switcher presence. A login-item launch always starts in the background form; every other launch starts in the foreground form by presenting the workspace window.
|
||||
|
||||
## Relationships and invariants
|
||||
|
||||
@@ -74,3 +76,5 @@
|
||||
- Proxy modes are mutually exclusive: local PAC writes the runtime PAC URL and global writes only the local SOCKS proxy. System proxy writes are allowed only after endpoint health; 2.0 stores the complete per-service Proxies dictionaries it owns and restores them only while the applied values still match, otherwise it reports an ownership conflict.
|
||||
- The proxy mode selector's choice persists with the settings snapshot and survives GUI restarts; a persistence failure keeps the previous mode in force and names the reason. Local PAC and global are always available, and user-provided remote PAC URLs are not accepted as proxy settings. Resetting preferences returns the mode to local PAC.
|
||||
- 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.
|
||||
- A login-item launch never presents the workspace window; every other launch presents it at startup.
|
||||
|
||||
@@ -1,8 +1,51 @@
|
||||
import AppKit
|
||||
|
||||
/// 混合应用形态的 AppKit 生命周期家:LSUIElement 让进程默认纯后台
|
||||
/// (accessory),主 workspace 可见期间由开窗 adapter 切成普通应用
|
||||
/// (regular);这里负责最后一个窗口关闭后切回纯后台。
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
/// LSUIElement 菜单栏形态的硬性要求:关闭最后一个窗口后进程必须保留。
|
||||
/// 混合形态的硬性要求:关窗不退进程,代理 GUI 仍驻留菜单栏。
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
false
|
||||
}
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(windowWillClose(_:)),
|
||||
name: NSWindow.willCloseNotification, object: nil)
|
||||
}
|
||||
|
||||
/// 前台形态下窗口被最小化时,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import Foundation
|
||||
|
||||
/// 进程启动来源判定(启动策略的输入,CONTEXT.md「Background form」不变量)。
|
||||
/// macOS 没有「本次因登录项而启动」的系统 API(SMAppService 只暴露注册态,
|
||||
/// 不暴露启动原因),故采用社区标准启发式:登录项已启用且距开机很近。
|
||||
/// 误判边界:登录项启用时,开机后窗口期内手动启动也会被按登录启动对待
|
||||
/// (静默不开主窗口);主窗口从状态菜单随时可开,损失可接受。
|
||||
enum LaunchContext {
|
||||
/// 判定窗口期:须覆盖慢启动(FileVault 解密、慢外设枚举),同时尽量
|
||||
/// 压缩手动启动的误判面。
|
||||
static let loginLaunchWindow: TimeInterval = 120
|
||||
|
||||
static func isLoginItemLaunch(
|
||||
loginItemEnabled: Bool,
|
||||
systemUptime: TimeInterval
|
||||
) -> Bool {
|
||||
guard loginItemEnabled else { return false }
|
||||
return systemUptime < loginLaunchWindow
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,14 @@ protocol LaunchAtLoginControlling {
|
||||
func unregister() throws
|
||||
}
|
||||
|
||||
extension LoginItemStatus {
|
||||
/// 已注册或已请求注册待批准都算开启——系统 API 状态的唯一口径,
|
||||
/// LaunchAtLoginController.isEnabled 与启动来源判定共用。
|
||||
var countsAsEnabled: Bool {
|
||||
self == .registered || self == .requiresApproval
|
||||
}
|
||||
}
|
||||
|
||||
struct SMAppLaunchAtLoginService: LaunchAtLoginControlling {
|
||||
private let service = SMAppService.mainApp
|
||||
|
||||
@@ -58,9 +66,9 @@ final class LaunchAtLoginController: ObservableObject {
|
||||
status = service.status
|
||||
}
|
||||
|
||||
/// 已注册或已请求注册待批准都算开启——两者都只来自系统 API 的状态。
|
||||
/// 已注册或已请求注册待批准都算开启。
|
||||
var isEnabled: Bool {
|
||||
status == .registered || status == .requiresApproval
|
||||
status.countsAsEnabled
|
||||
}
|
||||
|
||||
var requiresApproval: Bool { status == .requiresApproval }
|
||||
|
||||
@@ -70,7 +70,13 @@ struct ShadowsocksXNG2App: App {
|
||||
wrappedValue: DiagnosticsWorkflow(
|
||||
runtimeFacts: controller,
|
||||
catalogFacts: { catalogWorkflow.diagnosticCatalogFacts }))
|
||||
_workspaceRoute = StateObject(wrappedValue: WorkspaceRoute())
|
||||
// 启动策略(CONTEXT.md「Background form」不变量):登录项拉起静默,
|
||||
// 其余启动开主窗口(前台形态);Legacy 导入提示由已开窗口的 home 分区呈现。
|
||||
let opensWorkspaceAtLaunch = !LaunchContext.isLoginItemLaunch(
|
||||
loginItemEnabled: dependencies.loginService.status.countsAsEnabled,
|
||||
systemUptime: ProcessInfo.processInfo.systemUptime)
|
||||
_workspaceRoute = StateObject(
|
||||
wrappedValue: WorkspaceRoute(opensWorkspaceAtLaunch: opensWorkspaceAtLaunch))
|
||||
// GUI 事件接入内存环形缓冲(spec #21 D5,issue #34):主窗口日志查看器与
|
||||
// 诊断导出的来源;wrapper 侧不注册,仍走 stderr → agent.log 收敛。
|
||||
RuntimeLog.setSink(RuntimeEventStore.shared)
|
||||
|
||||
@@ -24,7 +24,7 @@ struct ProxyStatusMenu: View {
|
||||
guard !isUnitTesting else { return }
|
||||
await control.resyncOnLaunch()
|
||||
route.handle(
|
||||
.launch(legacyImportOffer: catalogWorkflow.legacyImportState.shouldOffer),
|
||||
.launch,
|
||||
using: WorkspaceWindowOpeningAdapter(openWindow: openWindow))
|
||||
}
|
||||
.alert(
|
||||
|
||||
@@ -15,7 +15,7 @@ enum WorkspaceDestination: String, CaseIterable, Hashable, Identifiable, Sendabl
|
||||
/// workspace 的跨入口意图。呈现意图会要求主 workspace 可见;workspace 内部
|
||||
/// 导航只改变 destination,不重复执行窗口 effect。
|
||||
enum WorkspaceRouteIntent: Equatable, Sendable {
|
||||
case launch(legacyImportOffer: Bool)
|
||||
case launch
|
||||
case navigate(destination: WorkspaceDestination)
|
||||
case present(destination: WorkspaceDestination)
|
||||
case reopen
|
||||
@@ -34,8 +34,16 @@ protocol WorkspaceWindowOpening {
|
||||
final class WorkspaceRoute: ObservableObject {
|
||||
@Published private(set) var destination: WorkspaceDestination = .home
|
||||
|
||||
/// 启动是否呈现主 workspace:登录项拉起为 false(静默驻留纯后台形态),
|
||||
/// 其余启动为 true(前台形态开窗)。判定来源见 LaunchContext。
|
||||
private let opensWorkspaceAtLaunch: Bool
|
||||
|
||||
private var didApplyLaunchPolicy = false
|
||||
|
||||
init(opensWorkspaceAtLaunch: Bool) {
|
||||
self.opensWorkspaceAtLaunch = opensWorkspaceAtLaunch
|
||||
}
|
||||
|
||||
/// workspace 内部导航只改变 route destination,不请求新的窗口呈现。
|
||||
func navigate(to destination: WorkspaceDestination) {
|
||||
self.destination = destination
|
||||
@@ -48,10 +56,10 @@ final class WorkspaceRoute: ObservableObject {
|
||||
using windowOpening: any WorkspaceWindowOpening
|
||||
) {
|
||||
switch intent {
|
||||
case .launch(let legacyImportOffer):
|
||||
case .launch:
|
||||
guard !didApplyLaunchPolicy else { return }
|
||||
didApplyLaunchPolicy = true
|
||||
if legacyImportOffer {
|
||||
if opensWorkspaceAtLaunch {
|
||||
windowOpening.ensureWorkspaceVisible()
|
||||
}
|
||||
case .navigate(let destination):
|
||||
|
||||
@@ -14,6 +14,10 @@ struct WorkspaceWindowOpeningAdapter: WorkspaceWindowOpening {
|
||||
}
|
||||
|
||||
func ensureWorkspaceVisible() {
|
||||
// 混合形态契约(CONTEXT.md「Foreground form」不变量):主 workspace 可见
|
||||
// 期间以普通应用呈现,Dock 图标随窗口出现;最后一个窗口关闭后由
|
||||
// AppDelegate 切回纯后台形态。LSUIElement 保证启动即静默。
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
openWindow(id: Self.workspaceWindowID)
|
||||
}
|
||||
|
||||
@@ -60,6 +60,34 @@ final class LaunchAtLoginServiceTests: XCTestCase {
|
||||
XCTAssertEqual(controller.errorMessage, AppPresentation.unknownError)
|
||||
}
|
||||
|
||||
// MARK: 登录项启动判定(LaunchContext,启动策略输入)
|
||||
|
||||
func testLoginLaunchRequiresEnabledLoginItem() {
|
||||
XCTAssertFalse(
|
||||
LaunchContext.isLoginItemLaunch(loginItemEnabled: false, systemUptime: 5),
|
||||
"登录项未启用时,开机后立即手动启动也不该被当成登录启动")
|
||||
XCTAssertTrue(LaunchContext.isLoginItemLaunch(loginItemEnabled: true, systemUptime: 5))
|
||||
}
|
||||
|
||||
func testApprovalPendingAndRegisteredCountAsEnabledLoginItem() {
|
||||
XCTAssertTrue(LoginItemStatus.registered.countsAsEnabled)
|
||||
XCTAssertTrue(LoginItemStatus.requiresApproval.countsAsEnabled)
|
||||
XCTAssertFalse(LoginItemStatus.notRegistered.countsAsEnabled)
|
||||
XCTAssertFalse(LoginItemStatus.notFound.countsAsEnabled)
|
||||
}
|
||||
|
||||
func testUptimeBeyondBootWindowIsManualLaunch() {
|
||||
XCTAssertFalse(
|
||||
LaunchContext.isLoginItemLaunch(
|
||||
loginItemEnabled: true,
|
||||
systemUptime: LaunchContext.loginLaunchWindow),
|
||||
"窗口期右边界(开区间)之外视为手动启动")
|
||||
XCTAssertFalse(
|
||||
LaunchContext.isLoginItemLaunch(
|
||||
loginItemEnabled: true,
|
||||
systemUptime: LaunchContext.loginLaunchWindow + 1))
|
||||
}
|
||||
|
||||
private enum FakeError: Error, CustomStringConvertible {
|
||||
case system
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ final class WorkspaceRouteTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testFreshRouteStartsAtHomeWithoutPersistedState() {
|
||||
XCTAssertEqual(WorkspaceRoute().destination, .home)
|
||||
XCTAssertEqual(WorkspaceRoute(opensWorkspaceAtLaunch: true).destination, .home)
|
||||
}
|
||||
|
||||
func testDestinationVocabularyIsFiniteAndStable() {
|
||||
@@ -26,45 +26,56 @@ final class WorkspaceRouteTests: XCTestCase {
|
||||
[.home, .servers, .subscriptions, .settings, .diagnostics])
|
||||
}
|
||||
|
||||
func testFirstLaunchWithLegacyOfferPresentsHome() {
|
||||
let route = WorkspaceRoute()
|
||||
func testRegularLaunchPresentsWorkspaceAtHome() {
|
||||
let route = WorkspaceRoute(opensWorkspaceAtLaunch: true)
|
||||
let opening = RecordingWindowOpening()
|
||||
opening.onOpen = { opening.destinationAtOpen = route.destination }
|
||||
|
||||
route.handle(.launch(legacyImportOffer: true), using: opening)
|
||||
route.handle(.launch, using: opening)
|
||||
|
||||
XCTAssertEqual(route.destination, .home)
|
||||
XCTAssertEqual(opening.openCount, 1)
|
||||
XCTAssertEqual(opening.destinationAtOpen, .home)
|
||||
}
|
||||
|
||||
func testFirstLaunchWithoutLegacyOfferDoesNotPresentWorkspace() {
|
||||
let route = WorkspaceRoute()
|
||||
func testLoginItemLaunchStaysSilent() {
|
||||
let route = WorkspaceRoute(opensWorkspaceAtLaunch: false)
|
||||
let opening = RecordingWindowOpening()
|
||||
|
||||
route.handle(.launch(legacyImportOffer: false), using: opening)
|
||||
route.handle(.launch, using: opening)
|
||||
|
||||
XCTAssertEqual(route.destination, .home)
|
||||
XCTAssertEqual(opening.openCount, 0)
|
||||
}
|
||||
|
||||
func testLaunchPolicyIsAppliedOnlyOnce() {
|
||||
let route = WorkspaceRoute()
|
||||
func testSilentLaunchStillAllowsExplicitPresentation() {
|
||||
let route = WorkspaceRoute(opensWorkspaceAtLaunch: false)
|
||||
let opening = RecordingWindowOpening()
|
||||
|
||||
route.handle(.launch(legacyImportOffer: true), using: opening)
|
||||
route.handle(.launch(legacyImportOffer: true), using: opening)
|
||||
route.handle(.launch, using: opening)
|
||||
route.handle(.present(destination: .settings), using: opening)
|
||||
|
||||
XCTAssertEqual(route.destination, .settings)
|
||||
XCTAssertEqual(opening.openCount, 1)
|
||||
}
|
||||
|
||||
func testLaunchPolicyIsAppliedOnlyOnce() {
|
||||
let route = WorkspaceRoute(opensWorkspaceAtLaunch: true)
|
||||
let opening = RecordingWindowOpening()
|
||||
|
||||
route.handle(.launch, using: opening)
|
||||
route.handle(.launch, using: opening)
|
||||
|
||||
XCTAssertEqual(opening.openCount, 1)
|
||||
}
|
||||
|
||||
func testDelayedLaunchPolicyDoesNotOverwriteExplicitDestination() {
|
||||
let route = WorkspaceRoute()
|
||||
let route = WorkspaceRoute(opensWorkspaceAtLaunch: true)
|
||||
let opening = RecordingWindowOpening()
|
||||
opening.onOpen = { opening.destinationAtOpen = route.destination }
|
||||
|
||||
route.handle(.present(destination: .settings), using: opening)
|
||||
route.handle(.launch(legacyImportOffer: true), using: opening)
|
||||
route.handle(.launch, using: opening)
|
||||
|
||||
XCTAssertEqual(route.destination, .settings)
|
||||
XCTAssertEqual(opening.openCount, 2)
|
||||
@@ -72,7 +83,7 @@ final class WorkspaceRouteTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testExplicitSettingsPresentationSelectsBeforeOpeningWorkspace() {
|
||||
let route = WorkspaceRoute()
|
||||
let route = WorkspaceRoute(opensWorkspaceAtLaunch: true)
|
||||
let opening = RecordingWindowOpening()
|
||||
opening.onOpen = { opening.destinationAtOpen = route.destination }
|
||||
|
||||
@@ -84,7 +95,7 @@ final class WorkspaceRouteTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testInWorkspaceNavigationChangesDestinationWithoutOpeningWindow() {
|
||||
let route = WorkspaceRoute()
|
||||
let route = WorkspaceRoute(opensWorkspaceAtLaunch: true)
|
||||
let opening = RecordingWindowOpening()
|
||||
|
||||
route.handle(.navigate(destination: .servers), using: opening)
|
||||
@@ -94,7 +105,7 @@ final class WorkspaceRouteTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testExplicitHomePresentationSelectsHomeBeforeOpeningWorkspace() {
|
||||
let route = WorkspaceRoute()
|
||||
let route = WorkspaceRoute(opensWorkspaceAtLaunch: true)
|
||||
let opening = RecordingWindowOpening()
|
||||
opening.onOpen = { opening.destinationAtOpen = route.destination }
|
||||
route.navigate(to: .diagnostics)
|
||||
@@ -107,7 +118,7 @@ final class WorkspaceRouteTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testPassiveReopenRetainsCurrentDestination() {
|
||||
let route = WorkspaceRoute()
|
||||
let route = WorkspaceRoute(opensWorkspaceAtLaunch: true)
|
||||
let opening = RecordingWindowOpening()
|
||||
opening.onOpen = { opening.destinationAtOpen = route.destination }
|
||||
route.handle(.navigate(destination: .diagnostics), using: opening)
|
||||
|
||||
Reference in New Issue
Block a user