From cf19ca2d34ae5d1a22997334966aa2bc3fd84666 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Tue, 30 Jun 2026 10:25:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(client/macos):=20=E5=BC=80=E6=9C=BA?= =?UTF-8?q?=E8=87=AA=E5=90=AF=E7=94=A8=20SMAppService=20+=20LoginItem=20he?= =?UTF-8?q?lper(=E5=AD=A6=20Tailscale)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:launch_at_startup 0.5.1 在 macOS 无自带实现,需宿主接 MethodChannel,而我们 Runner 从没实现 → enable() 抛 MissingPluginException 被 try/catch 吞掉 → 登录项从未注册,自启失败。 学 Tailscale(LoginItemHelper-macsys + SMAppService): - 新增无界面 LoginItem helper(macos/login_helper,LSBackgroundOnly),登录时带 --autostart 拉起主 app(→隐藏到托盘); - build_login_helper.sh 构建期编译+签名+嵌入 Contents/Library/LoginItems/(新 Run Script 阶段); - MainFlutterWindow 接 launch_at_startup MethodChannel → SMAppService.loginItem 注册/注销。 构建通过:helper 嵌入+签名,主 app --deep --strict 校验过。Windows 自启(schtasks)不变。 Co-Authored-By: Claude Opus 4.8 --- client/macos/Runner.xcodeproj/project.pbxproj | 15 ++++++ client/macos/Runner/MainFlutterWindow.swift | 40 +++++++++++++++ client/macos/build_login_helper.sh | 49 +++++++++++++++++++ client/macos/login_helper/main.swift | 27 ++++++++++ 4 files changed, 131 insertions(+) create mode 100755 client/macos/build_login_helper.sh create mode 100644 client/macos/login_helper/main.swift diff --git a/client/macos/Runner.xcodeproj/project.pbxproj b/client/macos/Runner.xcodeproj/project.pbxproj index eac7640..2b96571 100644 --- a/client/macos/Runner.xcodeproj/project.pbxproj +++ b/client/macos/Runner.xcodeproj/project.pbxproj @@ -342,6 +342,7 @@ 3399D490228B24CF009A79C7 /* ShellScript */, D3610C37E667F89FD1D27249 /* [CP] Embed Pods Frameworks */, A17B79EA2FE50746001ABF28 /* Copy System Extensions */, + FACE0FF0FACE0FF0FACE0002 /* Embed Login Helper */, ); buildRules = ( ); @@ -371,6 +372,20 @@ shellPath = /bin/sh; shellScript = "bash \"${SRCROOT}/sign_libbox.sh\"\n"; }; + FACE0FF0FACE0FF0FACE0002 /* Embed Login Helper */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Embed Login Helper"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "bash \"${SRCROOT}/build_login_helper.sh\"\n"; + }; A17B79DE2FE50745001ABF28 /* PacketTunnel */ = { isa = PBXNativeTarget; buildConfigurationList = A17B79EF2FE50746001ABF28 /* Build configuration list for PBXNativeTarget "PacketTunnel" */; diff --git a/client/macos/Runner/MainFlutterWindow.swift b/client/macos/Runner/MainFlutterWindow.swift index aaf2dd0..799544c 100644 --- a/client/macos/Runner/MainFlutterWindow.swift +++ b/client/macos/Runner/MainFlutterWindow.swift @@ -1,7 +1,12 @@ import Cocoa import FlutterMacOS +import ServiceManagement class MainFlutterWindow: NSWindow { + // launch_at_startup 插件在 macOS 没自带实现,需宿主接 MethodChannel。学 Tailscale 用 + // SMAppService.loginItem 注册内置的 PangolinLoginHelper(登录时它带 --autostart 拉起主 app)。 + private let loginHelperID = "com.pangolin.pangolin.LoginHelper" + override func awakeFromNib() { let flutterViewController = FlutterViewController() self.contentViewController = flutterViewController @@ -18,6 +23,9 @@ class MainFlutterWindow: NSWindow { // 需 VpnChannel.swift 已加入 Runner target 的 Compile Sources。 VpnChannel.register(with: flutterViewController.registrar(forPlugin: "VpnChannel")) + // 开机自启 MethodChannel(launch_at_startup 插件的原生侧,macOS 需宿主实现)。 + registerLaunchAtStartupChannel(messenger: flutterViewController.engine.binaryMessenger) + super.awakeFromNib() // 开机自启(登录项带 --autostart):不弹主窗口,直接隐藏到托盘(与 Windows 一致)。 @@ -27,4 +35,36 @@ class MainFlutterWindow: NSWindow { self.orderOut(nil) } } + + // launch_at_startup 的 macOS 原生实现:SMAppService.loginItem 注册/注销 LoginHelper。 + private func registerLaunchAtStartupChannel(messenger: FlutterBinaryMessenger) { + let channel = FlutterMethodChannel(name: "launch_at_startup", binaryMessenger: messenger) + let helperID = loginHelperID + channel.setMethodCallHandler { call, result in + guard #available(macOS 13.0, *) else { + result(FlutterError(code: "unsupported", message: "开机自启需 macOS 13+", details: nil)) + return + } + let svc = SMAppService.loginItem(identifier: helperID) + switch call.method { + case "launchAtStartupIsEnabled": + result(svc.status == .enabled) + case "launchAtStartupSetEnabled": + let on = ((call.arguments as? [String: Any])?["setEnabledValue"] as? Bool) ?? false + do { + if on { + if svc.status != .enabled { try svc.register() } + } else { + if svc.status == .enabled { try svc.unregister() } + } + result(nil) + } catch { + NSLog("[pangolin/launch] SMAppService %@ failed: %@", on ? "register" : "unregister", error.localizedDescription) + result(FlutterError(code: "smappservice", message: error.localizedDescription, details: nil)) + } + default: + result(FlutterMethodNotImplemented) + } + } + } } diff --git a/client/macos/build_login_helper.sh b/client/macos/build_login_helper.sh new file mode 100755 index 0000000..9dd5a21 --- /dev/null +++ b/client/macos/build_login_helper.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# build_login_helper.sh — Xcode 构建阶段:编译 + 打包 + 签名 PangolinLoginHelper, +# 嵌入主 app 的 Contents/Library/LoginItems/(供 SMAppService.loginItem 注册)。 +# 学 Tailscale 的 LoginItemHelper-macsys。须在主 app 最终签名前运行(即作为 Runner 的构建阶段)。 +set -euo pipefail + +HELPER_NAME="PangolinLoginHelper" +HELPER_ID="com.pangolin.pangolin.LoginHelper" +SRC="${SRCROOT}/login_helper/main.swift" +DEST="${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/Library/LoginItems/${HELPER_NAME}.app" +WORK="${DERIVED_FILE_DIR}/login_helper" +SDK="$(xcrun --sdk macosx --show-sdk-path)" + +rm -rf "${DEST}" +mkdir -p "${WORK}" "${DEST}/Contents/MacOS" + +# 按 ARCHS 逐架构编译,多架构则 lipo 成 universal。 +BIN="${DEST}/Contents/MacOS/${HELPER_NAME}" +SLICES=() +for arch in ${ARCHS}; do + out="${WORK}/${HELPER_NAME}-${arch}" + swiftc -O -sdk "${SDK}" -target "${arch}-apple-macos13.0" "${SRC}" -o "${out}" + SLICES+=("${out}") +done +if [ "${#SLICES[@]}" -gt 1 ]; then + lipo -create "${SLICES[@]}" -output "${BIN}" +else + cp "${SLICES[0]}" "${BIN}" +fi + +# Info.plist:LSBackgroundOnly(无界面/无 Dock)+ helper bundle ID。 +cat > "${DEST}/Contents/Info.plist" < + + + CFBundleExecutable${HELPER_NAME} + CFBundleIdentifier${HELPER_ID} + CFBundleName${HELPER_NAME} + CFBundlePackageTypeAPPL + CFBundleShortVersionString1.0 + CFBundleVersion1 + LSBackgroundOnly + LSMinimumSystemVersion13.0 + +PLIST + +# 与主 app 同身份签名(hardened runtime);时间戳跟随构建环境(可达即打,否则由外层流程决定)。 +codesign --force --options runtime --timestamp -s "${EXPANDED_CODE_SIGN_IDENTITY}" "${DEST}" +echo "embedded + signed login helper → ${DEST}" diff --git a/client/macos/login_helper/main.swift b/client/macos/login_helper/main.swift new file mode 100644 index 0000000..baf3db7 --- /dev/null +++ b/client/macos/login_helper/main.swift @@ -0,0 +1,27 @@ +// PangolinLoginHelper — 无界面登录项 helper(学 Tailscale 的 LoginItemHelper)。 +// +// 由 SMAppService.loginItem 在用户登录时拉起;它唯一的活儿:带 --autostart 启动主 app +// (让主 app 隐藏到托盘,见主 app main.dart / MainFlutterWindow),然后自己退出。 +// 打进主 app 的 Contents/Library/LoginItems/PangolinLoginHelper.app(见 build_login_helper.sh)。 +import AppKit + +// helper 位于 .app/Contents/Library/LoginItems/PangolinLoginHelper.app +// 主 app = helper bundle 上溯 4 级(LoginItems → Library → Contents → .app)。 +let helperURL = Bundle.main.bundleURL +let mainAppURL = helperURL + .deletingLastPathComponent() // LoginItems + .deletingLastPathComponent() // Library + .deletingLastPathComponent() // Contents + .deletingLastPathComponent() // .app + +let config = NSWorkspace.OpenConfiguration() +config.arguments = ["--autostart"] // 主 app 据此隐藏到托盘 +config.activates = false // 不抢焦点 +config.addsToRecentItems = false + +let done = DispatchSemaphore(value: 0) +NSWorkspace.shared.openApplication(at: mainAppURL, configuration: config) { _, _ in + done.signal() +} +// 等启动请求发出(最多 10s)再退出,避免 helper 过早结束导致启动被取消。 +_ = done.wait(timeout: .now() + 10)