diff --git a/.github/scripts/prepare-release-assets.cjs b/.github/scripts/prepare-release-assets.cjs new file mode 100644 index 00000000..095bd5a8 --- /dev/null +++ b/.github/scripts/prepare-release-assets.cjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +/** + * 整理 GitHub Release 待发布资源(CommonJS,便于用 NODE_PATH 引入隔离安装的 js-yaml) + * + * 背景:各平台/架构在独立 job 构建并各自上传 artifact。electron-builder 会为 + * Windows / macOS 生成同名的 `latest.yml` / `latest-mac.yml`(分别只含本架构条目), + * 直接上传会互相覆盖,导致另一架构无法自动更新。Linux 则使用按架构区分的 + * `latest-linux.yml` / `latest-linux-arm64.yml`,天然不冲突。 + * + * 本脚本将所有 artifact 扁平化到输出目录: + * - 合并 Windows / macOS 的 latest*.yml(合并 files 列表,path/sha512 优先指向 x64) + * - 保留各架构 Linux 清单原样 + * - 剔除调试文件 builder-debug.yml + * - 同名二进制去重(保留首个),避免 release 资源重名冲突 + * + * 用法: node prepare-release-assets.cjs + */ +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const yaml = require("js-yaml"); + +const srcDir = process.argv[2]; +const outDir = process.argv[3]; + +if (!srcDir || !outDir) { + console.error("用法: node prepare-release-assets.cjs "); + process.exit(1); +} + +/** 需要跨架构合并的更新清单文件名 */ +const MERGE_MANIFESTS = new Set(["latest.yml", "latest-mac.yml"]); + +/** 不需要上传的文件 */ +const SKIP_FILES = new Set(["builder-debug.yml"]); + +/** + * 递归收集目录下的所有文件(跳过 *-unpacked 目录) + * @param {string} dir 起始目录 + * @param {string[]} [out] 结果累加数组(递归内部使用) + * @returns {string[]} 收集到的文件路径列表 + */ +const walk = (dir, out = []) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name.endsWith("-unpacked")) continue; + walk(full, out); + } else { + out.push(full); + } + } + return out; +}; + +/** + * 合并多份同名更新清单:合并各架构的 files 列表(按 url 去重) + * 基准 path/sha512 优先指向 x64 + * @param {Array>} docs 同名清单的解析结果数组 + * @returns {Record} 合并后的清单对象 + */ +const mergeManifests = (docs) => { + if (docs.length === 1) return docs[0]; + const merged = { ...docs[0] }; + const byUrl = new Map(); + for (const doc of docs) { + for (const file of doc.files || []) { + if (!byUrl.has(file.url)) byUrl.set(file.url, file); + } + } + merged.files = [...byUrl.values()]; + const x64 = merged.files.find((file) => /x64|x86_64/i.test(file.url)); + if (x64) { + merged.path = x64.url; + merged.sha512 = x64.sha512; + } + return merged; +}; + +fs.mkdirSync(outDir, { recursive: true }); + +/** @type {Map>>} 清单名 -> 解析文档数组 */ +const manifestDocs = new Map(); + +/** @type {Set} 已写入输出目录的文件名 */ +const seen = new Set(); + +for (const file of walk(srcDir)) { + const base = path.basename(file); + if (SKIP_FILES.has(base)) continue; + + if (MERGE_MANIFESTS.has(base)) { + const doc = yaml.load(fs.readFileSync(file, "utf8")); + if (!manifestDocs.has(base)) manifestDocs.set(base, []); + manifestDocs.get(base).push(doc); + continue; + } + + if (seen.has(base)) { + console.warn(`⚠️ 跳过重复同名文件: ${base}`); + continue; + } + seen.add(base); + fs.copyFileSync(file, path.join(outDir, base)); +} + +for (const [name, docs] of manifestDocs) { + const merged = mergeManifests(docs); + fs.writeFileSync(path.join(outDir, name), yaml.dump(merged, { lineWidth: -1 })); + console.log(`✅ 合并清单 ${name}(files: ${(merged.files || []).length})`); +} + +console.log(`release-assets 准备完成,共 ${fs.readdirSync(outDir).length} 个文件`); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..7bda32de --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,209 @@ +# tag(v*)触发 +# 各架构原生 runner 构建 → 合并清单 → 创建 GitHub 草稿 Release +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: write + +env: + NODE_VERSION: 22.x + +# 同一个 tag 的发布串行执行,避免并发上传资源产生竞态 +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + # 各平台/架构在对应的原生 runner 上独立构建 + build: + name: Build on ${{ matrix.name }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - name: Windows x64 + runner: windows-latest + target: windows-x64 + - name: Windows arm64 + runner: windows-11-arm + target: windows-arm64 + - name: macOS x64 + runner: macos-15-intel + target: macos-x64 + - name: macOS arm64 + runner: macos-latest + target: macos-arm64 + - name: Linux x64 + runner: ubuntu-latest + target: linux-x64 + - name: Linux arm64 + runner: ubuntu-24.04-arm + target: linux-arm64 + steps: + - name: Check out git repository + uses: actions/checkout@v5 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Rust Cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: "native/audio-engine -> target\nnative/media-ctrl -> target" + + # 校验 tag 与 package.json 版本一致 + - name: Verify tag matches version + if: startsWith(github.ref, 'refs/tags/v') + shell: bash + run: | + PKG_VERSION="v$(node -p "require('./package.json').version")" + if [ "$PKG_VERSION" != "$GITHUB_REF_NAME" ]; then + echo "标签 $GITHUB_REF_NAME 与 package.json 版本 $PKG_VERSION 不一致" + exit 1 + fi + + # Linux: 安装系统构建/打包依赖 + - name: 安装系统依赖 (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install --no-install-recommends -y \ + libasound2-dev libdbus-1-dev pkg-config clang \ + rpm libarchive-tools + + # 清理旧构建产物 + - name: Clean workspace on Windows + if: runner.os == 'Windows' + run: | + if (Test-Path dist) { Remove-Item -Recurse -Force dist } + if (Test-Path out) { Remove-Item -Recurse -Force out } + + - name: Clean workspace on macOS & Linux + if: runner.os != 'Windows' + run: rm -rf dist out + + # CI 不需要国内镜像 + - name: 移除 .npmrc + shell: bash + run: rm -f .npmrc + + # 安装项目依赖 + - name: Install dependencies + run: pnpm install + + # 仅构建本机架构 + - name: Build ${{ matrix.name }} + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CSC_IDENTITY_AUTO_DISCOVERY: false + run: | + case "${{ matrix.target }}" in + windows-x64) + pnpm build:win -- --x64 --publish never + ;; + windows-arm64) + pnpm build:win -- --arm64 --publish never + ;; + macos-x64) + pnpm build:mac -- --x64 --publish never + ;; + macos-arm64) + pnpm build:mac -- --arm64 --publish never + ;; + linux-x64) + pnpm build:linux -- --x64 --publish never + ;; + linux-arm64) + pnpm build:linux -- --arm64 --publish never + ;; + *) + echo "Unsupported target: ${{ matrix.target }}" + exit 1 + ;; + esac + + # 修复 Linux arm64 自动更新清单命名 + # electron-builder 单独构建 arm64 时仍命名为 latest-linux.yml,会与 x64 撞名, + # 显式重命名为 latest-linux-arm64.yml(electron-updater 在 arm64 上据此查找) + - name: Rename Linux arm64 update manifest + if: matrix.target == 'linux-arm64' + shell: bash + run: | + if [ -f dist/latest-linux.yml ]; then + mv dist/latest-linux.yml dist/latest-linux-arm64.yml + echo "Renamed latest-linux.yml -> latest-linux-arm64.yml" + else + echo "dist/latest-linux.yml not found, skipping" + fi + + - name: Upload Artifact + uses: actions/upload-artifact@v7 + with: + name: SPlayer-Next-${{ matrix.name }} + path: dist/*.* + + # 合并多架构更新清单并创建 GitHub 草稿 Release + release: + name: Create GitHub Release + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Download all artifacts + uses: actions/download-artifact@v8 + with: + path: artifacts + + # 整理待发布资源 + - name: Prepare release assets + shell: bash + run: | + npm install --no-save --prefix "$RUNNER_TEMP/jsyaml" js-yaml@4.1.0 + NODE_PATH="$RUNNER_TEMP/jsyaml/node_modules" \ + node .github/scripts/prepare-release-assets.cjs artifacts release-assets + echo "待发布资源:" + ls -1 release-assets + + # 用官方 gh CLI 确定性创建草稿 Release + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + TAG="${GITHUB_REF#refs/tags/}" + # 重跑场景:若同名 release 已存在则先删除,避免脏状态(保留 tag) + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release delete "$TAG" --repo "$GITHUB_REPOSITORY" --yes + fi + gh release create "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --title "$TAG" \ + --generate-notes \ + --verify-tag \ + --draft \ + release-assets/* diff --git a/CLAUDE.md b/CLAUDE.md index 4310f7e4..12e3b229 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,7 +65,7 @@ Server protocol clients live in renderer (`src/services/streaming/`): subsonic / - `services/streaming/transform.ts` — Server response → unified `Track / Album / Artist / Playlist`. Trusts server's artist field; no client-side splitting. - `services/streaming/session.ts` — Jellyfin/Emby `/Sessions/Playing` heartbeat + PlaySessionId state machine; called from `core/player.ts`. - `stores/streaming.ts` — Server list, active state, connection, browse cache (IndexedDB via localforage `streaming-cache`). `fetchSongs` returns first batch then keeps fetching in background. -- Credentials — main process `electron/main/ipc/streaming.ts` encrypts via Electron `safeStorage` to `{userData}/streaming.json`. `accessToken / userId` not persisted; re-acquired on connect. +- Credentials — main process `electron/main/ipc/streaming.ts` encrypts via Electron `safeStorage` to `{userData}/app-data/config/streaming.json`. `accessToken / userId` not persisted; re-acquired on connect. ### Lyric Windows @@ -93,23 +93,28 @@ Declarative — defined in `src/settings/schema.ts`, types in `src/types/setting ### Data Storage ``` -{userData}/ -├── settings.json # Main config (electron/main/store/) -├── streaming.json # Streaming credentials (safeStorage encrypted) -├── app-cache/covers/ # Cover thumbnails (cover:// protocol) -├── Database/library.db # Music library (better-sqlite3, WAL) -└── logs/ +{userData}/app-data/ # 统一数据目录(与 Chromium 的 Cache/ 等隔开,便携版整体迁移此目录) +├── config/ +│ ├── settings.json # Main config (electron/main/store/) +│ ├── streaming.json # Streaming credentials (safeStorage encrypted) +│ └── lastfm.json # Last.fm credentials (safeStorage encrypted) +├── database/library.db # Music library (better-sqlite3, WAL) +├── cache/ # covers/ (cover:// protocol) + artists/ backgrounds/ songs/ +├── logs/ # App logs + native/ +└── plugins/ # scripts/ data/ logs/ + +# 全部路径集中定义于 electron/main/utils/paths.ts,改一处即可整体迁移 ``` Renderer IndexedDB (localforage): `splayer/library`, `splayer/queue`, `splayer/playlists`, `splayer/streaming-cache`. ### Cover Image -Rust extracts 300x300 JPEG thumbnail to `{userData}/app-cache/covers/` during decode; renderer reads via `cover://{filename}` protocol. Original via `getCoverRaw()` for SMTC, never cached. Streaming covers use remote URLs directly (browser cache). +Rust extracts 300x300 JPEG thumbnail to `{userData}/app-data/cache/covers/` during decode; renderer reads via `cover://{filename}` protocol. Original via `getCoverRaw()` for SMTC, never cached. Streaming covers use remote URLs directly (browser cache). ### Config Store (Main) -`electron/main/store/` is custom (not electron-store). Reads/writes `{userData}/settings.json`, merges with defaults from `shared/defaults/settings.ts`. Supports dot-path access (`store.get("system.taskbarProgress")`), atomic writes, schema migrations. +`electron/main/store/` is custom (not electron-store). Reads/writes `{userData}/app-data/config/settings.json` (path via `electron/main/utils/paths.ts`), merges with defaults from `shared/defaults/settings.ts`. Supports dot-path access (`store.get("system.taskbarProgress")`), atomic writes, schema migrations. ### i18n diff --git a/dev-app-update.yml b/dev-app-update.yml index 69aabe88..531ab419 100644 --- a/dev-app-update.yml +++ b/dev-app-update.yml @@ -1,3 +1,4 @@ -provider: generic -url: https://example.com/auto-updates +provider: github +owner: SPlayer-Dev +repo: SPlayer-Next updaterCacheDirName: splayer-next-updater diff --git a/docs/plugins-development.md b/docs/plugins-development.md index d8ed8c04..dd6aa2c0 100644 --- a/docs/plugins-development.md +++ b/docs/plugins-development.md @@ -193,7 +193,7 @@ splayer.log.warn(...args); splayer.log.error(...args); ``` -转发到宿主主日志系统,并落盘到 `{userData}/plugins/logs/{id}.log`。`console.*` 也会自动转发到同样的通道。 +转发到宿主主日志系统,并落盘到 `{userData}/app-data/plugins/logs/{id}.log`。`console.*` 也会自动转发到同样的通道。 ### 私有 KV 存储 @@ -204,7 +204,7 @@ splayer.storage.remove(key: string): Promise; splayer.storage.keys(): Promise; ``` -每个插件一个独立命名空间,落盘到 `{userData}/plugins/data/{id}.json`。卸载插件会自动清除。 +每个插件一个独立命名空间,落盘到 `{userData}/app-data/plugins/data/{id}.json`。卸载插件会自动清除。 ### 用户设置 @@ -313,7 +313,7 @@ splayer.on("musicUrl", async (req) => { }); ``` -2. 查看 `{userData}/plugins/logs/{id}.log` 拿插件的运行日志 +2. 查看 `{userData}/app-data/plugins/logs/{id}.log` 拿插件的运行日志 3. 修改脚本 → 重新导入一次(id 会因为源码 sha1 变化而变化,旧版本会自动被替换) diff --git a/docs/plugins-usage.md b/docs/plugins-usage.md index 6130220e..7f6fe828 100644 --- a/docs/plugins-usage.md +++ b/docs/plugins-usage.md @@ -17,7 +17,7 @@ SPlayer-Next 支持通过插件扩展音乐能力,包括 URL 解析、歌词 点击顶部「**本地导入**」→ 选一个 `.js` 脚本文件。脚本会被复制到: ``` -{userData}/plugins/scripts/{id}.js +{userData}/app-data/plugins/scripts/{id}.js ``` `{id}` 由 `插件名 + 源码 SHA1 前 8 位` 自动生成,不用你操心。 @@ -82,7 +82,7 @@ SPlayer-Next 支持通过插件扩展音乐能力,包括 URL 解析、歌词 **3. 插件的本地数据存哪?** ``` -{userData}/ +{userData}/app-data/ ├── plugins/ │ ├── scripts/{id}.js 脚本源码 │ ├── manifest.json 已安装列表 diff --git a/electron-builder.config.ts b/electron-builder.config.ts index 1ef32fae..0d5befa5 100644 --- a/electron-builder.config.ts +++ b/electron-builder.config.ts @@ -2,7 +2,7 @@ import type { Configuration } from "electron-builder"; const config: Configuration = { appId: "com.imsyy.splayer-next", - productName: "SPlayer Next", + productName: "SPlayer-Next", copyright: "Copyright © imsyy 2025", directories: { buildResources: "public" }, // afterPack: "./scripts/after-pack.ts", @@ -51,12 +51,7 @@ const config: Configuration = { icon: "public/icons/logo.ico", artifactName: "${productName}-${version}-${arch}.${ext}", forceCodeSigning: false, - target: [ - { - target: "nsis", - arch: ["x64"], - }, - ], + target: ["nsis", "portable"], }, nsis: { oneClick: false, @@ -64,8 +59,8 @@ const config: Configuration = { installerIcon: "public/icons/favicon.ico", uninstallerIcon: "public/icons/favicon.ico", artifactName: "${productName}-${version}-${arch}-setup.${ext}", - shortcutName: "${productName}", - uninstallDisplayName: "${productName}", + shortcutName: "SPlayer Next", + uninstallDisplayName: "SPlayer Next", createDesktopShortcut: "always", allowElevation: true, allowToChangeInstallationDirectory: true, @@ -92,16 +87,7 @@ const config: Configuration = { NSDownloadsFolderUsageDescription: "Application requests access to the user's Downloads folder.", }, - target: [ - { - target: "dmg", - arch: ["x64", "arm64"], - }, - { - target: "zip", - arch: ["x64", "arm64"], - }, - ], + target: ["dmg", "zip"], }, dmg: { artifactName: "${productName}-${version}-${arch}.${ext}", @@ -112,24 +98,7 @@ const config: Configuration = { artifactName: "${name}-${version}-${arch}.${ext}", maintainer: "imsyy.top", category: "Audio;Music;AudioVideo;", - target: [ - { - target: "AppImage", - arch: ["x64", "arm64"], - }, - { - target: "deb", - arch: ["x64", "arm64"], - }, - { - target: "rpm", - arch: ["x64", "arm64"], - }, - { - target: "tar.gz", - arch: ["x64", "arm64"], - }, - ], + target: ["AppImage", "deb", "rpm", "tar.gz"], }, appImage: { artifactName: "${name}-${version}-${arch}.${ext}", @@ -138,7 +107,11 @@ const config: Configuration = { electronDownload: { mirror: "https://npmmirror.com/mirrors/electron/", }, - publish: [], + publish: { + provider: "github", + owner: "SPlayer-Dev", + repo: "SPlayer-Next", + }, }; export default config; diff --git a/electron/main/core/index.ts b/electron/main/core/index.ts index 68f1b72c..cd04deda 100644 --- a/electron/main/core/index.ts +++ b/electron/main/core/index.ts @@ -10,6 +10,7 @@ import { init as initSongCache } from "@main/services/songCache"; import { pluginRegistry } from "@main/plugins/registry"; import { registerCacheScheme, handleCacheProtocol } from "@main/utils/protocol"; import { startServer, stopServer } from "@main/server"; +import { initUpdater, disposeUpdater } from "@main/services/updater"; import { coreLog, initLogger } from "@main/utils/logger"; /** @@ -36,7 +37,7 @@ export const initApp = (): void => { configureMemoryOptimizations(); // 初始化日志 initLogger(); - // 单例锁:已有实例时聚焦现有窗口 + // 单例锁 const gotLock = app.requestSingleInstanceLock(); if (!gotLock) { app.quit(); @@ -51,7 +52,7 @@ export const initApp = (): void => { }); // 注册缓存协议方案 registerCacheScheme(); - + // 其他初始化 app.whenReady().then(() => { electronApp.setAppUserModelId("com.imsyy.splayer-next"); // 注册 cache:// 协议处理 @@ -59,44 +60,46 @@ export const initApp = (): void => { app.on("browser-window-created", (_, window) => { optimizer.watchWindowShortcuts(window); }); - // 初始化数据库 - initDatabase(); - // 启动歌曲缓存服务 - initSongCache(); // 注册 IPC registerIpcHandlers(); - // 初始化系统媒体控件 + // 创建主窗口 + createMainWindow(); + // 初始化数据库 + initDatabase(); + // 启动歌曲缓存 + void initSongCache(); initMedia(); // 初始化 Last.fm 集成 initLastfm(); - // 初始化插件系统(扫描并启动已启用的插件) + // 初始化插件系统 pluginRegistry.init(); - // 创建主窗口 - createMainWindow(); // 恢复歌词相关窗口 restoreLyricWindows(); // 注册全局快捷键 initGlobalHotkey(); - // 启动外部 API 服务(fire-and-forget;监听结果通过 getStatus 暴露给渲染端) + // 启动外部 API 服务 void startServer(); + // 初始化自动更新 + initUpdater(); + // macOS 特例:激活应用时如果没有窗口则创建新窗口 app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) createMainWindow(); }); coreLog.info("应用初始化完成"); }); - + // 所有窗口关闭时退出应用 app.on("window-all-closed", () => { if (process.platform !== "darwin") { app.quit(); } }); - - // 退出前清理原生模块资源 + // 退出前清理 app.on("before-quit", () => { coreLog.info("应用即将退出,清理资源"); shutdownMedia(); closeDatabase(); void stopServer(); void pluginRegistry.shutdown(); + disposeUpdater(); }); }; diff --git a/electron/main/database/index.ts b/electron/main/database/index.ts index ed0d93dc..45e03361 100644 --- a/electron/main/database/index.ts +++ b/electron/main/database/index.ts @@ -1,13 +1,12 @@ import fs from "node:fs"; import path from "node:path"; -import { app } from "electron"; import Database from "better-sqlite3"; import { libraryLog } from "@main/utils/logger"; +import { databaseDir } from "@main/utils/paths"; import { migrate } from "./migration"; /** 数据库文件路径 */ -const dbDir = path.join(app.getPath("userData"), "Database"); -const dbPath = path.join(dbDir, "library.db"); +const dbPath = path.join(databaseDir, "library.db"); let db: Database.Database | null = null; @@ -22,7 +21,7 @@ export const isDbOpen = (): boolean => db !== null; /** 初始化数据库:打开连接、启用 WAL、建表建索引、执行迁移 */ export const initDatabase = (): void => { - fs.mkdirSync(dbDir, { recursive: true }); + fs.mkdirSync(databaseDir, { recursive: true }); db = new Database(dbPath); db.pragma("journal_mode = WAL"); diff --git a/electron/main/ipc/cache.ts b/electron/main/ipc/cache.ts index 16b26165..97bbb3da 100644 --- a/electron/main/ipc/cache.ts +++ b/electron/main/ipc/cache.ts @@ -4,7 +4,6 @@ import fs from "node:fs/promises"; import { existsSync, type Dirent } from "node:fs"; import { store } from "@main/store"; import { - defaultAppCacheDir, getAppCacheDir, getCoverCacheDir, getArtistCacheDir, @@ -19,6 +18,7 @@ import { clearLyricMatchCache } from "@main/database/lyricMatchCache"; import * as songCache from "@main/services/songCache"; import type { TrackSource } from "@shared/types/player"; import { systemLog } from "@main/utils/logger"; +import { defaultCacheDir } from "@main/utils/paths"; /** 已知的缓存类别 */ export type CacheCategory = @@ -240,13 +240,13 @@ export const registerCacheIpc = (): void => { }, ); - /** 还原默认缓存目录(同样清空旧的文件类缓存) */ + /** 还原默认缓存目录 */ ipcMain.handle("cache:resetDir", async (): Promise => { await Promise.all(idsByKind("file").map((id) => categoryHandlers[id].clear())); store.set("cache.dir", null); syncCoverCacheDir(); songCache.reloadDir(); - return defaultAppCacheDir; + return defaultCacheDir; }); /** 歌曲文件级缓存:命中查询 */ diff --git a/electron/main/ipc/index.ts b/electron/main/ipc/index.ts index 915fa5c4..47e65f95 100644 --- a/electron/main/ipc/index.ts +++ b/electron/main/ipc/index.ts @@ -14,6 +14,7 @@ import { registerLastfmIpc } from "./lastfm"; import { registerCacheIpc } from "./cache"; import { registerExternalApiIpc } from "./externalApi"; import { registerStatsIpc } from "./stats"; +import { registerUpdateIpc } from "./update"; /** 注册所有 IPC 处理 */ export const registerIpcHandlers = (): void => { @@ -33,4 +34,5 @@ export const registerIpcHandlers = (): void => { registerCacheIpc(); registerExternalApiIpc(); registerStatsIpc(); + registerUpdateIpc(); }; diff --git a/electron/main/ipc/streaming.ts b/electron/main/ipc/streaming.ts index f024569e..f2ed8294 100644 --- a/electron/main/ipc/streaming.ts +++ b/electron/main/ipc/streaming.ts @@ -4,12 +4,13 @@ */ import fs from "node:fs"; import path from "node:path"; -import { app, ipcMain, safeStorage } from "electron"; +import { ipcMain, safeStorage } from "electron"; import { writeFileSync as atomicWriteSync } from "atomically"; import { streamingLog } from "@main/utils/logger"; +import { configDir } from "@main/utils/paths"; import type { StreamingServerConfig } from "@shared/types/streaming"; -const STORAGE_FILE = path.join(app.getPath("userData"), "streaming.json"); +const STORAGE_FILE = path.join(configDir, "streaming.json"); /** 持久化形态:密码加密、accessToken/userId 不持久化(每次会话重新登录) */ interface PersistedServer extends Omit< diff --git a/electron/main/ipc/update.ts b/electron/main/ipc/update.ts new file mode 100644 index 00000000..971e951b --- /dev/null +++ b/electron/main/ipc/update.ts @@ -0,0 +1,10 @@ +import { ipcMain } from "electron"; +import * as updater from "@main/services/updater"; + +/** 注册更新相关 IPC */ +export const registerUpdateIpc = (): void => { + ipcMain.handle("update:check", (_event, manual: boolean) => updater.checkForUpdates(manual)); + ipcMain.handle("update:download", () => updater.downloadUpdate()); + ipcMain.handle("update:install", () => updater.quitAndInstall()); + ipcMain.handle("update:openDownloadPage", () => updater.openDownloadPage()); +}; diff --git a/electron/main/plugins/registry.ts b/electron/main/plugins/registry.ts index 0dfa5ccc..1e4fc239 100644 --- a/electron/main/plugins/registry.ts +++ b/electron/main/plugins/registry.ts @@ -1,7 +1,7 @@ /** * 插件注册表 * - * - 扫描 `{userData}/plugins/scripts/` 下的 .js 文件 + * - 扫描 `{userData}/app-data/plugins/scripts/` 下的 .js 文件 * - 维护 `Map`(manifest + 运行时状态 + sandbox) * - 提供 install / uninstall / setEnabled / 启停 * - 订阅 sandbox 事件,处理 hostCall、crash、重启 @@ -10,7 +10,6 @@ import fs from "node:fs"; import path from "node:path"; import { EventEmitter } from "node:events"; -import { app } from "electron"; import { writeFileSync as atomicWriteSync } from "atomically"; import type { PluginAction, @@ -23,12 +22,13 @@ import { PluginErrorCodes, RESTART_MAX_ATTEMPTS } from "@shared/defaults/plugin- import { store } from "@main/store"; import { getLocale } from "@main/utils/i18n"; import { coreLog } from "@main/utils/logger"; +import { pluginsDir } from "@main/utils/paths"; import { Sandbox } from "./sandbox"; import { loadScript } from "./loader"; import { dispatchHostCall } from "./host"; import { pluginStorageDrop } from "./storage"; -const pluginsRoot = (): string => path.join(app.getPath("userData"), "plugins"); +const pluginsRoot = (): string => pluginsDir; const scriptsDir = (): string => path.join(pluginsRoot(), "scripts"); const manifestFile = (): string => path.join(pluginsRoot(), "manifest.json"); diff --git a/electron/main/plugins/storage.ts b/electron/main/plugins/storage.ts index 4c3b2e92..b428d0aa 100644 --- a/electron/main/plugins/storage.ts +++ b/electron/main/plugins/storage.ts @@ -1,18 +1,17 @@ /** * 每插件隔离的 KV 存储 * - * 落盘:`{userData}/plugins/data/{pluginId}.json` + * 落盘:`{userData}/app-data/plugins/data/{pluginId}.json` * 原子写(atomically)防撕裂;内存缓存避免频繁读。 */ import fs from "node:fs"; import path from "node:path"; -import { app } from "electron"; import { writeFileSync as atomicWriteSync } from "atomically"; +import { pluginsDir } from "@main/utils/paths"; /** 所有插件数据的根目录 */ -export const getPluginsDataDir = (): string => - path.join(app.getPath("userData"), "plugins", "data"); +export const getPluginsDataDir = (): string => path.join(pluginsDir, "data"); const caches = new Map>(); diff --git a/electron/main/services/lastfm/credentials.ts b/electron/main/services/lastfm/credentials.ts index 88217d38..bd06f719 100644 --- a/electron/main/services/lastfm/credentials.ts +++ b/electron/main/services/lastfm/credentials.ts @@ -1,11 +1,12 @@ import fs from "node:fs"; import path from "node:path"; -import { app, safeStorage } from "electron"; +import { safeStorage } from "electron"; import { writeFileSync as atomicWriteSync } from "atomically"; import { lastfmLog } from "@main/utils/logger"; +import { configDir } from "@main/utils/paths"; /** 凭证文件 */ -const STORAGE_FILE = path.join(app.getPath("userData"), "lastfm.json"); +const STORAGE_FILE = path.join(configDir, "lastfm.json"); /** 解密后的凭证 */ export interface LastfmCredentials { diff --git a/electron/main/services/updater.ts b/electron/main/services/updater.ts new file mode 100644 index 00000000..bffc2392 --- /dev/null +++ b/electron/main/services/updater.ts @@ -0,0 +1,147 @@ +import electronUpdater, { type UpdateInfo } from "electron-updater"; +import { shell } from "electron"; +import { sendToMain } from "@main/utils/broadcast"; +import { store } from "@main/store"; +import { isDev, isMac, isPortable } from "@main/utils/config"; +import { updaterLog } from "@main/utils/logger"; +import type { UpdateEvent, UpdateMeta } from "@shared/types/update"; + +const { autoUpdater } = electronUpdater; + +/** 是否支持内置下载安装 */ +const canSelfInstall = !isMac && !isPortable; + +/** Releases 页:手动下载与兜底跳转 */ +const RELEASES_URL = "https://github.com/SPlayer-Dev/SPlayer-Next/releases/latest"; + +/** 定时检查间隔(6 小时) */ +const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; + +/** 本次检查是否由用户手动触发 */ +let manualCheck = false; + +/** 是否有检查正在进行 */ +let checking = false; + +let intervalTimer: ReturnType | null = null; + +const emit = (event: UpdateEvent): void => sendToMain("update:event", event); + +/** + * 规范化更新日志格式 + * @param notes 更新日志,可能是字符串或数组 + * @returns 规范化后的更新日志字符串 + */ +const normalizeNotes = (notes: UpdateInfo["releaseNotes"]): string => { + if (!notes) return ""; + if (typeof notes === "string") return notes; + return notes + .map((item) => item.note ?? "") + .filter(Boolean) + .join("\n\n"); +}; + +/** + * 将 electron-updater 的 UpdateInfo 转换为 UpdateMeta + * @param info 更新信息 + * @returns 更新元数据 + */ +const toMeta = (info: UpdateInfo): UpdateMeta => ({ + version: info.version, + releaseNotes: normalizeNotes(info.releaseNotes), + releaseDate: info.releaseDate, + size: Math.max(0, ...(info.files ?? []).map((file) => file.size ?? 0)), +}); + +const bindEvents = (): void => { + autoUpdater.on("checking-for-update", () => emit({ type: "checking" })); + autoUpdater.on("update-available", (info) => { + checking = false; + emit({ + type: "available", + meta: toMeta(info), + manual: manualCheck, + canInstall: canSelfInstall, + }); + }); + autoUpdater.on("update-not-available", () => { + checking = false; + emit({ type: "notAvailable", manual: manualCheck }); + }); + autoUpdater.on("download-progress", (progress) => + emit({ type: "progress", percent: Math.round(progress.percent) }), + ); + autoUpdater.on("update-downloaded", (info) => emit({ type: "downloaded", meta: toMeta(info) })); + autoUpdater.on("error", (error) => { + checking = false; + updaterLog.error("更新出错", error); + emit({ type: "error", message: error?.message ?? String(error), manual: manualCheck }); + }); +}; + +/** + * 执行更新检查 + * @param manual 是否由用户手动触发 + */ +const runCheck = (manual: boolean): void => { + if (checking) { + if (manual) manualCheck = true; + return; + } + checking = true; + manualCheck = manual; + autoUpdater.checkForUpdates().catch((error) => { + checking = false; + updaterLog.error("检查更新失败", error); + emit({ type: "error", message: error?.message ?? String(error), manual: manualCheck }); + }); +}; + +/** + * 检查更新:自动检查受设置开关约束,手动检查始终执行 + * @param manual 是否由用户手动触发 + */ +export const checkForUpdates = (manual: boolean): void => { + if (!manual && !store.get("update.autoCheck")) return; + runCheck(manual); +}; + +/** 下载更新 */ +export const downloadUpdate = (): void => { + if (!canSelfInstall) return; + autoUpdater.downloadUpdate().catch((error) => { + updaterLog.error("下载更新失败", error); + emit({ type: "error", message: error?.message ?? String(error), manual: true }); + }); +}; + +/** 退出并安装 */ +export const quitAndInstall = (): void => { + if (!canSelfInstall) return; + autoUpdater.quitAndInstall(); +}; + +/** 打开 Releases 下载页 */ +export const openDownloadPage = (): void => { + void shell.openExternal(RELEASES_URL); +}; + +/** 初始化更新器 */ +export const initUpdater = (): void => { + autoUpdater.logger = updaterLog; + autoUpdater.autoDownload = false; + autoUpdater.autoInstallOnAppQuit = true; + bindEvents(); + if (isDev) { + updaterLog.info("开发模式,跳过自动检查更新"); + return; + } + // 定时检查 + intervalTimer = setInterval(() => checkForUpdates(false), CHECK_INTERVAL_MS); +}; + +/** 清理定时器 */ +export const disposeUpdater = (): void => { + if (intervalTimer) clearInterval(intervalTimer); + intervalTimer = null; +}; diff --git a/electron/main/store/index.ts b/electron/main/store/index.ts index 476b9776..6c8d2105 100644 --- a/electron/main/store/index.ts +++ b/electron/main/store/index.ts @@ -7,9 +7,10 @@ import type { SystemConfig } from "@shared/types/settings"; import type { ConfigPath, PathValue } from "./types"; import { deepMerge, getByPath, setByPath } from "./utils"; import { migrations } from "./migrations"; +import { configDir } from "@main/utils/paths"; /** 配置文件路径 */ -const configPath = path.join(app.getPath("userData"), "settings.json"); +const configPath = path.join(configDir, "settings.json"); /** 配置版本键名 */ const META_KEY = "__configVersion"; diff --git a/electron/main/utils/config.ts b/electron/main/utils/config.ts index 8a513346..78e66fea 100644 --- a/electron/main/utils/config.ts +++ b/electron/main/utils/config.ts @@ -2,6 +2,7 @@ import { is } from "@electron-toolkit/utils"; import { app } from "electron"; import path from "node:path"; import { store } from "@main/store"; +import { defaultCacheDir } from "./paths"; /** * 是否为开发环境 @@ -16,6 +17,9 @@ export const isMac = process.platform === "darwin"; /** 是否为 Linux 系统 */ export const isLinux = process.platform === "linux"; +/** 是否为便携版 */ +export const isPortable = !!process.env.PORTABLE_EXECUTABLE_DIR; + /** * 软件版本 * @returns string @@ -25,11 +29,8 @@ export const appVersion = app.getVersion(); /** 应用名称 */ export const appName = app.getName(); -/** 默认缓存根目录(区别于 Electron 内置的 Cache/Code Cache) */ -export const defaultAppCacheDir = path.join(app.getPath("userData"), "app-cache"); - -/** 当前生效的缓存根目录:用户在设置中可选自定义路径,未配置时回退默认 */ -export const getAppCacheDir = (): string => store.get("cache.dir") || defaultAppCacheDir; +/** 当前生效的缓存根目录 */ +export const getAppCacheDir = (): string => store.get("cache.dir") || defaultCacheDir; /** 当前生效的封面缩略图目录 */ export const getCoverCacheDir = (): string => path.join(getAppCacheDir(), "covers"); diff --git a/electron/main/utils/logger.ts b/electron/main/utils/logger.ts index b0e8822f..68f33b10 100644 --- a/electron/main/utils/logger.ts +++ b/electron/main/utils/logger.ts @@ -1,14 +1,14 @@ import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs"; import path from "node:path"; -import { app } from "electron"; import log from "electron-log"; import { isDev } from "./config"; +import { logsDir as logsBaseDir } from "./paths"; /** 日志根目录:开发模式放 dev 子目录,生产模式直接放 logs/ */ -export const logsDir = isDev ? path.join(app.getPath("logs"), "dev") : app.getPath("logs"); +export const logsDir = isDev ? path.join(logsBaseDir, "dev") : logsBaseDir; /** 原生模块日志目录 */ -export const nativeLogsDir = path.join(app.getPath("userData"), "logs", "native"); +export const nativeLogsDir = path.join(logsBaseDir, "native"); /** * 自动清理超过指定天数的旧日志文件 @@ -88,3 +88,4 @@ export const songCacheLog = log.scope("songCache"); export const serverLog = log.scope("server"); export const pluginLog = log.scope("plugin"); export const lastfmLog = log.scope("lastfm"); +export const updaterLog = log.scope("updater"); diff --git a/electron/main/utils/paths.ts b/electron/main/utils/paths.ts new file mode 100644 index 00000000..bcfdc99a --- /dev/null +++ b/electron/main/utils/paths.ts @@ -0,0 +1,39 @@ +import { app } from "electron"; +import { existsSync, mkdirSync } from "node:fs"; +import path from "node:path"; + +/** + * 便携模式:将 userData 重定向到 exe 同级 UserData 目录 + * + * electron-builder 的便携版会注入 PORTABLE_EXECUTABLE_DIR,此时把整个 userData + * (含 Chromium 缓存与下方 app-data)落到 exe 同级目录,实现免安装、可整体拷贝 + */ +if (process.env.PORTABLE_EXECUTABLE_DIR) { + const portableUserData = path.join(process.env.PORTABLE_EXECUTABLE_DIR, "UserData"); + if (!existsSync(portableUserData)) mkdirSync(portableUserData, { recursive: true }); + app.setPath("userData", portableUserData); +} + +/** + * 统一数据根目录 + * + * 所有应用自定义数据(配置 / 数据库 / 缓存 / 日志 / 插件)集中存放于此, + * 与 Chromium 自建的 Cache/ GPUCache/ 等隔开,便于备份与后续便携版整体迁移。 + * 仅需改这一处即可整体改变数据落点(如便携版指向 exe 同级目录)。 + */ +export const dataRoot = path.join(app.getPath("userData"), "app-data"); + +/** 配置目录:settings.json / streaming.json / lastfm.json */ +export const configDir = path.join(dataRoot, "config"); + +/** 数据库目录:library.db */ +export const databaseDir = path.join(dataRoot, "database"); + +/** 默认缓存根目录:covers / artists / backgrounds / songs */ +export const defaultCacheDir = path.join(dataRoot, "cache"); + +/** 日志根目录:应用日志 + native/ */ +export const logsDir = path.join(dataRoot, "logs"); + +/** 插件根目录:scripts / data / logs */ +export const pluginsDir = path.join(dataRoot, "plugins"); diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index 21689ed1..9ae2aae9 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -17,6 +17,7 @@ import { StreamingApi } from "@shared/types/streaming"; import { LastfmApi } from "@shared/types/lastfm"; import { IpcResponse } from "@shared/types/player"; import { StatsApi } from "@shared/types/stats"; +import { UpdateApi } from "@shared/types/update"; declare global { interface Window { @@ -75,6 +76,7 @@ declare global { restart: () => Promise; getStatus: () => Promise; }; + update: UpdateApi; }; } } diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 350c7e0a..34dfad3d 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -6,6 +6,7 @@ import type { HotkeyActionId, HotkeyBinding, HotkeyConflict } from "@shared/type import type { LoadOptions, TrackSource } from "@shared/types/player"; import type { StreamingServerConfig } from "@shared/types/streaming"; import type { PlayEventInput, FavoriteEventInput } from "@shared/types/stats"; +import type { UpdateEvent } from "@shared/types/update"; /** 订阅主进程推送的事件 */ const subscribe = (channel: string, callback: (data: T) => void): (() => void) => { @@ -387,6 +388,18 @@ const api = { // 查询当前运行状态 getStatus: () => ipcRenderer.invoke("externalApi:getStatus"), }, + update: { + // 检查更新 + check: (manual: boolean) => ipcRenderer.invoke("update:check", manual), + // 下载更新(Win/Linux) + download: () => ipcRenderer.invoke("update:download"), + // 退出并安装 + install: () => ipcRenderer.invoke("update:install"), + // 打开 Releases 下载页(mac / 兜底) + openDownloadPage: () => ipcRenderer.invoke("update:openDownloadPage"), + // 订阅更新事件 + onEvent: (callback: (event: UpdateEvent) => void) => subscribe("update:event", callback), + }, stats: { // 记录一次播放 recordPlay: (event: PlayEventInput) => ipcRenderer.send("stats:recordPlay", event), diff --git a/eslint.config.mjs b/eslint.config.mjs index 2c636240..467d6323 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -16,6 +16,7 @@ export default defineConfig( "**/public/", "**/.git/", "native/*/index.d.ts", + ".github/scripts/", ]), tseslint.configs.recommended, { languageOptions: { globals: autoImports.globals } }, diff --git a/index.html b/index.html index 3cb7d4e0..b7bcd472 100644 --- a/index.html +++ b/index.html @@ -50,10 +50,12 @@ flex-direction: column; align-items: center; gap: 18px; + transform: translateZ(0); } .splash-logo { width: 72px; height: 72px; + will-change: transform, opacity; animation: splash-rise 0.5s cubic-bezier(0.16, 1, 0.3, 1) both, splash-flicker 1.8s ease-in-out 0.5s infinite; @@ -67,11 +69,13 @@ font-family: "logo", sans-serif; font-size: 34px; line-height: 1; + will-change: transform, opacity; animation: splash-rise 0.5s cubic-bezier(0.16, 1, 0.3, 1) 0.12s both; } .splash-next { width: 98px; height: 24px; + will-change: transform, opacity; animation: splash-rise 0.5s cubic-bezier(0.16, 1, 0.3, 1) 0.24s both; } .splash-next .letter { @@ -82,6 +86,7 @@ stroke-linejoin: round; stroke-dasharray: 400; stroke-dashoffset: 400; + will-change: stroke-dashoffset; animation: splash-draw 1s ease forwards; } .splash-next .letter:nth-child(1) { diff --git a/package.json b/package.json index 7ec6580d..ade4a088 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "splayer-next", - "productName": "SPlayer Next", + "productName": "SPlayer-Next", "version": "1.0.0", "description": "A modern cross-platform music player built with Electron, Vue 3, and TypeScript", "type": "module", diff --git a/shared/defaults/settings.ts b/shared/defaults/settings.ts index efdb170b..db99c964 100644 --- a/shared/defaults/settings.ts +++ b/shared/defaults/settings.ts @@ -113,6 +113,9 @@ export const defaultSystemConfig: SystemConfig = { wsEnabled: false, port: 14558, }, + update: { + autoCheck: true, + }, system: { rememberWindowState: true, taskbarProgress: true, diff --git a/shared/types/plugin.ts b/shared/types/plugin.ts index 3270232f..fd574578 100644 --- a/shared/types/plugin.ts +++ b/shared/types/plugin.ts @@ -47,7 +47,7 @@ export interface PluginManifest { hash: string; /** 安装时间戳(ms) */ installedAt: number; - /** 脚本相对 `{userData}/plugins/scripts/` 的文件名 */ + /** 脚本相对 `{userData}/app-data/plugins/scripts/` 的文件名 */ fileName: string; } diff --git a/shared/types/settings.ts b/shared/types/settings.ts index f12b4ef5..95fb4842 100644 --- a/shared/types/settings.ts +++ b/shared/types/settings.ts @@ -247,7 +247,7 @@ export interface SongCacheSettings { /** 缓存配置 */ export interface CacheSettings { - /** 自定义缓存目录;null 使用默认 {userData}/app-cache */ + /** 自定义缓存目录;null 使用默认 {userData}/app-data/cache */ dir: string | null; /** 歌曲文件级缓存 */ songCache: SongCacheSettings; @@ -295,6 +295,12 @@ export interface WindowStates { taskbarLyric: TaskbarLyricWindowState; } +/** 应用更新配置 */ +export interface AppUpdateSettings { + /** 自动检查更新 */ + autoCheck: boolean; +} + /** 后端配置汇总 */ export interface SystemConfig { /** 播放器配置 */ @@ -321,6 +327,8 @@ export interface SystemConfig { lastfm: LastfmSettings; /** 外部 API 服务(HTTP + WS) */ externalApi: ExternalApiSettings; + /** 应用更新配置 */ + update: AppUpdateSettings; /** 系统配置 */ system: { /** 记忆窗口状态 */ diff --git a/shared/types/update.ts b/shared/types/update.ts new file mode 100644 index 00000000..40f5a720 --- /dev/null +++ b/shared/types/update.ts @@ -0,0 +1,44 @@ +/** 应用更新阶段 */ +export type UpdatePhase = + | "idle" + | "checking" + | "available" + | "downloading" + | "downloaded" + | "upToDate" + | "error"; + +/** 更新信息 */ +export interface UpdateMeta { + /** 新版本号 */ + version: string; + /** release notes */ + releaseNotes: string; + /** 发布日期(ISO 字符串) */ + releaseDate: string; + /** 更新包大小(字节,0 表示未知) */ + size: number; +} + +/** 主进程推送到渲染层的更新事件 */ +export type UpdateEvent = + | { type: "checking" } + | { type: "available"; meta: UpdateMeta; manual: boolean; canInstall: boolean } + | { type: "notAvailable"; manual: boolean } + | { type: "progress"; percent: number } + | { type: "downloaded"; meta: UpdateMeta } + | { type: "error"; message: string; manual: boolean }; + +/** 更新模块对渲染层暴露的 API */ +export interface UpdateApi { + /** 检查更新 */ + check: (manual: boolean) => Promise; + /** 下载更新(Win/Linux) */ + download: () => Promise; + /** 退出并安装 */ + install: () => Promise; + /** 打开 Releases 下载页 */ + openDownloadPage: () => Promise; + /** 订阅更新事件,返回取消订阅函数 */ + onEvent: (callback: (event: UpdateEvent) => void) => () => void; +} diff --git a/src/components/layout/NavHeader.vue b/src/components/layout/NavHeader.vue index 5dfc9ea1..4c885ef1 100644 --- a/src/components/layout/NavHeader.vue +++ b/src/components/layout/NavHeader.vue @@ -1,6 +1,7 @@ + + diff --git a/src/components/onboarding/StepAgreement.vue b/src/components/onboarding/StepAgreement.vue index 373f0bad..8afb66e4 100644 --- a/src/components/onboarding/StepAgreement.vue +++ b/src/components/onboarding/StepAgreement.vue @@ -47,7 +47,7 @@ const handleContinue = (): void => {
@@ -76,62 +76,3 @@ const handleContinue = (): void => {
- - diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 1fdc2a8d..c7790bc2 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -1,4 +1,14 @@ { + "update": { + "dialogTitle": "New Version Available", + "download": "Download", + "downloading": "Downloading", + "installNow": "Restart & Install", + "goDownload": "Go to Download", + "upToDate": "You're on the latest version", + "readyToast": "Update ready — restart to install", + "failed": "Update failed, please try again later" + }, "onboarding": { "next": "Next", "back": "Back", @@ -549,6 +559,7 @@ "plugins": "Plugin Manager" }, "section": { + "update": "Update", "language": "Language", "playback": "Player", "playControl": "Playback Control", @@ -1164,6 +1175,14 @@ "label": "Remember Window State", "description": "Restore window size, position and maximized state on next launch" }, + "autoCheckUpdate": { + "label": "Auto Check for Updates", + "description": "Check for new versions in the background on launch and periodically" + }, + "checkUpdate": { + "label": "Check for Updates", + "description": "Check for a new version now" + }, "showPerformanceMonitor": { "label": "Performance Monitor", "description": "Floating widget showing FPS / frame time / memory. Click to switch" diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 1190c2a0..a6743545 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1,4 +1,14 @@ { + "update": { + "dialogTitle": "发现新版本", + "download": "下载", + "downloading": "下载中", + "installNow": "立即重启安装", + "goDownload": "前往下载页", + "upToDate": "已是最新版本", + "readyToast": "更新已就绪,可重启安装", + "failed": "更新失败,请稍后重试" + }, "onboarding": { "next": "下一步", "back": "上一步", @@ -537,6 +547,7 @@ "plugins": "插件管理" }, "section": { + "update": "更新", "language": "语言", "playback": "播放器", "playControl": "播放控制", @@ -1152,6 +1163,14 @@ "label": "记忆窗口状态", "description": "下次启动时恢复窗口的大小、位置和最大化状态" }, + "autoCheckUpdate": { + "label": "自动检查更新", + "description": "启动后及定时在后台检查新版本" + }, + "checkUpdate": { + "label": "检查更新", + "description": "立即检查是否有新版本" + }, "showPerformanceMonitor": { "label": "性能监视器", "description": "悬浮显示 FPS / 帧时间 / 内存占用,点击切换指标" diff --git a/src/layouts/MainLayout.vue b/src/layouts/MainLayout.vue index e394a7f8..4edb8c9c 100644 --- a/src/layouts/MainLayout.vue +++ b/src/layouts/MainLayout.vue @@ -128,4 +128,6 @@ const playerBarInnerClass = computed(() => { + + diff --git a/src/settings/categories/general.ts b/src/settings/categories/general.ts index 382dc63b..cbf7d6fb 100644 --- a/src/settings/categories/general.ts +++ b/src/settings/categories/general.ts @@ -1,6 +1,7 @@ import type { SettingCategory } from "@/types/settings-schema"; import { LOCALES } from "@shared/types/settings"; import StorageManager from "@/components/settings/custom/StorageManager.vue"; +import { useUpdateStore } from "@/stores/update"; import IconLucideCog from "~icons/lucide/cog"; const generalCategory: SettingCategory = { @@ -52,6 +53,22 @@ const generalCategory: SettingCategory = { }, ], }, + { + id: "update", + items: [ + { + key: "autoCheckUpdate", + type: "switch", + binding: { store: "settings", path: "system.update.autoCheck" }, + defaultValue: true, + }, + { + key: "checkUpdate", + type: "button", + action: () => useUpdateStore().checkManually(), + }, + ], + }, { id: "debug", items: [ diff --git a/src/stores/update.ts b/src/stores/update.ts new file mode 100644 index 00000000..28c75fa5 --- /dev/null +++ b/src/stores/update.ts @@ -0,0 +1,99 @@ +import type { UpdateEvent, UpdateMeta, UpdatePhase } from "@shared/types/update"; +import { toast } from "@/composables/useToast"; +import i18n from "@/i18n"; + +const { t } = i18n.global; + +export const useUpdateStore = defineStore("update", () => { + /** 当前阶段 */ + const phase = ref("idle"); + /** 更新信息(版本 / 日志 / 日期 / 大小) */ + const meta = ref(null); + /** 下载进度 0–100 */ + const percent = ref(0); + /** 当前平台是否支持应用内安装 */ + const canInstall = ref(true); + /** 更新弹窗开关 */ + const dialogOpen = ref(false); + + /** 是否有可用更新(驱动顶栏图标) */ + const hasUpdate = computed(() => + ["available", "downloading", "downloaded"].includes(phase.value), + ); + + const handleEvent = (event: UpdateEvent): void => { + switch (event.type) { + case "checking": + phase.value = "checking"; + break; + case "available": + phase.value = "available"; + meta.value = event.meta; + canInstall.value = event.canInstall; + percent.value = 0; + dialogOpen.value = true; + break; + case "notAvailable": + phase.value = "upToDate"; + if (event.manual) toast.success(t("update.upToDate")); + break; + case "progress": + phase.value = "downloading"; + percent.value = event.percent; + break; + case "downloaded": + phase.value = "downloaded"; + meta.value = event.meta; + toast.success(t("update.readyToast")); + break; + case "error": + phase.value = "error"; + if (event.manual) toast.error(t("update.failed")); + break; + } + }; + + // 订阅主进程推送的更新事件 + const unsubscribe = window.api.update.onEvent(handleEvent); + onScopeDispose(unsubscribe); + // 触发启动检查 + void window.api.update.check(false); + + /** 手动检查更新 */ + const checkManually = (): void => { + phase.value = "checking"; + void window.api.update.check(true); + }; + + /** 下载更新 */ + const download = (): void => { + phase.value = "downloading"; + percent.value = 0; + void window.api.update.download(); + }; + + /** 退出并安装 */ + const install = (): void => void window.api.update.install(); + + /** 打开 Releases 下载页(mac) */ + const openDownloadPage = (): void => void window.api.update.openDownloadPage(); + + /** 打开更新弹窗 */ + const openDialog = (): void => { + dialogOpen.value = true; + }; + + return { + phase, + meta, + percent, + canInstall, + dialogOpen, + hasUpdate, + checkManually, + download, + install, + openDownloadPage, + openDialog, + }; +}); diff --git a/src/styles/global.css b/src/styles/global.css index ebd76578..fecf3e71 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1,4 +1,5 @@ @import "/fonts/font.css"; +@import "./markdown.css"; * { margin: 0; diff --git a/src/styles/markdown.css b/src/styles/markdown.css new file mode 100644 index 00000000..105732dd --- /dev/null +++ b/src/styles/markdown.css @@ -0,0 +1,63 @@ +/* Markdown 渲染内容(v-html)统一排版:协议页、更新日志等共用 */ +.markdown-body { + line-height: 1.7; + font-size: 13px; + color: rgb(var(--s-on-surface-variant) / 0.85); +} +.markdown-body h1 { + margin: 0 0 0.75em; + font-size: 18px; + font-weight: 700; + color: rgb(var(--s-on-surface)); +} +.markdown-body h2 { + margin: 1.4em 0 0.6em; + font-size: 15px; + font-weight: 600; + color: rgb(var(--s-on-surface)); +} +.markdown-body h3 { + margin: 1.1em 0 0.5em; + font-size: 13.5px; + font-weight: 600; + color: rgb(var(--s-on-surface)); +} +.markdown-body p { + margin: 0.5em 0; +} +.markdown-body ul, +.markdown-body ol { + margin: 0.5em 0; + padding-left: 1.3em; +} +.markdown-body ul { + list-style: disc; +} +.markdown-body ol { + list-style: decimal; +} +.markdown-body li { + margin: 0.25em 0; +} +.markdown-body strong { + font-weight: 600; + color: rgb(var(--s-on-surface)); +} +.markdown-body a { + color: rgb(var(--s-primary)); + text-decoration: none; +} +.markdown-body a:hover { + text-decoration: underline; +} +.markdown-body code { + padding: 0.1em 0.35em; + border-radius: 4px; + background: rgb(var(--s-on-surface) / 0.08); + font-size: 0.9em; +} +.markdown-body hr { + margin: 1.2em 0; + border: none; + border-top: 1px solid rgb(var(--s-on-surface) / 0.1); +}