mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-08 11:24:50 +00:00
378 lines
17 KiB
Swift
378 lines
17 KiB
Swift
import AppKit
|
||
import SwiftUI
|
||
|
||
@main
|
||
struct ShadowsocksXNG2App: App {
|
||
/// 进程期强持有(组合根装配一次)。body 不读它:事实发布经各 workflow 的
|
||
/// didChange 通道,不借 @StateObject 订阅控制器变化。
|
||
@NSApplicationDelegateAdaptor(ConnectivityApplicationDelegate.self) private var appDelegate
|
||
private let connectivityLifecycle: ConnectivityLifecycle
|
||
private let proxyController: ProxyRuntimeController
|
||
/// 状态显示 debounce 模型:进程期强持有(与首页/菜单栏两个表面共享同一
|
||
/// 门控状态机),body 不读它。
|
||
private let statusDisplay: StatusDisplayModel
|
||
@StateObject private var catalogWorkflow: CatalogWorkflow
|
||
@StateObject private var proxyControl: ProxyControlWorkflow
|
||
@StateObject private var loginController: LaunchAtLoginController
|
||
@StateObject private var settingsWorkflow: SettingsWorkflow
|
||
@StateObject private var diagnosticsWorkflow: DiagnosticsWorkflow
|
||
@StateObject private var workspaceRoute: WorkspaceRoute
|
||
@StateObject private var silentLaunch: SilentLaunchController
|
||
@StateObject private var rulesWorkflow: RulesWorkspaces
|
||
@StateObject private var windowActivation: WindowActivationPolicyCoordinator
|
||
private let textClipboard: any TextClipboard
|
||
private let diagnosticReportExporter: any DiagnosticReportExporter
|
||
@StateObject private var diagnosticsWindow = DiagnosticsWindowState()
|
||
private let workspaceContent: MainWindowView
|
||
/// 窗口工具栏标签风格默认「图标和文本」。绑定版修饰符让用户仍可经工具栏
|
||
/// 右键改选,选择回写本 AppStorage 键持久化,下次启动延续。
|
||
@AppStorage("toolbarLabelStyle") private var toolbarLabelStyle: ToolbarLabelStyle = .titleAndIcon
|
||
/// 启动呈现行为在进程内一次性定格(ADR 0017):静默启动偏好关闭(默认)
|
||
/// 时 presented,开启时 suppressed。切换偏好当次会话无影响,下次启动生效。
|
||
private let launchPresentation: SceneLaunchBehavior
|
||
|
||
init() {
|
||
let composition = AppComposition.make()
|
||
connectivityLifecycle = composition.connectivityLifecycle
|
||
proxyController = composition.controller
|
||
statusDisplay = composition.statusDisplay
|
||
_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)
|
||
_silentLaunch = StateObject(wrappedValue: composition.silentLaunch)
|
||
_rulesWorkflow = StateObject(wrappedValue: composition.rulesWorkflow)
|
||
_windowActivation = StateObject(
|
||
wrappedValue: WindowActivationPolicyCoordinator(applying: NSAppWindowActivationApplier()))
|
||
textClipboard = composition.textClipboard
|
||
diagnosticReportExporter = composition.diagnosticReportExporter
|
||
workspaceContent = composition.workspaceContent
|
||
launchPresentation = composition.silentLaunch.isEnabled ? .suppressed : .presented
|
||
// GUI 事件接入内存环形缓冲:独立诊断窗口日志查看器与
|
||
// 诊断导出的来源;wrapper 侧不注册,仍走 stderr → agent.log 收敛。
|
||
RuntimeLog.setSink(RuntimeEventStore.shared)
|
||
appDelegate.lifecycle = connectivityLifecycle
|
||
appDelegate.catalogWorkflow = composition.catalogWorkflow
|
||
}
|
||
|
||
var body: some Scene {
|
||
MenuBarExtra {
|
||
ProxyStatusMenu(
|
||
control: proxyControl, statusDisplay: statusDisplay)
|
||
} label: {
|
||
StatusMenuLabel(control: proxyControl, statusDisplay: statusDisplay)
|
||
}
|
||
// .menuBarExtraStyle(.window)
|
||
|
||
// 主 workspace 窗口(ADR 0016/0017):SwiftUI `Window` scene。启动呈现由
|
||
// defaultLaunchBehavior 决定:静默启动关闭(默认)时 presented 启动即呈现
|
||
// (scene 方式启动开窗,LSUIElement 下实测生效),开启时 suppressed 直接
|
||
// 进菜单栏形态。窗口开着期间 app 为 regular(Dock 图标/Cmd-Tab/默认菜单
|
||
// 栏),最后一个窗口关闭回 accessory 菜单栏形态——随窗激活策略由窗口 NSWindow 生命
|
||
// 周期通知驱动(WindowActivationPolicy,scenePhase 在 macOS 跟随应用而非
|
||
// 窗口、关窗无事件,实测不可用);关窗后由状态菜单首项(打开主窗口)经
|
||
// openWindow 重开。
|
||
// 关窗不退进程(MenuBarExtra 持进程)。
|
||
Window("ShadowsocksX-NG2", id: WorkspaceRoute.workspaceSceneID) {
|
||
workspaceContent
|
||
.modifier(WindowActivationPolicy(coordinator: windowActivation))
|
||
}
|
||
.commands {
|
||
AboutCommands()
|
||
DiagnosticsCommands()
|
||
}
|
||
.defaultLaunchBehavior(launchPresentation)
|
||
.defaultSize(width: 960, height: 640)
|
||
.windowToolbarLabelStyle($toolbarLabelStyle)
|
||
|
||
rulesReportScene
|
||
diagnosticsScene
|
||
AboutScenes(windowActivation: windowActivation)
|
||
}
|
||
|
||
private var diagnosticsScene: some Scene {
|
||
Window("诊断", id: DiagnosticsWindowState.sceneID) {
|
||
DiagnosticsView(
|
||
diagnostics: diagnosticsWorkflow,
|
||
windowState: diagnosticsWindow,
|
||
clipboard: textClipboard,
|
||
exporter: diagnosticReportExporter
|
||
)
|
||
.modifier(WindowActivationPolicy(coordinator: windowActivation))
|
||
}
|
||
.defaultLaunchBehavior(.suppressed)
|
||
.restorationBehavior(.disabled)
|
||
.commandsRemoved()
|
||
.defaultSize(width: 1200, height: 600)
|
||
.windowToolbarLabelStyle($toolbarLabelStyle)
|
||
}
|
||
|
||
private var rulesReportScene: some Scene {
|
||
Window(RulesCopy.text("快照制作时的转换报告"), id: RulesReportView.sceneID) {
|
||
RulesReportView(workflow: rulesWorkflow)
|
||
.modifier(WindowActivationPolicy(coordinator: windowActivation))
|
||
}
|
||
.defaultLaunchBehavior(.suppressed)
|
||
.restorationBehavior(.disabled)
|
||
.commandsRemoved()
|
||
.defaultSize(width: 680, height: 480)
|
||
.windowToolbarLabelStyle($toolbarLabelStyle)
|
||
}
|
||
}
|
||
|
||
/// 随窗激活策略与激活重试的真实 NSApp 落点。
|
||
@MainActor
|
||
private final class NSAppWindowActivationApplier: WindowActivationPolicyApplying {
|
||
func apply(_ policy: NSApplication.ActivationPolicy) {
|
||
guard NSApp.activationPolicy() != policy else { return }
|
||
NSApp.setActivationPolicy(policy)
|
||
if policy == .regular {
|
||
// Window scene 会呈现窗口,但 LSUIElement 进程仍需显式激活才能置前。
|
||
NSApp.activate(ignoringOtherApps: true)
|
||
}
|
||
}
|
||
|
||
var isAppActive: Bool { NSApp.isActive }
|
||
|
||
var ownsMenuBar: Bool {
|
||
NSWorkspace.shared.menuBarOwningApplication?.bundleIdentifier
|
||
== Bundle.main.bundleIdentifier
|
||
}
|
||
|
||
var isPolicyRegular: Bool { NSApp.activationPolicy() == .regular }
|
||
|
||
func activateApp() {
|
||
NSApp.activate(ignoringOtherApps: true)
|
||
}
|
||
}
|
||
|
||
/// 组合根装配:目录提交协调器与全部生产运行时适配器只在此接线一次,各 scene
|
||
/// 不再重复设置提交回调;目录工作流的实现依赖经 CatalogWorkflowDependencies
|
||
/// 显式装配(workflow 不自行创建生产默认实现);Legacy 导入后的 2.0 运行时
|
||
/// 边界同为一次性注入。主窗口是 SwiftUI `Window` scene(MainApp body 声明),
|
||
/// 内容视图在组合根构造一次;MainWindowView 值廉价且只捕获工作流对象,其
|
||
/// StateObject 状态与 onAppear 副作用在窗口首次打开时才发生。
|
||
@MainActor
|
||
private struct AppComposition {
|
||
let connectivityLifecycle: ConnectivityLifecycle
|
||
let controller: ProxyRuntimeController
|
||
let catalogWorkflow: CatalogWorkflow
|
||
let proxyControl: ProxyControlWorkflow
|
||
let statusDisplay: StatusDisplayModel
|
||
let loginController: LaunchAtLoginController
|
||
let settingsWorkflow: SettingsWorkflow
|
||
let diagnosticsWorkflow: DiagnosticsWorkflow
|
||
let workspaceRoute: WorkspaceRoute
|
||
let silentLaunch: SilentLaunchController
|
||
let expansion: CatalogExpansionState
|
||
let textClipboard: any TextClipboard
|
||
let diagnosticReportExporter: any DiagnosticReportExporter
|
||
let imageClipboard: any ImageClipboard
|
||
let rulesWorkflow: RulesWorkspaces
|
||
let workspaceContent: MainWindowView
|
||
|
||
static func make() -> AppComposition {
|
||
let dependencies = ApplicationDependencies.make()
|
||
let textClipboard = dependencies.textClipboard
|
||
// 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.
|
||
let catalogBootstrap = CatalogCommitCoordinator.bootstrap(
|
||
fileStore: dependencies.catalogFileStore)
|
||
let plugins = PluginCatalog()
|
||
let controller = makeRuntimeController(
|
||
dependencies: dependencies, bootstrap: catalogBootstrap, plugins: plugins)
|
||
let loginController = LaunchAtLoginController(service: dependencies.loginService)
|
||
let catalogWorkflow = makeCatalogWorkflow(
|
||
dependencies: dependencies,
|
||
controller: controller,
|
||
bootstrap: catalogBootstrap, plugins: plugins)
|
||
connectPluginCatalog(plugins, controller: controller, workflow: catalogWorkflow)
|
||
// 代理控制工作流 module:状态菜单等 UI 表面的唯一代理控制
|
||
// seam,组合根接线一次。生产 runtime adapter 包装既有控制器(不复制运行
|
||
// 时语义);目录目标事实经窄缝从目录工作流读取;本机接口事实经独立缝
|
||
// 提供命令地址候选。
|
||
let proxyControl = ProxyControlWorkflow(
|
||
runtime: ControllerProxyRuntimeAdapter(controller: controller),
|
||
targetFacts: catalogWorkflow,
|
||
interfaceFacts: SystemInterfaceFactsProvider())
|
||
// 状态显示 debounce 模型:订阅代理控制工作流 snapshot,组合根接线一次,
|
||
// 首页运行控制卡与菜单栏状态卡共享同一门控状态机。
|
||
let statusDisplay = StatusDisplayModel(control: proxyControl)
|
||
// 设置工作流 module(Candidate 02):设置窗口的唯一 seam,组合根接线一次;
|
||
// 写入侧经窄缝 SettingsCommitting,运行时控制器薄扩展即生产实现。
|
||
let settingsWorkflow = SettingsWorkflow(
|
||
committing: controller)
|
||
let (connectivity, connectivityLifecycle) = makeConnectivity(
|
||
workflow: catalogWorkflow, settings: settingsWorkflow)
|
||
catalogWorkflow.connectivity = connectivity
|
||
let diagnosticsWorkflow = makeDiagnosticsWorkflow(
|
||
controller: controller, workflow: catalogWorkflow, plugins: plugins)
|
||
let workspace = makeWorkspace(
|
||
WorkspaceCompositionContext(
|
||
dependencies: dependencies, controller: controller, workflow: catalogWorkflow,
|
||
control: proxyControl, statusDisplay: statusDisplay, plugins: plugins,
|
||
settings: settingsWorkflow,
|
||
login: loginController, clipboard: textClipboard))
|
||
return AppComposition(
|
||
connectivityLifecycle: connectivityLifecycle,
|
||
controller: controller,
|
||
catalogWorkflow: catalogWorkflow,
|
||
proxyControl: proxyControl,
|
||
statusDisplay: statusDisplay,
|
||
loginController: loginController,
|
||
settingsWorkflow: settingsWorkflow,
|
||
diagnosticsWorkflow: diagnosticsWorkflow,
|
||
workspaceRoute: workspace.route,
|
||
silentLaunch: workspace.silentLaunch,
|
||
expansion: workspace.expansion,
|
||
textClipboard: textClipboard,
|
||
diagnosticReportExporter: dependencies.diagnosticReportExporter,
|
||
imageClipboard: dependencies.imageClipboard,
|
||
rulesWorkflow: workspace.rulesWorkflow,
|
||
workspaceContent: workspace.content)
|
||
}
|
||
|
||
private static func connectPluginCatalog(
|
||
_ plugins: PluginCatalog, controller: ProxyRuntimeController, workflow: CatalogWorkflow
|
||
) {
|
||
plugins.connect(
|
||
invalidateRuntime: { [weak controller] in controller?.pluginMappingsDidPublish() },
|
||
revalidate: { [weak workflow] in workflow?.republishCommittedState() },
|
||
converge: { [weak controller] in
|
||
guard let controller else { return }
|
||
_ = await controller.catalogDidCommit(
|
||
snapshot: controller.catalogSnapshotReader.catalogSnapshot)
|
||
})
|
||
}
|
||
|
||
private static func makeConnectivity(
|
||
workflow: CatalogWorkflow, settings: SettingsWorkflow
|
||
) -> (module: ServerConnectivity, lifecycle: ConnectivityLifecycle) {
|
||
let testRuntime = ConnectivityRuntime(
|
||
executable: Bundle.main.bundleURL.appendingPathComponent("Contents/Helpers/sslocal"))
|
||
let connectivity = ServerConnectivity(
|
||
configuration: { [weak workflow] id in
|
||
guard let workflow else { throw ServerFormLoadError.notFound }
|
||
return try workflow.connectivityConfiguration(for: id)
|
||
},
|
||
revision: { [weak workflow] in workflow?.connectivityRevision(for: $0) ?? "missing" },
|
||
measure: testRuntime.measure)
|
||
connectivity.setURL(ServerConnectivity.validURL(settings.beginConnectivityURLEditing()))
|
||
settings.connectivityURLDidChange = { [weak connectivity] in connectivity?.setURL($0) }
|
||
return (connectivity, ConnectivityLifecycle(module: connectivity))
|
||
}
|
||
|
||
private static func makeDiagnosticsWorkflow(
|
||
controller: ProxyRuntimeController, workflow: CatalogWorkflow, plugins: PluginCatalog
|
||
) -> DiagnosticsWorkflow {
|
||
DiagnosticsWorkflow(
|
||
runtimeFacts: controller,
|
||
catalogFacts: { workflow.diagnosticCatalogFacts },
|
||
managedPlugins: {
|
||
plugins.catalogSnapshot().entries.map { entry in
|
||
DiagnosticPluginFacts(
|
||
program: entry.program, version: entry.managedInfo?.dependency?.version ?? "",
|
||
present: entry.availability == .available, source: entry.source)
|
||
}
|
||
})
|
||
}
|
||
|
||
private static func makeWorkspace(_ context: WorkspaceCompositionContext) -> WorkspaceComposition
|
||
{
|
||
let route = WorkspaceRoute()
|
||
let silentLaunch = SilentLaunchController(store: context.dependencies.silentLaunchStore)
|
||
let expansion = CatalogExpansionState()
|
||
let rulesWorkflow = RulesWorkspaces { preset in
|
||
RulesWorkflow(
|
||
preset: preset,
|
||
loadDocument: { try context.controller.documents(for: preset).load() },
|
||
commitDocument: { await context.controller.commitRuleDocument($0, preset: preset) },
|
||
builtinSnapshots: context.controller.ruleSnapshots)
|
||
}
|
||
let content = MainWindowView(
|
||
rulesWorkflow: rulesWorkflow,
|
||
route: route,
|
||
workflow: context.workflow,
|
||
control: context.control,
|
||
statusDisplay: context.statusDisplay,
|
||
plugins: PluginManagementModel(catalog: context.plugins),
|
||
settingsWorkflow: context.settings,
|
||
loginController: context.login,
|
||
silentLaunch: silentLaunch,
|
||
expansion: expansion,
|
||
clipboard: context.clipboard,
|
||
imageClipboard: context.dependencies.imageClipboard,
|
||
configurationGroupFileExporter: context.dependencies.configurationGroupFileExporter,
|
||
qrImageSaver: context.dependencies.qrImageSaver)
|
||
return WorkspaceComposition(
|
||
route: route, silentLaunch: silentLaunch, expansion: expansion,
|
||
rulesWorkflow: rulesWorkflow, content: content)
|
||
}
|
||
|
||
/// 运行时控制器接线:读侧接目录提交协调器快照,写侧接各持久化 seam 与
|
||
/// 系统服务适配器。
|
||
private static func makeRuntimeController(
|
||
dependencies: ApplicationDependencies,
|
||
bootstrap: CatalogCommitBootstrap,
|
||
plugins: PluginCatalog
|
||
) -> ProxyRuntimeController {
|
||
ProxyRuntimeController(
|
||
catalogSnapshotReader: bootstrap.catalogSnapshotReader,
|
||
activationFileStore: dependencies.activationFileStore,
|
||
runtimeFileStore: dependencies.runtimeFileStore,
|
||
plugins: plugins,
|
||
refreshPluginSecurityFacts: { [weak plugins] in _ = plugins?.refreshSecurityFacts() },
|
||
listeningStore: dependencies.listeningStore,
|
||
settingsRestore: dependencies.settingsRestore,
|
||
agent: dependencies.launchAgent,
|
||
systemProxyNetworkChangeMonitor: SystemProxyNetworkChangeMonitor())
|
||
}
|
||
|
||
/// 目录工作流接线:提交协调器与生产运行时适配器在此
|
||
/// 一次性装配;Legacy 导入后的 2.0 运行时边界同为一次性注入。
|
||
private static func makeCatalogWorkflow(
|
||
dependencies: ApplicationDependencies,
|
||
controller: ProxyRuntimeController,
|
||
bootstrap: CatalogCommitBootstrap,
|
||
plugins: PluginCatalog
|
||
) -> CatalogWorkflow {
|
||
let coordinator = CatalogCommitCoordinator(
|
||
fileStore: dependencies.catalogFileStore,
|
||
runtime: ProxyRuntimeSyncAdapter(controller: controller),
|
||
bootstrap: bootstrap)
|
||
return CatalogWorkflow(
|
||
dependencies: CatalogWorkflowDependencies(
|
||
coordinator: coordinator,
|
||
plugins: plugins,
|
||
subscriptionFetcher: HTTPSSubscriptionFetcher(),
|
||
legacyImportService: dependencies.legacyImportService,
|
||
postLegacyImport: { _ in
|
||
await controller.legacyImportDidCommit()
|
||
},
|
||
activator: controller))
|
||
}
|
||
}
|
||
|
||
private struct WorkspaceCompositionContext {
|
||
let dependencies: ApplicationDependencies
|
||
let controller: ProxyRuntimeController
|
||
let workflow: CatalogWorkflow
|
||
let control: ProxyControlWorkflow
|
||
let statusDisplay: StatusDisplayModel
|
||
let plugins: PluginCatalog
|
||
let settings: SettingsWorkflow
|
||
let login: LaunchAtLoginController
|
||
let clipboard: any TextClipboard
|
||
}
|
||
|
||
private struct WorkspaceComposition {
|
||
let route: WorkspaceRoute
|
||
let silentLaunch: SilentLaunchController
|
||
let expansion: CatalogExpansionState
|
||
let rulesWorkflow: RulesWorkspaces
|
||
let content: MainWindowView
|
||
}
|