Files
ShadowsocksX-NG/ShadowsocksX-NG2/App/Composition/MainApp.swift
T

367 lines
17 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
@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: RulesWorkflow
@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
_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
}
var body: some Scene {
MenuBarExtra {
ProxyStatusMenu(
control: proxyControl)
} label: {
StatusMenuLabel(control: proxyControl)
}
// .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))
}
.defaultLaunchBehavior(launchPresentation)
.defaultSize(width: 960, height: 640)
.windowToolbarLabelStyle($toolbarLabelStyle)
rulesReportScene
diagnosticsScene
}
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: 800, 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) {
NSApp.setActivationPolicy(policy)
}
}
/// 状态菜单的标签视图(状态栏图标)。图标为静态 SF Symbol,不随代理状态
/// 变化,故不观察 control——control 仅承载启动 resync。挂载时机与菜单内容
/// 视图不同:内容视图首次点开才创建,而标签在启动时即挂载——启动 resync
/// 只能挂在这里,挂在菜单内容上等于从不执行。主窗口的启动呈现由 Window
/// scene 的 defaultLaunchBehavior 负责,与本任务无时序耦合。
private struct StatusMenuLabel: View {
let control: ProxyControlWorkflow
var body: some View {
Image(systemName: "paperplane.fill")
.accessibilityLabel("ShadowsocksX-NG2")
.task {
await control.resyncOnLaunch()
}
}
}
/// 组合根装配:目录提交协调器与全部生产运行时适配器只在此接线一次,各 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 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: RulesWorkflow
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)
plugins.connect(
invalidateRuntime: { [weak controller] in controller?.pluginMappingsDidPublish() },
revalidate: { [weak catalogWorkflow] in catalogWorkflow?.republishCommittedState() },
converge: { [weak controller] in
guard let controller else { return }
_ = await controller.catalogDidCommit(
snapshot: controller.catalogSnapshotReader.catalogSnapshot)
})
// 代理控制工作流 module:状态菜单等 UI 表面的唯一代理控制
// seam,组合根接线一次。生产 runtime adapter 包装既有控制器(不复制运行
// 时语义);目录目标事实经窄缝从目录工作流读取;本机接口事实经独立缝
// 提供命令地址候选。
let proxyControl = ProxyControlWorkflow(
runtime: ControllerProxyRuntimeAdapter(controller: controller),
targetFacts: catalogWorkflow,
interfaceFacts: SystemInterfaceFactsProvider())
// 设置工作流 module(Candidate 02):设置窗口的唯一 seam,组合根接线一次;
// 写入侧经窄缝 SettingsCommitting,运行时控制器薄扩展即生产实现。
let settingsWorkflow = SettingsWorkflow(
committing: controller, credentials: controller.credentials)
let testRuntime = ConnectivityRuntime(
executable: Bundle.main.bundleURL.appendingPathComponent("Contents/Helpers/sslocal"))
let connectivity = ServerConnectivity(
configuration: { [weak catalogWorkflow] id in
guard let catalogWorkflow else { throw ServerFormLoadError.notFound }
return try catalogWorkflow.connectivityConfiguration(for: id)
},
revision: { [weak catalogWorkflow] in
catalogWorkflow?.connectivityRevision(for: $0) ?? "missing"
},
measure: testRuntime.measure)
catalogWorkflow.connectivity = connectivity
connectivity.setURL(
(try? settingsWorkflow.beginConnectivityURLEditing()).flatMap(ServerConnectivity.validURL))
settingsWorkflow.connectivityURLDidChange = { [weak connectivity] in connectivity?.setURL($0) }
let connectivityLifecycle = ConnectivityLifecycle(module: connectivity)
// 诊断工作流 module:诊断区唯一 seam,组合根接线一次;进程期
// 实例供独立诊断窗口使用。
let diagnosticsWorkflow = DiagnosticsWorkflow(
runtimeFacts: controller,
catalogFacts: { catalogWorkflow.diagnosticCatalogFacts },
managedPlugins: {
plugins.catalogSnapshot().entries.map { entry in
DiagnosticPluginFacts(
program: entry.program, version: entry.managedInfo?.release ?? "",
present: entry.availability == .available, source: entry.source)
}
})
let workspaceRoute = WorkspaceRoute()
let silentLaunch = SilentLaunchController(store: dependencies.silentLaunchStore)
let expansion = CatalogExpansionState()
let rulesWorkflow = RulesWorkflow(
loadDocument: { try controller.ruleDocuments.load() },
commitDocument: { await controller.commitRuleDocument($0) },
builtinSnapshots: controller.ruleSnapshots)
let workspaceContent = MainWindowView(
rulesWorkflow: rulesWorkflow,
route: workspaceRoute,
workflow: catalogWorkflow,
control: proxyControl,
plugins: PluginManagementModel(catalog: plugins),
settingsWorkflow: settingsWorkflow,
loginController: loginController,
silentLaunch: silentLaunch,
expansion: expansion,
clipboard: textClipboard,
imageClipboard: dependencies.imageClipboard,
configurationGroupFileExporter: dependencies.configurationGroupFileExporter,
qrImageSaver: dependencies.qrImageSaver)
return AppComposition(
connectivityLifecycle: connectivityLifecycle,
controller: controller,
catalogWorkflow: catalogWorkflow,
proxyControl: proxyControl,
loginController: loginController,
settingsWorkflow: settingsWorkflow,
diagnosticsWorkflow: diagnosticsWorkflow,
workspaceRoute: workspaceRoute,
silentLaunch: silentLaunch,
expansion: expansion,
textClipboard: textClipboard,
diagnosticReportExporter: dependencies.diagnosticReportExporter,
imageClipboard: dependencies.imageClipboard,
rulesWorkflow: rulesWorkflow,
workspaceContent: workspaceContent)
}
/// 运行时控制器接线:读侧接目录提交协调器快照,写侧接各持久化 seam 与
/// 系统服务适配器。
private static func makeRuntimeController(
dependencies: ApplicationDependencies,
bootstrap: CatalogCommitBootstrap,
plugins: PluginCatalog
) -> ProxyRuntimeController {
ProxyRuntimeController(
catalogSnapshotReader: bootstrap.catalogSnapshotReader,
activationFileStore: dependencies.activationFileStore,
runtimeFileStore: dependencies.runtimeFileStore,
credentials: dependencies.credentials,
plugins: plugins,
refreshPluginSecurityFacts: { [weak plugins] in _ = plugins?.refreshSecurityFacts() },
listenRestore: dependencies.listenRestore,
settingsStore: dependencies.settingsStore,
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,
credentials: dependencies.credentials,
plugins: plugins,
subscriptionFetcher: HTTPSSubscriptionFetcher(),
legacyImportService: dependencies.legacyImportService,
postLegacyImport: { _ in
await controller.legacyImportDidCommit()
},
activator: controller))
}
}
/// 组合根依赖集(仅生产形态):单测不再以本 app 为宿主(去宿主化,ADR 0021),
/// 装配永远面向真实存储、系统服务与用户默认域。
@MainActor
private struct ApplicationDependencies {
let credentials: CredentialStoring
let catalogFileStore: CatalogFileStore
let activationFileStore: ActivationStateFileStore
let runtimeFileStore: RuntimeFileStore
let settingsStore: ProxySettingsFileStore
let settingsRestore: RestoredProxySettings
let listenRestore: RestoredListenSettings
let legacyImportService: LegacyImportService
let launchAgent: LaunchAgentControlling
let loginService: LaunchAtLoginControlling
let silentLaunchStore: SilentLaunchStore
let textClipboard: any TextClipboard
let imageClipboard: any ImageClipboard
let diagnosticReportExporter: any DiagnosticReportExporter
let configurationGroupFileExporter: any ConfigurationGroupFileExporter
let qrImageSaver: any QrImageSaver
static func make() -> ApplicationDependencies {
// ADR 0017 初版文件形态的迁移(发布链从未包含,仅存量开发机):文件不存在
// 即空操作,随每次启动调用无害。
SilentLaunchStore.migrateLegacyFileIfPresent(
at: SilentLaunchStore.legacyFileURL, into: .standard)
let credentials = KeychainCredentialStore()
let catalogFileStore = CatalogFileStore(fileURL: CatalogFileStore.defaultFileURL())
let settingsStore = ProxySettingsFileStore()
let restoredSettings = ProxySettingsFileStore.restored(store: settingsStore)
return ApplicationDependencies(
credentials: credentials,
catalogFileStore: catalogFileStore,
activationFileStore: ActivationStateFileStore(
fileURL: ActivationStateFileStore.defaultFileURL()),
runtimeFileStore: RuntimeFileStore(),
settingsStore: settingsStore,
settingsRestore: restoredSettings,
listenRestore: RestoredListenSettings(
settings: restoredSettings.settings.listen, unreadableError: nil),
legacyImportService: LegacyImportService(
catalogStore: catalogFileStore, credentials: credentials),
launchAgent: SMAppLaunchAgentService(),
loginService: SMAppLaunchAtLoginService(),
silentLaunchStore: SilentLaunchStore(),
textClipboard: AppKitTextClipboard(),
imageClipboard: AppKitImageClipboard(),
diagnosticReportExporter: AppKitDiagnosticReportExporter(),
configurationGroupFileExporter: AppKitConfigurationGroupFileExporter(),
qrImageSaver: AppKitQrImageSaver())
}
}