├── locales └── nb_NO.json ├── src-tauri ├── build.rs ├── src │ ├── preclude │ │ ├── traits │ │ │ ├── mod.rs │ │ │ └── sanitization.rs │ │ ├── mod.rs │ │ ├── utils.rs │ │ └── errors.rs │ ├── cloud_sync │ │ ├── mod.rs │ │ ├── cloud_settings.rs │ │ ├── utils.rs │ │ └── backend.rs │ ├── main.rs │ ├── config │ │ ├── mod.rs │ │ ├── app_config.rs │ │ ├── quick_actions_settings.rs │ │ ├── settings.rs │ │ └── utils.rs │ ├── updater │ │ ├── mod.rs │ │ ├── versions │ │ │ ├── mod.rs │ │ │ └── v1_4_0.rs │ │ ├── probe.rs │ │ └── migration.rs │ ├── backup │ │ ├── mod.rs │ │ ├── game_snapshots.rs │ │ ├── snapshot.rs │ │ ├── save_unit.rs │ │ ├── extra_backups.rs │ │ ├── utils.rs │ │ └── archive_tests.rs │ ├── quick_actions │ │ ├── mod.rs │ │ ├── hotkeys.rs │ │ └── tray.rs │ ├── default_value.rs │ ├── embedded_resources.rs │ ├── system_fonts.rs │ ├── device.rs │ ├── app_dirs.rs │ └── lib.rs ├── icons │ ├── 32x32.png │ ├── icon.icns │ ├── icon.ico │ ├── icon.png │ ├── 128x128.png │ ├── 128x128@2x.png │ ├── StoreLogo.png │ ├── Square30x30Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ └── Square310x310Logo.png ├── rustfmt.toml ├── capabilities │ ├── desktop.json │ └── default.json ├── resources │ └── ludusavi_manifest.meta.json ├── .gitignore ├── tauri.conf.json └── Cargo.toml ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── -----feature-request.md │ └── -----bug-report.md └── workflows │ ├── release.yml │ ├── pre-release.yml │ ├── copilot-setup-steps.yml │ ├── code-test.yml │ ├── code-style.yml │ └── tauri.yml ├── src ├── server │ └── tsconfig.json ├── public │ ├── orange.png │ └── favicon.ico ├── middleware │ └── error.global.ts ├── layouts │ └── default.vue ├── ui │ └── layers.ts ├── composables │ ├── useGlobalLoading.ts │ ├── useNavigationLinks.ts │ ├── useFeedback.ts │ ├── useConfig.ts │ ├── useSidebarResize.ts │ ├── useSaveListExpandBehavior.ts │ └── useNotification.ts ├── i18n.ts ├── components │ ├── DeviceSetupDialog.vue │ ├── HotkeySelector.vue │ ├── PathVariableSelector.vue │ ├── GameImportDialog.vue │ ├── MainSideBar.vue │ ├── ExtraBackupDrawer.vue │ └── SnapshotNode.vue └── pages │ └── index.vue ├── tsconfig.json ├── .prettierrc ├── .vscode ├── extensions.json └── i18n-ally-custom-framework.yml ├── eslint.config.mjs ├── .prettierignore ├── .gitignore ├── .gitattributes ├── .editorconfig ├── nuxt.config.ts ├── scripts └── portable.mjs ├── package.json ├── README.md ├── doc ├── zh-CN │ └── README.md └── en │ └── README.md ├── README_EN.md └── AGENTS.md /locales/nb_NO.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: sworld233 2 | custom: ['https://afdian.com/a/Sworld'] 3 | -------------------------------------------------------------------------------- /src/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json" 3 | } 4 | -------------------------------------------------------------------------------- /src-tauri/src/preclude/traits/mod.rs: -------------------------------------------------------------------------------- 1 | mod sanitization; 2 | 3 | pub use sanitization::Sanitizable; 4 | -------------------------------------------------------------------------------- /src/public/orange.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcthesw/game-save-manager/HEAD/src/public/orange.png -------------------------------------------------------------------------------- /src/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcthesw/game-save-manager/HEAD/src/public/favicon.ico -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcthesw/game-save-manager/HEAD/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcthesw/game-save-manager/HEAD/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcthesw/game-save-manager/HEAD/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcthesw/game-save-manager/HEAD/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcthesw/game-save-manager/HEAD/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcthesw/game-save-manager/HEAD/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcthesw/game-save-manager/HEAD/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcthesw/game-save-manager/HEAD/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcthesw/game-save-manager/HEAD/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcthesw/game-save-manager/HEAD/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcthesw/game-save-manager/HEAD/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcthesw/game-save-manager/HEAD/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcthesw/game-save-manager/HEAD/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcthesw/game-save-manager/HEAD/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcthesw/game-save-manager/HEAD/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcthesw/game-save-manager/HEAD/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /src-tauri/rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2024" 2 | max_width = 100 3 | tab_spaces = 4 4 | newline_style = "Unix" 5 | use_small_heuristics = "Default" 6 | -------------------------------------------------------------------------------- /src-tauri/src/preclude/mod.rs: -------------------------------------------------------------------------------- 1 | mod errors; 2 | mod traits; 3 | mod utils; 4 | 5 | pub use errors::*; 6 | pub use traits::*; 7 | pub use utils::*; 8 | -------------------------------------------------------------------------------- /src-tauri/src/cloud_sync/mod.rs: -------------------------------------------------------------------------------- 1 | mod backend; 2 | mod cloud_settings; 3 | mod utils; 4 | 5 | pub use backend::Backend; 6 | pub use cloud_settings::CloudSettings; 7 | pub use utils::*; 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "trailingComma": "es5", 5 | "tabWidth": 2, 6 | "printWidth": 100, 7 | "endOfLine": "lf", 8 | "arrowParens": "always" 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "tauri-apps.tauri-vscode", 4 | "rust-lang.rust-analyzer", 5 | "lokalise.i18n-ally", 6 | "vivaxy.vscode-conventional-commits", 7 | "vue.volar" 8 | ] 9 | } -------------------------------------------------------------------------------- /src-tauri/capabilities/desktop.json: -------------------------------------------------------------------------------- 1 | { 2 | "identifier": "desktop-capability", 3 | "platforms": ["macOS", "windows", "linux"], 4 | "windows": ["main"], 5 | "permissions": ["global-shortcut:default", "window-state:default"] 6 | } 7 | -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!! 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 3 | 4 | fn main() -> anyhow::Result<()> { 5 | rgsm_lib::run() 6 | } 7 | -------------------------------------------------------------------------------- /src-tauri/capabilities/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../gen/schemas/desktop-schema.json", 3 | "identifier": "default", 4 | "description": "enables the default permissions", 5 | "windows": ["main"], 6 | "permissions": ["log:default", "core:default"] 7 | } 8 | -------------------------------------------------------------------------------- /src-tauri/resources/ludusavi_manifest.meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "updatedAt": "2025-12-14T11:10:47Z", 3 | "etag": "W/\"dca881fa9391f5222b00228a3e28c42ea946ca1c49cf7e16d61e8d1bfc488611\"", 4 | "sourceUrl": "https://raw.githubusercontent.com/mtkennerly/ludusavi-manifest/master/data/manifest.yaml" 5 | } 6 | 7 | -------------------------------------------------------------------------------- /.vscode/i18n-ally-custom-framework.yml: -------------------------------------------------------------------------------- 1 | languageIds: 2 | - rust 3 | - javascript 4 | - typescript 5 | - javascriptreact 6 | - typescriptreact 7 | - vue 8 | - vue-html 9 | 10 | usageMatchRegex: 11 | - "[^\\w\\d]t!\\([\\s\\n\\r]*['\"]({key})['\"]" 12 | - "[^\\w\\d]\\$t\\([\\s\\n\\r]*['\"]({key})['\"]" 13 | 14 | monopoly: true 15 | -------------------------------------------------------------------------------- /src-tauri/src/preclude/traits/sanitization.rs: -------------------------------------------------------------------------------- 1 | /// A trait for types that can be sanitized. 2 | /// 3 | /// Types implementing this trait can be processed to produce a sanitized output. 4 | /// The `sanitize` method is used to perform this operation. 5 | pub trait Sanitizable { 6 | /// Sanitizes the current instance and returns the sanitized output. 7 | fn sanitize(self) -> Self; 8 | } 9 | -------------------------------------------------------------------------------- /src-tauri/src/config/mod.rs: -------------------------------------------------------------------------------- 1 | mod app_config; 2 | mod quick_actions_settings; 3 | mod settings; 4 | mod utils; 5 | 6 | pub use app_config::{Config, FavoriteTreeNode}; 7 | pub use quick_actions_settings::{ 8 | QuickActionSoundPreferences, QuickActionSoundSlots, QuickActionSoundSource, 9 | QuickActionsSettings, 10 | }; 11 | pub use settings::{AppearanceSettings, SaveListExpandBehavior, Settings}; 12 | pub use utils::*; 13 | -------------------------------------------------------------------------------- /src-tauri/src/updater/mod.rs: -------------------------------------------------------------------------------- 1 | //! Updater module for handling version migrations of various components 2 | //! 3 | //! This module provides functionality for: 4 | //! - Version probing 5 | //! - Data migration between versions 6 | //! - Backup creation 7 | //! - Component updates 8 | 9 | pub mod migration; 10 | pub mod probe; 11 | 12 | #[allow(dead_code)] 13 | pub mod versions; 14 | 15 | pub use migration::update_config; 16 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import withNuxt from './.nuxt/eslint.config.mjs'; 2 | import eslintConfigPrettier from 'eslint-config-prettier'; 3 | 4 | export default withNuxt({ 5 | ignores: [ 6 | '**/node_modules/**', 7 | '.nuxt/**', 8 | '.output/**', 9 | 'dist/**', 10 | 'src-tauri/**', 11 | 'scripts/**', 12 | 'types/**', 13 | 'src/public/**', 14 | 'src/bindings.ts', 15 | ], 16 | }).append(eslintConfigPrettier); 17 | -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | /gen/schemas 5 | 6 | GameSaveManager.config.json 7 | *.bak 8 | save_data/ 9 | 10 | # Ludusavi manifest cache (downloaded by user action in debug mode) 11 | /ludusavi_manifest.yaml 12 | /ludusavi_manifest.meta.json 13 | 14 | .vscode/* 15 | !.vscode/i18n-ally-custom-framework.yml 16 | !.vscode/launch.json 17 | .idea/ 18 | 19 | log/ 20 | -------------------------------------------------------------------------------- /src-tauri/src/preclude/utils.rs: -------------------------------------------------------------------------------- 1 | use log::error; 2 | 3 | pub fn show_notification, T2: AsRef>(title: T1, body: T2) { 4 | if let Err(e) = notify_rust::Notification::new() 5 | .summary(title.as_ref()) 6 | .body(body.as_ref()) 7 | .timeout(6000) // milliseconds 8 | .show() 9 | { 10 | error!(target:"rgsm::quick_action", "Failed to show notification: {}", e); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/-----feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 功能请求 Feature request 3 | about: 提出功能性的建议 Suggest an idea for this project 4 | title: '[功能/FEATURE]' 5 | labels: enhancement 6 | assignees: '' 7 | --- 8 | 9 | **新功能描述 Describe the feature** 10 | 你可以简要描述你期望的新功能是什么样的。 11 | You can briefly describe what new features you expect 12 | 13 | **应用场景简述 Application scenario** 14 | 请简要描述一下这个功能的应用场景,以及它的作用是否重要。 15 | Please briefly describe the application scenario of this function. 16 | -------------------------------------------------------------------------------- /src-tauri/src/updater/versions/mod.rs: -------------------------------------------------------------------------------- 1 | //! Version-specific migration modules 2 | 3 | /// Minimum supported version for auto-migration 4 | pub const MIN_SUPPORTED_VERSION: &str = "1.0.0"; 5 | /// Current version from Cargo.toml 6 | pub const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); 7 | /// Version 1.6.0 - introduced branch/tree view for snapshots 8 | pub const VERSION_1_6_0: &str = "1.6.0"; 9 | 10 | // 1.4.X 11 | mod v1_4_0; 12 | pub use v1_4_0::{Config as Config1_4_0, VERSION as VERSION_1_4_0}; 13 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: 5 | - 'v1*' 6 | jobs: 7 | create-release: 8 | permissions: 9 | contents: write 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Github release 14 | uses: 'marvinpinto/action-automatic-releases@latest' 15 | with: 16 | repo_token: '${{ secrets.RELEASE_TOKEN }}' 17 | prerelease: false 18 | files: | 19 | LICENSE 20 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Build outputs 2 | .output 3 | .nuxt 4 | .cache 5 | dist 6 | dist-ssr 7 | 8 | # Dependencies 9 | node_modules 10 | pnpm-lock.yaml 11 | 12 | # Auto-generated files 13 | components.d.ts 14 | auto-imports.d.ts 15 | src/bindings.ts 16 | src-tauri/gen 17 | 18 | # Rust 19 | src-tauri/target 20 | src-tauri/Cargo.lock 21 | 22 | # Logs 23 | *.log 24 | 25 | # IDE 26 | .vscode 27 | .idea 28 | 29 | # OS 30 | .DS_Store 31 | 32 | # Misc 33 | .git 34 | 35 | # Test related 36 | src-tauri/GameSaveManager.config.json 37 | src-tauri/save_data/* 38 | src-tauri/resources/* -------------------------------------------------------------------------------- /src-tauri/src/backup/mod.rs: -------------------------------------------------------------------------------- 1 | mod archive; 2 | #[cfg(test)] 3 | mod archive_tests; 4 | mod extra_backups; 5 | mod game; 6 | mod game_snapshots; 7 | mod save_unit; 8 | mod snapshot; 9 | mod utils; 10 | 11 | use archive::{compress_to_file, decompress_from_file}; 12 | pub use extra_backups::ExtraBackupItem; 13 | pub use extra_backups::{ 14 | delete_extra_backup, extra_backup_folder_path, list_extra_backups, restore_extra_backup, 15 | }; 16 | pub use game::Game; 17 | pub use game_snapshots::GameSnapshots; 18 | pub use save_unit::{SaveUnit, SaveUnitType}; 19 | pub use snapshot::Snapshot; 20 | pub use utils::*; 21 | -------------------------------------------------------------------------------- /src-tauri/src/quick_actions/mod.rs: -------------------------------------------------------------------------------- 1 | mod hotkeys; 2 | mod manager; 3 | mod tray; 4 | mod utils; 5 | 6 | pub use manager::QuickActionManager; 7 | pub use utils::{QuickActionCompleted, QuickActionType, quick_apply, quick_backup}; 8 | 9 | use hotkeys::setup_hotkeys; 10 | use tauri::Manager; 11 | use tray::setup_tray; 12 | 13 | use crate::config::get_config; 14 | 15 | pub fn setup(app: &mut tauri::App) -> anyhow::Result<()> { 16 | let manager = QuickActionManager::new(app.handle()); 17 | app.manage(manager); 18 | 19 | let config = get_config()?; 20 | setup_tray(app)?; 21 | setup_hotkeys(&config, app)?; 22 | Ok(()) 23 | } 24 | -------------------------------------------------------------------------------- /src/middleware/error.global.ts: -------------------------------------------------------------------------------- 1 | import { warn } from '@tauri-apps/plugin-log'; 2 | 3 | const { config } = useConfig(); 4 | const pages = ['/', '/About', '/AddGame', '/Settings', '/SyncSettings']; 5 | 6 | export default defineNuxtRouteMiddleware((to, _from) => { 7 | if ( 8 | to.fullPath.startsWith('/Management') && 9 | !config.value.games.find((x) => x.name == to.params.name) 10 | ) { 11 | warn(`Game ${to.params.name} not found`); 12 | return navigateTo('/'); 13 | } 14 | if (!to.fullPath.startsWith('/Management') && pages.find((x) => x == to.fullPath) === undefined) { 15 | warn(`Page ${to.fullPath} not found`); 16 | return navigateTo('/'); 17 | } 18 | }); 19 | -------------------------------------------------------------------------------- /src-tauri/src/backup/game_snapshots.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use specta::Type; 3 | 4 | use super::Snapshot; 5 | use crate::default_value; 6 | 7 | /// A backup list info is a json file in a backup folder for a game. 8 | /// It contains the name of the game, 9 | /// and all backups' path 10 | #[derive(Debug, Serialize, Deserialize, Type, Clone)] 11 | pub struct GameSnapshots { 12 | pub name: String, 13 | pub backups: Vec, 14 | /// HEAD points to the current snapshot that new snapshots will branch from. 15 | /// If None, new snapshots will be created as root nodes. 16 | #[serde(default = "default_value::default_none")] 17 | pub head: Option, 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/pre-release.yml: -------------------------------------------------------------------------------- 1 | name: pre-release 2 | on: 3 | push: 4 | branches: 5 | - dev 6 | paths: 7 | - src 8 | - src-tauri 9 | - scripts 10 | - locales 11 | workflow_dispatch: 12 | jobs: 13 | create-release: 14 | permissions: 15 | contents: write 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Github release 20 | uses: 'marvinpinto/action-automatic-releases@latest' 21 | with: 22 | repo_token: '${{ secrets.RELEASE_TOKEN }}' 23 | automatic_release_tag: latest 24 | prerelease: true 25 | title: 'Bleeding edge/前沿版本' 26 | files: | 27 | LICENSE 28 | -------------------------------------------------------------------------------- /src-tauri/src/backup/snapshot.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use specta::Type; 3 | 4 | use crate::default_value; 5 | 6 | /// A backup is a zip file that contains 7 | /// all the file that the save unit has declared. 8 | /// The date is the unique indicator for a backup 9 | #[derive(Debug, Serialize, Deserialize, Type, Clone)] 10 | pub struct Snapshot { 11 | pub date: String, 12 | pub describe: String, 13 | pub path: String, // like "D:\\SaveManager\save_data\Game1\date.zip" 14 | #[serde(default = "default_value::default_zero")] 15 | pub size: u64, // in bytes 16 | /// Parent snapshot's date (None means this is a root node) 17 | #[serde(default = "default_value::default_none")] 18 | pub parent: Option, 19 | } 20 | -------------------------------------------------------------------------------- /src/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 23 | 24 | 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Nuxt dev/build outputs 2 | .output 3 | .data 4 | .nuxt 5 | .nitro 6 | .cache 7 | dist 8 | 9 | # Node dependencies 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Logs 16 | logs 17 | *.log 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | pnpm-debug.log* 22 | lerna-debug.log* 23 | 24 | # Misc 25 | .DS_Store 26 | .fleet 27 | .idea 28 | 29 | # Local env files 30 | .env 31 | .env.* 32 | !.env.example 33 | 34 | # Editor directories and files 35 | .vscode/* 36 | !.vscode/extensions.json 37 | !.vscode/i18n-ally-custom-framework.yml 38 | .idea 39 | .DS_Store 40 | *.suo 41 | *.ntvs* 42 | *.njsproj 43 | *.sln 44 | *.sw? 45 | 46 | # auto-generated files 47 | components.d.ts 48 | auto-imports.d.ts 49 | 50 | # agent 51 | .claude 52 | .aider* 53 | .cursor* 54 | .windsurf* -------------------------------------------------------------------------------- /src/ui/layers.ts: -------------------------------------------------------------------------------- 1 | // Centralized UI layering tokens. 2 | // Keep these values stable and semantic so overlays/notifications don't fight each other. 3 | // 4 | // Note: Element Plus uses a z-index stack (default starting around 2000). We intentionally 5 | // keep our app-level layers above that to avoid accidental overlap. 6 | 7 | export const LAYER = { 8 | base: 1, 9 | sidebarSticky: 100, 10 | 11 | // Element Plus poppers/dialogs typically sit around 2xxx. Keep semantic room above. 12 | drawer: 3000, 13 | dialog: 3100, 14 | messageBox: 3200, 15 | 16 | // App-level global overlay for long operations. 17 | globalLoading: 9000, 18 | 19 | // Toast/notification should always be visible even when global loading is active. 20 | notification: 9100, 21 | } as const; 22 | 23 | export type LayerToken = keyof typeof LAYER; 24 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and normalize line endings to LF in the repository 2 | * text=auto eol=lf 3 | 4 | # Explicitly declare text files 5 | *.rs text eol=lf 6 | *.toml text eol=lf 7 | *.json text eol=lf 8 | *.yml text eol=lf 9 | *.yaml text eol=lf 10 | *.md text eol=lf 11 | *.ts text eol=lf 12 | *.js text eol=lf 13 | *.vue text eol=lf 14 | *.css text eol=lf 15 | *.html text eol=lf 16 | *.sh text eol=lf 17 | 18 | # Declare files that will always have CRLF line endings on checkout 19 | *.bat text eol=crlf 20 | *.cmd text eol=crlf 21 | 22 | # Denote all files that are truly binary and should not be modified 23 | *.png binary 24 | *.jpg binary 25 | *.jpeg binary 26 | *.gif binary 27 | *.ico binary 28 | *.icns binary 29 | *.woff binary 30 | *.woff2 binary 31 | *.ttf binary 32 | *.eot binary 33 | *.zip binary 34 | *.gz binary 35 | *.tar binary 36 | *.exe binary 37 | *.dll binary 38 | *.so binary 39 | *.dylib binary 40 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | charset = utf-8 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | # Rust files 14 | [*.rs] 15 | indent_style = space 16 | indent_size = 4 17 | 18 | # TOML files 19 | [*.toml] 20 | indent_style = space 21 | indent_size = 2 22 | 23 | # JavaScript/TypeScript/Vue files 24 | [*.{js,ts,vue,json,jsonc}] 25 | indent_style = space 26 | indent_size = 2 27 | 28 | # YAML files 29 | [*.{yml,yaml}] 30 | indent_style = space 31 | indent_size = 2 32 | 33 | # Markdown files 34 | [*.md] 35 | trim_trailing_whitespace = false 36 | indent_style = space 37 | indent_size = 2 38 | 39 | # Makefiles 40 | [Makefile] 41 | indent_style = tab 42 | 43 | # Batch files (Windows) 44 | [*.{bat,cmd}] 45 | end_of_line = crlf 46 | -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", 3 | "productName": "RGSM", 4 | "identifier": "com.game-save-manager", 5 | "build": { 6 | "frontendDist": "../dist", 7 | "devUrl": "http://localhost:3000", 8 | "beforeDevCommand": "pnpm web:dev", 9 | "beforeBuildCommand": "pnpm web:generate" 10 | }, 11 | "app": { 12 | "windows": [ 13 | { 14 | "title": "Game Save Manager", 15 | "width": 1280, 16 | "height": 850, 17 | "resizable": true, 18 | "fullscreen": false, 19 | "dragDropEnabled": false 20 | } 21 | ], 22 | "security": { 23 | "csp": null 24 | } 25 | }, 26 | "bundle": { 27 | "active": true, 28 | "targets": "all", 29 | "icon": [ 30 | "icons/32x32.png", 31 | "icons/128x128.png", 32 | "icons/128x128@2x.png", 33 | "icons/icon.icns", 34 | "icons/icon.ico" 35 | ] 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src-tauri/src/default_value.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use crate::cloud_sync::Backend; 4 | 5 | pub fn default_false() -> bool { 6 | false 7 | } 8 | pub fn default_true() -> bool { 9 | true 10 | } 11 | pub fn default_zero() -> u64 { 12 | 0 13 | } 14 | pub fn default_zero_u32() -> u32 { 15 | 0 16 | } 17 | pub fn default_five_u32() -> u32 { 18 | 5 19 | } 20 | pub fn default_root_path() -> String { 21 | "/game-save-manager".to_string() 22 | } 23 | pub fn default_home_page() -> String { 24 | "/".to_string() 25 | } 26 | pub fn default_backend() -> Backend { 27 | Backend::Disabled 28 | } 29 | pub fn default_locale() -> String { 30 | "zh_SIMPLIFIED".to_owned() 31 | } 32 | pub fn empty_vec() -> Vec { 33 | Vec::new() 34 | } 35 | pub fn default_none() -> Option { 36 | None 37 | } 38 | pub fn default() -> T { 39 | T::default() 40 | } 41 | pub fn empty_map() -> HashMap { 42 | HashMap::new() 43 | } 44 | -------------------------------------------------------------------------------- /src-tauri/src/backup/save_unit.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use specta::Type; 3 | use std::collections::HashMap; 4 | 5 | use crate::default_value; 6 | use crate::device::DeviceId; 7 | 8 | /// A save unit should be a file or a folder 9 | #[derive(Debug, Serialize, Deserialize, Clone, Type)] 10 | pub enum SaveUnitType { 11 | File, 12 | Folder, 13 | } 14 | 15 | /// A save unit declares one of the files/folders 16 | /// that should be backup for a game 17 | #[derive(Debug, Serialize, Deserialize, Clone, Type)] 18 | pub struct SaveUnit { 19 | pub unit_type: SaveUnitType, 20 | #[serde(default)] // 如果反序列化时字段不存在,则使用默认值 (空 HashMap) 21 | pub paths: HashMap, // 存储不同设备的路径 22 | #[serde(default = "default_value::default_false")] 23 | pub delete_before_apply: bool, 24 | } 25 | 26 | impl SaveUnit { 27 | /// 获取指定设备的路径 28 | pub fn get_path_for_device(&self, device_id: &DeviceId) -> Option<&String> { 29 | self.paths.get(device_id) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // https://nuxt.com/docs/api/configuration/nuxt-config 2 | export default defineNuxtConfig({ 3 | srcDir: 'src', 4 | compatibilityDate: '2024-11-01', 5 | devtools: { enabled: process.env.NUXT_DEVTOOLS === 'true' }, 6 | ssr: false, 7 | devServer: { host: process.env.TAURI_DEV_HOST || 'localhost' }, 8 | modules: ['@vueuse/nuxt', '@element-plus/nuxt', '@nuxt/eslint'], 9 | imports: { 10 | dirs: ['src/composables'], 11 | }, 12 | vite: { 13 | // Better support for Tauri CLI output 14 | clearScreen: false, 15 | // Enable environment variables 16 | // Additional environment variables can be found at 17 | // https://v2.tauri.app/reference/environment-variables/ 18 | envPrefix: ['VITE_', 'TAURI_'], 19 | server: { 20 | // Tauri requires a consistent port 21 | strictPort: true, 22 | }, 23 | }, 24 | app: { 25 | pageTransition: { name: 'page', mode: 'out-in' }, 26 | }, 27 | dir: { 28 | public: 'src/public', 29 | modules: 'src/modules', 30 | shared: 'src/shared', 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/-----bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 错误报告 Bug report 3 | about: 创建帮助我们改进的报告 Create a report to help us improve 4 | title: '[错误/BUG]' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | **问题简述 Describe the bug** 10 | 请用简要清晰的语言描述一下这个错误发生在何时何处。 11 | A clear and concise description of what the bug is. 12 | 13 | **如何触发错误 To Reproduce** 14 | 15 | 你可以用这样的方式来描述: 16 | 17 | 1. 进入'...'界面 18 | 2. 单击'...'按钮 19 | 3. '...'发生了'...' 20 | 21 | Steps to reproduce the behavior: 22 | 23 | 1. Go to '...' 24 | 2. Click on '....' 25 | 3. Scroll down to '....' 26 | 4. See error 27 | 28 | **正确表现 Expected behavior** 29 | 请简要描述正常时应该发生什么?(就是在BUG修好之后) 30 | A clear and concise description of what you expected to happen. 31 | 32 | **截图 Screenshots** 33 | 如果可以的话,请用一些截图描述发生的问题。 34 | If applicable, add screenshots to help explain your problem. 35 | 36 | **环境 Desktop:** 37 | 请填写下方信息。 38 | Please complete the following information. 39 | 40 | - 操作系统 OS: [e.g. iOS] 41 | - 软件版本 Version [e.g. 22] 42 | 43 | **其余信息 Additional context** 44 | 你可以在这补充更多信息。 45 | Add any other context about the problem here. 46 | -------------------------------------------------------------------------------- /src/composables/useGlobalLoading.ts: -------------------------------------------------------------------------------- 1 | import { computed, ref } from 'vue'; 2 | import { $t } from '../i18n'; 3 | 4 | const messageStack = ref([]); 5 | 6 | function startLoading(message?: string) { 7 | messageStack.value.push(message ?? $t('common.operation_in_progress')); 8 | } 9 | 10 | function stopLoading() { 11 | if (messageStack.value.length > 0) { 12 | messageStack.value.pop(); 13 | } 14 | } 15 | 16 | async function withLoading(operation: () => Promise, message?: string): Promise { 17 | startLoading(message); 18 | try { 19 | return await operation(); 20 | } finally { 21 | stopLoading(); 22 | } 23 | } 24 | 25 | const isLoading = computed(() => messageStack.value.length > 0); 26 | const loadingMessage = computed(() => { 27 | if (messageStack.value.length === 0) { 28 | return $t('common.operation_in_progress'); 29 | } 30 | return messageStack.value[messageStack.value.length - 1]; 31 | }); 32 | 33 | export function useGlobalLoading() { 34 | return { 35 | isLoading, 36 | loadingMessage, 37 | startLoading, 38 | stopLoading, 39 | withLoading, 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /src-tauri/src/updater/probe.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | use std::path::Path; 3 | 4 | use log::error; 5 | use semver::Version; 6 | use serde_json::Value; 7 | 8 | use crate::preclude::UpdaterError; 9 | 10 | /// Probe the version field from a config file 11 | /// 12 | /// This function only reads the "version" field from the JSON file without 13 | /// deserializing the entire structure, which is more efficient for version checking. 14 | /// 15 | /// # Arguments 16 | /// * `path` - Path to the config file 17 | /// 18 | /// # Returns 19 | /// * `Ok(Version)` - The parsed version from the config file 20 | /// * `Err(UpdaterError)` - If the version field is missing or invalid 21 | pub fn probe_config_version>(path: P) -> Result { 22 | let content = fs::read_to_string(path.as_ref())?; 23 | let v: Value = serde_json::from_str(&content)?; 24 | 25 | if let Some(s) = v.get("version").and_then(Value::as_str) { 26 | Ok(Version::parse(s)?) 27 | } else { 28 | error!(target: "rgsm::updater", "Missing version field in config file"); 29 | Err(UpdaterError::MissingVersion) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src-tauri/src/cloud_sync/cloud_settings.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use specta::Type; 3 | 4 | use crate::default_value; 5 | use crate::preclude::*; 6 | 7 | use super::Backend; 8 | 9 | #[derive(Debug, Serialize, Deserialize, Clone, Type)] 10 | pub struct CloudSettings { 11 | /// 是否启用跟随云同步(用户添加、删除时自动同步) 12 | #[serde(default = "default_value::default_false")] 13 | pub always_sync: bool, 14 | /// 同步间隔,单位分钟,为0则不自动同步 15 | #[serde(default = "default_value::default_zero")] 16 | pub auto_sync_interval: u64, 17 | /// 云同步根目录 18 | #[serde(default = "default_value::default_root_path")] 19 | pub root_path: String, 20 | /// 云同步后端设置 21 | #[serde(default = "default_value::default_backend")] 22 | pub backend: Backend, 23 | } 24 | 25 | impl Default for CloudSettings { 26 | fn default() -> Self { 27 | CloudSettings { 28 | always_sync: false, 29 | auto_sync_interval: 0, 30 | root_path: "/game-save-manager".to_string(), 31 | backend: Backend::Disabled, 32 | } 33 | } 34 | } 35 | 36 | impl Sanitizable for CloudSettings { 37 | fn sanitize(self) -> Self { 38 | CloudSettings { 39 | backend: self.backend.sanitize(), 40 | ..self 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/i18n.ts: -------------------------------------------------------------------------------- 1 | import { createI18n } from 'vue-i18n'; 2 | 3 | import en_US from '../locales/en_US.json'; 4 | import fr from '../locales/fr.json'; 5 | import ko from '../locales/ko.json'; 6 | import nb_NO from '../locales/nb_NO.json'; 7 | import ta from '../locales/ta.json'; 8 | import uk from '../locales/uk.json'; 9 | import zh_SIMPLIFIED from '../locales/zh_SIMPLIFIED.json'; 10 | 11 | // 导出 i18n 实例 12 | export const i18n = createI18n({ 13 | messages: { 14 | en_US: en_US, 15 | fr: fr, 16 | ko: ko, 17 | nb_NO: nb_NO, 18 | ta: ta, 19 | uk: uk, 20 | zh_SIMPLIFIED: zh_SIMPLIFIED, 21 | }, 22 | locale: 'zh_SIMPLIFIED', // 默认语言 23 | fallbackLocale: 'en_US', // 备用语言改为英语 24 | legacy: false, 25 | }); 26 | 27 | // 导出简单的翻译函数 28 | export function $t(key: string, params?: Record) { 29 | return i18n.global.t(key, params ?? {}); 30 | } 31 | 32 | // 导出所有支持的语言及其本地化名称 33 | export function getSupportedLanguages() { 34 | type LocaleMessage = { settings?: { locale_name?: string } }; 35 | const messages = i18n.global.messages.value as Record; 36 | const locales = Object.keys(messages); 37 | 38 | return locales.map((locale) => { 39 | return { 40 | code: locale, 41 | name: messages[locale]?.settings?.locale_name || locale, 42 | }; 43 | }); 44 | } 45 | -------------------------------------------------------------------------------- /src-tauri/src/embedded_resources.rs: -------------------------------------------------------------------------------- 1 | use rust_embed::Embed; 2 | 3 | #[derive(Embed)] 4 | #[folder = "resources/"] 5 | #[include = "ludusavi_manifest.yaml"] 6 | #[include = "ludusavi_manifest.meta.json"] 7 | struct BundledResources; 8 | 9 | fn load_bytes(path: &str) -> std::borrow::Cow<'static, [u8]> { 10 | BundledResources::get(path) 11 | .unwrap_or_else(|| panic!("Missing embedded resource: {path}")) 12 | .data 13 | } 14 | 15 | fn load_utf8(path: &str) -> std::borrow::Cow<'static, str> { 16 | match load_bytes(path) { 17 | std::borrow::Cow::Borrowed(bytes) => std::borrow::Cow::Borrowed( 18 | std::str::from_utf8(bytes) 19 | .unwrap_or_else(|e| panic!("Embedded resource {path} is not valid UTF-8: {e}")), 20 | ), 21 | std::borrow::Cow::Owned(bytes) => std::borrow::Cow::Owned( 22 | String::from_utf8(bytes) 23 | .unwrap_or_else(|e| panic!("Embedded resource {path} is not valid UTF-8: {e}")), 24 | ), 25 | } 26 | } 27 | 28 | pub fn ludusavi_manifest_yaml() -> std::borrow::Cow<'static, str> { 29 | load_utf8("ludusavi_manifest.yaml") 30 | } 31 | 32 | pub fn ludusavi_manifest_meta_json() -> std::borrow::Cow<'static, str> { 33 | load_utf8("ludusavi_manifest.meta.json") 34 | } 35 | 36 | pub fn ludusavi_manifest_yaml_len() -> u64 { 37 | load_bytes("ludusavi_manifest.yaml").len() as u64 38 | } 39 | -------------------------------------------------------------------------------- /src/composables/useNavigationLinks.ts: -------------------------------------------------------------------------------- 1 | import { computed, type Component } from 'vue'; 2 | import { 3 | DocumentAdd, 4 | HotWater, 5 | InfoFilled, 6 | MostlyCloudy, 7 | Setting, 8 | SwitchFilled, 9 | } from '@element-plus/icons-vue'; 10 | import { $t } from '../i18n'; 11 | 12 | export interface NavigationLink { 13 | text: string; 14 | link: string; 15 | icon: Component; 16 | } 17 | 18 | const { config } = useConfig(); 19 | 20 | /** 21 | * 提供导航链接的 composable 22 | * - baseLinks: 基础导航链接(静态页面) 23 | * - linksWithGames: 基础链接 + 游戏管理页面链接 24 | */ 25 | export function useNavigationLinks() { 26 | const baseLinks = computed(() => [ 27 | { text: $t('sidebar.homepage'), link: '/', icon: HotWater }, 28 | { text: $t('sidebar.add_game'), link: '/AddGame', icon: DocumentAdd }, 29 | { text: $t('sidebar.sync_settings'), link: '/SyncSettings', icon: MostlyCloudy }, 30 | { text: $t('sidebar.settings'), link: '/Settings', icon: Setting }, 31 | { text: $t('sidebar.about'), link: '/About', icon: InfoFilled }, 32 | ]); 33 | 34 | const linksWithGames = computed(() => { 35 | const list = [...baseLinks.value]; 36 | config.value?.games.forEach((game) => { 37 | list.push({ text: game.name, link: `/Management/${game.name}`, icon: SwitchFilled }); 38 | }); 39 | return list; 40 | }); 41 | 42 | return { baseLinks, linksWithGames }; 43 | } 44 | -------------------------------------------------------------------------------- /src/composables/useFeedback.ts: -------------------------------------------------------------------------------- 1 | import { ElMessageBox } from 'element-plus'; 2 | import { LAYER } from '../ui/layers'; 3 | 4 | // A small, centralized façade for user feedback patterns. 5 | // - Keep business code focused on intent (confirm/prompt) instead of UI library details. 6 | // - Provide consistent defaults (e.g. z-index), without changing copy/text. 7 | 8 | type ConfirmOptions = Parameters[2]; 9 | type PromptOptions = Parameters[2]; 10 | 11 | function withDefaults(options: ConfirmOptions): ConfirmOptions { 12 | if (!options) { 13 | return { zIndex: LAYER.messageBox } as unknown as ConfirmOptions; 14 | } 15 | // Avoid overriding user-provided values; only fill in missing fields. 16 | if ('zIndex' in options) return options; 17 | return { ...options, zIndex: LAYER.messageBox } as unknown as ConfirmOptions; 18 | } 19 | 20 | function withPromptDefaults(options: PromptOptions): PromptOptions { 21 | if (!options) { 22 | return { zIndex: LAYER.messageBox } as unknown as PromptOptions; 23 | } 24 | if ('zIndex' in options) return options; 25 | return { ...options, zIndex: LAYER.messageBox } as unknown as PromptOptions; 26 | } 27 | 28 | export function useFeedback() { 29 | return { 30 | confirm: (message: string, title: string, options?: ConfirmOptions) => 31 | ElMessageBox.confirm(message, title, withDefaults(options)), 32 | 33 | prompt: (message: string, title: string, options?: PromptOptions) => 34 | ElMessageBox.prompt(message, title, withPromptDefaults(options)), 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /src-tauri/src/system_fonts.rs: -------------------------------------------------------------------------------- 1 | //! System font enumeration module 2 | //! 3 | //! Provides cross-platform functionality to list installed fonts on the system 4 | //! using font-kit which leverages native system APIs (DirectWrite on Windows, 5 | //! Core Text on macOS, fontconfig on Linux). 6 | 7 | use font_kit::source::SystemSource; 8 | use log::{debug, warn}; 9 | 10 | /// Get a sorted list of unique font family names installed on the system. 11 | /// 12 | /// Uses the system's native font API: 13 | /// - Windows: DirectWrite 14 | /// - macOS: Core Text 15 | /// - Linux: fontconfig 16 | pub fn get_system_fonts() -> Vec { 17 | let source = SystemSource::new(); 18 | 19 | match source.all_families() { 20 | Ok(mut families) => { 21 | debug!(target: "rgsm::fonts", "Found {} system font families", families.len()); 22 | families.sort(); 23 | families 24 | } 25 | Err(e) => { 26 | warn!(target: "rgsm::fonts", "Failed to enumerate system fonts: {:?}", e); 27 | Vec::new() 28 | } 29 | } 30 | } 31 | 32 | #[cfg(test)] 33 | mod tests { 34 | use super::*; 35 | 36 | #[test] 37 | fn test_get_system_fonts() { 38 | let fonts = get_system_fonts(); 39 | // Should return at least some fonts on any desktop system 40 | assert!( 41 | !fonts.is_empty(), 42 | "Should find at least one font on the system" 43 | ); 44 | 45 | // Fonts should be sorted 46 | let mut sorted = fonts.clone(); 47 | sorted.sort(); 48 | assert_eq!(fonts, sorted, "Fonts should be sorted alphabetically"); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src-tauri/src/device.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use specta::Type; 3 | use std::sync::OnceLock; 4 | 5 | // 使用 String 作为设备 ID 的类型别名 6 | pub type DeviceId = String; 7 | 8 | // 设备信息结构体 9 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Type)] 10 | pub struct Device { 11 | pub id: DeviceId, 12 | pub name: String, 13 | } 14 | 15 | // 存储当前设备的静态变量,使用 OnceLock 确保只初始化一次 16 | static CURRENT_DEVICE_ID: OnceLock = OnceLock::new(); 17 | 18 | /// 获取当前设备的ID。 19 | /// 首次调用时会生成 UUID 作为设备 ID。 20 | /// 后续调用将返回缓存的设备ID。 21 | pub fn get_current_device_id() -> &'static DeviceId { 22 | CURRENT_DEVICE_ID.get_or_init(|| machine_uid::get().expect("Failed to get machine ID")) 23 | } 24 | 25 | /// 获取当前系统的主机名 26 | /// 如果无法获取,则返回"Unknown Device"作为默认值 27 | pub fn get_system_hostname() -> String { 28 | hostname::get() 29 | .ok() 30 | .and_then(|name| name.into_string().ok()) 31 | .unwrap_or_else(|| "Unknown Device".to_string()) 32 | } 33 | 34 | impl Default for Device { 35 | fn default() -> Self { 36 | Self { 37 | id: machine_uid::get().expect("Failed to get machine ID"), 38 | name: get_system_hostname(), 39 | } 40 | } 41 | } 42 | 43 | // 单元测试 44 | #[cfg(test)] 45 | mod tests { 46 | use super::*; 47 | 48 | #[test] 49 | fn test_get_current_device_returns_consistent_info() { 50 | // 多次调用应返回相同的设备信息(在同一次运行中) 51 | let device1 = get_current_device_id(); 52 | let device2 = get_current_device_id(); 53 | assert_eq!(device1, device2); 54 | assert!(!device1.is_empty()); 55 | println!("Device ID: {}", device1); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /scripts/portable.mjs: -------------------------------------------------------------------------------- 1 | // Modified From https://github.com/zzzgydi/clash-verge/blob/main/scripts/portable.mjs 2 | // GPL-3.0 3 | import fs from 'fs-extra'; 4 | import path from 'path'; 5 | import AdmZip from 'adm-zip'; 6 | import { createRequire } from 'module'; 7 | import { getOctokit, context } from '@actions/github'; 8 | 9 | async function resolvePortable() { 10 | if (process.platform !== 'win32') return; 11 | 12 | const releaseDir = './src-tauri/target/release'; 13 | 14 | if (!(await fs.pathExists(releaseDir))) { 15 | throw new Error('could not found the release dir'); 16 | } 17 | 18 | const zip = new AdmZip(); 19 | 20 | zip.addLocalFile(path.join(releaseDir, 'rgsm.exe')); 21 | // zip.addLocalFolder(path.join(releaseDir, "resources"), "resources"); 22 | 23 | const require = createRequire(import.meta.url); 24 | const packageJson = require('../package.json'); 25 | const { version } = packageJson; 26 | 27 | const zipFile = `RGSM_${version}_x64-portable.zip`; 28 | zip.writeZip(zipFile); 29 | 30 | console.log('[INFO]: create portable zip successfully'); 31 | 32 | if (process.env.GITHUB_TOKEN === undefined) { 33 | throw new Error('GITHUB_TOKEN is required'); 34 | } 35 | 36 | const options = { owner: context.repo.owner, repo: context.repo.repo }; 37 | const github = getOctokit(process.env.GITHUB_TOKEN); 38 | 39 | console.log('[INFO]: upload to ', process.env.RELEASE_ID); 40 | 41 | // https://octokit.github.io/rest.js 42 | await github.rest.repos.uploadReleaseAsset({ 43 | ...options, 44 | release_id: process.env.RELEASE_ID, 45 | name: zipFile, 46 | data: zip.toBuffer(), 47 | }); 48 | } 49 | 50 | resolvePortable().catch(console.error); 51 | -------------------------------------------------------------------------------- /.github/workflows/copilot-setup-steps.yml: -------------------------------------------------------------------------------- 1 | name: 'Copilot Setup Steps' 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | paths: 7 | - .github/workflows/copilot-setup-steps.yml 8 | pull_request: 9 | paths: 10 | - .github/workflows/copilot-setup-steps.yml 11 | 12 | jobs: 13 | # The job MUST be called 'copilot-setup-steps' 14 | copilot-setup-steps: 15 | runs-on: ubuntu-24.04 16 | timeout-minutes: 30 17 | 18 | permissions: 19 | contents: read 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v4 24 | 25 | - name: Setup Node.js 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: lts/* 29 | 30 | - name: Setup pnpm 31 | uses: pnpm/action-setup@v4 32 | with: 33 | version: latest 34 | 35 | - name: Install Rust stable 36 | uses: dtolnay/rust-toolchain@stable 37 | with: 38 | components: clippy, rustfmt 39 | 40 | - name: Install Tauri system dependencies (Ubuntu) 41 | run: | 42 | sudo apt-get update 43 | sudo apt-get install -y \ 44 | libwebkit2gtk-4.1-dev \ 45 | build-essential \ 46 | curl \ 47 | wget \ 48 | file \ 49 | libxdo-dev \ 50 | libssl-dev \ 51 | libayatana-appindicator3-dev \ 52 | librsvg2-dev \ 53 | libasound2-dev \ 54 | pkg-config 55 | 56 | - name: Install frontend dependencies 57 | run: pnpm install 58 | 59 | - name: Restore Rust cache 60 | uses: Swatinem/rust-cache@v2 61 | with: 62 | workspaces: src-tauri 63 | -------------------------------------------------------------------------------- /.github/workflows/code-test.yml: -------------------------------------------------------------------------------- 1 | name: Code Tests 2 | 3 | on: 4 | pull_request: 5 | branches: ['v1-tauri', 'v0-electron', 'dev'] 6 | push: 7 | branches: ['v1-tauri', 'dev'] 8 | 9 | concurrency: 10 | group: code-test-${{ github.event.pull_request.number || github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | tests: 15 | name: Backend Tests 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Install system dependencies 21 | run: | 22 | sudo apt-get update 23 | sudo apt-get install -y \ 24 | libwebkit2gtk-4.1-dev \ 25 | build-essential \ 26 | curl \ 27 | wget \ 28 | file \ 29 | libxdo-dev \ 30 | libssl-dev \ 31 | libayatana-appindicator3-dev \ 32 | librsvg2-dev \ 33 | libasound2-dev \ 34 | pkg-config 35 | 36 | - name: Setup pnpm 37 | uses: pnpm/action-setup@v4 38 | with: 39 | version: latest 40 | 41 | - name: Setup Node.js 42 | uses: actions/setup-node@v4 43 | with: 44 | node-version: lts/* 45 | cache: 'pnpm' 46 | 47 | - name: Install frontend dependencies 48 | run: pnpm install --frozen-lockfile 49 | 50 | - name: Install Rust toolchain 51 | uses: dtolnay/rust-toolchain@stable 52 | with: 53 | components: rustfmt, clippy 54 | 55 | - name: Cache Rust dependencies 56 | uses: Swatinem/rust-cache@v2 57 | with: 58 | workspaces: src-tauri 59 | 60 | - name: Run tests 61 | working-directory: src-tauri 62 | run: cargo test --all-features 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nuxt-app", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "web:build": "nuxt build", 7 | "web:dev": "nuxt dev", 8 | "web:generate": "nuxt generate", 9 | "web:preview": "nuxt preview", 10 | "dev": "tauri dev", 11 | "build": "tauri build", 12 | "postinstall": "nuxt prepare", 13 | "portable": "node scripts/portable.mjs", 14 | "web:format": "prettier --check .", 15 | "web:format:fix": "prettier --write .", 16 | "web:lint": "eslint src --ext .ts,.tsx,.js,.jsx,.vue", 17 | "web:lint:fix": "eslint src --ext .ts,.tsx,.js,.jsx,.vue --fix", 18 | "web:typecheck": "nuxt typecheck" 19 | }, 20 | "dependencies": { 21 | "@element-plus/icons-vue": "2.3.2", 22 | "@element-plus/nuxt": "1.1.4", 23 | "@tauri-apps/api": "2.9.0", 24 | "@tauri-apps/plugin-dialog": "2.4.2", 25 | "@tauri-apps/plugin-global-shortcut": "2.3.1", 26 | "@tauri-apps/plugin-log": "2.7.1", 27 | "@tauri-apps/plugin-window-state": "~2.4.1", 28 | "@vue-flow/background": "^1.3.2", 29 | "@vue-flow/controls": "^1.1.3", 30 | "@vue-flow/core": "^1.48.0", 31 | "@vueuse/core": "14.0.0", 32 | "@vueuse/nuxt": "14.0.0", 33 | "dayjs": "1.11.19", 34 | "element-plus": "2.11.8", 35 | "nuxt": "4.2.1", 36 | "uuid": "13.0.0", 37 | "vue": "3.5.24", 38 | "vue-i18n": "^11.1.12", 39 | "vue-router": "4.6.3", 40 | "vuedraggable": "4.1.0" 41 | }, 42 | "devDependencies": { 43 | "@actions/github": "6.0.1", 44 | "@nuxt/eslint": "^1.10.0", 45 | "@tauri-apps/cli": "2.9.4", 46 | "@types/node": "^24.10.1", 47 | "@types/uuid": "10.0.0", 48 | "adm-zip": "0.5.16", 49 | "eslint": "^9.39.1", 50 | "eslint-config-prettier": "^10.1.8", 51 | "fs-extra": "11.3.2", 52 | "prettier": "^3.6.2", 53 | "typescript": "^5.9.3", 54 | "vue-tsc": "3.1.3" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/composables/useConfig.ts: -------------------------------------------------------------------------------- 1 | import { error } from '@tauri-apps/plugin-log'; 2 | import { commands, DEFAULT_CONFIG, events, type Config } from '../bindings'; 3 | import { $t } from '../i18n'; 4 | 5 | // 定义默认配置 6 | const defaultConfig: Config = DEFAULT_CONFIG as unknown as Config; 7 | const { showError } = useNotification(); 8 | const config = ref(defaultConfig); 9 | const isLoading = ref(false); 10 | 11 | async function refreshConfig() { 12 | isLoading.value = true; 13 | try { 14 | const result = await commands.getLocalConfig(); 15 | if (result.status === 'error') { 16 | throw new Error(result.error); 17 | } 18 | config.value = result.data; 19 | } catch (e) { 20 | error(`Failed to load config: ${e}`); 21 | showError({ 22 | message: $t('error.config_load_failed'), 23 | }); 24 | // 加载失败时使用默认配置 25 | config.value = defaultConfig; 26 | } finally { 27 | isLoading.value = false; 28 | } 29 | } 30 | 31 | async function saveConfig() { 32 | try { 33 | const result = await commands.setConfig(config.value); 34 | if (result.status === 'error') { 35 | throw new Error(result.error); 36 | } 37 | } catch (e) { 38 | error(`Failed to set config: ${e}`); 39 | showError({ 40 | message: $t('error.set_config_failed'), 41 | }); 42 | } 43 | } 44 | 45 | if (import.meta.client) { 46 | events.quickActionCompleted 47 | .listen((event) => { 48 | const payload = event.payload; 49 | if (payload.status === 'Success' && payload.operation === 'Backup') { 50 | void refreshConfig(); 51 | } 52 | }) 53 | .catch((err) => { 54 | error(`Failed to listen quick action events: ${err}`); 55 | }); 56 | } 57 | // 初始加载 58 | refreshConfig(); 59 | 60 | export function useConfig() { 61 | return { 62 | config, 63 | isLoading, 64 | refreshConfig, 65 | saveConfig, 66 | }; 67 | } 68 | -------------------------------------------------------------------------------- /src/composables/useSidebarResize.ts: -------------------------------------------------------------------------------- 1 | import { ref, inject, onUnmounted, type Ref } from 'vue'; 2 | 3 | export interface SidebarResizeOptions { 4 | minWidth?: number; 5 | maxWidth?: number; 6 | initialWidth?: number; 7 | } 8 | 9 | /** 10 | * 提供侧边栏拖动调整大小功能的 composable 11 | * - sidebarWidth: 当前侧边栏宽度 12 | * - isResizing: 是否正在调整大小 13 | * - startResize: 开始调整大小的事件处理函数 14 | */ 15 | export function useSidebarResize(options?: SidebarResizeOptions) { 16 | const minWidth = options?.minWidth ?? 200; 17 | const maxWidth = options?.maxWidth ?? 400; 18 | const defaultWidth = options?.initialWidth ?? 240; 19 | 20 | // 从父组件注入侧边栏宽度,如果没有则使用默认值 21 | const sidebarWidth = inject>('sidebarWidth', ref(defaultWidth)); 22 | const isResizing = ref(false); 23 | const startX = ref(0); 24 | const startWidth = ref(0); 25 | 26 | function handleMouseMove(event: MouseEvent) { 27 | if (!isResizing.value) return; 28 | const delta = event.clientX - startX.value; 29 | const newWidth = Math.max(minWidth, Math.min(maxWidth, startWidth.value + delta)); 30 | sidebarWidth.value = newWidth; 31 | } 32 | 33 | function stopResize() { 34 | isResizing.value = false; 35 | document.removeEventListener('mousemove', handleMouseMove); 36 | document.removeEventListener('mouseup', stopResize); 37 | } 38 | 39 | function startResize(event: MouseEvent) { 40 | event.preventDefault(); 41 | isResizing.value = true; 42 | startX.value = event.clientX; 43 | startWidth.value = sidebarWidth.value; 44 | document.addEventListener('mousemove', handleMouseMove); 45 | document.addEventListener('mouseup', stopResize); 46 | } 47 | 48 | // 组件卸载时清理事件监听器 49 | onUnmounted(() => { 50 | document.removeEventListener('mousemove', handleMouseMove); 51 | document.removeEventListener('mouseup', stopResize); 52 | }); 53 | 54 | return { sidebarWidth, isResizing, startResize }; 55 | } 56 | -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rgsm" 3 | version = "1.7.4" 4 | description = "A user friendly game save manager" 5 | authors = ["Sworld"] 6 | license = "" 7 | repository = "" 8 | edition = "2024" 9 | rust-version = "1.85.1" 10 | 11 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 12 | 13 | [lib] 14 | name = "rgsm_lib" 15 | crate-type = ["staticlib", "cdylib", "rlib"] 16 | 17 | [build-dependencies] 18 | tauri-build = { version = "2.4.1", features = [] } 19 | 20 | [dependencies] 21 | serde_json = "1.0.140" 22 | serde = { version = "1.0.219", features = ["derive"] } 23 | log = "0.4.28" 24 | tauri = { version = "2.8.5", features = ["tray-icon"] } 25 | tauri-plugin-log = "2.7.0" 26 | rust-i18n = "3.1.5" 27 | anyhow = "1.0.97" 28 | semver = "1.0.26" 29 | opendal = { version = "0.54.0", features = ["services-webdav", "services-s3"] } 30 | fs_extra = "1.3.0" 31 | zip = "5.1.1" 32 | filetime = "0.2" 33 | open = "5.3.2" 34 | chrono = "0.4.40" 35 | thiserror = "2.0.12" 36 | tokio = { version = "1.44.2", features = ["full"] } 37 | tokio-util = { version = "0.7.13", features = ["full"] } 38 | tauri-plugin-dialog = "2.4.0" 39 | specta = "=2.0.0-rc.22" 40 | specta-typescript = "0.0.9" 41 | tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] } 42 | temp-dir = "0.1.14" 43 | notify-rust = "4.11.5" 44 | hostname = "0.4.0" 45 | dirs = "6.0.0" 46 | whoami = "1.6.0" 47 | machine-uid = "0.5.3" 48 | rodio = { version = "0.18.1", features = ["flac", "mp3", "vorbis"] } 49 | serde_yaml = "0.9" 50 | reqwest = "0.12" 51 | lazy_static = "1.5" 52 | rust-embed = { version = "8.9.0", features = ["compression", "debug-embed", "include-exclude"] } 53 | font-kit = "0.14" 54 | 55 | [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] 56 | tauri-plugin-global-shortcut = "2.3.0" 57 | tauri-plugin-single-instance = "2.3.4" 58 | tauri-plugin-window-state = "2.4.0" 59 | 60 | [target.'cfg(windows)'.dependencies] 61 | winreg = "0.55" 62 | -------------------------------------------------------------------------------- /.github/workflows/code-style.yml: -------------------------------------------------------------------------------- 1 | name: Code Style 2 | 3 | on: 4 | pull_request: 5 | branches: ['v1-tauri', 'v0-electron', 'dev'] 6 | push: 7 | branches: ['v1-tauri', 'dev'] 8 | 9 | concurrency: 10 | group: code-style-${{ github.event.pull_request.number || github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | style: 15 | name: Style & Lint 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Install system dependencies 21 | run: | 22 | sudo apt-get update 23 | sudo apt-get install -y \ 24 | libwebkit2gtk-4.1-dev \ 25 | build-essential \ 26 | curl \ 27 | wget \ 28 | file \ 29 | libxdo-dev \ 30 | libssl-dev \ 31 | libayatana-appindicator3-dev \ 32 | librsvg2-dev \ 33 | libasound2-dev \ 34 | pkg-config 35 | 36 | - name: Setup pnpm 37 | uses: pnpm/action-setup@v4 38 | with: 39 | version: latest 40 | 41 | - name: Setup Node.js 42 | uses: actions/setup-node@v4 43 | with: 44 | node-version: lts/* 45 | cache: 'pnpm' 46 | 47 | - name: Install frontend dependencies 48 | run: pnpm install --frozen-lockfile 49 | 50 | - name: Run Prettier check 51 | run: pnpm web:format 52 | 53 | - name: Run ESLint 54 | run: pnpm web:lint 55 | 56 | - name: Run TypeScript typecheck 57 | run: pnpm web:typecheck 58 | 59 | - name: Install Rust toolchain 60 | uses: dtolnay/rust-toolchain@stable 61 | with: 62 | components: rustfmt, clippy 63 | 64 | - name: Cache Rust dependencies 65 | uses: Swatinem/rust-cache@v2 66 | with: 67 | workspaces: src-tauri 68 | 69 | - name: Check Rust formatting 70 | working-directory: src-tauri 71 | run: cargo fmt --check 72 | 73 | - name: Run Clippy 74 | working-directory: src-tauri 75 | run: cargo clippy --all-targets --all-features -- -D warnings 76 | -------------------------------------------------------------------------------- /.github/workflows/tauri.yml: -------------------------------------------------------------------------------- 1 | name: tauri 2 | on: 3 | release: 4 | types: [published] 5 | 6 | concurrency: 7 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 8 | cancel-in-progress: true 9 | 10 | jobs: 11 | build-tauri: 12 | permissions: 13 | contents: write 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | include: 18 | - platform: 'macos-latest' # for Arm based macs (M1 and above). 19 | args: '--target aarch64-apple-darwin' 20 | # - platform: 'macos-latest' # for Intel based macs. 21 | # args: '--target x86_64-apple-darwin' 22 | - platform: 'ubuntu-24.04' # for Tauri v1 you could replace this with ubuntu-20.04. 23 | args: '' 24 | - platform: 'windows-latest' 25 | args: '' 26 | runs-on: ${{ matrix.platform }} 27 | steps: 28 | - uses: actions/checkout@v4 29 | - name: setup node 30 | uses: actions/setup-node@v4 31 | with: 32 | node-version: lts/* 33 | - name: setup pnpm 34 | uses: pnpm/action-setup@v4 35 | with: 36 | version: latest 37 | - name: get release id 38 | id: get_release 39 | uses: bruceadams/get-release@v1.3.2 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | - name: install Rust stable 43 | uses: dtolnay/rust-toolchain@stable 44 | - name: install dependencies (ubuntu only) 45 | if: matrix.platform == 'ubuntu-24.04' # This must match the platform value defined above. 46 | run: | 47 | sudo apt-get update 48 | sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev pkg-config libasound2-dev 49 | - name: install frontend dependencies 50 | run: pnpm install 51 | - name: restore rust cache 52 | uses: Swatinem/rust-cache@v2 53 | with: 54 | workspaces: src-tauri 55 | - uses: tauri-apps/tauri-action@v0 56 | env: 57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | with: 59 | releaseId: ${{ steps.get_release.outputs.id }} 60 | args: ${{ matrix.args }} 61 | - name: build portable (windows only) 62 | if: matrix.platform == 'windows-latest' 63 | run: | 64 | pnpm build 65 | pnpm portable 66 | env: 67 | RELEASE_ID: ${{ steps.get_release.outputs.id }} 68 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 69 | VITE_WIN_PORTABLE: 1 70 | -------------------------------------------------------------------------------- /src-tauri/src/quick_actions/hotkeys.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use log::info; 4 | use tauri::{App, Manager}; 5 | use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; 6 | 7 | use crate::{ 8 | config::Config, 9 | quick_actions::{QuickActionManager, QuickActionType}, 10 | }; 11 | 12 | pub fn setup_hotkeys(config: &Config, app: &mut App) -> anyhow::Result<()> { 13 | info!(target:"rgsm::quick_action::hotkeys", "Setting up hotkeys"); 14 | 15 | let manager_state: tauri::State> = app.state(); 16 | let manager = Arc::clone(manager_state.inner()); 17 | 18 | let apply_keys = config 19 | .quick_action 20 | .hotkeys 21 | .apply 22 | .clone() 23 | .into_iter() 24 | .filter(|x| !x.is_empty()) 25 | .collect::>(); 26 | let backup_keys = config 27 | .quick_action 28 | .hotkeys 29 | .backup 30 | .clone() 31 | .into_iter() 32 | .filter(|x| !x.is_empty()) 33 | .collect::>(); 34 | 35 | if !apply_keys.is_empty() { 36 | info!( 37 | target:"rgsm::quick_action::hotkeys", 38 | "Registering apply hotkey: {}", apply_keys.join("+") 39 | ); 40 | let apply_manager = Arc::clone(&manager); 41 | let apply_shortcut = Shortcut::try_from(apply_keys.join("+"))?; 42 | app.global_shortcut() 43 | .on_shortcut(apply_shortcut, move |_app, _shortcut, event| { 44 | if event.state() == ShortcutState::Released { 45 | info!(target:"rgsm::quick_action::hotkeys", "Apply hotkey pressed"); 46 | apply_manager.trigger_apply(QuickActionType::Hotkey); 47 | } 48 | })?; 49 | } 50 | 51 | if !backup_keys.is_empty() { 52 | info!( 53 | target:"rgsm::quick_action::hotkeys", 54 | "Registering backup hotkey: {}", backup_keys.join("+") 55 | ); 56 | let backup_manager = Arc::clone(&manager); 57 | let backup_shortcut = Shortcut::try_from(backup_keys.join("+"))?; 58 | app.global_shortcut() 59 | .on_shortcut(backup_shortcut, move |_app, _shortcut, event| { 60 | if event.state() == ShortcutState::Released { 61 | info!(target:"rgsm::quick_action::hotkeys", "Backup hotkey pressed"); 62 | backup_manager.trigger_backup(QuickActionType::Hotkey); 63 | } 64 | })?; 65 | } 66 | info!(target:"rgsm::quick_action::hotkeys","All hotkey are registered."); 67 | Ok(()) 68 | } 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Game-save-manager 💖 2 | 3 | [![translate](https://hosted.weblate.org/widget/game-save-manager/-/en_US/287x66-grey.png)](https://hosted.weblate.org/engage/game-save-manager) 4 | 5 | 🌍 [简体中文](README.md) | [English](README_EN.md) 6 | 7 | 这是一个简单易用的开源游戏存档管理工具。它可以帮助你管理游戏的存档文件,并且以用户友好的图像化窗口对你的存档进行描述、保存、删除、覆盖等操作。当前版本已经支持了云备份(WebDAV)、快捷操作等功能,且考虑到玩家的性能需求,该软件后台占用极小。 8 | 9 | - [官方网站](https://help.sworld.club):提供了帮助文档、下载等资源 10 | - [更新日志](https://help.sworld.club/blog):Github和这里都可以看到最近的更新 11 | - [下个版本计划](https://github.com/mcthesw/game-save-manager/milestone/3):记录了未来计划实现的功能 12 | - [QQ交流群](http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=2zkfioUwcqA-Y2ZZqfnhjhQcOUEfcYFD&authKey=7eFKqarle0w7QUsFXZbp%2BLkIvEI0ORoggsnNATOSU6maYiu9mSWSTRxcSorp9eex&noverify=0&group_code=837390423):837390423 13 | 14 | 特性列表: 15 | 16 | - 可以在恢复前删除 17 | - 可以云备份,并指定云路径 18 | - 可以快速打开存挡位置 19 | - 支持多文件、文件夹 20 | - 定时备份 21 | - 角标快捷操作 22 | 23 | 该软件使用[Weblate](https://weblate.org/)进行翻译,你可以通过上方图标参与贡献 24 | 25 | ## 用户使用指南 👻 26 | 27 | > 建议阅读[官方网站](https://help.sworld.club)的指南,此处为简略版 28 | 29 | ### 下载软件 😎 30 | 31 | 在[官网的下载界面](https://help.sworld.club/docs/intro)可以下载到本软件,而在[Release 页面](https://github.com/mcthesw/game-save-manager/releases)可以下载到前沿测试版。Win10 或以上版本的用户推荐使用便携版(portable),值得注意的是,本软件依赖于 WebView2 ,如果你不在 Win 上使用,请手动安装,如果你使用的是 Win7,或者你的系统没有附带 WebView2,请注意阅读下方文本。 32 | 33 | #### Win7用户请注意 ⚠️ 34 | 35 | 本软件依赖 WebView2 运行,而 Win7 和一些特殊版本的 Windows 并没有自带此环境,因此提供两个办法安装环境 36 | 37 | - 使用 msi 格式的安装包,在有网络的情况下它会要求安装运行环境 38 | - 从[官方网站](https://developer.microsoft.com/zh-cn/microsoft-edge/webview2/)下载安装运行环境 39 | 40 | #### 安装包用户请注意 ⚠️ 41 | 42 | 本软件会将全部内容安装到安装程序指定的位置,不会额外创建文件夹,且在卸载时勾选“删除应用程序数据”会清空该文件夹,如果你安装到了错误的位置,可以参考[这篇教程](https://help.sworld.club/docs/help/install_to_wrong_location)解决 43 | 44 | ### 问题提交 | 功能建议 😕 45 | 46 | 你可以从以下平台提出建议或反馈问题,我会看到会尽快回复的,不过最好还是在 Github 提出 Issue,以便我们尽快解决,当然,也可以在QQ群参与讨论 47 | 48 | - 📝[Github Issue](https://github.com/mcthesw/game-save-manager/issues/new/choose) 49 | - 🤝[Github Discussion](https://github.com/mcthesw/game-save-manager/discussions) 50 | - ⚡[哔哩哔哩](https://space.bilibili.com/4087637) 51 | 52 | ## 开发者指南 🐱 53 | 54 | > 如果你在寻找旧版基于Electron框架的开发者指南,请看[旧版分支](https://github.com/mcthesw/game-save-manager/tree/v0-electron) 55 | 56 | 如果你能亲自参与这个项目,那真的太好了,不论是解决问题,还是说添加新功能,我们都是非常欢迎的,开发者使用的文档将会放置于本仓库的 `doc/` 文件夹下,请点[此链接](doc/zh-CN/README.md)查看 57 | 58 | 本项目使用的技术: 59 | 60 | - Rust 61 | - TypeScript 62 | - Vue3 63 | - Element Plus 64 | - Tauri 65 | 66 | ## 捐赠 67 | 68 | 你可以通过[爱发电](https://afdian.net/a/Sworld)和微信支持这个项目,可以参考[这个网页](https://help.sworld.club/docs/about) 69 | 70 | ## Star History 71 | 72 | [![Star History Chart](https://api.star-history.com/svg?repos=mcthesw/game-save-manager&type=Date)](https://star-history.com/#mcthesw/game-save-manager&Date) 73 | -------------------------------------------------------------------------------- /src-tauri/src/config/app_config.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use specta::Type; 5 | 6 | use crate::backup::Game; 7 | use crate::cloud_sync::CloudSettings; 8 | use crate::config::{AppearanceSettings, QuickActionsSettings, SaveListExpandBehavior, Settings}; 9 | use crate::default_value; 10 | use crate::device::{Device, DeviceId}; 11 | use crate::preclude::*; 12 | 13 | /// The software's configuration 14 | /// include the version, backup's location path, games'info, 15 | /// and the settings 16 | #[derive(Debug, Serialize, Deserialize, Clone, Type)] 17 | pub struct Config { 18 | pub version: String, 19 | pub backup_path: String, 20 | pub games: Vec, 21 | pub settings: Settings, 22 | #[serde(default = "default_value::empty_vec")] 23 | pub favorites: Vec, 24 | #[serde(default = "default_value::default")] 25 | pub quick_action: QuickActionsSettings, 26 | /// 设备ID到设备名称的映射 27 | #[serde(default = "default_value::empty_map")] 28 | pub devices: HashMap, 29 | } 30 | 31 | impl Sanitizable for Config { 32 | fn sanitize(self) -> Self { 33 | Config { 34 | settings: self.settings.sanitize(), 35 | ..self 36 | } 37 | } 38 | } 39 | 40 | impl Default for Config { 41 | fn default() -> Self { 42 | Config { 43 | version: String::from(std::env!("CARGO_PKG_VERSION")), 44 | backup_path: String::from("save_data"), 45 | games: Vec::new(), 46 | settings: Settings { 47 | prompt_when_not_described: false, 48 | extra_backup_when_apply: true, 49 | show_edit_button: false, 50 | prompt_when_auto_backup: true, 51 | cloud_settings: CloudSettings::default(), 52 | exit_to_tray: true, 53 | locale: default_value::default_locale(), 54 | default_delete_before_apply: false, 55 | default_expend_favorites_tree: false, 56 | home_page: default_value::default_home_page(), 57 | log_to_file: true, 58 | add_new_to_favorites: false, 59 | save_list_expand_behavior: SaveListExpandBehavior::default(), 60 | save_list_last_expanded: false, 61 | max_auto_backup_count: 0, 62 | max_extra_backup_count: 5, 63 | appearance: AppearanceSettings::default(), 64 | }, 65 | favorites: vec![], 66 | quick_action: QuickActionsSettings::default(), 67 | devices: HashMap::new(), 68 | } 69 | } 70 | } 71 | 72 | #[derive(Debug, Serialize, Deserialize, Clone, Type)] 73 | pub struct FavoriteTreeNode { 74 | node_id: String, 75 | label: String, 76 | is_leaf: bool, 77 | children: Option>, 78 | } 79 | -------------------------------------------------------------------------------- /src/components/DeviceSetupDialog.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 94 | 95 | 101 | -------------------------------------------------------------------------------- /src-tauri/src/config/quick_actions_settings.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use specta::Type; 3 | 4 | use crate::{backup::Game, default_value}; 5 | 6 | #[derive(Debug, Serialize, Deserialize, Clone, Type)] 7 | pub struct QuickActionHotkeys { 8 | pub apply: Vec, 9 | pub backup: Vec, 10 | } 11 | 12 | impl Default for QuickActionHotkeys { 13 | fn default() -> Self { 14 | Self { 15 | apply: vec!["".to_string(), "".to_string(), "".to_string()], 16 | backup: vec!["".to_string(), "".to_string(), "".to_string()], 17 | } 18 | } 19 | } 20 | 21 | #[derive(Debug, Serialize, Deserialize, Clone, Type, Default)] 22 | #[serde(tag = "kind", rename_all = "snake_case")] 23 | pub enum QuickActionSoundSource { 24 | #[default] 25 | Default, 26 | File { 27 | path: String, 28 | }, 29 | } 30 | 31 | #[derive(Debug, Serialize, Deserialize, Clone, Default, Type)] 32 | pub struct QuickActionSoundSlots { 33 | #[serde(default)] 34 | pub success: QuickActionSoundSource, 35 | #[serde(default)] 36 | pub failure: QuickActionSoundSource, 37 | } 38 | 39 | #[derive(Debug, Serialize, Deserialize, Clone, Type)] 40 | pub struct QuickActionSoundPreferences { 41 | #[serde(default = "default_value::default_true")] 42 | pub enable_sound: bool, 43 | #[serde(default)] 44 | pub sounds: QuickActionSoundSlots, 45 | } 46 | 47 | impl Default for QuickActionSoundPreferences { 48 | fn default() -> Self { 49 | Self { 50 | enable_sound: default_value::default_true(), 51 | sounds: QuickActionSoundSlots::default(), 52 | } 53 | } 54 | } 55 | 56 | #[derive(Debug, Serialize, Deserialize, Clone, Type)] 57 | pub struct QuickActionsSettings { 58 | #[serde(default = "default_value::default_none")] 59 | pub quick_action_game: Option, 60 | #[serde(default = "default_value::default")] 61 | pub hotkeys: QuickActionHotkeys, 62 | #[serde(default = "default_value::default_true")] 63 | pub enable_sound: bool, 64 | #[serde(default = "default_value::default_true")] 65 | pub enable_notification: bool, 66 | #[serde(default)] 67 | pub sounds: QuickActionSoundSlots, 68 | } 69 | 70 | impl Default for QuickActionsSettings { 71 | fn default() -> Self { 72 | Self { 73 | quick_action_game: default_value::default_none(), 74 | hotkeys: QuickActionHotkeys::default(), 75 | enable_sound: default_value::default_true(), 76 | enable_notification: default_value::default_true(), 77 | sounds: QuickActionSoundSlots::default(), 78 | } 79 | } 80 | } 81 | 82 | impl From<&QuickActionsSettings> for QuickActionSoundPreferences { 83 | fn from(value: &QuickActionsSettings) -> Self { 84 | Self { 85 | enable_sound: value.enable_sound, 86 | sounds: value.sounds.clone(), 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src-tauri/src/updater/versions/v1_4_0.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use std::collections::HashMap; 3 | 4 | use crate::{ 5 | backup::{Game as CurrentGame, SaveUnit, SaveUnitType}, 6 | config::{Config as CurrentConfig, FavoriteTreeNode, QuickActionsSettings, Settings}, 7 | device::Device, 8 | }; 9 | 10 | /// Version constant for 1.4.0 11 | pub const VERSION: &str = "1.4.0"; 12 | 13 | /// Config structure for version 1.4.0 14 | #[derive(Deserialize)] 15 | pub struct Config { 16 | version: String, 17 | backup_path: String, 18 | games: Vec, 19 | #[serde(default)] 20 | settings: Settings, 21 | #[serde(default)] 22 | favorites: Vec, 23 | #[serde(default)] 24 | quick_action: QuickActionsSettings, 25 | } 26 | 27 | /// Game structure for version 1.4.0 28 | #[derive(Deserialize)] 29 | pub struct Game { 30 | name: String, 31 | save_paths: Vec, 32 | game_path: Option, 33 | } 34 | 35 | /// SaveUnit structure for version 1.4.0 36 | #[derive(Deserialize)] 37 | pub struct SaveUnit1_4_0 { 38 | unit_type: SaveUnitType, 39 | path: String, 40 | #[serde(default)] 41 | delete_before_apply: bool, 42 | } 43 | 44 | impl From for CurrentConfig { 45 | fn from(old: Config) -> Self { 46 | let current_device = Device::default(); 47 | let current_device_id = current_device.id.clone(); 48 | 49 | let games = old 50 | .games 51 | .into_iter() 52 | .map(|g| { 53 | let mut game_paths = HashMap::new(); 54 | if let Some(p) = g.game_path { 55 | game_paths.insert(current_device_id.clone(), p); 56 | } 57 | let save_paths = g 58 | .save_paths 59 | .into_iter() 60 | .map(|su| { 61 | let mut paths = HashMap::new(); 62 | paths.insert(current_device_id.clone(), su.path); 63 | SaveUnit { 64 | unit_type: su.unit_type, 65 | paths, 66 | delete_before_apply: su.delete_before_apply, 67 | } 68 | }) 69 | .collect(); 70 | CurrentGame { 71 | name: g.name, 72 | save_paths, 73 | game_paths, 74 | } 75 | }) 76 | .collect(); 77 | 78 | let mut devices = HashMap::new(); 79 | devices.insert(current_device_id, current_device); 80 | 81 | CurrentConfig { 82 | version: env!("CARGO_PKG_VERSION").to_string(), 83 | backup_path: old.backup_path, 84 | games, 85 | settings: old.settings, 86 | favorites: old.favorites, 87 | quick_action: old.quick_action, 88 | devices, 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src-tauri/src/backup/extra_backups.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use specta::Type; 3 | use std::{ffi::OsStr, fs, path::PathBuf, time::SystemTime}; 4 | use tauri::AppHandle; 5 | 6 | use crate::config::get_backup_path; 7 | use crate::preclude::*; 8 | 9 | use super::{Game, decompress_from_file}; 10 | 11 | #[derive(Debug, Serialize, Deserialize, Clone, Type)] 12 | pub struct ExtraBackupItem { 13 | /// Filename without extension, e.g. `Overwrite_2025-12-22_12-34-56`. 14 | pub date: String, 15 | pub size: u64, 16 | /// File modification time in milliseconds since Unix epoch. 17 | pub modified_time_ms: Option, 18 | } 19 | 20 | pub fn extra_backup_folder_path(game: &Game) -> Result { 21 | Ok(get_backup_path()?.join(&game.name).join("extra_backup")) 22 | } 23 | 24 | pub fn list_extra_backups(game: &Game) -> Result, BackupError> { 25 | let dir = extra_backup_folder_path(game)?; 26 | if !dir.exists() { 27 | return Ok(Vec::new()); 28 | } 29 | 30 | let mut items = Vec::new(); 31 | for entry in fs::read_dir(&dir)? { 32 | let entry = entry?; 33 | let path = entry.path(); 34 | if path.extension() != Some(OsStr::new("zip")) { 35 | continue; 36 | } 37 | 38 | let file_stem = match path.file_stem().and_then(|s| s.to_str()) { 39 | Some(v) => v.to_string(), 40 | None => continue, 41 | }; 42 | if !file_stem.starts_with("Overwrite_") { 43 | continue; 44 | } 45 | 46 | let metadata = fs::metadata(&path)?; 47 | let modified_time_ms = metadata.modified().ok().and_then(system_time_to_ms); 48 | items.push(ExtraBackupItem { 49 | date: file_stem, 50 | size: metadata.len(), 51 | modified_time_ms, 52 | }); 53 | } 54 | 55 | // Sort by modified time descending (newest first) for better UX. 56 | items.sort_by(|a, b| match (a.modified_time_ms, b.modified_time_ms) { 57 | (Some(a_ms), Some(b_ms)) => b_ms.cmp(&a_ms).then_with(|| b.date.cmp(&a.date)), 58 | (Some(_), None) => std::cmp::Ordering::Less, 59 | (None, Some(_)) => std::cmp::Ordering::Greater, 60 | (None, None) => b.date.cmp(&a.date), 61 | }); 62 | 63 | Ok(items) 64 | } 65 | 66 | pub fn delete_extra_backup(game: &Game, date: &str) -> Result<(), BackupError> { 67 | let dir = extra_backup_folder_path(game)?; 68 | let zip_path = dir.join(format!("{date}.zip")); 69 | if !zip_path.exists() { 70 | return Ok(()); 71 | } 72 | fs::remove_file(zip_path)?; 73 | Ok(()) 74 | } 75 | 76 | pub fn restore_extra_backup( 77 | game: &Game, 78 | date: &str, 79 | app_handle: Option<&AppHandle>, 80 | ) -> Result<(), BackupError> { 81 | let dir = extra_backup_folder_path(game)?; 82 | decompress_from_file(&game.save_paths, &dir, date, app_handle)?; 83 | Ok(()) 84 | } 85 | 86 | fn system_time_to_ms(t: SystemTime) -> Option { 87 | let d = t.duration_since(SystemTime::UNIX_EPOCH).ok()?; 88 | i64::try_from(d.as_millis()).ok() 89 | } 90 | -------------------------------------------------------------------------------- /doc/zh-CN/README.md: -------------------------------------------------------------------------------- 1 | # 开发者指南 2 | 3 | ## 简介 4 | 5 | 本文档为希望为 Game-save-manager 项目做出贡献的开发者提供指南。其中包括有关项目目标、架构和开发流程的信息。 6 | 7 | ## 如何在本地开发 8 | 9 | ### 环境配置 10 | 11 | 你需要预先安装好以下环境: 12 | 13 | - [Node.js](https://nodejs.org/) 和 [pnpm](https://pnpm.io/) 14 | - [Rust 编译环境](https://www.rust-lang.org/)和 Cargo 15 | 16 | ### 编辑器和插件 17 | 18 | - Visual Studio Code(推荐) 19 | - Rust-analyzer 20 | - Tauri 21 | - Vue - Official 22 | - Element Plus Snippets 23 | - i18n Allay 24 | - WebStorm 25 | - RustRover 26 | 27 | ### 安装依赖 28 | 29 | `pnpm i` 30 | 31 | ### 编译与开发 32 | 33 | 请参考`package.json`来了解指令 34 | 35 | - `pnpm dev` 开发模式,一边预览一边开发 36 | - `pnpm build` 编译打包,输出会存放在`src-tauri/target` 37 | 38 | **提示**:设置环境变量 `NUXT_DEVTOOLS=true` 可启用 Nuxt DevTools,默认禁用以加快启动速度。 39 | 40 | ## 架构 41 | 42 | 该软件分为两个主要部分: 43 | 44 | - 前端负责用户界面和交互。它使用 TypeScript 和 Vue3 编写 45 | - 使用 Element Plus 组件库 46 | - 使用 pinia 进行状态管理 47 | - 使用 vue-router 作为前端路由 48 | - 使用 vue-i18n 进行国际化 49 | - 后端负责管理游戏存档文件。它使用 Rust 编写 50 | - 使用 opendal 来访问云存储 51 | - 使用 serde 来序列化和反序列化数据 52 | - 使用 thiserror 和 anyhow 进行错误处理 53 | 54 | ## 开发流程 55 | 56 | 若要为 Game-save-manager 项目做出贡献,你需要: 57 | 58 | 1. 在 GitHub 上 Fork 存储库的 `dev` 分支 59 | 2. 将 Fork 的存储库克隆到你的本地计算机 60 | 3. 为你的更改创建一个新的分支,如 `feat/webdav-essentials` 61 | 4. 对代码进行更改,将你的更改提交到你的本地分支 62 | 5. 将你的更改推送到你在 GitHub 上 Fork 的存储库 63 | 6. 创建一个 pull request,将你的更改合并到主存储库的 `dev` 分支中,注意,你总是需要以 rebase 的方式来合并代码 64 | 65 | ### 合并上游更新 66 | 67 | 在你开发一段时间之后,你可能会发现上游的代码已经更新了。为了保持你的分支与上游的代码同步,你可以使用以下命令: 68 | 69 | ```bash 70 | git switch dev 71 | git pull 72 | git switch 73 | git rebase dev 74 | ``` 75 | 76 | 这样我们可以保持提交历史的整洁,并且避免不必要的冲突,但是如果已经有冲突了,你需要手动解决冲突,此时我们推荐使用 squash merge 的方式来合并代码。 77 | 78 | ## 使用`vue-devtools` 79 | 80 | 首先需要安装 devtools,并且正确启动 81 | 82 | ```bash 83 | pnpm add -g @vue/devtools@next 84 | vue-devtools 85 | ``` 86 | 87 | 接下来请在项目根目录下找到`index.html`,并且在``标签中添加以下内容 88 | 89 | ```html 90 | 91 | ``` 92 | 93 | ## 编码风格 94 | 95 | 暂时没有完善的编码风格文档,如果你能帮助完成这部分文档我将不胜感激,暂时请参考其余部分代码,尽量保持简洁,且留下合适的文档 96 | 97 | ### UI 覆盖层、层级(z-index)与用户反馈 98 | 99 | - 耗时操作使用 `src/App.vue` 的全局 Loading 覆盖层(通过 `useGlobalLoading()` 控制)。 100 | - 右上角 toast 通知统一使用 `useNotification()`。 101 | - 确认/输入对话框统一使用 `useFeedback()`。 102 | - 任何弹层/覆盖层的层级(z-index)**不要散落写魔法数字**,统一使用 `src/ui/layers.ts` 中的语义化常量 `LAYER.*`,以确保通知在全局覆盖层开启时仍可见。 103 | 104 | ## 提交信息 105 | 106 | 请按照[约定式提交](https://www.conventionalcommits.org/)来编写 commit 信息,这样有助于合作以及自动化构建,你可以使用 VSCode 插件 `Conventional Commits` 来辅助编写你的提交信息 107 | 108 | ## 版本号说明 109 | 110 | 版本号的格式为`x.y.z`,其中`x`为大版本号,`y`为小版本号,`z`为修订号。其中`x`的变化大概率会导致不兼容的改动,`y`的变化可能是重要功能更新,`z`的变化只是一些小的改动,一般后两者可以自动升级。 111 | 112 | ### 更新需要做的改动 113 | 114 | 其余开发者没有必要改动版本号,只需要在更新日志中添加自己的更新内容即可。版本号会在合并进主分支时由 Maintainer 进行修改。 115 | 116 | - 在`src-tauri\Cargo.toml`中修改版本号 117 | 118 | ## 文件夹说明 119 | 120 | - doc: 开发文档 121 | - public: 静态文件 122 | - scripts: 用于 Github Action 的脚本 123 | - src: 前端项目的源代码 124 | - assets: 静态资源 125 | - locales: 国际化资源 126 | - schemas: 保存数据的格式 127 | - 其他请参考文件夹名 128 | - src-tauri: 后端项目的根目录 129 | - src: 后端项目的源代码 130 | -------------------------------------------------------------------------------- /src-tauri/src/backup/utils.rs: -------------------------------------------------------------------------------- 1 | use crate::cloud_sync::upload_game_snapshots; 2 | use crate::config::{get_backup_path, get_config, set_config}; 3 | use crate::preclude::*; 4 | 5 | use log::{error, info}; 6 | use std::fs; 7 | use tauri::AppHandle; 8 | 9 | use super::{Game, GameSnapshots}; 10 | 11 | async fn create_backup_folder(name: &str) -> Result<(), BackupError> { 12 | let config = get_config()?; 13 | 14 | let backup_path = get_backup_path()?.join(name); 15 | let info: GameSnapshots = if !backup_path.exists() { 16 | fs::create_dir_all(&backup_path)?; 17 | GameSnapshots { 18 | name: name.to_string(), 19 | backups: Vec::new(), 20 | head: None, 21 | } 22 | } else { 23 | // 如果已经存在,info从原来的文件中读取 24 | let bytes = fs::read(backup_path.join("Backups.json")); 25 | serde_json::from_slice(&bytes?)? 26 | }; 27 | fs::write( 28 | backup_path.join("Backups.json"), 29 | serde_json::to_string_pretty(&info)?, 30 | )?; 31 | 32 | // 处理云同步 33 | if config.settings.cloud_settings.always_sync { 34 | let op = config.settings.cloud_settings.backend.get_op()?; 35 | // 上传存档记录信息 36 | upload_game_snapshots(&op, info).await?; 37 | } 38 | 39 | Ok(()) 40 | } 41 | 42 | pub async fn create_game_backup(game: &Game) -> Result<(), BackupError> { 43 | let mut config = get_config()?; 44 | create_backup_folder(&game.name).await?; 45 | 46 | // 查找是否存在与新游戏中的 `name` 字段相同的游戏 47 | let pos = config.games.iter().position(|g| g.name == game.name); 48 | match pos { 49 | Some(index) => { 50 | // 如果找到了,就用新的游戏覆盖它 51 | config.games[index] = game.clone(); 52 | } 53 | None => { 54 | // 如果没有找到,就将新的游戏添加到 `games` 数组中 55 | config.games.push(game.clone()); 56 | } 57 | } 58 | set_config(&config).await?; 59 | Ok(()) 60 | } 61 | 62 | pub async fn backup_all() -> Result<(), BackupError> { 63 | let config = get_config()?; 64 | for game in &config.games { 65 | if let Err(e) = game.create_snapshot("Backup all").await { 66 | error!(target: "rgsm::backup", "Backup all failed for game {:#?}", game); 67 | return Err(e); 68 | } else { 69 | info!(target: "rgsm::backup", "Backup all succeeded for game {:#?}", game.name); 70 | } 71 | } 72 | Ok(()) 73 | } 74 | 75 | pub async fn apply_all(app_handle: Option<&AppHandle>) -> Result<(), BackupError> { 76 | let config = get_config()?; 77 | for game in &config.games { 78 | let date = game 79 | .get_game_snapshots_info()? 80 | .backups 81 | .last() 82 | .ok_or(BackupError::NoBackupAvailable)? 83 | .date 84 | .clone(); 85 | if let Err(e) = game.restore_snapshot(&date, app_handle) { 86 | error!(target: "rgsm::backup", "Apply all failed for game {:#?} with date {}", game, date); 87 | return Err(e); 88 | } else { 89 | info!(target: "rgsm::backup", "Apply all succeeded for game {:#?} with date {}", game.name, date); 90 | } 91 | } 92 | Ok(()) 93 | } 94 | -------------------------------------------------------------------------------- /src-tauri/src/app_dirs.rs: -------------------------------------------------------------------------------- 1 | use log::info; 2 | use std::path::PathBuf; 3 | use std::sync::OnceLock; 4 | 5 | /// Stores the application's data directory path 6 | static APP_DATA_DIR: OnceLock = OnceLock::new(); 7 | 8 | /// Get the directory where application data should be stored 9 | /// 10 | /// This function implements the following logic: 11 | /// - In debug mode: Check pwd first (to avoid test configs in target/debug being cleared) 12 | /// - Always use the executable's directory for data storage 13 | /// 14 | /// The result is cached after the first call. 15 | /// The data directory is determined at startup and remains fixed for the application lifetime. 16 | pub fn get_app_data_dir() -> &'static PathBuf { 17 | APP_DATA_DIR.get_or_init(|| { 18 | // In debug mode, check pwd first to avoid test configs in target/debug 19 | // being cleared during cargo clean or rebuilds 20 | #[cfg(debug_assertions)] 21 | { 22 | if let Ok(cwd) = std::env::current_dir() { 23 | let pwd_config_path = cwd.join("GameSaveManager.config.json"); 24 | if pwd_config_path.exists() { 25 | info!("Debug mode: Using pwd as data directory: {}", cwd.display()); 26 | return cwd; 27 | } 28 | } 29 | } 30 | 31 | // Standard behavior: use executable directory for both portable and installed versions 32 | if let Ok(exe_path) = std::env::current_exe() { 33 | if let Some(exe_dir) = exe_path.parent() { 34 | info!( 35 | "Using executable directory as data directory: {}", 36 | exe_dir.display() 37 | ); 38 | return exe_dir.to_path_buf(); 39 | } 40 | } 41 | 42 | // Fallback only if we cannot determine executable directory 43 | let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); 44 | log::warn!( 45 | "Failed to determine executable directory, falling back to current directory: {}", 46 | cwd.display() 47 | ); 48 | cwd 49 | }) 50 | } 51 | 52 | /// Resolve a path relative to the app data directory 53 | /// 54 | /// If the path is already absolute, return it as-is. 55 | /// Otherwise, resolve it relative to the app data directory. 56 | pub fn resolve_app_path(path: &str) -> PathBuf { 57 | let candidate = PathBuf::from(path); 58 | if candidate.is_absolute() { 59 | return candidate; 60 | } 61 | 62 | get_app_data_dir().join(path) 63 | } 64 | 65 | #[cfg(test)] 66 | mod tests { 67 | use super::*; 68 | 69 | #[test] 70 | fn test_resolve_absolute_path() { 71 | #[cfg(target_os = "windows")] 72 | let absolute_path = "C:\\test\\path"; 73 | #[cfg(not(target_os = "windows"))] 74 | let absolute_path = "/test/path"; 75 | 76 | let result = resolve_app_path(absolute_path); 77 | assert_eq!(result, PathBuf::from(absolute_path)); 78 | } 79 | 80 | #[test] 81 | fn test_resolve_relative_path() { 82 | let relative_path = "config.json"; 83 | let result = resolve_app_path(relative_path); 84 | 85 | // The result should be relative to app data dir 86 | assert!(result.ends_with(relative_path)); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src-tauri/src/cloud_sync/utils.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | 3 | use log::info; 4 | use opendal::Operator; 5 | 6 | use crate::backup::GameSnapshots; 7 | use crate::config::{Config, get_config, set_config}; 8 | use crate::preclude::*; 9 | 10 | pub async fn upload_all(op: &Operator) -> Result<(), BackendError> { 11 | let config = get_config()?; 12 | // 上传配置文件 13 | upload_config(op).await?; 14 | // 依次上传所有游戏的存档记录和存档 15 | for game in config.games { 16 | // !NOTICE: 这个地方必须硬编码,因为云端目录必须固定 17 | let cloud_backup_path = format!("save_data/{}", game.name); 18 | let backup_info = game.get_game_snapshots_info()?; 19 | // 写入存档记录 20 | op.write( 21 | &format!("{}/Backups.json", &cloud_backup_path), 22 | serde_json::to_string_pretty(&backup_info)?, 23 | ) 24 | .await?; 25 | // 写入存档zip文件(不包括额外备份) 26 | for backup in backup_info.backups { 27 | // TODO: 此处的cloud_backup_path应当改为本地的路径 28 | let save_path = format!("{}/{}.zip", &cloud_backup_path, backup.date); 29 | info!(target:"rgsm::cloud::utils","Uploading {}", save_path); 30 | op.write(&save_path, fs::read(&save_path)?).await?; 31 | } 32 | } 33 | Ok(()) 34 | } 35 | 36 | pub async fn download_all(op: &Operator) -> Result<(), BackendError> { 37 | // 下载配置文件 38 | let config = String::from_utf8(op.read("/GameSaveManager.config.json").await?.to_vec())?; 39 | let config: Config = serde_json::from_str(&config)?; 40 | set_config(&config).await?; 41 | // 依次下载所有游戏的存档记录和存档 42 | for game in config.games { 43 | // !NOTICE: 这个地方必须硬编码,因为云端目录必须固定 44 | let backup_path = format!("save_data/{}", game.name); 45 | let backup_info = op 46 | .read(&format!("{}/Backups.json", &backup_path)) 47 | .await? 48 | .to_vec(); 49 | let backup_info: GameSnapshots = serde_json::from_str(&String::from_utf8(backup_info)?)?; 50 | game.set_game_snapshots_info(&backup_info)?; 51 | // 写入存档记录 52 | // TODO: 此处的cloud_backup_path应当改为本地的路径 53 | fs::write( 54 | format!("{}/Backups.json", &backup_path), 55 | serde_json::to_string_pretty(&backup_info)?, 56 | )?; 57 | // 写入存档zip文件(不包括额外备份) 58 | for backup in backup_info.backups { 59 | let save_path = format!("{}/{}.zip", &backup_path, backup.date); 60 | info!(target:"rgsm::cloud::utils","Downloading {}", save_path); 61 | let data = op.read(&save_path).await?.to_vec(); 62 | fs::write(&save_path, &data)?; 63 | } 64 | } 65 | Ok(()) 66 | } 67 | 68 | /// 上传单个游戏的配置文件 69 | pub async fn upload_game_snapshots(op: &Operator, info: GameSnapshots) -> Result<(), BackendError> { 70 | // !NOTICE: 这个地方必须硬编码,因为云端目录必须固定 71 | let backup_path = format!("save_data/{}", info.name); 72 | op.write( 73 | &format!("{}/Backups.json", &backup_path), 74 | serde_json::to_string_pretty(&info)?, 75 | ) 76 | .await?; 77 | Ok(()) 78 | } 79 | 80 | // 上传配置文件 81 | pub async fn upload_config(op: &Operator) -> Result<(), BackendError> { 82 | // !NOTICE: 这个地方必须硬编码,因为云端目录必须固定 83 | let config = get_config()?; 84 | // 上传配置文件 85 | op.write( 86 | "/GameSaveManager.config.json", 87 | serde_json::to_string_pretty(&config)?, 88 | ) 89 | .await?; 90 | Ok(()) 91 | } 92 | -------------------------------------------------------------------------------- /src/composables/useSaveListExpandBehavior.ts: -------------------------------------------------------------------------------- 1 | import { computed, watch, nextTick, onMounted, type Ref } from 'vue'; 2 | import type { MenuInstance } from 'element-plus'; 3 | 4 | export interface SaveListExpandOptions { 5 | menuRef: Ref; 6 | saveListMenuIndex?: string; 7 | filteredGames?: Ref; 8 | showFavorite?: Ref; 9 | } 10 | 11 | /** 12 | * 管理侧边栏保存列表展开/折叠行为的 composable 13 | * - saveListDefaultOpeneds: 默认展开的菜单项索引 14 | * - handleMenuOpen: 菜单展开事件处理函数 15 | * - handleMenuClose: 菜单折叠事件处理函数 16 | */ 17 | export function useSaveListExpandBehavior(options: SaveListExpandOptions) { 18 | const { config, saveConfig } = useConfig(); 19 | const { menuRef, filteredGames, showFavorite } = options; 20 | const saveListMenuIndex = options.saveListMenuIndex ?? 'save-list'; 21 | 22 | function getSaveListBehavior() { 23 | return config.value.settings?.save_list_expand_behavior ?? 'always_closed'; 24 | } 25 | 26 | function getSavedExpandState() { 27 | return config.value.settings?.save_list_last_expanded ?? false; 28 | } 29 | 30 | function shouldExpandSaveList() { 31 | const behavior = getSaveListBehavior(); 32 | if (behavior === 'always_open') { 33 | return true; 34 | } 35 | if (behavior === 'remember_last') { 36 | return getSavedExpandState(); 37 | } 38 | return false; 39 | } 40 | 41 | const saveListDefaultOpeneds = computed(() => 42 | shouldExpandSaveList() ? [saveListMenuIndex] : [] 43 | ); 44 | 45 | async function applySaveListExpandState() { 46 | await nextTick(); 47 | const menu = menuRef.value; 48 | if (!menu) { 49 | return; 50 | } 51 | if (shouldExpandSaveList()) { 52 | menu.open(saveListMenuIndex); 53 | } else { 54 | menu.close(saveListMenuIndex); 55 | } 56 | } 57 | 58 | async function persistSaveListState(expanded: boolean) { 59 | if (getSavedExpandState() === expanded) { 60 | return; 61 | } 62 | if (!config.value.settings) { 63 | return; 64 | } 65 | config.value.settings.save_list_last_expanded = expanded; 66 | await saveConfig(); 67 | } 68 | 69 | async function handleMenuOpen(index: string) { 70 | if (index !== saveListMenuIndex) { 71 | return; 72 | } 73 | if (getSaveListBehavior() === 'remember_last') { 74 | await persistSaveListState(true); 75 | } 76 | } 77 | 78 | async function handleMenuClose(index: string) { 79 | if (index !== saveListMenuIndex) { 80 | return; 81 | } 82 | if (getSaveListBehavior() === 'remember_last') { 83 | await persistSaveListState(false); 84 | } 85 | } 86 | 87 | // 监听设置变化 88 | watch( 89 | () => config.value.settings?.save_list_expand_behavior, 90 | async (behavior) => { 91 | await applySaveListExpandState(); 92 | if (behavior === 'always_open') { 93 | await persistSaveListState(true); 94 | } else if (behavior === 'always_closed') { 95 | await persistSaveListState(false); 96 | } 97 | }, 98 | { immediate: true } 99 | ); 100 | 101 | watch( 102 | () => config.value.settings?.save_list_last_expanded, 103 | async () => { 104 | if (getSaveListBehavior() === 'remember_last') { 105 | await applySaveListExpandState(); 106 | } 107 | } 108 | ); 109 | 110 | // 监听过滤后的游戏列表变化 111 | if (filteredGames) { 112 | watch( 113 | filteredGames, 114 | () => { 115 | void applySaveListExpandState(); 116 | }, 117 | { deep: true } 118 | ); 119 | } 120 | 121 | // 监听收藏视图切换 122 | if (showFavorite) { 123 | watch(showFavorite, (value) => { 124 | if (!value) { 125 | void applySaveListExpandState(); 126 | } 127 | }); 128 | } 129 | 130 | onMounted(() => { 131 | void applySaveListExpandState(); 132 | }); 133 | 134 | return { saveListDefaultOpeneds, handleMenuOpen, handleMenuClose }; 135 | } 136 | -------------------------------------------------------------------------------- /src/components/HotkeySelector.vue: -------------------------------------------------------------------------------- 1 | 129 | 130 | 167 | 168 | 174 | -------------------------------------------------------------------------------- /src-tauri/src/config/settings.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use specta::Type; 3 | 4 | use crate::cloud_sync::CloudSettings; 5 | use crate::default_value; 6 | use crate::preclude::*; 7 | 8 | #[derive(Debug, Serialize, Deserialize, Clone, Type)] 9 | pub struct AppearanceSettings { 10 | #[serde(default = "default_value::default_false")] 11 | pub custom_font_enabled: bool, 12 | #[serde(default = "default_value::default")] 13 | pub ui_font_family: String, 14 | } 15 | 16 | impl Default for AppearanceSettings { 17 | fn default() -> Self { 18 | Self { 19 | custom_font_enabled: default_value::default_false(), 20 | ui_font_family: default_value::default(), 21 | } 22 | } 23 | } 24 | 25 | /// Settings that can be configured by user 26 | #[derive(Debug, Serialize, Deserialize, Clone, Type, Default)] 27 | #[serde(rename_all = "snake_case")] 28 | pub enum SaveListExpandBehavior { 29 | AlwaysOpen, 30 | #[default] 31 | AlwaysClosed, 32 | RememberLast, 33 | } 34 | 35 | #[derive(Debug, Serialize, Deserialize, Clone, Type)] 36 | pub struct Settings { 37 | #[serde(default = "default_value::default_true")] 38 | pub prompt_when_not_described: bool, 39 | #[serde(default = "default_value::default_true")] 40 | pub extra_backup_when_apply: bool, 41 | #[serde(default = "default_value::default_false")] 42 | pub show_edit_button: bool, 43 | #[serde(default = "default_value::default_true")] 44 | pub prompt_when_auto_backup: bool, 45 | #[serde(default = "default_value::default_true")] 46 | pub exit_to_tray: bool, 47 | #[serde(default = "default_value::default")] 48 | pub cloud_settings: CloudSettings, 49 | #[serde(default = "default_value::default_locale")] 50 | pub locale: String, 51 | #[serde(default = "default_value::default_false")] 52 | pub default_delete_before_apply: bool, 53 | #[serde(default = "default_value::default_false")] 54 | pub default_expend_favorites_tree: bool, 55 | #[serde(default = "default_value::default_home_page")] 56 | pub home_page: String, 57 | #[serde(default = "default_value::default_true")] 58 | pub log_to_file: bool, 59 | #[serde(default = "default_value::default_false")] 60 | pub add_new_to_favorites: bool, 61 | #[serde(default)] 62 | pub save_list_expand_behavior: SaveListExpandBehavior, 63 | #[serde(default = "default_value::default_false")] 64 | pub save_list_last_expanded: bool, 65 | #[serde(default = "default_value::default_zero_u32")] 66 | pub max_auto_backup_count: u32, 67 | /// Maximum number of extra overwrite backups to keep per game. 68 | /// Keep the newest N backups; 0 means unlimited. 69 | #[serde(default = "default_value::default_five_u32")] 70 | pub max_extra_backup_count: u32, 71 | #[serde(default = "default_value::default")] 72 | pub appearance: AppearanceSettings, 73 | } 74 | 75 | impl Default for Settings { 76 | fn default() -> Self { 77 | Settings { 78 | prompt_when_not_described: default_value::default_true(), 79 | extra_backup_when_apply: default_value::default_true(), 80 | show_edit_button: default_value::default_false(), 81 | prompt_when_auto_backup: default_value::default_true(), 82 | exit_to_tray: default_value::default_true(), 83 | cloud_settings: CloudSettings::default(), 84 | locale: default_value::default_locale(), 85 | default_delete_before_apply: default_value::default_false(), 86 | default_expend_favorites_tree: default_value::default_false(), 87 | home_page: default_value::default_home_page(), 88 | log_to_file: default_value::default_true(), 89 | add_new_to_favorites: default_value::default_false(), 90 | save_list_expand_behavior: SaveListExpandBehavior::default(), 91 | save_list_last_expanded: default_value::default_false(), 92 | max_auto_backup_count: default_value::default_zero_u32(), 93 | max_extra_backup_count: default_value::default_five_u32(), 94 | appearance: AppearanceSettings::default(), 95 | } 96 | } 97 | } 98 | 99 | impl Sanitizable for Settings { 100 | fn sanitize(self) -> Self { 101 | Settings { 102 | cloud_settings: self.cloud_settings.sanitize(), 103 | ..self 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /README_EN.md: -------------------------------------------------------------------------------- 1 | # Game-save-manager 💖 2 | 3 | [![translate](https://hosted.weblate.org/widget/game-save-manager/-/en_US/287x66-grey.png)](https://hosted.weblate.org/engage/game-save-manager) 4 | 5 | 🌍 [简体中文](README.md) | [English](README_EN.md) 6 | 7 | This is a simple and easy-to-use open source game save manager. It can help you manage your game save files, and describe, save, delete, and overwrite your saves in a user-friendly graphical window. The current version supports features such as cloud backup (WebDAV) and quick operations, and considering the performance needs of players, the software has a very small footprint. 8 | 9 | - [Official Website](https://help.sworld.club): Provides resources such as help documentation and downloads 10 | - [Changelog](https://help.sworld.club/blog): Recent updates can be viewed on Github and here 11 | - [Milestone](https://github.com/mcthesw/game-save-manager/milestone/3): Records the functions planned to be implemented in the future 12 | - [QQ Group](http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=2zkfioUwcqA-Y2ZZqfnhjhQcOUEfcYFD&authKey=7eFKqarle0w7QUsFXZbp%2BLkIvEI0ORoggsnNATOSU6maYiu9mSWSTRxcSorp9eex&noverify=0&group_code=837390423): 837390423 13 | 14 | Feature list: 15 | 16 | - Delete before restoring (Optional) 17 | - WebDAV supported and a path can be specified 18 | - Can quickly open the save location 19 | - Supports multiple files and folders 20 | - Scheduled backups 21 | - Tray shortcuts 22 | 23 | This software uses [Weblate](https://weblate.org/) for translation, and you can participate in the contribution through the icon above 24 | 25 | ## User Guide 👻 26 | 27 | > It is recommended to read the guide on the [official website](https://help.sworld.club), this is a simplified version 28 | 29 | ### Download the software 😎 30 | 31 | You can download the software from the [download page of the official website](https://help.sworld.club/docs/intro), and you can download the latest test version from the [Release Page](https://github.com/mcthesw/game-save-manager/releases). Users of Win10 or above are recommended to use the portable version. It is worth noting that this software depends on WebView2. If you are not using it on Windows, please install it manually. If you are using Win7 or your system does not come with WebView2, please read the text below carefully. 32 | 33 | #### Win7 users please note ⚠️ 34 | 35 | This software depends on WebView2 to run, and Win7 and some special versions of Windows do not come with this environment, so there are two ways to install the environment 36 | 37 | - Use the msi format installation package, which will ask to install the runtime environment if there is a network connection 38 | - Download the runtime environment from the [official website](https://developer.microsoft.com/zh-cn/microsoft-edge/webview2/) 39 | 40 | #### Msi installation package users please note ⚠️ 41 | 42 | This software will install all content to the location specified by the installer, will not create additional folders, and will empty the folder when "Delete application data" is checked during uninstallation. If you installed it in the wrong location, you can refer to [this tutorial](https://help.sworld.club/docs/help/install_to_wrong_location) to solve the problem 43 | 44 | ### Submit issues | Feature suggestions 😕 45 | 46 | You can make suggestions or submit feedback from the following platforms, I will see and reply as soon as possible, but it is best to raise an Issue on Github so that we can resolve it as soon as possible. Of course, you can also participate in the discussion in the QQ group 47 | 48 | - 📝[Github Issue](https://github.com/mcthesw/game-save-manager/issues/new/choose) 49 | - 🤝[Github Discussion](https://github.com/mcthesw/game-save-manager/discussions) 50 | - ⚡[Bilibili](https://space.bilibili.com/4087637) 51 | 52 | ## Developer Guide 🐱 53 | 54 | > If you are looking for the old developer guide based on the Electron framework, please see the [old branch](https://github.com/mcthesw/game-save-manager/tree/v0-electron) 55 | 56 | If you can personally participate in this project, it would be great. Whether it's solving problems or adding new features, we are very welcome. The documentation used by developers will be placed in the `doc/` folder of this repository. Please click [this link](doc/en/README.md) to view 57 | 58 | The technologies used in this project: 59 | 60 | - Rust 61 | - TypeScript 62 | - Vue3 63 | - Element Plus 64 | - Tauri 65 | 66 | ## Donate 67 | 68 | [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/M4M2XD2WO) 69 | 70 | ## Star History 71 | 72 | [![Star History Chart](https://api.star-history.com/svg?repos=mcthesw/game-save-manager&type=Date)](https://star-history.com/#mcthesw/game-save-manager&Date) 73 | -------------------------------------------------------------------------------- /src/composables/useNotification.ts: -------------------------------------------------------------------------------- 1 | import { ElNotification } from 'element-plus'; 2 | import type { NotificationParams, NotificationHandle } from 'element-plus'; 3 | import { $t } from '../i18n'; 4 | import { ref } from 'vue'; 5 | import { LAYER } from '../ui/layers'; 6 | 7 | /** 8 | * 通知类型,包括成功、警告、错误和信息 9 | */ 10 | type NotificationType = 'success' | 'warning' | 'error' | 'info'; 11 | 12 | /** 13 | * 通知选项接口,定义通知的基本属性 14 | */ 15 | interface NotificationOptions { 16 | /** 17 | * 可选的通知标题 18 | */ 19 | title?: string; 20 | /** 21 | * 通知显示时长 22 | */ 23 | duration?: number; 24 | /** 25 | * 通知消息内容 26 | */ 27 | message: string; 28 | /** 29 | * 是否持久显示通知 30 | */ 31 | persistent?: boolean; 32 | } 33 | 34 | /** 35 | * 队列中的通知接口,包含类型、选项和唯一标识符 36 | */ 37 | interface QueuedNotification { 38 | /** 39 | * 通知类型 40 | */ 41 | type: NotificationType; 42 | /** 43 | * 通知选项 44 | */ 45 | options: NotificationOptions; 46 | /** 47 | * 唯一标识符 48 | */ 49 | id: string; 50 | } 51 | 52 | /** 53 | * 活跃通知接口,记录当前显示的通知 54 | */ 55 | interface ActiveNotification { 56 | /** 57 | * 通知唯一标识符 58 | */ 59 | id: string; 60 | /** 61 | * 通知处理句柄 62 | */ 63 | handle: NotificationHandle; 64 | } 65 | 66 | /** 67 | * 消息队列,用于管理待显示的通知 68 | */ 69 | const messageQueue = ref([]); 70 | /** 71 | * 当前活跃的通知列表 72 | */ 73 | const activeNotifications = ref([]); 74 | /** 75 | * 处理状态标志 76 | */ 77 | let isProcessing = false; 78 | 79 | /** 80 | * 不同通知类型的默认显示时长 81 | */ 82 | const defaultDurations = { 83 | success: 3000, 84 | warning: 3000, 85 | error: 3000, 86 | info: 3000, 87 | }; 88 | 89 | /** 90 | * 不同通知类型的默认标题(国际化) 91 | */ 92 | const defaultTitles = { 93 | success: $t('misc.success'), 94 | warning: $t('misc.warning'), 95 | error: $t('misc.error'), 96 | info: $t('misc.info'), 97 | }; 98 | 99 | /** 100 | * 生成唯一标识符的函数 101 | */ 102 | const generateId = () => Math.random().toString(36).substring(2, 15); 103 | 104 | /** 105 | * 显示通知的核心函数 106 | * @param type 通知类型 107 | * @param options 通知选项 108 | * @param id 通知唯一标识符 109 | * @returns 通知处理句柄 110 | */ 111 | const show = (type: NotificationType, options: NotificationOptions, id: string) => { 112 | const { 113 | message, 114 | title = defaultTitles[type], 115 | duration = options.persistent ? 0 : defaultDurations[type], 116 | } = options; 117 | 118 | const handle = ElNotification({ 119 | title, 120 | message, 121 | type, 122 | duration, 123 | // Keep notifications visible when app-level overlays (e.g. global loading) are active. 124 | zIndex: LAYER.notification, 125 | } as NotificationParams); 126 | 127 | activeNotifications.value.push({ id, handle }); 128 | return handle; 129 | }; 130 | 131 | /** 132 | * 处理通知队列的函数,确保通知按顺序显示 133 | */ 134 | const processQueue = async () => { 135 | if (isProcessing || messageQueue.value.length === 0) return; 136 | 137 | isProcessing = true; 138 | 139 | while (messageQueue.value.length > 0) { 140 | const notification = messageQueue.value.shift(); 141 | if (notification) { 142 | show(notification.type, notification.options, notification.id); 143 | await new Promise((resolve) => setTimeout(resolve, 100)); // 等待0.1秒 144 | } 145 | } 146 | 147 | isProcessing = false; 148 | }; 149 | 150 | /** 151 | * 将通知添加到队列的函数 152 | * @param type 通知类型 153 | * @param options 通知选项 154 | * @returns 通知的唯一标识符 155 | */ 156 | const addToQueue = (type: NotificationType, options: NotificationOptions): string => { 157 | const id = generateId(); 158 | messageQueue.value.push({ type, options, id }); 159 | processQueue(); 160 | return id; 161 | }; 162 | 163 | /** 164 | * 关闭特定通知的函数 165 | * @param id 要关闭的通知的唯一标识符 166 | */ 167 | const closeNotification = (id: string) => { 168 | // 从队列中移除 169 | messageQueue.value = messageQueue.value.filter((item) => item.id !== id); 170 | 171 | const index = activeNotifications.value.findIndex((n) => n.id === id); 172 | if (index !== -1) { 173 | const notification = activeNotifications.value[index]; 174 | if (notification) { 175 | notification.handle.close(); 176 | } 177 | activeNotifications.value.splice(index, 1); 178 | } 179 | }; 180 | 181 | /** 182 | * 提供通知相关的方法 183 | */ 184 | export function useNotification() { 185 | return { 186 | showSuccess: (options: NotificationOptions) => addToQueue('success', options), 187 | showWarning: (options: NotificationOptions) => addToQueue('warning', options), 188 | showError: (options: NotificationOptions) => addToQueue('error', options), 189 | showInfo: (options: NotificationOptions) => addToQueue('info', options), 190 | closeNotification, // 导出关闭方法 191 | }; 192 | } 193 | -------------------------------------------------------------------------------- /src-tauri/src/cloud_sync/backend.rs: -------------------------------------------------------------------------------- 1 | use opendal::Operator; 2 | use opendal::services; 3 | use serde::{Deserialize, Serialize}; 4 | use specta::Type; 5 | 6 | use crate::config::get_config; 7 | use crate::preclude::*; 8 | 9 | #[derive(Debug, Serialize, Deserialize, Clone, Type)] 10 | #[serde(tag = "type")] 11 | pub enum Backend { 12 | // TODO:增加更多后端支持 13 | Disabled, 14 | /// WebDAV 后端 15 | /// 参考:https://docs.rs/opendal/latest/opendal/services/struct.Webdav.html 16 | /// 不支持 blocking 17 | WebDAV { 18 | endpoint: String, 19 | username: String, 20 | password: String, 21 | }, 22 | /// Amazon S3 后端 23 | /// 参考:https://docs.rs/opendal/latest/opendal/services/struct.S3.html 24 | /// 不支持 rename 和 blocking 25 | S3 { 26 | endpoint: String, 27 | bucket: String, 28 | region: String, 29 | access_key_id: String, 30 | secret_access_key: String, 31 | }, 32 | } 33 | 34 | impl Backend { 35 | /// 获取 Operator 实例 36 | pub fn get_op(&self) -> Result { 37 | let root = get_config()?.settings.cloud_settings.root_path; 38 | match self { 39 | Backend::Disabled => Err(BackendError::Disabled), 40 | Backend::WebDAV { 41 | endpoint, 42 | username, 43 | password, 44 | } => { 45 | let builder = services::Webdav::default() 46 | .endpoint(endpoint) 47 | .username(username) 48 | .password(password) 49 | .root(&root); 50 | Ok(Operator::new(builder)?.finish()) 51 | } 52 | Backend::S3 { 53 | endpoint, 54 | bucket, 55 | region, 56 | access_key_id, 57 | secret_access_key, 58 | } => { 59 | let builder = services::S3::default() 60 | .endpoint(endpoint) 61 | .bucket(bucket) 62 | .region(region) 63 | .access_key_id(access_key_id) 64 | .secret_access_key(secret_access_key) 65 | .root(&root); 66 | Ok(Operator::new(builder)?.finish()) 67 | } 68 | } 69 | } 70 | 71 | /// 检查后端是否可用 72 | pub async fn check(&self) -> Result<(), BackendError> { 73 | const TEST_FILENAME: &str = "test.txt"; 74 | const TEST_CONTENT: &str = "Hello from game save manager"; 75 | const TEST_DIR: &str = "test_dir"; 76 | 77 | let op = self.get_op()?; 78 | // Step1: 检查是否可以列出文件 79 | op.list(".") 80 | .await 81 | .map_err(|_| BackendError::OperatorCheck("Failed to list files.".into()))?; 82 | // Step2: 检查是否可以创建文件 83 | op.write(TEST_FILENAME, TEST_CONTENT) 84 | .await 85 | .map_err(|_| BackendError::OperatorCheck("Failed to create test file.".into()))?; 86 | // Step3: 检查是否可以读取文件 87 | let text = op 88 | .read(TEST_FILENAME) 89 | .await 90 | .map_err(|_| BackendError::OperatorCheck("Failed to read test file.".into()))?; 91 | let text = String::from_utf8(text.to_vec()).map_err(|_| { 92 | BackendError::OperatorCheck("Failed to convert test file to string.".into()) 93 | })?; 94 | if text != TEST_CONTENT { 95 | return Err(BackendError::OperatorCheck( 96 | "Test file content does not match.".into(), 97 | )); 98 | } 99 | // Step4: 检查是否可以删除文件 100 | op.delete(TEST_FILENAME) 101 | .await 102 | .map_err(|_| BackendError::OperatorCheck("Failed to delete test file.".into()))?; 103 | // Step5: 检查是否可以创建目录 104 | op.create_dir(TEST_DIR) 105 | .await 106 | .map_err(|_| BackendError::OperatorCheck("Failed to create test directory.".into()))?; 107 | // Step6: 检查是否可以删除目录 108 | op.delete(TEST_DIR) 109 | .await 110 | .map_err(|_| BackendError::OperatorCheck("Failed to delete test directory.".into()))?; 111 | Ok(()) 112 | } 113 | } 114 | 115 | impl Sanitizable for Backend { 116 | fn sanitize(self) -> Self { 117 | match self { 118 | Backend::Disabled => Backend::Disabled, 119 | Backend::WebDAV { 120 | endpoint, 121 | username: _, 122 | password: _, 123 | } => Backend::WebDAV { 124 | endpoint: endpoint.clone(), 125 | username: "*username*".to_string(), 126 | password: "*password*".to_string(), 127 | }, 128 | Backend::S3 { 129 | endpoint: _, 130 | bucket: _, 131 | region: _, 132 | access_key_id: _, 133 | secret_access_key: _, 134 | } => Backend::S3 { 135 | endpoint: "*endpoint*".to_string(), 136 | bucket: "*bucket*".to_string(), 137 | region: "*region*".to_string(), 138 | access_key_id: "*access_key_id*".to_string(), 139 | secret_access_key: "*secret_access_key*".to_string(), 140 | }, 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src-tauri/src/preclude/errors.rs: -------------------------------------------------------------------------------- 1 | use std::{io, path::PathBuf, string::FromUtf8Error}; 2 | use thiserror::Error; 3 | 4 | use crate::path_resolver::ResolveError; 5 | 6 | #[derive(Debug, Error)] 7 | pub enum BackupFileError { 8 | #[error("Cannot create file: {0:#?}")] 9 | CreateFileFailed(#[from] std::io::Error), 10 | #[error("File to backup not exists: {0:#?}")] 11 | NotExists(PathBuf), 12 | #[error("Cannot write zip file: {0:#?}")] 13 | Zip(#[from] zip::result::ZipError), 14 | #[error("Fs_extra error: {0:#?}")] 15 | Fs(#[from] fs_extra::error::Error), 16 | #[error("Cannot convert path to string")] 17 | NonePathError, 18 | #[error("Path resolution error: {0:#?}")] 19 | PathResolution(#[from] ResolveError), 20 | #[error(transparent)] 21 | Unexpected(#[from] anyhow::Error), 22 | } 23 | 24 | /// 压缩或解压缩时发生的错误 25 | #[derive(Debug, Error)] 26 | pub enum CompressError { 27 | #[error(transparent)] 28 | Single(#[from] BackupFileError), 29 | #[error("Multiple errors: {0:#?}")] 30 | Multiple(Vec), 31 | #[error(transparent)] 32 | Unexpected(#[from] anyhow::Error), 33 | } 34 | 35 | #[derive(Debug, Error)] 36 | pub enum BackendError { 37 | #[error("Backend is disabled")] 38 | Disabled, 39 | #[error("IO error: {0:#?}")] 40 | Io(#[from] io::Error), 41 | #[error("Opendal error: {0:#?}")] 42 | Cloud(Box), 43 | #[error("Cannot read cloud file: {0:#?}")] 44 | ReadCloudInfo(#[from] FromUtf8Error), 45 | #[error("Deserialize error: {0:#?}")] 46 | Deserialize(#[from] serde_json::Error), 47 | #[error("Cloud operator error: {0:#?}")] 48 | OperatorCheck(String), 49 | #[error(transparent)] 50 | Unexpected(#[from] anyhow::Error), 51 | } 52 | impl From for BackendError { 53 | fn from(value: opendal::Error) -> Self { 54 | Self::Cloud(Box::new(value)) 55 | } 56 | } 57 | 58 | impl From for BackendError { 59 | fn from(e: ConfigError) -> Self { 60 | match e { 61 | ConfigError::Io(e) => Self::Io(e), 62 | ConfigError::Deserialize(e) => Self::Deserialize(e), 63 | ConfigError::Backend(inner) => *inner, 64 | other => Self::Unexpected(other.into()), 65 | } 66 | } 67 | } 68 | impl From for BackendError { 69 | fn from(e: BackupError) -> Self { 70 | match e { 71 | BackupError::Io(e) => Self::Io(e), 72 | BackupError::Deserialize(e) => Self::Deserialize(e), 73 | BackupError::Backend(inner) => *inner, 74 | other => Self::Unexpected(other.into()), 75 | } 76 | } 77 | } 78 | 79 | /// 备份或恢复快照时可能产生的错误 80 | #[derive(Debug, Error)] 81 | pub enum BackupError { 82 | #[error("Backup for {name} not exists: {date}")] 83 | BackupNotExist { name: String, date: String }, 84 | #[error("No backups available")] 85 | NoBackupAvailable, 86 | #[error("Backend error: {0:#?}")] 87 | Backend(Box), 88 | #[error("Compress/Decompress error: {0:#?}")] 89 | Compress(#[from] CompressError), 90 | #[error("Deserialize error: {0:#?}")] 91 | Deserialize(#[from] serde_json::Error), 92 | #[error("Cannot convert path to string")] 93 | NonePathError, 94 | #[error("IO error: {0:#?}")] 95 | Io(#[from] io::Error), 96 | #[error(transparent)] 97 | Unexpected(#[from] anyhow::Error), 98 | } 99 | impl From for BackupError { 100 | fn from(e: opendal::Error) -> Self { 101 | Self::Backend(Box::new(BackendError::from(e))) 102 | } 103 | } 104 | impl From for BackupError { 105 | fn from(value: BackendError) -> Self { 106 | Self::Backend(Box::new(value)) 107 | } 108 | } 109 | 110 | impl From for BackupError { 111 | fn from(e: ConfigError) -> Self { 112 | match e { 113 | ConfigError::Io(e) => Self::Io(e), 114 | ConfigError::Deserialize(e) => Self::Deserialize(e), 115 | other => Self::Unexpected(other.into()), 116 | } 117 | } 118 | } 119 | 120 | impl From for ConfigError { 121 | fn from(value: BackendError) -> Self { 122 | Self::Backend(Box::new(value)) 123 | } 124 | } 125 | 126 | #[derive(Debug, Error)] 127 | pub enum ConfigError { 128 | #[error("Deserialize error: {0:#?}")] 129 | Deserialize(#[from] serde_json::Error), 130 | #[error("IO error: {0:#?}")] 131 | Io(#[from] io::Error), 132 | #[error("Backend error: {0:#?}")] 133 | Backend(Box), 134 | #[error("Tauri error: {0:#?}")] 135 | Tauri(#[from] tauri::Error), 136 | #[error(transparent)] 137 | Updater(#[from] UpdaterError), 138 | } 139 | 140 | #[derive(Debug, Error)] 141 | pub enum UpdaterError { 142 | #[error("Deserialize error: {0:#?}")] 143 | Deserialize(#[from] serde_json::Error), 144 | #[error("IO error: {0:#?}")] 145 | Io(#[from] io::Error), 146 | #[error("Semver error: {0:#?}")] 147 | Semver(#[from] semver::Error), 148 | #[error("Missing version field")] 149 | MissingVersion, 150 | #[error("Config version too old")] 151 | ConfigVersionTooOld, 152 | #[error("Config version higher than software")] 153 | ConfigVersionTooNew, 154 | #[error(transparent)] 155 | Unexpected(#[from] anyhow::Error), 156 | } 157 | -------------------------------------------------------------------------------- /doc/en/README.md: -------------------------------------------------------------------------------- 1 | # Developer Guide 2 | 3 | ## Introduction 4 | 5 | This document provides a guide for developers who wish to contribute to the Game-save-manager project. It includes information about the project's goals, architecture, and development process. 6 | 7 | ## How to Develop Locally 8 | 9 | ### Environment Setup 10 | 11 | You need to have the following environments pre-installed: 12 | 13 | - [Node.js](https://nodejs.org/) and [pnpm](https://pnpm.io/) 14 | - [Rust Compiler Environment](https://www.rust-lang.org/) and Cargo 15 | 16 | ### Editors and Plugins 17 | 18 | - Visual Studio Code (Recommended) 19 | - Rust-analyzer 20 | - Tauri 21 | - Vue - Official 22 | - Element Plus Snippets 23 | - i18n Allay 24 | - WebStorm 25 | - RustRover 26 | 27 | ### Installing Dependencies 28 | 29 | `pnpm i` 30 | 31 | ### Compilation and Development 32 | 33 | Refer to `package.json` for instructions 34 | 35 | - `pnpm dev` Development mode, preview while developing 36 | - `pnpm build` Compile and package, output will be stored in `src-tauri/target` 37 | 38 | **Tip**: Set environment variable `NUXT_DEVTOOLS=true` to enable Nuxt DevTools. Disabled by default for faster startup. 39 | 40 | ## Architecture 41 | 42 | The software is divided into two main parts: 43 | 44 | - The frontend is responsible for the user interface and interactions. It is written in TypeScript and Vue3 45 | - Uses the Element Plus component library 46 | - Uses pinia for state management 47 | - Uses vue-router for frontend routing 48 | - Uses vue-i18n for internationalization 49 | - The backend is responsible for managing game save files. It is written in Rust 50 | - Uses opendal to access cloud storage 51 | - Uses serde for serialization and deserialization of data 52 | - Uses thiserror and anyhow for error handling 53 | 54 | ## Development Process 55 | 56 | To contribute to the Game-save-manager project, you need to: 57 | 58 | 1. Fork the repository's `dev` branch on GitHub 59 | 2. Clone the forked repository to your local machine 60 | 3. Create a new branch for your changes, such as `feat/webdav-essentials` 61 | 4. Make changes to the code and commit your changes to your local branch 62 | 5. Push your changes to your forked repository on GitHub 63 | 6. Create a pull request to merge your changes into the main repository's `dev` branch. Note that you always need to merge code in a rebase manner 64 | 65 | ### Merging Upstream Updates 66 | 67 | After developing for a while, you may find that the upstream code has been updated. To keep your branch in sync with the upstream code, you can use the following commands: 68 | 69 | ```bash 70 | git switch dev 71 | git pull 72 | git switch 73 | git rebase dev 74 | ``` 75 | 76 | This way, we can keep the commit history clean and avoid unnecessary conflicts. However, if there are already conflicts, you need to resolve them manually. In this case, we recommend using squash merge to merge the code. 77 | 78 | ## Using `vue-devtools` 79 | 80 | First, you need to install devtools and start it correctly 81 | 82 | ```bash 83 | pnpm add -g @vue/devtools@next 84 | vue-devtools 85 | ``` 86 | 87 | Next, please find `index.html` in the project root directory and add the following content in the `` tag 88 | 89 | ```html 90 | 91 | ``` 92 | 93 | ## Coding Style 94 | 95 | There is no complete coding style document for now. If you can help complete this part of the document, I would be very grateful. For now, please refer to the rest of the code, try to keep it concise, and leave appropriate documentation. 96 | 97 | ### UI overlays, z-index, and user feedback 98 | 99 | - Long-running operations use the global loading overlay in `src/App.vue` (via `useGlobalLoading()`). 100 | - Toast notifications must go through `useNotification()`. 101 | - Confirm/prompt dialogs must go through `useFeedback()`. 102 | - For any stacking/layering decisions, **do not add magic numbers**. 103 | Use the semantic tokens in `src/ui/layers.ts` (`LAYER.*`) so notifications stay visible above overlays. 104 | 105 | ## Commit Messages 106 | 107 | Please follow [Conventional Commits](https://www.conventionalcommits.org/) to write commit messages. This will help with collaboration and automated builds. You can use the VSCode plugin `Conventional Commits` to assist in writing your commit messages. 108 | 109 | ## Version Number Explanation 110 | 111 | The version number format is `x.y.z`, where `x` is the major version number, `y` is the minor version number, and `z` is the revision number. Changes in `x` are likely to cause incompatible changes, changes in `y` may be important feature updates, and changes in `z` are just minor changes. Generally, the latter two can be automatically upgraded. 112 | 113 | ### Changes Required for Updates 114 | 115 | Other developers do not need to change the version number, just add their update content to the changelog. The version number will be modified by the Maintainer when merging into the main branch. 116 | 117 | - Update the version number in `src-tauri\Cargo.toml` 118 | 119 | ## Folder Explanation 120 | 121 | - doc: Development documentation 122 | - public: Static files 123 | - scripts: Scripts for Github Action 124 | - src: Source code for the frontend project 125 | - assets: Static resources 126 | - locales: Internationalization resources 127 | - schemas: Data format for saving 128 | - Others, please refer to the folder name 129 | - src-tauri: Root directory for the backend project 130 | - src: Source code for the backend project 131 | 132 | _This document was translated by Deepseek and manually proofread._ 133 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # AI Agent Development Guidelines for Game Save Manager 2 | 3 | This document provides guidance for an AI agent working on this repository. Your goal is to understand the project structure, conventions, and workflows to contribute effectively. 4 | 5 | ## Project Overview 6 | 7 | This is a cross-platform desktop application for managing game saves, built with Tauri (Rust backend) and Nuxt 3 (Vue 3 frontend). It features local backups, cloud synchronization (WebDAV/S3), and quick actions via hotkeys and a system tray menu. 8 | 9 | This project depends on the following softwares: 10 | 11 | ### Tauri deps 12 | 13 | The code block below shows how to install tauri's deps in Debian. For more information, see . 14 | 15 | ```bash 16 | # For debian 17 | sudo apt install libwebkit2gtk-4.1-dev \ 18 | build-essential \ 19 | curl \ 20 | wget \ 21 | file \ 22 | libxdo-dev \ 23 | libssl-dev \ 24 | libayatana-appindicator3-dev \ 25 | librsvg2-dev \ 26 | libasound2-dev \ 27 | pkg-config 28 | 29 | ``` 30 | 31 | ## Development Commands 32 | 33 | - `pnpm install`: Install all dependencies and run `nuxt prepare`. 34 | - `pnpm dev`: Run the full application in development mode with hot-reloading. 35 | - `pnpm build`: Build the application for production. 36 | - `pnpm web:dev`: Run the frontend only for UI-focused work. 37 | - `pnpm portable`: Create a portable build. 38 | 39 | ## Project Structure & Module Organization 40 | 41 | The application is divided into a frontend and a backend. 42 | 43 | - **Frontend (`src/`)**: A Nuxt 3 application. 44 | - `pages/`: Routed Vue components. 45 | - `components/`: Reusable Vue components. 46 | - `composables/`: Shared state and logic using Vue Composition API. 47 | - `assets/`: Static assets like CSS and images. 48 | - `locales/`: i18n translation files (JSON). 49 | 50 | - **Backend (`src-tauri/`)**: A Rust-based Tauri application. 51 | - `src/main.rs`: Application entry point. 52 | - `src/lib.rs`: Main library, defines Tauri commands. 53 | - `src/ipc_handler.rs`: **Thin export layer only.** This file should only contain `#[tauri::command]` function signatures that delegate to other modules. Do not put business logic here - keep commands simple (1-3 lines) that just call functions from domain modules and handle error conversion. Complex logic belongs in dedicated modules like `backup/`, `config/`, `path_resolver.rs`, etc. 54 | - `src/backup/`: Logic for creating and restoring game save backups. 55 | - `src/cloud_sync/`: Logic for WebDAV and S3 synchronization. 56 | - `src/config/`: Manages `GameSaveManager.config.json`. 57 | - `src/quick_actions/`: Implements hotkeys, tray menu, and timers. 58 | - `src/path_resolver.rs`: Path variable resolution and filesystem checks. 59 | - Any IPC commands should be placed in `ipc_handler.rs`, but their implementation should be in domain modules. 60 | 61 | - **Contracts (`src/bindings.ts`)**: This auto-generated file contains TypeScript definitions for all Rust `#[tauri::command]` functions. It is the primary contract between the frontend and backend. **Never edit it manually.** 62 | 63 | ## Coding Style & Naming Conventions 64 | 65 | - **Frontend (Vue/TypeScript)**: 66 | - Use ` 98 | 99 | 142 | 143 | 199 | -------------------------------------------------------------------------------- /src-tauri/src/config/utils.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | use std::fs::File; 3 | use std::path::PathBuf; 4 | 5 | use crate::app_dirs::resolve_app_path; 6 | use crate::config::Config; 7 | use crate::preclude::*; 8 | use crate::updater::update_config; 9 | use log::info; 10 | 11 | /// Set settings to original state 12 | pub async fn reset_settings() -> Result<(), ConfigError> { 13 | let settings = Config::default().settings; 14 | let mut config = get_config()?; 15 | config.settings = settings; 16 | set_config(&config).await 17 | } 18 | 19 | /// Create a config file 20 | fn init_config() -> Result<(), ConfigError> { 21 | let config_path = resolve_app_path("GameSaveManager.config.json"); 22 | info!("Init config file at: {}", config_path.display()); 23 | fs::write( 24 | config_path, 25 | serde_json::to_string_pretty(&Config::default())?, 26 | )?; 27 | Ok(()) 28 | } 29 | 30 | /// Get the current config file 31 | pub fn get_config() -> Result { 32 | let config_path = resolve_app_path("GameSaveManager.config.json"); 33 | let file = File::open(config_path)?; 34 | Ok(serde_json::from_reader(file)?) 35 | } 36 | 37 | /// Replace the config file with a new config struct 38 | pub async fn set_config(config: &Config) -> Result<(), ConfigError> { 39 | let config_path = resolve_app_path("GameSaveManager.config.json"); 40 | fs::write(config_path, serde_json::to_string_pretty(&config)?)?; 41 | // 处理云同步,上传新的配置文件 42 | if config.settings.cloud_settings.always_sync { 43 | let op = config.settings.cloud_settings.backend.get_op()?; 44 | crate::cloud_sync::upload_config(&op).await?; 45 | } 46 | Ok(()) 47 | } 48 | 49 | /// Check the config file exists or not 50 | /// if not, then create one 51 | /// then send the config to the front end 52 | pub fn config_check() -> Result<(), ConfigError> { 53 | let config_path = resolve_app_path("GameSaveManager.config.json"); 54 | info!("Config file path: {}", config_path.display()); 55 | 56 | if !config_path.is_file() || !config_path.exists() { 57 | init_config()?; 58 | } 59 | // 执行配置迁移与升级 60 | update_config(&config_path)?; 61 | // 重新加载配置 62 | let config = get_config()?; 63 | // 应用本地化语言 64 | rust_i18n::set_locale(&config.settings.locale); 65 | Ok(()) 66 | } 67 | 68 | /// Get the resolved backup path from the config 69 | /// 70 | /// If the backup_path in config is relative, it will be resolved relative to the app data directory. 71 | /// If it's absolute, it will be returned as-is. 72 | pub fn get_backup_path() -> Result { 73 | let config = get_config()?; 74 | Ok(resolve_backup_path(&config.backup_path)) 75 | } 76 | 77 | /// Resolve a backup path 78 | /// 79 | /// If the path is relative, resolve it relative to the app data directory. 80 | /// If it's absolute, return it as-is. 81 | pub fn resolve_backup_path(backup_path: &str) -> PathBuf { 82 | let path = PathBuf::from(backup_path); 83 | if path.is_absolute() { 84 | path 85 | } else { 86 | resolve_app_path(backup_path) 87 | } 88 | } 89 | 90 | #[cfg(test)] 91 | mod tests { 92 | use super::*; 93 | 94 | #[test] 95 | fn test_old_config_backup_path_compatibility() { 96 | // Test that old default path "./save_data" resolves the same as new default "save_data" 97 | let old_default = "./save_data"; 98 | let new_default = "save_data"; 99 | 100 | let old_resolved = resolve_backup_path(old_default); 101 | let new_resolved = resolve_backup_path(new_default); 102 | 103 | // Both should resolve to the same location 104 | assert_eq!( 105 | old_resolved, new_resolved, 106 | "Old default path './save_data' should resolve to same location as new default 'save_data'" 107 | ); 108 | 109 | // Both should end with "save_data" 110 | assert!(old_resolved.ends_with("save_data")); 111 | assert!(new_resolved.ends_with("save_data")); 112 | } 113 | 114 | #[test] 115 | fn test_backup_path_resolution_relative() { 116 | // Test various relative path formats 117 | let test_cases = vec![ 118 | ("save_data", "save_data"), 119 | ("./save_data", "save_data"), 120 | ("backups/games", "games"), 121 | ("./backups/games", "games"), 122 | ]; 123 | 124 | for (input, expected_end) in test_cases { 125 | let resolved = resolve_backup_path(input); 126 | assert!( 127 | resolved.ends_with(expected_end), 128 | "Path '{}' should end with '{}'", 129 | input, 130 | expected_end 131 | ); 132 | } 133 | } 134 | 135 | #[test] 136 | fn test_backup_path_resolution_absolute() { 137 | // Test that absolute paths are preserved 138 | #[cfg(target_os = "windows")] 139 | let absolute_path = "C:\\Users\\Test\\Backups"; 140 | #[cfg(not(target_os = "windows"))] 141 | let absolute_path = "/home/test/backups"; 142 | 143 | let resolved = resolve_backup_path(absolute_path); 144 | assert_eq!( 145 | resolved, 146 | PathBuf::from(absolute_path), 147 | "Absolute paths should be preserved as-is" 148 | ); 149 | } 150 | 151 | #[test] 152 | fn test_config_path_formats_compatibility() { 153 | // Test that different path formats work correctly 154 | let formats = vec![ 155 | "save_data", // New default 156 | "./save_data", // Old default 157 | "save_data/", // With trailing slash 158 | "./save_data/", // Old with trailing slash 159 | ]; 160 | 161 | for format in formats { 162 | let resolved = resolve_backup_path(format); 163 | // All should resolve to paths containing "save_data" 164 | let path_str = resolved.to_string_lossy(); 165 | assert!( 166 | path_str.contains("save_data"), 167 | "Format '{}' should resolve to path containing 'save_data', got: {}", 168 | format, 169 | path_str 170 | ); 171 | } 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src-tauri/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr( 2 | all(not(debug_assertions), target_os = "windows"), 3 | windows_subsystem = "windows" 4 | )] 5 | 6 | use rust_i18n::{i18n, t}; 7 | i18n!("../locales", fallback = ["en_US", "zh_SIMPLIFIED"]); 8 | 9 | use config::get_config; 10 | use tauri::Manager; 11 | 12 | use log::{error, info}; 13 | use tauri_plugin_window_state::{AppHandleExt, StateFlags}; 14 | 15 | use crate::config::config_check; 16 | 17 | mod app_dirs; 18 | mod backup; 19 | mod cloud_sync; 20 | mod config; 21 | mod default_value; 22 | mod device; 23 | mod embedded_resources; 24 | mod ipc_handler; 25 | mod ludusavi_manifest; 26 | mod path_resolver; 27 | mod preclude; 28 | mod quick_actions; 29 | mod sound; 30 | mod system_fonts; 31 | mod updater; 32 | 33 | pub fn run() -> anyhow::Result<()> { 34 | info!("{}", t!("home.hello_world")); 35 | config_check()?; 36 | 37 | // 将 panic 信息记录到日志中 38 | std::panic::set_hook(Box::new(|panic_info| { 39 | // 获取 panic 的位置信息 40 | let location = panic_info.location().unwrap(); // 可以使用 unwrap_or_else() 处理 location 为 None 的情况 41 | 42 | // 获取 panic 的原因 43 | let message = if let Some(s) = panic_info.payload().downcast_ref::<&str>() { 44 | s.to_string() 45 | } else if let Some(s) = panic_info.payload().downcast_ref::() { 46 | s.clone() 47 | } else { 48 | "unknown reason".to_string() 49 | }; 50 | 51 | // 使用 log crate 记录错误信息,并包含位置和原因 52 | error!( 53 | "{}:{}:{} - {}", 54 | location.file(), 55 | location.line(), 56 | location.column(), 57 | message, 58 | ); 59 | })); 60 | 61 | let command_builder = tauri_specta::Builder::::new() 62 | .commands(tauri_specta::collect_commands![ 63 | ipc_handler::open_url, 64 | ipc_handler::open_file_or_folder, 65 | ipc_handler::choose_save_file, 66 | ipc_handler::choose_save_dir, 67 | ipc_handler::get_local_config, 68 | ipc_handler::add_game, 69 | ipc_handler::restore_snapshot, 70 | ipc_handler::delete_snapshot, 71 | ipc_handler::delete_game, 72 | ipc_handler::get_game_snapshots_info, 73 | ipc_handler::set_config, 74 | ipc_handler::reset_settings, 75 | ipc_handler::create_snapshot, 76 | ipc_handler::open_backup_folder, 77 | ipc_handler::get_game_extra_backups, 78 | ipc_handler::delete_extra_backup, 79 | ipc_handler::restore_extra_backup, 80 | ipc_handler::open_extra_backup_folder, 81 | ipc_handler::check_cloud_backend, 82 | ipc_handler::cloud_upload_all, 83 | ipc_handler::cloud_download_all, 84 | ipc_handler::set_snapshot_description, 85 | ipc_handler::backup_all, 86 | ipc_handler::apply_all, 87 | ipc_handler::set_quick_backup_game, 88 | ipc_handler::resolve_path, 89 | ipc_handler::get_current_device_info, 90 | ipc_handler::toggle_quick_action_sound_preview, 91 | ipc_handler::stop_sound_playback, 92 | ipc_handler::choose_quick_action_sound_file, 93 | ipc_handler::set_snapshot_head, 94 | ipc_handler::detach_snapshot, 95 | ipc_handler::create_snapshot_at, 96 | ipc_handler::fetch_ludusavi_games, 97 | ipc_handler::get_game_save_paths, 98 | ipc_handler::get_ludusavi_manifest_status, 99 | ipc_handler::update_ludusavi_manifest, 100 | ipc_handler::reset_ludusavi_manifest_to_bundled, 101 | ipc_handler::check_paths, 102 | ipc_handler::get_system_fonts, 103 | ]) 104 | .events(tauri_specta::collect_events![ 105 | ipc_handler::IpcNotification, 106 | quick_actions::QuickActionCompleted 107 | ]) 108 | .constant("DEFAULT_CONFIG", config::Config::default()); 109 | 110 | #[cfg(debug_assertions)] 111 | command_builder.export( 112 | specta_typescript::Typescript::default() 113 | .bigint(specta_typescript::BigIntExportBehavior::Number) // 设置 bigint 为 number 114 | .header("/* tslint:disable */"), // 添加头部,关闭TS的检查,避免编译失败 115 | "../src/bindings.ts", 116 | )?; 117 | 118 | // Init app 119 | let app = tauri::Builder::default() 120 | .plugin(tauri_plugin_window_state::Builder::new().build()) 121 | .plugin( 122 | tauri_plugin_log::Builder::new() 123 | .target(tauri_plugin_log::Target::new( 124 | tauri_plugin_log::TargetKind::LogDir { 125 | file_name: Some("logs".to_string()), 126 | }, 127 | )) 128 | .max_file_size(50_000 /* bytes */) 129 | .timezone_strategy(tauri_plugin_log::TimezoneStrategy::UseLocal) 130 | .build(), 131 | ) 132 | .plugin(tauri_plugin_dialog::init()) 133 | .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { 134 | app.get_webview_window("main") 135 | .expect("no main window") 136 | .set_focus() 137 | .expect("failed to set focus"); 138 | })) 139 | .plugin(tauri_plugin_global_shortcut::Builder::new().build()) 140 | .invoke_handler(command_builder.invoke_handler()) 141 | .setup(move |app| { 142 | sound::setup(app).expect("Cannot setup sound manager"); 143 | // 处理快捷备份,包括托盘、定时、快捷键 144 | quick_actions::setup(app).expect("Cannot setup quick actions"); 145 | // 注册命令 146 | command_builder.mount_events(app); 147 | Ok(()) 148 | }); 149 | 150 | // 处理退出到托盘(关闭窗口不退出) 151 | let config = get_config()?; 152 | info!(target: "rgsm::main", "App has started."); 153 | 154 | let exit_code = app 155 | .build(tauri::generate_context!()) 156 | .expect("Cannot build tauri app") 157 | .run_return(move |handle, event| { 158 | if let tauri::RunEvent::ExitRequested { api, code, .. } = event { 159 | handle 160 | .save_window_state(StateFlags::all()) 161 | .expect("Cannot save window state"); 162 | // Only prevent exit when exit to tray is enabled and exit code is not provided(User requested exit) 163 | if config.settings.exit_to_tray && code.is_none() { 164 | api.prevent_exit(); 165 | } 166 | } 167 | }); 168 | 169 | if exit_code == 0 { 170 | info!(target: "rgsm::main", "App has exited successfully."); 171 | Ok(()) 172 | } else { 173 | error!(target: "rgsm::main", "App has exited with error code {}.", exit_code); 174 | Err(anyhow::anyhow!( 175 | "App has exited with error code {}.", 176 | exit_code 177 | )) 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/components/GameImportDialog.vue: -------------------------------------------------------------------------------- 1 | 92 | 93 | 192 | 193 | 254 | -------------------------------------------------------------------------------- /src-tauri/src/updater/migration.rs: -------------------------------------------------------------------------------- 1 | use rust_i18n::t; 2 | use std::fs; 3 | use std::path::{Path, PathBuf}; 4 | 5 | use log::{error, info, warn}; 6 | use semver::Version; 7 | 8 | use crate::backup::GameSnapshots; 9 | use crate::config::{Config, get_backup_path}; 10 | use crate::preclude::*; 11 | use crate::updater::{ 12 | probe::probe_config_version, 13 | versions::{CURRENT_VERSION, Config1_4_0, MIN_SUPPORTED_VERSION, VERSION_1_4_0, VERSION_1_6_0}, 14 | }; 15 | 16 | /// Update configuration file to the latest version 17 | /// 18 | /// This function handles the entire migration process: 19 | /// 1. Version probing 20 | /// 2. Version compatibility check 21 | /// 3. Backup creation 22 | /// 4. Data migration 23 | /// 5. New config writing 24 | /// 25 | /// # Arguments 26 | /// * `path` - Path to the config file 27 | /// 28 | /// # Returns 29 | /// * `Ok(())` - If migration succeeds or not needed 30 | /// * `Err(UpdaterError)` - If any step fails 31 | pub fn update_config>(path: P) -> Result<(), UpdaterError> { 32 | let path: &Path = path.as_ref(); 33 | let version = probe_config_version(path)?; 34 | let current = Version::parse(CURRENT_VERSION)?; 35 | let min_supported = Version::parse(MIN_SUPPORTED_VERSION)?; 36 | let version_1_6_0 = Version::parse(VERSION_1_6_0)?; 37 | 38 | // Version compatibility check 39 | if version > current { 40 | error!(target: "rgsm::updater", "Config version too new: {} > {}", version, current); 41 | return Err(UpdaterError::ConfigVersionTooNew); 42 | } 43 | if version < min_supported { 44 | error!(target: "rgsm::updater", "Config version too old: {} < {}", version, min_supported); 45 | return Err(UpdaterError::ConfigVersionTooOld); 46 | } 47 | if version == current { 48 | return Ok(()); 49 | } 50 | 51 | warn!(target: "rgsm::updater", "Config version is older than current version, updating..."); 52 | // Create backup 53 | backup_config(path)?; 54 | 55 | // Read original content 56 | let content = fs::read_to_string(path)?; 57 | 58 | // Migrate based on version 59 | let new_cfg = migrate_config(&content, &version)?; 60 | 61 | // Migrate game snapshots if upgrading from before 1.6.0 62 | if version < version_1_6_0 { 63 | migrate_game_snapshots_to_chain()?; 64 | } 65 | 66 | // Write new config 67 | fs::write(path, serde_json::to_string_pretty(&new_cfg)?)?; 68 | info!(target: "rgsm::updater", "Config updated successfully to version {}", CURRENT_VERSION); 69 | Ok(()) 70 | } 71 | 72 | /// Migrate config content based on its version 73 | fn migrate_config(content: &str, version: &Version) -> Result { 74 | if version.to_string().as_str() <= VERSION_1_4_0 { 75 | let old_cfg: Config1_4_0 = serde_json::from_str(content)?; 76 | Ok(Config::from(old_cfg)) 77 | } else { 78 | // Try direct deserialization for compatible versions 79 | let mut new_cfg: Config = serde_json::from_str(content)?; 80 | new_cfg.version = CURRENT_VERSION.to_string(); 81 | Ok(new_cfg) 82 | } 83 | } 84 | 85 | /// Create a backup of the config file 86 | fn backup_config>(path: P) -> Result { 87 | let path = path.as_ref(); 88 | let backup_path = path.with_extension("json.bak"); 89 | 90 | // Show notification 91 | show_notification( 92 | t!("backend.config.updating_config_title"), 93 | t!("backend.config.updating_config_body"), 94 | ); 95 | 96 | // Create backup 97 | fs::copy(path, &backup_path)?; 98 | info!(target: "rgsm::updater", "Created backup at {:?}", backup_path); 99 | 100 | Ok(backup_path) 101 | } 102 | 103 | /// Migrate game snapshots from flat list to chained structure (for versions < 1.6.0) 104 | /// 105 | /// This function: 106 | /// 1. Scans all game folders in the backup path 107 | /// 2. For each Backups.json, sorts snapshots by date (ascending) 108 | /// 3. Creates a parent chain from oldest to newest 109 | /// 4. Sets head to the newest snapshot 110 | fn migrate_game_snapshots_to_chain() -> Result<(), UpdaterError> { 111 | let backup_path = match get_backup_path() { 112 | Ok(p) => p, 113 | Err(e) => { 114 | warn!(target: "rgsm::updater", "Failed to get backup path, skipping snapshot migration: {}", e); 115 | return Ok(()); 116 | } 117 | }; 118 | 119 | if !backup_path.exists() { 120 | info!(target: "rgsm::updater", "Backup path does not exist, skipping snapshot migration"); 121 | return Ok(()); 122 | } 123 | 124 | // Iterate through all directories in backup path 125 | let entries = fs::read_dir(&backup_path)?; 126 | for entry in entries.flatten() { 127 | let path = entry.path(); 128 | if !path.is_dir() { 129 | continue; 130 | } 131 | 132 | let backups_json_path = path.join("Backups.json"); 133 | if !backups_json_path.exists() { 134 | continue; 135 | } 136 | 137 | // Read the existing Backups.json 138 | let content = match fs::read_to_string(&backups_json_path) { 139 | Ok(c) => c, 140 | Err(e) => { 141 | warn!(target: "rgsm::updater", "Failed to read {:?}: {}", backups_json_path, e); 142 | continue; 143 | } 144 | }; 145 | 146 | let mut game_snapshots: GameSnapshots = match serde_json::from_str(&content) { 147 | Ok(gs) => gs, 148 | Err(e) => { 149 | warn!(target: "rgsm::updater", "Failed to parse {:?}: {}", backups_json_path, e); 150 | continue; 151 | } 152 | }; 153 | 154 | // Skip if already migrated (has parent or head set) 155 | if game_snapshots.head.is_some() 156 | || game_snapshots.backups.iter().any(|s| s.parent.is_some()) 157 | { 158 | info!(target: "rgsm::updater", "Skipping {:?}: already has chain structure", backups_json_path); 159 | continue; 160 | } 161 | 162 | // Skip if no snapshots or only one snapshot 163 | if game_snapshots.backups.len() <= 1 { 164 | // For single snapshot, just set it as head 165 | if let Some(snapshot) = game_snapshots.backups.first() { 166 | game_snapshots.head = Some(snapshot.date.clone()); 167 | } 168 | } else { 169 | // Sort snapshots by date (ascending - oldest first) 170 | game_snapshots.backups.sort_by(|a, b| a.date.cmp(&b.date)); 171 | 172 | // Create parent chain: each snapshot points to the previous one 173 | for i in 1..game_snapshots.backups.len() { 174 | let parent_date = game_snapshots.backups[i - 1].date.clone(); 175 | game_snapshots.backups[i].parent = Some(parent_date); 176 | } 177 | 178 | // Set head to the newest (last) snapshot 179 | if let Some(newest) = game_snapshots.backups.last() { 180 | game_snapshots.head = Some(newest.date.clone()); 181 | } 182 | } 183 | 184 | // Write updated Backups.json 185 | match fs::write( 186 | &backups_json_path, 187 | serde_json::to_string_pretty(&game_snapshots)?, 188 | ) { 189 | Ok(_) => { 190 | info!(target: "rgsm::updater", "Migrated snapshots to chain structure: {:?}", backups_json_path); 191 | } 192 | Err(e) => { 193 | error!(target: "rgsm::updater", "Failed to write {:?}: {}", backups_json_path, e); 194 | } 195 | } 196 | } 197 | 198 | info!(target: "rgsm::updater", "Game snapshots migration completed"); 199 | Ok(()) 200 | } 201 | -------------------------------------------------------------------------------- /src-tauri/src/backup/archive_tests.rs: -------------------------------------------------------------------------------- 1 | use crate::backup::archive::{ 2 | add_directory, system_time_to_zip_datetime, zip_datetime_to_system_time, 3 | }; 4 | use filetime::{FileTime, set_file_mtime}; 5 | use std::{ 6 | fs::{self, File}, 7 | io::{Read, Write}, 8 | path::PathBuf, 9 | time::SystemTime, 10 | }; 11 | use zip::{ZipWriter, write::SimpleFileOptions}; 12 | 13 | #[cfg(test)] 14 | mod tests { 15 | use super::*; 16 | 17 | #[test] 18 | fn test_timestamp_preservation_file() -> Result<(), Box> { 19 | let temp_dir = temp_dir::TempDir::new()?; 20 | let temp_path = temp_dir.path(); 21 | 22 | let test_file = temp_path.join("test_file.txt"); 23 | let mut file = File::create(&test_file)?; 24 | file.write_all(b"test content")?; 25 | drop(file); 26 | 27 | let past_time = SystemTime::now() - std::time::Duration::from_secs(86400); 28 | let file_time = FileTime::from_system_time(past_time); 29 | set_file_mtime(&test_file, file_time)?; 30 | 31 | let original_mtime = fs::metadata(&test_file)?.modified()?; 32 | 33 | let zip_path = temp_path.join("test.zip"); 34 | let zip_file = File::create(&zip_path)?; 35 | let mut zip_writer = ZipWriter::new(zip_file); 36 | 37 | let file_metadata = fs::metadata(&test_file)?; 38 | let file_mtime = file_metadata.modified()?; 39 | let file_datetime = system_time_to_zip_datetime(file_mtime); 40 | 41 | let mut test_file_read = File::open(&test_file)?; 42 | let mut buf = vec![]; 43 | test_file_read.read_to_end(&mut buf)?; 44 | 45 | zip_writer.start_file( 46 | "test_file.txt", 47 | SimpleFileOptions::default() 48 | .compression_method(zip::CompressionMethod::Bzip2) 49 | .last_modified_time(file_datetime), 50 | )?; 51 | zip_writer.write_all(&buf)?; 52 | zip_writer.finish()?; 53 | 54 | let extract_dir = temp_path.join("extract"); 55 | fs::create_dir_all(&extract_dir)?; 56 | 57 | let zip_file = File::open(&zip_path)?; 58 | let mut zip_archive = zip::ZipArchive::new(zip_file)?; 59 | 60 | for i in 0..zip_archive.len() { 61 | let mut file = zip_archive.by_index(i)?; 62 | let outpath = extract_dir.join(file.name()); 63 | 64 | if let Some(parent) = outpath.parent() { 65 | fs::create_dir_all(parent)?; 66 | } 67 | let mut outfile = File::create(&outpath)?; 68 | std::io::copy(&mut file, &mut outfile)?; 69 | drop(outfile); 70 | 71 | if let Some(zip_time) = file.last_modified() { 72 | let system_time = zip_datetime_to_system_time(zip_time); 73 | let file_time = FileTime::from_system_time(system_time); 74 | set_file_mtime(&outpath, file_time)?; 75 | } 76 | } 77 | 78 | let extracted_file = extract_dir.join("test_file.txt"); 79 | let extracted_mtime = fs::metadata(&extracted_file)?.modified()?; 80 | 81 | let original_secs = original_mtime 82 | .duration_since(SystemTime::UNIX_EPOCH)? 83 | .as_secs(); 84 | let extracted_secs = extracted_mtime 85 | .duration_since(SystemTime::UNIX_EPOCH)? 86 | .as_secs(); 87 | 88 | assert!( 89 | (original_secs as i64 - extracted_secs as i64).abs() <= 2, 90 | "Timestamp should be preserved (within 2 seconds due to MS-DOS timestamp precision)" 91 | ); 92 | 93 | Ok(()) 94 | } 95 | 96 | #[test] 97 | fn test_timestamp_preservation_directory() -> Result<(), Box> { 98 | let temp_dir = temp_dir::TempDir::new()?; 99 | let temp_path = temp_dir.path(); 100 | 101 | let test_dir = temp_path.join("test_dir"); 102 | fs::create_dir_all(&test_dir)?; 103 | 104 | let test_file = test_dir.join("nested_file.txt"); 105 | let mut file = File::create(&test_file)?; 106 | file.write_all(b"nested content")?; 107 | drop(file); 108 | 109 | let past_time_file = SystemTime::now() - std::time::Duration::from_secs(3600); 110 | let file_time = FileTime::from_system_time(past_time_file); 111 | set_file_mtime(&test_file, file_time)?; 112 | 113 | let past_time_dir = SystemTime::now() - std::time::Duration::from_secs(7200); 114 | let dir_time = FileTime::from_system_time(past_time_dir); 115 | set_file_mtime(&test_dir, dir_time)?; 116 | 117 | let original_file_mtime = fs::metadata(&test_file)?.modified()?; 118 | let original_dir_mtime = fs::metadata(&test_dir)?.modified()?; 119 | 120 | let zip_path = temp_path.join("test_dir.zip"); 121 | let zip_file = File::create(&zip_path)?; 122 | let mut zip_writer = ZipWriter::new(zip_file); 123 | 124 | add_directory(&mut zip_writer, &test_dir, &PathBuf::from("test_dir"))?; 125 | zip_writer.finish()?; 126 | 127 | let extract_dir = temp_path.join("extract"); 128 | fs::create_dir_all(&extract_dir)?; 129 | 130 | let zip_file = File::open(&zip_path)?; 131 | let mut zip_archive = zip::ZipArchive::new(zip_file)?; 132 | 133 | let mut dir_timestamps: Vec<(PathBuf, FileTime)> = Vec::new(); 134 | 135 | for i in 0..zip_archive.len() { 136 | let mut file = zip_archive.by_index(i)?; 137 | let outpath = extract_dir.join(file.name()); 138 | 139 | if file.is_dir() { 140 | fs::create_dir_all(&outpath)?; 141 | if let Some(zip_time) = file.last_modified() { 142 | let system_time = zip_datetime_to_system_time(zip_time); 143 | let file_time = FileTime::from_system_time(system_time); 144 | dir_timestamps.push((outpath.clone(), file_time)); 145 | } 146 | } else { 147 | if let Some(parent) = outpath.parent() { 148 | fs::create_dir_all(parent)?; 149 | } 150 | let mut outfile = File::create(&outpath)?; 151 | std::io::copy(&mut file, &mut outfile)?; 152 | drop(outfile); 153 | 154 | if let Some(zip_time) = file.last_modified() { 155 | let system_time = zip_datetime_to_system_time(zip_time); 156 | let file_time = FileTime::from_system_time(system_time); 157 | let _ = set_file_mtime(&outpath, file_time); 158 | } 159 | } 160 | } 161 | 162 | dir_timestamps.sort_by(|a, b| { 163 | let depth_a = a.0.components().count(); 164 | let depth_b = b.0.components().count(); 165 | depth_b.cmp(&depth_a) 166 | }); 167 | for (dir_path, file_time) in dir_timestamps { 168 | let _ = set_file_mtime(&dir_path, file_time); 169 | } 170 | 171 | let extracted_file = extract_dir.join("test_dir").join("nested_file.txt"); 172 | let extracted_file_mtime = fs::metadata(&extracted_file)?.modified()?; 173 | 174 | let extracted_dir = extract_dir.join("test_dir"); 175 | let extracted_dir_mtime = fs::metadata(&extracted_dir)?.modified()?; 176 | 177 | let original_file_secs = original_file_mtime 178 | .duration_since(SystemTime::UNIX_EPOCH)? 179 | .as_secs(); 180 | let extracted_file_secs = extracted_file_mtime 181 | .duration_since(SystemTime::UNIX_EPOCH)? 182 | .as_secs(); 183 | 184 | let original_dir_secs = original_dir_mtime 185 | .duration_since(SystemTime::UNIX_EPOCH)? 186 | .as_secs(); 187 | let extracted_dir_secs = extracted_dir_mtime 188 | .duration_since(SystemTime::UNIX_EPOCH)? 189 | .as_secs(); 190 | 191 | assert!( 192 | (original_file_secs as i64 - extracted_file_secs as i64).abs() <= 2, 193 | "File timestamp should be preserved (within 2 seconds)" 194 | ); 195 | 196 | assert!( 197 | (original_dir_secs as i64 - extracted_dir_secs as i64).abs() <= 2, 198 | "Directory timestamp should be preserved (within 2 seconds)" 199 | ); 200 | 201 | Ok(()) 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /src/pages/index.vue: -------------------------------------------------------------------------------- 1 | 68 | 69 | 130 | 131 | 321 | -------------------------------------------------------------------------------- /src/components/MainSideBar.vue: -------------------------------------------------------------------------------- 1 | 64 | 65 | 151 | 152 | 290 | -------------------------------------------------------------------------------- /src/components/ExtraBackupDrawer.vue: -------------------------------------------------------------------------------- 1 | 139 | 140 | 192 | 193 | 289 | -------------------------------------------------------------------------------- /src/components/SnapshotNode.vue: -------------------------------------------------------------------------------- 1 | 66 | 67 | 178 | 179 | 310 | --------------------------------------------------------------------------------