feat: 新增开机自启功能

This commit is contained in:
Yuhang Wu
2026-10-20 10:03:36 +08:00
parent 341161c20f
commit 7cbc80176d
13 changed files with 303 additions and 23 deletions
+1
View File
@@ -12,6 +12,7 @@
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-autostart": "^2.7.0",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.4.0",
"@tauri-apps/plugin-updater": "^2.13.3",
+10
View File
@@ -14,6 +14,9 @@ importers:
'@tauri-apps/api':
specifier: ^2
version: 2.12.3
'@tauri-apps/plugin-autostart':
specifier: ^2.7.0
version: 2.7.0
'@tauri-apps/plugin-opener':
specifier: ^2
version: 2.7.1
@@ -535,6 +538,9 @@ packages:
engines: {node: '>= 10'}
hasBin: true
'@tauri-apps/plugin-autostart@2.7.0':
resolution: {integrity: sha512-4uRq+rqKSAh6MWAxgk6FnUPigc8VMG8eDVYTLk3A7OfKs9sLVwfeM8kbUvzjpabRQvyl4zd9tfzXBTrrtuprsA==}
'@tauri-apps/plugin-opener@2.7.1':
resolution: {integrity: sha512-XkXdt30rontti9TII/gBBxFRXWfj8DiprFW7RgBFRBj1OhdfjBNVcFqSEWkkZ4hXafHnuEnB7F1sFsmu6uyywQ==}
@@ -1578,6 +1584,10 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.12.2
'@tauri-apps/cli-win32-x64-msvc': 2.12.2
'@tauri-apps/plugin-autostart@2.7.0':
dependencies:
'@tauri-apps/api': 2.12.3
'@tauri-apps/plugin-opener@2.7.1':
dependencies:
'@tauri-apps/api': 2.12.3
+112 -5
View File
@@ -231,6 +231,20 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "auto-launch"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17918dba7ecf78b9a14507ec9f984e7977d13fad87dc86d61038980a45d128cf"
dependencies = [
"dirs 6.0.0",
"os_info",
"smappservice-rs",
"thiserror 2.0.21",
"windows-registry",
"windows-result",
]
[[package]]
name = "autocfg"
version = "1.5.1"
@@ -493,6 +507,12 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chacha20"
version = "0.10.2"
@@ -889,6 +909,15 @@ dependencies = [
"ctutils",
]
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs"
version = "7.0.0"
@@ -1280,6 +1309,7 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-helper",
"tauri-plugin-autostart",
"tauri-plugin-dialog",
"tauri-plugin-opener",
"tauri-plugin-prevent-default",
@@ -2571,6 +2601,18 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags 2.13.2",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "ntapi"
version = "0.4.3"
@@ -2802,6 +2844,30 @@ dependencies = [
"objc2-foundation",
]
[[package]]
name = "objc2-security"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a"
dependencies = [
"bitflags 2.13.2",
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-service-management"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b213642d6959cc6023ceb1217aa595eaaf09b8094ce95127c103cab611fe65e8"
dependencies = [
"block2",
"objc2",
"objc2-core-foundation",
"objc2-foundation",
"objc2-security",
]
[[package]]
name = "objc2-ui-kit"
version = "0.3.2"
@@ -2923,6 +2989,21 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "os_info"
version = "3.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b"
dependencies = [
"android_system_properties",
"log",
"nix",
"objc2",
"objc2-foundation",
"objc2-ui-kit",
"windows-sys 0.61.2",
]
[[package]]
name = "osakit"
version = "0.3.1"
@@ -3988,6 +4069,18 @@ dependencies = [
"serde",
]
[[package]]
name = "smappservice-rs"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52703b97a53101cf5d4580e0737aaa634ce1fdcfc123c16b2a9658e358013488"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-service-management",
"thiserror 2.0.21",
]
[[package]]
name = "socket2"
version = "0.6.5"
@@ -4494,7 +4587,7 @@ dependencies = [
"anyhow",
"bytes",
"cookie",
"dirs",
"dirs 7.0.0",
"dunce",
"embed_plist",
"getrandom 0.3.4",
@@ -4544,7 +4637,7 @@ checksum = "2eea0b6302f82c574b21af6fc6ae6e86183b9280853c22621d0abf0936911ffc"
dependencies = [
"anyhow",
"cargo_toml",
"dirs",
"dirs 7.0.0",
"glob",
"heck 0.5.0",
"json-patch",
@@ -4629,6 +4722,20 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-autostart"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bce56ceb0a99b8ab5eebc977ba9ffc62391b862648cb9ab76fc8bc7231b8f7e2"
dependencies = [
"auto-launch",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.21",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.8.2"
@@ -4740,7 +4847,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "855a336eb389d2c1502244fd48fa2d8cb487a430f691bbaed025195f99a04489"
dependencies = [
"base64 0.22.1",
"dirs",
"dirs 7.0.0",
"flate2",
"futures-util",
"http",
@@ -5277,7 +5384,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72"
dependencies = [
"crossbeam-channel",
"dirs",
"dirs 7.0.0",
"libappindicator",
"muda",
"objc2",
@@ -6137,7 +6244,7 @@ dependencies = [
"block2",
"cookie",
"crossbeam-channel",
"dirs",
"dirs 7.0.0",
"dom_query",
"dpi",
"dunce",
+1
View File
@@ -39,6 +39,7 @@ sysinfo = "0.39"
tauri-plugin-dialog = "2.7.2"
tauri-plugin-updater = "2.10.1"
tauri-plugin-process = "2.3.1"
tauri-plugin-autostart = "2"
tauri-plugin-prevent-default = "6.0.1"
reqwest = { version = "0.12", features = ["json", "stream"] }
futures-util = "0.3"
+1
View File
@@ -8,6 +8,7 @@
"opener:default",
"updater:default",
"process:default",
"autostart:default",
"core:window:allow-start-dragging",
"core:window:allow-minimize",
"core:window:allow-unminimize",
+5
View File
@@ -28,6 +28,11 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init()) // 对话框插件
.plugin(tauri_plugin_updater::Builder::new().build()) // 更新插件
.plugin(tauri_plugin_process::init()) // 进程插件(更新后重启)
// 开机自启插件:读写系统启动项(Windows 注册表 Run 键 / macOS LaunchAgent / Linux XDG autostart)
.plugin(tauri_plugin_autostart::init(
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
None,
))
.plugin(tauri_plugin_prevent_default::Builder::new().build())
.setup(|app| {
let pool = tauri::async_runtime::block_on(async {
+23
View File
@@ -0,0 +1,23 @@
import { isTauri } from "@/utils/window"
/**
* 开机自启封装(Tauri 官方 autostart 插件)。
*
* 自启状态由系统启动项决定(Windows 注册表 Run 键 / macOS LaunchAgent /
* Linux XDG autostart),这里只做读写,不在前端另行持久化,避免两端不一致。
* 非 Tauri 环境(纯浏览器调试)下调用会被安全忽略。
*/
/** 读取系统启动项:本应用是否已设为开机自启 */
export async function isAutostartEnabled(): Promise<boolean> {
if (!isTauri()) return false
const { isEnabled } = await import("@tauri-apps/plugin-autostart")
return isEnabled()
}
/** 开启 / 关闭开机自启(直接写入系统启动项) */
export async function setAutostartEnabled(enabled: boolean): Promise<void> {
if (!isTauri()) return
const { enable, disable } = await import("@tauri-apps/plugin-autostart")
await (enabled ? enable() : disable())
}
+9 -7
View File
@@ -29,7 +29,7 @@ const model = defineModel<T>({ required: true })
.card-radio-group {
display: flex;
flex-direction: column;
gap: 10px;
gap: 6px;
width: 100%;
}
@@ -38,8 +38,8 @@ const model = defineModel<T>({ required: true })
align-items: flex-start;
width: 100%;
margin-inline-end: 0;
padding: 10px 12px;
border-radius: 10px;
padding: 7px 10px;
border-radius: 8px;
border: 1px solid rgba(15, 23, 42, 0.1);
transition: border-color 0.2s ease, background-color 0.2s ease;
@@ -54,13 +54,13 @@ const model = defineModel<T>({ required: true })
}
:deep(.ant-radio) {
margin-top: 2px;
margin-top: 1px;
}
:deep(> span:last-child) {
display: flex;
flex-direction: column;
gap: 2px;
gap: 0;
padding-inline-end: 0;
}
}
@@ -75,12 +75,14 @@ const model = defineModel<T>({ required: true })
}
.card-radio-title {
font-size: 13px;
font-weight: 500;
line-height: 1.5;
}
.card-radio-desc {
font-size: 12px;
line-height: 1.4;
font-size: 11.5px;
line-height: 1.35;
opacity: 0.6;
}
</style>
+10 -2
View File
@@ -7,15 +7,16 @@
:mask-closable="false"
:closable="!store.quitting"
:keyboard="!store.quitting"
:body-style="{ paddingTop: '10px', paddingBottom: '4px' }"
@cancel="store.cancelClose()"
>
<!-- 关闭方式:最小化到系统托盘 / 直接退出 -->
<CardRadioGroup v-model="draftChoice" :options="choiceOptions" :disabled="store.quitting" />
<a-divider class="!my-3" />
<a-divider class="close-divider" />
<!-- 关闭行为:每次询问 / 保持以上选择 -->
<div class="mb-2 text-xs opacity-60">{{ t("closeDialog.behavior") }}</div>
<div class="mb-1.5 text-xs opacity-60">{{ t("closeDialog.behavior") }}</div>
<CardRadioGroup v-model="draftBehavior" :options="behaviorOptions" :disabled="store.quitting" />
<template #footer>
@@ -68,3 +69,10 @@ function onConfirm() {
void store.confirmClose(draftChoice.value, draftBehavior.value)
}
</script>
<style scoped>
/* 弹窗内两组选项之间只留窄窄一段分隔,整体更紧凑 */
.close-divider {
margin: 8px 0;
}
</style>
+7 -1
View File
@@ -84,7 +84,13 @@
},
"general": {
"title": "General",
"subtitle": "Basic settings such as close behavior and app language",
"subtitle": "Basic settings such as launch at startup, close behavior and app language",
"autostart": "Launch at startup",
"autostartTip": "Start FrpcPilot automatically after you sign in. The switch writes straight to the system startup entry.",
"autostartUnsupported": "System startup entries are not accessible here, so this setting is unavailable.",
"autostartEnabled": "Launch at startup enabled",
"autostartDisabled": "Launch at startup disabled",
"autostartFailed": "Failed to update launch at startup: {message}",
"closeBehavior": "Close behavior",
"closeBehaviorTip": "Choose what happens when you click the close button. Kept in sync with the close dialog.",
"closeAction": "Close method",
+7 -1
View File
@@ -84,7 +84,13 @@
},
"general": {
"title": "常规",
"subtitle": "关闭行为与应用语言等基础设置",
"subtitle": "开机自启、关闭行为与应用语言等基础设置",
"autostart": "开机自启",
"autostartTip": "登录系统后自动启动 FrpcPilot,开关状态直接写入系统启动项",
"autostartUnsupported": "当前环境无法读写系统启动项,暂不支持该设置",
"autostartEnabled": "已开启开机自启",
"autostartDisabled": "已关闭开机自启",
"autostartFailed": "设置开机自启失败:{message}",
"closeBehavior": "关闭行为",
"closeBehaviorTip": "设置点击关闭按钮时的处理方式,与关闭弹窗中的选项保持一致",
"closeAction": "关闭方式",
+65
View File
@@ -0,0 +1,65 @@
import { defineStore } from "pinia"
import { message } from "ant-design-vue"
import { isTauri } from "@/utils/window"
import { isAutostartEnabled, setAutostartEnabled } from "@/api/autostart"
import { translate } from "@/i18n"
/**
* 开机自启:直接读写系统启动项,状态以系统为准,不做本地持久化。
*
* - 进入设置页时读一次系统启动项,保证开关展示的就是系统真实状态;
* - 切换后重新读取一次,写入失败则回滚开关并提示原因,避免界面与系统不一致;
* - 无法访问系统启动项(浏览器调试 / 插件不可用)时 supported 为 false,开关置灰。
*/
export const useAutostartStore = defineStore("autostart", () => {
/** 系统启动项是否已登记本应用 */
const enabled = ref(false)
/** 读取 / 写入系统启动项期间显示加载态,同时防止重复操作 */
const loading = ref(isTauri())
/** 当前环境能否读写系统启动项 */
const supported = ref(isTauri())
function errMsg(e: unknown) {
return e instanceof Error ? e.message : String(e)
}
/** 读取系统启动项的真实状态(进入设置页时调用) */
async function refresh() {
if (!isTauri()) return
loading.value = true
try {
enabled.value = await isAutostartEnabled()
supported.value = true
} catch {
// 读取失败(如插件未注册、权限缺失):禁用开关,不展示可能错误的状态
supported.value = false
} finally {
loading.value = false
}
}
/** 切换开机自启:写入系统启动项,成功 / 失败都以系统状态为准并给出提示 */
async function toggle(value: boolean) {
if (!supported.value || loading.value || value === enabled.value) return
const previous = enabled.value
loading.value = true
try {
await setAutostartEnabled(value)
// 以系统启动项为准;读回失败时先按目标值展示,下次进入设置页会再次校准
enabled.value = await isAutostartEnabled().catch(() => value)
message.success(
translate(
value ? "settings.general.autostartEnabled" : "settings.general.autostartDisabled",
),
)
} catch (e) {
enabled.value = previous
message.error(translate("settings.general.autostartFailed", { message: errMsg(e) }))
} finally {
loading.value = false
}
}
return { enabled, loading, supported, refresh, toggle }
})
+52 -7
View File
@@ -1,26 +1,45 @@
<template>
<div class="max-w-2xl">
<h2 class="mb-1 text-lg font-semibold">{{ t("settings.general.title") }}</h2>
<p class="mb-6 text-xs opacity-60">{{ t("settings.general.subtitle") }}</p>
<p class="mb-5 text-xs opacity-60">{{ t("settings.general.subtitle") }}</p>
<!-- 开机自启:开关直接写入系统启动项,状态由系统决定 -->
<section>
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<div class="text-sm font-medium">{{ t("settings.general.autostart") }}</div>
<p class="mt-1 text-xs opacity-60">{{ autostartTip }}</p>
</div>
<a-switch
class="shrink-0"
:checked="autostartStore.enabled"
:loading="autostartStore.loading"
:disabled="!autostartStore.supported"
@change="onAutostartChange"
/>
</div>
</section>
<a-divider class="general-divider" />
<!-- 关闭行为:与关闭弹窗内容保持一致,可在此再次修改 -->
<section class="mb-6">
<section>
<div class="mb-1 text-sm font-medium">{{ t("settings.general.closeBehavior") }}</div>
<p class="mb-3 text-xs opacity-60">{{ t("settings.general.closeBehaviorTip") }}</p>
<p class="mb-2 text-xs opacity-60">{{ t("settings.general.closeBehaviorTip") }}</p>
<CardRadioGroup v-model="windowStore.closeBehavior" :options="behaviorOptions" />
<!-- 关闭方式:与关闭弹窗一致,可随时修改;选择「保持以上选择」后关闭时直接生效 -->
<div class="mt-4 mb-1 text-sm font-medium">{{ t("settings.general.closeAction") }}</div>
<p class="mb-3 text-xs opacity-60">{{ t("settings.general.closeActionTip") }}</p>
<div class="mt-3 mb-1 text-sm font-medium">{{ t("settings.general.closeAction") }}</div>
<p class="mb-2 text-xs opacity-60">{{ t("settings.general.closeActionTip") }}</p>
<CardRadioGroup v-model="windowStore.closeChoice" :options="choiceOptions" />
</section>
<a-divider />
<a-divider class="general-divider" />
<!-- 语言:与顶部栏语言切换共用同一状态 -->
<section>
<div class="mb-1 text-sm font-medium">{{ t("settings.general.language") }}</div>
<p class="mb-3 text-xs opacity-60">{{ t("settings.general.languageTip") }}</p>
<p class="mb-2 text-xs opacity-60">{{ t("settings.general.languageTip") }}</p>
<a-radio-group
v-model:value="appStore.language"
:options="languageRadios"
@@ -35,10 +54,12 @@
import { useI18n } from "vue-i18n"
import { languageOptions } from "@/i18n"
import { useAppStore } from "@/stores/app"
import { useAutostartStore } from "@/stores/autostart"
import { useWindowStore } from "@/stores/window"
const { t } = useI18n()
const appStore = useAppStore()
const autostartStore = useAutostartStore()
const windowStore = useWindowStore()
const behaviorOptions = computed(() => [
@@ -57,4 +78,28 @@ const choiceOptions = computed(() => [
/** 语言选项与顶部栏共用 languageOptions,保证两端一致 */
const languageRadios = languageOptions.map((item) => ({ label: item.label, value: item.key }))
/** 无法访问系统启动项时(非桌面环境)给出说明,避免开关无故置灰 */
const autostartTip = computed(() =>
autostartStore.supported
? t("settings.general.autostartTip")
: t("settings.general.autostartUnsupported"),
)
/** 切换开机自启:失败时由 store 回滚开关状态并提示原因 */
async function onAutostartChange(checked: boolean | string | number) {
await autostartStore.toggle(Boolean(checked))
}
// 每次进入设置页都读一次系统启动项,用户可能在系统设置里改过
onMounted(() => {
void autostartStore.refresh()
})
</script>
<style scoped>
/* 各组设置之间留白收紧,避免分隔线两侧过于松散 */
.general-divider {
margin: 16px 0;
}
</style>