feat: 按安装目录隔离存储应用数据,实现多实例独立运行

新增 StoragePaths 模块,将数据库、设置、frp 版本等业务数据统一保存在应用实际安装位置,避免不同实例或版本间数据混淆。新增 Preferences 模块管理安装目录内的持久化设置,重构数据库初始化逻辑,修改 Frpc 运行目录、frp 版本存储路径,新增数据目录锁机制防止多进程冲突,同时更新文档说明数据目录规则与平台差异。
This commit is contained in:
Yuhang Wu
2026-10-26 14:16:18 +08:00
parent 93b15ccb16
commit ec644e5434
24 changed files with 603 additions and 106 deletions
+2
View File
@@ -1,5 +1,6 @@
import type { Update } from "@tauri-apps/plugin-updater"
import { isTauri } from "@/utils/window"
import { flushPreferences } from "@/utils/preferences"
/** 项目 GitHub 仓库地址(与 tauri.conf.json 中 updater 端点保持一致) */
export const PROJECT_GITHUB_URL = "https://github.com/SparksFly91/FrpcPilot"
@@ -18,6 +19,7 @@ export async function checkForAppUpdate(): Promise<Update | null> {
/** 重启应用(macOS / Linux 安装更新后需要重启才能运行新版本) */
export async function relaunchApp() {
if (!isTauri()) return
await flushPreferences()
const { relaunch } = await import("@tauri-apps/plugin-process")
await relaunch()
}
+44
View File
@@ -0,0 +1,44 @@
import { createApp } from "vue"
import { createPinia } from "pinia"
import App from "./App.vue"
import "@/assets/main.css"
// 引入 Ant Design Vue 组件库
import Antd from "ant-design-vue"
import "ant-design-vue/dist/reset.css"
// 引入路由
import router from "@/router/router"
// 引入国际化
import i18n from "@/i18n"
import { detectPlatform } from "@/composables/usePlatform"
import { nextTick } from "vue"
import { useOnboardingStore } from "@/stores/onboarding"
/*
* macOS 的无边框窗口系统不会自动裁圆角,需要配合 transparent 窗口,
* 由 #app 自身裁出 8px 圆角(见 assets/main.css)。这里在挂载前打上平台标记,
* 避免首帧出现方角闪烁。
*/
if (detectPlatform() === "macos") {
document.documentElement.classList.add("is-macos")
}
const app = createApp(App)
app.use(createPinia())
app.use(Antd)
app.use(i18n)
app.use(router)
// 路由懒加载完成后再挂载,避免启动画面移除后出现空白内容区。
async function mountApp() {
await router.isReady()
app.mount("#app")
await nextTick()
// 让浏览器先绘制完整布局,再揭开启动画面并开始首次引导。
requestAnimationFrame(() => {
requestAnimationFrame(() => {
document.getElementById("startup-loader")?.remove()
useOnboardingStore().ready = true
})
})
}
void mountApp()
+12 -41
View File
@@ -1,44 +1,15 @@
import { createApp } from "vue"
import { createPinia } from "pinia"
import App from "./App.vue"
import "@/assets/main.css"
// 引入 Ant Design Vue 组件库
import Antd from "ant-design-vue"
import "ant-design-vue/dist/reset.css"
// 引入路由
import router from "@/router/router"
// 引入国际化
import i18n from "@/i18n"
import { detectPlatform } from "@/composables/usePlatform"
import { nextTick } from "vue"
import { useOnboardingStore } from "@/stores/onboarding"
import { initializePreferences } from "@/utils/preferences"
/*
* macOS 的无边框窗口系统不会自动裁圆角,需要配合 transparent 窗口,
* 由 #app 自身裁出 8px 圆角(见 assets/main.css)。这里在挂载前打上平台标记,
* 避免首帧出现方角闪烁。
*/
if (detectPlatform() === "macos") {
document.documentElement.classList.add("is-macos")
// 状态仓库创建时会读取初始设置,因此先加载安装目录内保存的设置。
async function start() {
try {
await initializePreferences()
await import("./bootstrap")
} catch (error) {
console.error("Failed to initialize application", error)
const loader = document.getElementById("startup-loader")
if (loader) loader.textContent = `无法读取应用设置 / Cannot load settings: ${String(error)}`
}
}
const app = createApp(App)
app.use(createPinia())
app.use(Antd)
app.use(i18n)
app.use(router)
// 路由懒加载完成后再挂载,避免启动画面移除后出现空白内容区。
async function mountApp() {
await router.isReady()
app.mount("#app")
await nextTick()
// 让浏览器先绘制完整布局,再揭开启动画面并开始首次引导。
requestAnimationFrame(() => {
requestAnimationFrame(() => {
document.getElementById("startup-loader")?.remove()
useOnboardingStore().ready = true
})
})
}
void mountApp()
void start()
+8 -6
View File
@@ -1,4 +1,5 @@
import { defineStore } from "pinia"
import { preferences } from "@/utils/preferences"
import zhCN from "ant-design-vue/es/locale/zh_CN"
import enUS from "ant-design-vue/es/locale/en_US"
import { setI18nLanguage, type Language } from "@/i18n"
@@ -15,13 +16,14 @@ export type RunStatus = "running" | "error" | "stopped"
* 全局应用配置:语言、主题、窗口背景、Frpc 运行状态
*/
export const useAppStore = defineStore("app", () => {
// 语言(持久化到 localStorage)
const language = useStorage<Language>("frpcpilot:language", "zh-CN")
// 安装目录内的持久化设置
const language = useStorage<Language>("frpcpilot:language", "zh-CN", preferences)
const sidebarCollapsed = useStorage("frpcpilot:sidebar-collapsed", false)
const sidebarCollapsed = useStorage("frpcpilot:sidebar-collapsed", false, preferences)
// 明暗主题:useDark 会在 <html> 上切换 .dark 类名
const isDark = useDark({
storage: preferences,
storageKey: "frpcpilot:theme",
selector: "html",
attribute: "class",
@@ -31,7 +33,7 @@ export const useAppStore = defineStore("app", () => {
const toggleDark = useToggle(isDark)
// 窗口背景图片(持久化)
const background = useStorage<BackgroundKey>("frpcpilot:background", DEFAULT_BACKGROUND)
const background = useStorage<BackgroundKey>("frpcpilot:background", DEFAULT_BACKGROUND, preferences)
const backgroundUrl = computed(() => getBackgroundUrl(background.value))
// 把背景图同步到 <html>(整窗背景,避免元素高度误差造成的上下留白)
@@ -48,8 +50,8 @@ export const useAppStore = defineStore("app", () => {
})
// 磨砂玻璃透明度(0 - 100,数值越大越透明,持久化)
const cardTransparency = useStorage<number>("frpcpilot:card-transparency", 55)
const modalTransparency = useStorage<number>("frpcpilot:modal-transparency", 38)
const cardTransparency = useStorage<number>("frpcpilot:card-transparency", 55, preferences)
const modalTransparency = useStorage<number>("frpcpilot:modal-transparency", 38, preferences)
/** 透明度百分比 → 背景 alpha(越大越透明,alpha 越小;并做范围兜底) */
function alphaFromTransparency(value: number) {
+2 -1
View File
@@ -1,7 +1,8 @@
import { defineStore } from "pinia"
import { preferences } from "@/utils/preferences"
export const useOnboardingStore = defineStore("onboarding", () => {
const dismissed = useStorage("frpcpilot:onboarding-dismissed", false)
const dismissed = useStorage("frpcpilot:onboarding-dismissed", false, preferences)
const ready = ref(false)
const active = ref(false)
+3 -1
View File
@@ -1,4 +1,5 @@
import { defineStore } from "pinia"
import { preferences, flushPreferences } from "@/utils/preferences"
import { message } from "ant-design-vue"
import type { Update } from "@tauri-apps/plugin-updater"
import { checkForAppUpdate, relaunchApp } from "@/api/updater"
@@ -20,7 +21,7 @@ export interface UpdateInfo {
*/
export const useUpdateStore = defineStore("update", () => {
/** 用户选择「忽略该版本」后持久化的版本号,静默检查时不再提示 */
const ignoredVersion = useStorage<string>("frpcpilot:ignored-update", "")
const ignoredVersion = useStorage<string>("frpcpilot:ignored-update", "", preferences)
const checking = ref(false)
const installing = ref(false)
@@ -137,6 +138,7 @@ export const useUpdateStore = defineStore("update", () => {
installing.value = true
resetProgress()
try {
await flushPreferences()
await pending.downloadAndInstall((event) => {
switch (event.event) {
case "Started":
+3 -2
View File
@@ -1,4 +1,5 @@
import { defineStore } from "pinia"
import { preferences } from "@/utils/preferences"
import { exitApp, hideWindow, isTauri, revealWindow } from "@/utils/window"
import { useFrpcStore } from "@/stores/frpc"
@@ -24,9 +25,9 @@ export const useWindowStore = defineStore("window", () => {
/** 关闭确认对话框是否打开 */
const closeConfirmOpen = ref(false)
/** 记住的关闭方式(弹窗默认选中项;「保持以上选择」时直接生效,持久化) */
const closeChoice = useStorage<CloseChoice>("frpcpilot:close-choice", "tray")
const closeChoice = useStorage<CloseChoice>("frpcpilot:close-choice", "tray", preferences)
/** 关闭行为:每次询问 / 保持以上选择(持久化,默认每次询问) */
const closeBehavior = useStorage<CloseBehavior>("frpcpilot:close-behavior", "ask")
const closeBehavior = useStorage<CloseBehavior>("frpcpilot:close-behavior", "ask", preferences)
/** 是否正在退出(停止穿透 + 退出软件) */
const quitting = ref(false)
+61
View File
@@ -0,0 +1,61 @@
import { invoke } from "@tauri-apps/api/core"
const desktop = "__TAURI_INTERNALS__" in window
let values: Record<string, string> = {}
let pending: Promise<void> = Promise.resolve()
let saveError: unknown
/** 在加载 Vue 状态仓库前读取安装目录内的设置。 */
export async function initializePreferences() {
if (!desktop) return
const saved = await invoke<Record<string, string> | null>("get_preferences")
if (saved !== null) {
values = saved
} else {
values = {}
await invoke("set_preferences", { preferences: values })
}
}
function persist() {
const snapshot = { ...values }
pending = pending.then(async () => {
try {
await invoke("set_preferences", { preferences: snapshot })
saveError = undefined
} catch (error) {
saveError = error
console.error("Failed to save preferences", error)
const { message } = await import("ant-design-vue")
message.error(`保存设置失败 / Failed to save settings: ${String(error)}`)
}
})
}
/** VueUse 存储适配器,将应用偏好设置保存在 data/preferences.json 中。 */
export const preferences: Storage = {
get length() { return desktop ? Object.keys(values).length : localStorage.length },
key(index) { return desktop ? Object.keys(values)[index] ?? null : localStorage.key(index) },
getItem(key) { return desktop ? values[key] ?? null : localStorage.getItem(key) },
setItem(key, value) {
if (!desktop) return localStorage.setItem(key, value)
if (values[key] === value) return
values[key] = value
persist()
},
removeItem(key) {
if (!desktop) return localStorage.removeItem(key)
delete values[key]
persist()
},
clear() {
if (!desktop) return localStorage.clear()
values = {}
persist()
},
}
export async function flushPreferences() {
await pending
if (saveError) throw saveError
}
+3
View File
@@ -3,6 +3,8 @@
* 在纯浏览器环境(非 Tauri)下调用会被安全忽略,方便 `pnpm dev` 调试。
*/
import { flushPreferences } from "@/utils/preferences"
export function isTauri() {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window
}
@@ -49,6 +51,7 @@ export async function revealWindow() {
/** 退出整个应用(退出前由调用方负责停止穿透等收尾工作) */
export async function exitApp() {
if (!isTauri()) return
await flushPreferences()
const { exit } = await import("@tauri-apps/plugin-process")
await exit(0)
}
+10 -8
View File
@@ -15,14 +15,16 @@
<a-divider class="general-divider" />
<!-- 语言:与顶部栏语言切换共用同一状态 -->
<section>
<div class="mb-1 text-sm font-medium">{{ t("settings.general.language") }}</div>
<p class="mb-2 text-xs opacity-60">{{ t("settings.general.languageTip") }}</p>
<a-radio-group
<section class="flex items-center justify-between gap-4">
<div class="min-w-0">
<div class="text-sm font-medium">{{ t("settings.general.language") }}</div>
<p class="mt-1 text-xs opacity-60">{{ t("settings.general.languageTip") }}</p>
</div>
<a-select
v-model:value="appStore.language"
:options="languageRadios"
option-type="button"
button-style="solid"
:options="languageSelectOptions"
:aria-label="t('settings.general.language')"
class="w-36 shrink-0"
/>
</section>
@@ -91,7 +93,7 @@ const choiceOptions = computed(() => [
])
/** 语言选项与顶部栏共用 languageOptions,保证两端一致 */
const languageRadios = languageOptions.map((item) => ({ label: item.label, value: item.key }))
const languageSelectOptions = languageOptions.map((item) => ({ label: item.label, value: item.key }))
/** 无法访问系统启动项时(非桌面环境)给出说明,避免开关无故置灰 */
const autostartTip = computed(() =>