feat(client/macos): 开机自启用 SMAppService + LoginItem helper(学 Tailscale)
根因: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 <noreply@anthropic.com>
This commit is contained in:
@@ -342,6 +342,7 @@
|
|||||||
3399D490228B24CF009A79C7 /* ShellScript */,
|
3399D490228B24CF009A79C7 /* ShellScript */,
|
||||||
D3610C37E667F89FD1D27249 /* [CP] Embed Pods Frameworks */,
|
D3610C37E667F89FD1D27249 /* [CP] Embed Pods Frameworks */,
|
||||||
A17B79EA2FE50746001ABF28 /* Copy System Extensions */,
|
A17B79EA2FE50746001ABF28 /* Copy System Extensions */,
|
||||||
|
FACE0FF0FACE0FF0FACE0002 /* Embed Login Helper */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -371,6 +372,20 @@
|
|||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "bash \"${SRCROOT}/sign_libbox.sh\"\n";
|
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 */ = {
|
A17B79DE2FE50745001ABF28 /* PacketTunnel */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = A17B79EF2FE50746001ABF28 /* Build configuration list for PBXNativeTarget "PacketTunnel" */;
|
buildConfigurationList = A17B79EF2FE50746001ABF28 /* Build configuration list for PBXNativeTarget "PacketTunnel" */;
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import Cocoa
|
import Cocoa
|
||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
|
import ServiceManagement
|
||||||
|
|
||||||
class MainFlutterWindow: NSWindow {
|
class MainFlutterWindow: NSWindow {
|
||||||
|
// launch_at_startup 插件在 macOS 没自带实现,需宿主接 MethodChannel。学 Tailscale 用
|
||||||
|
// SMAppService.loginItem 注册内置的 PangolinLoginHelper(登录时它带 --autostart 拉起主 app)。
|
||||||
|
private let loginHelperID = "com.pangolin.pangolin.LoginHelper"
|
||||||
|
|
||||||
override func awakeFromNib() {
|
override func awakeFromNib() {
|
||||||
let flutterViewController = FlutterViewController()
|
let flutterViewController = FlutterViewController()
|
||||||
self.contentViewController = flutterViewController
|
self.contentViewController = flutterViewController
|
||||||
@@ -18,6 +23,9 @@ class MainFlutterWindow: NSWindow {
|
|||||||
// 需 VpnChannel.swift 已加入 Runner target 的 Compile Sources。
|
// 需 VpnChannel.swift 已加入 Runner target 的 Compile Sources。
|
||||||
VpnChannel.register(with: flutterViewController.registrar(forPlugin: "VpnChannel"))
|
VpnChannel.register(with: flutterViewController.registrar(forPlugin: "VpnChannel"))
|
||||||
|
|
||||||
|
// 开机自启 MethodChannel(launch_at_startup 插件的原生侧,macOS 需宿主实现)。
|
||||||
|
registerLaunchAtStartupChannel(messenger: flutterViewController.engine.binaryMessenger)
|
||||||
|
|
||||||
super.awakeFromNib()
|
super.awakeFromNib()
|
||||||
|
|
||||||
// 开机自启(登录项带 --autostart):不弹主窗口,直接隐藏到托盘(与 Windows 一致)。
|
// 开机自启(登录项带 --autostart):不弹主窗口,直接隐藏到托盘(与 Windows 一致)。
|
||||||
@@ -27,4 +35,36 @@ class MainFlutterWindow: NSWindow {
|
|||||||
self.orderOut(nil)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+49
@@ -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" <<PLIST
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0"><dict>
|
||||||
|
<key>CFBundleExecutable</key><string>${HELPER_NAME}</string>
|
||||||
|
<key>CFBundleIdentifier</key><string>${HELPER_ID}</string>
|
||||||
|
<key>CFBundleName</key><string>${HELPER_NAME}</string>
|
||||||
|
<key>CFBundlePackageType</key><string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key><string>1.0</string>
|
||||||
|
<key>CFBundleVersion</key><string>1</string>
|
||||||
|
<key>LSBackgroundOnly</key><true/>
|
||||||
|
<key>LSMinimumSystemVersion</key><string>13.0</string>
|
||||||
|
</dict></plist>
|
||||||
|
PLIST
|
||||||
|
|
||||||
|
# 与主 app 同身份签名(hardened runtime);时间戳跟随构建环境(可达即打,否则由外层流程决定)。
|
||||||
|
codesign --force --options runtime --timestamp -s "${EXPANDED_CODE_SIGN_IDENTITY}" "${DEST}"
|
||||||
|
echo "embedded + signed login helper → ${DEST}"
|
||||||
@@ -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 位于 <MainApp>.app/Contents/Library/LoginItems/PangolinLoginHelper.app
|
||||||
|
// 主 app = helper bundle 上溯 4 级(LoginItems → Library → Contents → <MainApp>.app)。
|
||||||
|
let helperURL = Bundle.main.bundleURL
|
||||||
|
let mainAppURL = helperURL
|
||||||
|
.deletingLastPathComponent() // LoginItems
|
||||||
|
.deletingLastPathComponent() // Library
|
||||||
|
.deletingLastPathComponent() // Contents
|
||||||
|
.deletingLastPathComponent() // <MainApp>.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)
|
||||||
Reference in New Issue
Block a user