feat: 按安装目录隔离存储应用数据,实现多实例独立运行
新增 StoragePaths 模块,将数据库、设置、frp 版本等业务数据统一保存在应用实际安装位置,避免不同实例或版本间数据混淆。新增 Preferences 模块管理安装目录内的持久化设置,重构数据库初始化逻辑,修改 Frpc 运行目录、frp 版本存储路径,新增数据目录锁机制防止多进程冲突,同时更新文档说明数据目录规则与平台差异。
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
Reference in New Issue
Block a user