diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 896755b..8a40cb6 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -6,6 +6,8 @@ "permissions": [ "core:default", "opener:default", + "updater:default", + "process:default", "core:window:allow-start-dragging", "core:window:allow-minimize", "core:window:allow-unminimize", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 5e37017..a552d68 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -28,6 +28,7 @@ "bundle": { "active": true, "targets": "all", + "createUpdaterArtifacts": true, "icon": [ "icons/32x32.png", "icons/128x128.png", @@ -39,7 +40,7 @@ "plugins": { "updater": { "active": true, - "dialog": true, + "dialog": false, "endpoints": ["https://ghproxy.net/https://github.com/SparksFly91/Frpilot/releases/latest/download/latest.json"], "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU0REVGMTREODFFOTI2MUEKUldRYUp1bUJUZkhlNU1qRmlPQm5xSmdQd24waFltVmNtTjlUallYVmdva1VGNkp5bTVGcVlsZUgK" } diff --git a/src/api/updater.ts b/src/api/updater.ts new file mode 100644 index 0000000..387a2f1 --- /dev/null +++ b/src/api/updater.ts @@ -0,0 +1,46 @@ +import type { Update } from "@tauri-apps/plugin-updater" +import { isTauri } from "@/utils/window" + +/** 项目 GitHub 仓库地址(与 tauri.conf.json 中 updater 端点保持一致) */ +export const PROJECT_GITHUB_URL = "https://github.com/SparksFly91/Frpilot" + +/** + * 检查是否有可用更新。 + * + * 非 Tauri 环境(纯浏览器调试)下无法调用插件,直接返回 null。 + */ +export async function checkForAppUpdate(): Promise { + if (!isTauri()) return null + const { check } = await import("@tauri-apps/plugin-updater") + return check() +} + +/** 重启应用(macOS / Linux 安装更新后需要重启才能运行新版本) */ +export async function relaunchApp() { + if (!isTauri()) return + const { relaunch } = await import("@tauri-apps/plugin-process") + await relaunch() +} + +/** 用系统默认浏览器打开外部链接 */ +export async function openExternal(url: string) { + if (!isTauri()) { + // 浏览器调试环境退化为新标签页打开 + window.open(url, "_blank", "noopener,noreferrer") + return + } + const { openUrl } = await import("@tauri-apps/plugin-opener") + await openUrl(url) +} + +/** 打开项目 GitHub 仓库首页 */ +export function openProjectRepo() { + return openExternal(PROJECT_GITHUB_URL) +} + +/** 读取当前应用版本(来自 tauri.conf.json / package.json) */ +export async function getAppVersion(): Promise { + if (!isTauri()) return "dev" + const { getVersion } = await import("@tauri-apps/api/app") + return getVersion() +} diff --git a/src/assets/main.css b/src/assets/main.css index 93d8564..5ee9d50 100644 --- a/src/assets/main.css +++ b/src/assets/main.css @@ -163,6 +163,51 @@ body { overflow-y: auto; } +/* + * 更新弹窗:高度不超过窗口的 60%,内容过多时仅主体(body)滚动, + * 标题与底部按钮固定可见;沿用磨砂玻璃质感。 + */ +.update-modal-wrap .ant-modal { + padding-bottom: 0; +} + +.update-modal-wrap .ant-modal-content { + display: flex; + flex-direction: column; + max-height: 60vh; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.55); + background: rgba(255, 255, 255, 0.72) !important; + backdrop-filter: blur(24px) saturate(170%); + -webkit-backdrop-filter: blur(24px) saturate(170%); + box-shadow: 0 16px 48px rgba(15, 23, 42, 0.18) !important; +} + +.update-modal-wrap .ant-modal-header, +.update-modal-wrap .ant-modal-footer { + flex: 0 0 auto; + border-color: rgba(15, 23, 42, 0.06); + background: transparent !important; +} + +.dark .update-modal-wrap .ant-modal-content { + border-color: rgba(255, 255, 255, 0.1); + background: rgba(30, 41, 59, 0.72) !important; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5) !important; +} + +.dark .update-modal-wrap .ant-modal-header, +.dark .update-modal-wrap .ant-modal-footer { + border-color: rgba(255, 255, 255, 0.08); +} + +.update-modal-wrap .ant-modal-body { + flex: 1 1 auto; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; +} + /* 细滚动条 */ ::-webkit-scrollbar { width: 6px; diff --git a/src/components/TitleBarActions.vue b/src/components/TitleBarActions.vue index cd92528..80ffd9d 100644 --- a/src/components/TitleBarActions.vue +++ b/src/components/TitleBarActions.vue @@ -37,6 +37,27 @@ + + + + + + + + + + () const { t } = useI18n() const appStore = useAppStore() +const updateStore = useUpdateStore() const onLanguageClick: MenuProps["onClick"] = ({ key }) => { appStore.setLanguage(key as Language) } + +/** 顶部栏手动检查更新(非静默,无更新 / 失败都会给出提示) */ +function onCheckUpdate() { + void updateStore.checkForUpdate(false) +} + +/** 打开项目 GitHub 仓库 */ +function onOpenGithub() { + void openProjectRepo() +} diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 20542e7..823ece5 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -32,9 +32,9 @@ export function setI18nLanguage(language: Language) { i18n.global.locale.value = language } -/** 在非组件环境(如路由守卫)中获取翻译函数 */ -export function translate(key: string) { - return i18n.global.t(key) +/** 在非组件环境(如路由守卫、store)中获取翻译函数 */ +export function translate(key: string, params?: Record) { + return i18n.global.t(key, params ?? {}) } export default i18n diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 901c8b9..ead2eb7 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -8,6 +8,8 @@ "theme": "Theme", "lightMode": "Light mode", "darkMode": "Dark mode", + "checkUpdate": "Check for updates", + "github": "GitHub", "settings": "Settings", "minimize": "Minimize", "maximize": "Maximize", @@ -57,7 +59,8 @@ "title": "Settings", "placeholder": "The settings page is under construction.", "menu": { - "appearance": "Appearance" + "appearance": "Appearance", + "about": "About" }, "appearance": { "title": "Appearance", @@ -210,6 +213,44 @@ "error": "Download failed" } }, + "about": { + "title": "About Frpilot", + "subtitle": "A lightweight, friendly visual client for frp", + "introTitle": "Introduction", + "intro": "Frpilot is a visual client for frp built with Tauri 2 + Vue 3 + TypeScript. Manage frpc versions, servers and tunnels through a friendly GUI — and say goodbye to hand-writing frpc.toml.", + "flowTitle": "How to use", + "flow": { + "step1Title": "Download frpc", + "step1Desc": "Open \"Download frpc\", grab the build for your platform and architecture, or import a local one.", + "step2Title": "Configure a server", + "step2Desc": "Add the frps address, port and auth token under \"Servers\", then enable one of them.", + "step3Title": "Configure tunnels", + "step3Desc": "Create and enable the tunnels you need (TCP / HTTP, etc.) under \"Tunnels\".", + "step4Title": "One-click start", + "step4Desc": "Head back to the \"Dashboard\" and start Frpc with a single click, watching live logs and status." + }, + "repoTitle": "Open source", + "repoDesc": "Stars, issues and contributions are all welcome~", + "openRepo": "Open GitHub", + "versionTitle": "Version", + "currentVersion": "Current version", + "checkUpdate": "Check for updates", + "newVersion": "New version v{version} is available" + }, + "update": { + "title": "New version v{version} is available ✨", + "hello": "Ta-da~ A brand new version is ready and waiting for you (๑•̀ㅂ•́)و✧", + "tip": "Here's what this little update brings — scroll down and take a peek~", + "log": "What's new", + "emptyLog": "No release notes this time — the author was too lazy, but updating is still a good idea~", + "ignore": "Ignore this version", + "confirm": "Update now", + "downloading": "Downloading the package… hang tight, almost there!", + "desktopOnly": "Update checks are unavailable in dev mode. Please use the desktop app~", + "upToDate": "You're already on the latest version, enjoy~", + "checkFailed": "Update check failed: {message}", + "ignoreSuccess": "Version ignored. We won't bother you again~" + }, "common": { "developing": "This feature is under construction", "confirm": "OK", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 83540d6..985674b 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -8,6 +8,8 @@ "theme": "主题", "lightMode": "浅色模式", "darkMode": "深色模式", + "checkUpdate": "检查更新", + "github": "GitHub", "settings": "设置", "minimize": "最小化", "maximize": "最大化", @@ -57,7 +59,8 @@ "title": "设置", "placeholder": "设置页面开发中,敬请期待。", "menu": { - "appearance": "外观设置" + "appearance": "外观设置", + "about": "关于" }, "appearance": { "title": "外观设置", @@ -210,6 +213,44 @@ "error": "下载失败" } }, + "about": { + "title": "关于 Frpilot", + "subtitle": "轻量、顺手的 frp 可视化桌面客户端", + "introTitle": "项目介绍", + "intro": "Frpilot 是一个基于 Tauri 2 + Vue 3 + TypeScript 打造的 frp 可视化客户端,帮你用图形界面管理 frpc 版本、服务器与隧道,从此告别手写 frpc.toml。", + "flowTitle": "使用流程", + "flow": { + "step1Title": "下载 frpc", + "step1Desc": "进入「Frpc 下载」,按平台与架构下载,或导入本地已有的 frpc。", + "step2Title": "配置服务器", + "step2Desc": "在「服务器」中填写 frps 地址、端口与鉴权 Token,并启用其中一台。", + "step3Title": "配置隧道", + "step3Desc": "在「隧道列表」里按需创建 TCP / HTTP 等类型的隧道并启用。", + "step4Title": "一键启动", + "step4Desc": "回到「仪表盘」一键启动 Frpc,实时查看运行日志与状态。" + }, + "repoTitle": "开源地址", + "repoDesc": "欢迎 Star、提 Issue 或一起参与共建~", + "openRepo": "打开 GitHub", + "versionTitle": "版本信息", + "currentVersion": "当前版本", + "checkUpdate": "检查更新", + "newVersion": "发现新版本 v{version}" + }, + "update": { + "title": "发现新版本 v{version} ✨", + "hello": "呀吼~新版本已经打包好,就等你翻牌啦 (๑•̀ㅂ•́)و✧", + "tip": "这次的小更新带来了下面这些变化,往下滑看看嘛~", + "log": "更新日志", + "emptyLog": "作者这次偷懒没写更新说明,总之先更新了就对了~", + "ignore": "忽略该版本", + "confirm": "确认更新", + "downloading": "正在下载安装包…坐好扶稳,马上就到!", + "desktopOnly": "开发模式下无法检查更新,请在桌面应用中使用~", + "upToDate": "已经是最新版本啦,安心享用~", + "checkFailed": "检查更新失败:{message}", + "ignoreSuccess": "已经帮你忽略这个版本啦,下次不再打扰~" + }, "common": { "developing": "功能开发中,敬请期待", "confirm": "确定", diff --git a/src/layout/Layout.vue b/src/layout/Layout.vue index 17f3c21..6fe9cfb 100644 --- a/src/layout/Layout.vue +++ b/src/layout/Layout.vue @@ -12,17 +12,28 @@ + + + diff --git a/src/router/router.ts b/src/router/router.ts index bdb36b6..c75ea1b 100644 --- a/src/router/router.ts +++ b/src/router/router.ts @@ -45,6 +45,12 @@ const routes: RouteRecordRaw[] = [ component: () => import("@/views/setting/Appearance.vue"), meta: { title: "settings.menu.appearance" }, }, + { + path: "about", + name: "About", + component: () => import("@/views/setting/About.vue"), + meta: { title: "settings.menu.about" }, + }, ], }, ], diff --git a/src/stores/update.ts b/src/stores/update.ts new file mode 100644 index 0000000..d48b56a --- /dev/null +++ b/src/stores/update.ts @@ -0,0 +1,173 @@ +import { defineStore } from "pinia" +import { message } from "ant-design-vue" +import type { Update } from "@tauri-apps/plugin-updater" +import { checkForAppUpdate, relaunchApp } from "@/api/updater" +import { isTauri } from "@/utils/window" +import { translate } from "@/i18n" + +/** 供弹窗展示的更新信息(纯数据,不含方法) */ +export interface UpdateInfo { + version: string + currentVersion: string + date?: string + body?: string +} + +/** + * 应用更新状态:检查 / 忽略版本 / 下载安装。 + * + * 更新弹窗由 {@link UpdateModal} 全局渲染,顶部栏与「关于」页共用此处的检查逻辑。 + */ +export const useUpdateStore = defineStore("update", () => { + /** 用户选择「忽略该版本」后持久化的版本号,静默检查时不再提示 */ + const ignoredVersion = useStorage("frpilot:ignored-update", "") + + const checking = ref(false) + const installing = ref(false) + const modalOpen = ref(false) + const info = ref(null) + + /** 下载进度 */ + const downloaded = ref(0) + const contentLength = ref(null) + const progress = ref(0) + + /** 持有原始 Update 资源(含下载 / 安装方法),不放进响应式以免代理破坏其方法 */ + let pending: Update | null = null + + const hasUpdate = computed(() => info.value !== null) + + function errMsg(e: unknown) { + return e instanceof Error ? e.message : String(e) + } + + function resetProgress() { + downloaded.value = 0 + contentLength.value = null + progress.value = 0 + } + + /** + * 检查更新。 + * + * @param silent 静默检查(启动时自动调用):无更新或已忽略该版本时都不打扰用户。 + */ + async function checkForUpdate(silent = false) { + if (checking.value) return + + if (!isTauri()) { + if (!silent) message.info(translate("update.desktopOnly")) + return + } + + checking.value = true + try { + const update = await checkForAppUpdate() + + if (!update) { + if (!silent) message.success(translate("update.upToDate")) + return + } + + // 静默检查时跳过用户已忽略的版本 + if (silent && ignoredVersion.value === update.version) { + await update.close() + return + } + + pending = update + info.value = { + version: update.version, + currentVersion: update.currentVersion, + date: update.date, + body: update.body, + } + resetProgress() + modalOpen.value = true + } catch (e) { + if (!silent) { + message.error(translate("update.checkFailed", { message: errMsg(e) })) + } + } finally { + checking.value = false + } + } + + /** 放弃当前更新:关闭弹窗并释放后端资源 */ + async function discard() { + const update = pending + pending = null + info.value = null + modalOpen.value = false + resetProgress() + try { + await update?.close() + } catch { + // 资源释放失败不影响交互 + } + } + + /** 取消(本次不更新,下次启动仍会提示) */ + function cancel() { + void discard() + } + + /** 忽略该版本(持久化,静默检查不再提示) */ + async function ignoreVersion() { + if (info.value) ignoredVersion.value = info.value.version + await discard() + message.success(translate("update.ignoreSuccess")) + } + + /** 下载并安装更新,随后重启应用以运行新版本 */ + async function install() { + if (!pending || installing.value) return + + installing.value = true + resetProgress() + try { + await pending.downloadAndInstall((event) => { + switch (event.event) { + case "Started": + contentLength.value = event.data.contentLength ?? null + break + case "Progress": + downloaded.value += event.data.chunkLength + progress.value = contentLength.value + ? Math.min(100, Math.round((downloaded.value / contentLength.value) * 100)) + : Math.min(99, progress.value + 1) + break + case "Finished": + progress.value = 100 + break + } + }) + + // Windows 会在安装后自动退出;macOS / Linux 需要主动重启 + pending = null + info.value = null + modalOpen.value = false + await relaunchApp() + } catch (e) { + message.error(errMsg(e)) + } finally { + installing.value = false + } + } + + return { + ignoredVersion, + checking, + installing, + modalOpen, + info, + downloaded, + contentLength, + progress, + hasUpdate, + checkForUpdate, + cancel, + ignoreVersion, + install, + } +}) diff --git a/src/types/components.d.ts b/src/types/components.d.ts index 342eeab..09021ec 100644 --- a/src/types/components.d.ts +++ b/src/types/components.d.ts @@ -36,6 +36,7 @@ declare module 'vue' { ASpace: typeof import('ant-design-vue/es')['Space'] ASpin: typeof import('ant-design-vue/es')['Spin'] AStatistic: typeof import('ant-design-vue/es')['Statistic'] + ASteps: typeof import('ant-design-vue/es')['Steps'] ASwitch: typeof import('ant-design-vue/es')['Switch'] ATag: typeof import('ant-design-vue/es')['Tag'] ATextarea: typeof import('ant-design-vue/es')['Textarea'] @@ -49,6 +50,7 @@ declare module 'vue' { TitleBarActions: typeof import('./../components/TitleBarActions.vue')['default'] TunnelCard: typeof import('./../components/tunnel/TunnelCard.vue')['default'] TunnelFormModal: typeof import('./../components/tunnel/TunnelFormModal.vue')['default'] + UpdateModal: typeof import('./../components/UpdateModal.vue')['default'] WindowsTitleBar: typeof import('./../components/WindowsTitleBar.vue')['default'] } } diff --git a/src/views/Setting.vue b/src/views/Setting.vue index 951e7d1..ab4f098 100644 --- a/src/views/Setting.vue +++ b/src/views/Setting.vue @@ -20,7 +20,7 @@