From 3aa4247a898b91b62a3b94669ff3536759f5e6ca Mon Sep 17 00:00:00 2001 From: rain9 <15911122312@163.com> Date: Mon, 29 Jun 2026 23:16:40 +0800 Subject: [PATCH] feat: add version update module - Add update related types (UpdateStatus, UpdateInfo, UpdateProgress, UpdateState) - Add updater service with check, download, install, skip, dismiss functions - Add useAppUpdater hook for state management - Add UpdateCard and UpdateDialog components - Integrate update module into SettingsPanel - Add auto-check on app startup - Add multi-language support for update strings - Add storage functions for update configuration - Configure Tauri updater plugin - Add button styles and timeout mechanism --- desktop/package.json | 1 + desktop/pnpm-lock.yaml | 10 + desktop/src-tauri/Cargo.lock | 376 +++++++++++++++++- desktop/src-tauri/Cargo.toml | 1 + desktop/src-tauri/capabilities/default.json | 3 +- desktop/src-tauri/capabilities/settings.json | 3 +- desktop/src-tauri/src/lib.rs | 1 + desktop/src-tauri/tauri.conf.json | 8 + desktop/src/App.css | 32 ++ desktop/src/App.tsx | 7 + desktop/src/components/SettingsPanel.tsx | 48 ++- desktop/src/components/update/UpdateCard.tsx | 239 +++++++++++ .../src/components/update/UpdateDialog.tsx | 186 +++++++++ desktop/src/components/update/index.ts | 2 + desktop/src/hooks/useAppUpdater.ts | 76 ++++ desktop/src/i18n/en.ts | 36 ++ desktop/src/i18n/zh.ts | 36 ++ desktop/src/pages/Settings.tsx | 26 +- desktop/src/services/storage.ts | 49 +++ desktop/src/services/updater/index.ts | 232 +++++++++++ desktop/src/types/index.ts | 44 ++ 21 files changed, 1406 insertions(+), 10 deletions(-) create mode 100644 desktop/src/components/update/UpdateCard.tsx create mode 100644 desktop/src/components/update/UpdateDialog.tsx create mode 100644 desktop/src/components/update/index.ts create mode 100644 desktop/src/hooks/useAppUpdater.ts create mode 100644 desktop/src/services/updater/index.ts diff --git a/desktop/package.json b/desktop/package.json index 6e1148f..d3f9f2c 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -27,6 +27,7 @@ "@tauri-apps/plugin-global-shortcut": "^2.3.2", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-shell": "^2.3.5", + "@tauri-apps/plugin-updater": "^2.10.1", "@tauri-apps/plugin-window": "2.0.0-alpha.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index c3c8a05..aa3874e 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@tauri-apps/plugin-shell': specifier: ^2.3.5 version: 2.3.5 + '@tauri-apps/plugin-updater': + specifier: ^2.10.1 + version: 2.10.1 '@tauri-apps/plugin-window': specifier: 2.0.0-alpha.1 version: 2.0.0-alpha.1 @@ -1588,6 +1591,9 @@ packages: '@tauri-apps/plugin-shell@2.3.5': resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==} + '@tauri-apps/plugin-updater@2.10.1': + resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==} + '@tauri-apps/plugin-window@2.0.0-alpha.1': resolution: {integrity: sha512-dFOAgal/3Txz3SQ+LNQq0AK1EPC+acdaFlwPVB/6KXUZYmaFleIlzgxDVoJCQ+/xOhxvYrdQaFLefh0I/Kldbg==} @@ -4641,6 +4647,10 @@ snapshots: dependencies: '@tauri-apps/api': 2.11.0 + '@tauri-apps/plugin-updater@2.10.1': + dependencies: + '@tauri-apps/api': 2.11.0 + '@tauri-apps/plugin-window@2.0.0-alpha.1': dependencies: '@tauri-apps/api': 2.0.0-alpha.6 diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 2d072f3..463a61b 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -47,6 +47,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -702,6 +711,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -737,6 +757,7 @@ dependencies = [ "tauri-plugin-global-shortcut", "tauri-plugin-opener", "tauri-plugin-shell", + "tauri-plugin-updater", "tauri-plugin-zustand", ] @@ -1038,6 +1059,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1656,6 +1687,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1952,6 +1998,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -2148,6 +2224,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2406,6 +2488,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2481,6 +2575,12 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -2507,6 +2607,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "pango" version = "0.18.3" @@ -2934,15 +3048,20 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -2978,6 +3097,20 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3006,6 +3139,79 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3021,6 +3227,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3078,6 +3293,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.36.1" @@ -3330,6 +3568,22 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.3" @@ -3442,6 +3696,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3525,7 +3785,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -3558,6 +3818,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -3583,7 +3854,7 @@ dependencies = [ "http", "http-range", "image", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -3809,6 +4080,39 @@ dependencies = [ "tokio", ] +[[package]] +name = "tauri-plugin-updater" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" +dependencies = [ + "base64 0.22.1", + "dirs 6.0.0", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.18", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + [[package]] name = "tauri-plugin-zustand" version = "1.2.0" @@ -3831,7 +4135,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -3854,7 +4158,7 @@ checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -4109,6 +4413,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4428,6 +4742,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4720,6 +5040,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -4950,6 +5279,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -5342,7 +5680,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -5406,6 +5744,16 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "xkeysym" version = "0.2.1" @@ -5517,6 +5865,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.4" @@ -5550,6 +5904,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.0", + "memchr", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 536ffb3..861e6a1 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -25,6 +25,7 @@ tauri-plugin-opener = "2" tauri-plugin-shell = "2" tauri-plugin-global-shortcut = "2" tauri-plugin-zustand = "1" +tauri-plugin-updater = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index 57e7907..858283e 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -27,6 +27,7 @@ "core:tray:default", "core:menu:default", "core:event:default", - "dialog:default" + "dialog:default", + "updater:default" ] } diff --git a/desktop/src-tauri/capabilities/settings.json b/desktop/src-tauri/capabilities/settings.json index cab7a49..5f2159d 100644 --- a/desktop/src-tauri/capabilities/settings.json +++ b/desktop/src-tauri/capabilities/settings.json @@ -16,6 +16,7 @@ "autostart:allow-is-enabled", "global-shortcut:allow-register", "global-shortcut:allow-unregister", - "global-shortcut:allow-is-registered" + "global-shortcut:allow-is-registered", + "updater:default" ] } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index a9da587..469bbf5 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -44,6 +44,7 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_zustand::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) .invoke_handler(tauri::generate_handler![ commands::greet, commands::quit_app, diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 2345b46..5dd7d55 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -69,5 +69,13 @@ "icons/icon.icns", "icons/icon.ico" ] + }, + "plugins": { + "updater": { + "endpoints": [ + "https://github.com/rain9/pinwall/releases/download/latest/{{target}}-update.json" + ], + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IGZpbGU6c3ByaW5nLXNpZ25hdHVyZS5zaWcK" + } } } diff --git a/desktop/src/App.css b/desktop/src/App.css index 6c26326..4d3ff52 100644 --- a/desktop/src/App.css +++ b/desktop/src/App.css @@ -747,6 +747,38 @@ html, body, #root { margin: 0 16px; } +/* ── Button ── */ +.ap-btn { + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + background: rgba(255, 255, 255, 0.12); + color: rgba(255, 255, 255, 0.85); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + padding: 8px 16px; +} + +.ap-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.3); + color: #fff; +} + +.ap-btn:active:not(:disabled) { + transform: scale(0.97); + background: rgba(255, 255, 255, 0.15); +} + +.ap-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + .ap-widget-security-note { padding: 14px 16px; background: rgba(245, 158, 11, 0.08); diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 2ebdc23..0a0d021 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -5,6 +5,7 @@ import Wall from "./pages/Wall"; import Settings from "./pages/Settings"; import Notification from "./pages/Notification"; import { useI18n } from "./i18n"; +import { autoCheckForUpdates } from "./services/updater"; import "./App.css"; async function getWindowLabel(): Promise { @@ -27,6 +28,12 @@ function WindowRouter() { getWindowLabel().then(setWindowLabel); }, []); + useEffect(() => { + if (windowLabel === "main") { + autoCheckForUpdates(); + } + }, [windowLabel]); + if (windowLabel === null) { return
{t.loading}
; } diff --git a/desktop/src/components/SettingsPanel.tsx b/desktop/src/components/SettingsPanel.tsx index 98c19a0..5b06e99 100644 --- a/desktop/src/components/SettingsPanel.tsx +++ b/desktop/src/components/SettingsPanel.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; import { open } from "@tauri-apps/plugin-dialog"; import { useI18n } from "../i18n"; -import type { Settings, AIConfig, QuotaMonitorConfig, QuotaMonitorModel, CareTone, WidgetManifest, WidgetPermission } from "../types"; +import type { Settings, AIConfig, QuotaMonitorConfig, QuotaMonitorModel, CareTone, WidgetManifest, WidgetPermission, UpdateChannel } from "../types"; import { AUTO_CHANGE_INTERVALS, DEFAULT_GLOBAL_SHORTCUT, @@ -22,6 +22,9 @@ import { hasHighRiskWidgetPermissions, WIDGET_PERMISSION_RISK, } from "../data/widgetPermissions"; +import { UpdateCard, UpdateDialog } from "./update"; +import { useAppUpdater } from "../hooks/useAppUpdater"; +import { version } from "../../package.json"; const PROVIDER_PRESETS = [ { @@ -58,6 +61,8 @@ interface SettingsPanelProps { onLaunchOnStartupChange?: (enabled: boolean) => void; onCareToneChange?: (tone: CareTone) => void; onCareSettingsChange?: (partial: Partial) => void; + onAutoCheckUpdatesChange?: (enabled: boolean) => void; + onUpdateChannelChange?: (channel: UpdateChannel) => void; } export function SettingsPanel({ @@ -71,10 +76,22 @@ export function SettingsPanel({ onLaunchOnStartupChange, onCareToneChange, onCareSettingsChange, + onAutoCheckUpdatesChange, + onUpdateChannelChange, }: SettingsPanelProps) { const { t, lang, setLang } = useI18n(); const [activeTab, setActiveTab] = useState("basics"); const [statusMessage, setStatusMessage] = useState(""); + const [showUpdateDialog, setShowUpdateDialog] = useState(false); + + const { + state: updateState, + checkForUpdates, + downloadAndInstall, + skipVersion, + dismissVersion, + getReleaseUrl, + } = useAppUpdater(version); const [aiConfig, setAIConfig] = useState(settings.ai ?? { enabled: false, @@ -259,6 +276,17 @@ export function SettingsPanel({ + + onAutoCheckUpdatesChange?.(enabled)} + onChannelChange={(channel) => onUpdateChannelChange?.(channel)} + onShowUpdateDialog={() => setShowUpdateDialog(true)} + /> ); } @@ -631,6 +659,24 @@ export function SettingsPanel({ {renderSection()} + + { + setShowUpdateDialog(false); + downloadAndInstall(); + }} + onSkip={() => { + setShowUpdateDialog(false); + skipVersion(); + }} + onLater={() => { + setShowUpdateDialog(false); + dismissVersion(); + }} + getReleaseUrl={getReleaseUrl} + /> ); } diff --git a/desktop/src/components/update/UpdateCard.tsx b/desktop/src/components/update/UpdateCard.tsx new file mode 100644 index 0000000..0f066f4 --- /dev/null +++ b/desktop/src/components/update/UpdateCard.tsx @@ -0,0 +1,239 @@ +import { useI18n } from "../../i18n"; +import type { UpdateState, UpdateChannel } from "../../types"; + +interface UpdateCardProps { + state: UpdateState; + autoCheckUpdates: boolean; + updateChannel: string; + lastCheckAt: number; + onCheckUpdates: () => void; + onToggleAutoCheck: (enabled: boolean) => void; + onChannelChange: (channel: UpdateChannel) => void; + onShowUpdateDialog: () => void; +} + +export function UpdateCard({ + state, + autoCheckUpdates, + updateChannel, + lastCheckAt, + onCheckUpdates, + onToggleAutoCheck, + onChannelChange, + onShowUpdateDialog, +}: UpdateCardProps) { + const { t, lang } = useI18n(); + + const formatLastCheck = (timestamp: number) => { + if (!timestamp) return t.update_status_idle; + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + + if (diffMins < 1) return t.update_status_checking; + if (diffMins < 60) return `${diffMins}${lang === "zh" ? "分钟前" : "m ago"}`; + if (diffMins < 1440) return `${Math.floor(diffMins / 60)}${lang === "zh" ? "小时前" : "h ago"}`; + return date.toLocaleDateString(lang); + }; + + const getStatusText = () => { + switch (state.status) { + case "idle": + return t.update_status_idle; + case "checking": + return t.update_status_checking; + case "up-to-date": + return t.update_status_up_to_date; + case "available": + return t.update_status_available; + case "downloading": + return t.update_status_downloading; + case "installing": + return t.update_status_installing; + case "pending-restart": + return t.update_status_pending_restart; + case "failed": + return t.update_status_failed; + default: + return t.update_status_idle; + } + }; + + const getStatusColor = () => { + switch (state.status) { + case "available": + return "text-green-400"; + case "downloading": + case "installing": + return "text-blue-400"; + case "pending-restart": + return "text-yellow-400"; + case "failed": + return "text-red-400"; + case "up-to-date": + return "text-white/60"; + default: + return "text-white/45"; + } + }; + + return ( +
+
{t.update_title}
+
+
+
+
{t.update_current_version}
+
v{state.currentVersion}
+
+
+ {getStatusText()} +
+
+ +
+ +
+
+
{t.update_auto_check}
+
{t.update_auto_check_desc}
+
+ +
+ +
+ +
+
+
{t.update_channel}
+
+ + +
+
+
+ +
+ +
+
+
{t.update_last_check}
+
{formatLastCheck(lastCheckAt)}
+
+ +
+ + {state.status === "available" && state.info && ( + <> +
+
+
+
+ {t.update_available_title}: v{state.info.version} +
+ {state.info.date && ( +
+ {t.update_release_date}: {state.info.date} +
+ )} +
+ +
+ + )} + + {state.status === "downloading" && state.progress && ( + <> +
+
+
+ {t.update_download_size} + + {Math.round(state.progress.downloadedBytes / 1024 / 1024)} /{" "} + {Math.round(state.progress.totalBytes / 1024 / 1024)} MB + +
+
+
+
+
+ {state.progress.percentage}% +
+
+ + )} + + {state.status === "pending-restart" && ( + <> +
+
+
+
+ {t.update_status_pending_restart} +
+
+ +
+ + )} + + {state.status === "failed" && state.error && ( + <> +
+
+
+
+ {t.update_status_failed} +
+
{state.error}
+
+
+ + )} +
+
+ ); +} \ No newline at end of file diff --git a/desktop/src/components/update/UpdateDialog.tsx b/desktop/src/components/update/UpdateDialog.tsx new file mode 100644 index 0000000..340a5bb --- /dev/null +++ b/desktop/src/components/update/UpdateDialog.tsx @@ -0,0 +1,186 @@ +import { useI18n } from "../../i18n"; +import type { UpdateState } from "../../types"; +import { openUrl } from "@tauri-apps/plugin-opener"; + +interface UpdateDialogProps { + state: UpdateState; + isOpen: boolean; + onUpdateNow: () => void; + onSkip: () => void; + onLater: () => void; + getReleaseUrl: () => string; +} + +export function UpdateDialog({ + state, + isOpen, + onUpdateNow, + onSkip, + onLater, + getReleaseUrl, +}: UpdateDialogProps) { + const { t } = useI18n(); + + if (!isOpen || !state.info) return null; + + const handleOpenReleasePage = async () => { + await openUrl(getReleaseUrl()); + }; + + const isUpdating = state.status === "downloading" || state.status === "installing"; + + return ( +
+
+
+
+ {t.update_available_title} +
+
{t.update_available_desc}
+
+ +
+
+
+ {t.update_version_label} + + v{state.info.version} + +
+ + {state.info.date && ( +
+ {t.update_release_date} + {state.info.date} +
+ )} + + {state.info.size && ( +
+ {t.update_download_size} + + {Math.round(state.info.size / 1024 / 1024)} MB + +
+ )} + + {state.info.body && ( +
+
+ {t.update_changelog} +
+
+ {state.info.body} +
+
+ )} + + {state.status === "downloading" && state.progress && ( +
+
+ {t.update_download_size} + + {Math.round(state.progress.downloadedBytes / 1024 / 1024)} /{" "} + {Math.round(state.progress.totalBytes / 1024 / 1024)} MB ({state.progress.percentage}%) + +
+
+
+
+
+ )} + + {state.status === "installing" && ( +
+
{t.update_status_installing}
+
+ )} + + {state.status === "pending-restart" && ( +
+
+ {t.update_status_pending_restart} +
+
+ )} + + {state.status === "failed" && state.error && ( +
+
+ {t.update_status_failed} +
+
{state.error}
+
+ )} +
+
+ +
+ {!isUpdating && state.status !== "pending-restart" && state.status !== "failed" && ( + + )} + + {state.status === "pending-restart" && ( + + )} + + {state.status === "failed" && ( + <> + + + + )} + + {!isUpdating && state.status !== "pending-restart" && state.status !== "failed" && ( +
+ + +
+ )} + + +
+
+
+ ); +} \ No newline at end of file diff --git a/desktop/src/components/update/index.ts b/desktop/src/components/update/index.ts new file mode 100644 index 0000000..2a94036 --- /dev/null +++ b/desktop/src/components/update/index.ts @@ -0,0 +1,2 @@ +export { UpdateCard } from "./UpdateCard"; +export { UpdateDialog } from "./UpdateDialog"; \ No newline at end of file diff --git a/desktop/src/hooks/useAppUpdater.ts b/desktop/src/hooks/useAppUpdater.ts new file mode 100644 index 0000000..c049f13 --- /dev/null +++ b/desktop/src/hooks/useAppUpdater.ts @@ -0,0 +1,76 @@ +import { useState, useEffect, useCallback } from "react"; +import type { UpdateState } from "../types"; +import { + initUpdater, + getUpdateState, + subscribeToUpdates, + checkForUpdates, + downloadAndInstall, + skipVersion, + dismissVersion, + resetUpdateStatus, + getReleaseUrl, +} from "../services/updater"; + +interface UseAppUpdaterReturn { + state: UpdateState; + checkForUpdates: () => Promise; + downloadAndInstall: () => Promise; + skipVersion: () => Promise; + dismissVersion: () => Promise; + resetUpdateStatus: () => Promise; + getReleaseUrl: () => string; +} + +export function useAppUpdater(version: string): UseAppUpdaterReturn { + const [state, setState] = useState({ + status: "idle", + currentVersion: version, + }); + + useEffect(() => { + initUpdater(version); + setState(getUpdateState()); + }, [version]); + + useEffect(() => { + const unsubscribe = subscribeToUpdates(setState); + return unsubscribe; + }, []); + + const handleCheckForUpdates = useCallback(async () => { + try { + await checkForUpdates(true); + } catch (error) { + console.error("Check for updates error:", error); + } + }, []); + + const handleDownloadAndInstall = useCallback(async () => { + await downloadAndInstall(); + }, []); + + const handleSkipVersion = useCallback(async () => { + if (state.info) { + await skipVersion(state.info.version); + } + }, [state.info]); + + const handleDismissVersion = useCallback(async () => { + await dismissVersion(); + }, []); + + const handleResetUpdateStatus = useCallback(async () => { + await resetUpdateStatus(); + }, []); + + return { + state, + checkForUpdates: handleCheckForUpdates, + downloadAndInstall: handleDownloadAndInstall, + skipVersion: handleSkipVersion, + dismissVersion: handleDismissVersion, + resetUpdateStatus: handleResetUpdateStatus, + getReleaseUrl, + }; +} \ No newline at end of file diff --git a/desktop/src/i18n/en.ts b/desktop/src/i18n/en.ts index 63849da..5b595c9 100644 --- a/desktop/src/i18n/en.ts +++ b/desktop/src/i18n/en.ts @@ -184,6 +184,42 @@ export const en = { btn_checkin: "Check in", btn_checked_in: "Checked in", + // App Update + update_title: "Version Update", + update_current_version: "Current Version", + update_channel: "Update Channel", + update_auto_check: "Auto-check for Updates", + update_auto_check_desc: "Automatically check for new versions on startup", + update_check_now: "Check for Updates", + update_status_idle: "Not checked", + update_status_checking: "Checking...", + update_status_up_to_date: "You're on the latest version", + update_status_available: "New version available", + update_status_downloading: "Downloading update", + update_status_installing: "Installing update, do not close the app", + update_status_pending_restart: "Update installed, restart to apply", + update_status_failed: "Update failed", + update_available_title: "New Version Available", + update_available_desc: "We recommend updating for the latest features and fixes", + update_version_label: "Version", + update_release_date: "Release Date", + update_changelog: "Changelog", + update_btn_update_now: "Update Now", + update_btn_later: "Remind Me Later", + update_btn_skip: "Skip This Version", + update_btn_view_details: "View Full Release Notes", + update_btn_open_download: "Open Download Page", + update_btn_restart: "Restart Now", + update_download_speed: "Download Speed", + update_download_size: "File Size", + update_error_network: "Network connection failed", + update_error_server: "Server configuration error", + update_error_signature: "Security verification failed", + update_error_unknown: "Unknown error", + update_last_check: "Last Check", + update_stable: "Stable", + update_beta: "Beta", + // Card default messages (used when no content provided) msg_1: "Stay in a good mood", msg_2: "Drink more water", diff --git a/desktop/src/i18n/zh.ts b/desktop/src/i18n/zh.ts index e6c0132..7ee35fb 100644 --- a/desktop/src/i18n/zh.ts +++ b/desktop/src/i18n/zh.ts @@ -184,6 +184,42 @@ export const zh = { btn_checkin: "打卡", btn_checked_in: "已打卡", + // App Update + update_title: "版本更新", + update_current_version: "当前版本", + update_channel: "更新通道", + update_auto_check: "自动检测更新", + update_auto_check_desc: "启动时自动检查新版本", + update_check_now: "检查更新", + update_status_idle: "未检测", + update_status_checking: "检测中...", + update_status_up_to_date: "当前已是最新版本", + update_status_available: "发现新版本", + update_status_downloading: "更新包下载中", + update_status_installing: "更新安装中,请勿关闭应用", + update_status_pending_restart: "更新已安装,点击重启生效", + update_status_failed: "更新失败", + update_available_title: "发现新版本", + update_available_desc: "建议立即更新以获取最新功能和修复", + update_version_label: "版本", + update_release_date: "发布时间", + update_changelog: "更新说明", + update_btn_update_now: "立即更新", + update_btn_later: "稍后再说", + update_btn_skip: "跳过此版本", + update_btn_view_details: "查看完整发布说明", + update_btn_open_download: "打开下载页", + update_btn_restart: "立即重启", + update_download_speed: "下载速度", + update_download_size: "文件大小", + update_error_network: "网络连接失败", + update_error_server: "服务器配置异常", + update_error_signature: "安全校验失败", + update_error_unknown: "未知错误", + update_last_check: "最近检查", + update_stable: "稳定版", + update_beta: "测试版", + // Card default messages (used when no content provided) msg_1: "保持好心情", msg_2: "多喝水哦", diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index f0d928f..7f1e8c4 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -3,9 +3,9 @@ import { webviewWindow } from "@tauri-apps/api"; import { emit } from "@tauri-apps/api/event"; import { useI18n } from "../i18n"; import { SettingsPanel } from "../components/SettingsPanel"; -import type { Settings as SettingsType, AIConfig, QuotaMonitorConfig, CareTone } from "../types"; +import type { Settings as SettingsType, AIConfig, QuotaMonitorConfig, CareTone, UpdateChannel } from "../types"; import { DEFAULT_GLOBAL_SHORTCUT } from "../types"; -import { getSettings, saveSettings, updateAIConfig, updateQuotaMonitorConfig, updateHolidayEnabledCn, updateHolidayEnabledIntl, updateGlobalShortcut, updateCareTone, updateCareSettings } from "../services/storage"; +import { getSettings, saveSettings, updateAIConfig, updateQuotaMonitorConfig, updateHolidayEnabledCn, updateHolidayEnabledIntl, updateGlobalShortcut, updateCareTone, updateCareSettings, updateAutoCheckUpdates, updateUpdateChannel } from "../services/storage"; import { setLaunchOnStartup, syncLaunchOnStartupSetting } from "../services/autostart"; import { invoke } from "@tauri-apps/api/core"; @@ -106,6 +106,26 @@ function Settings() { } }, []); + const handleAutoCheckUpdatesChange = useCallback(async (enabled: boolean) => { + try { + const updated = await updateAutoCheckUpdates(enabled); + setSettings(updated); + await emit("settings:changed"); + } catch (err) { + console.error("Failed to update auto check updates:", err); + } + }, []); + + const handleUpdateChannelChange = useCallback(async (channel: UpdateChannel) => { + try { + const updated = await updateUpdateChannel(channel); + setSettings(updated); + await emit("settings:changed"); + } catch (err) { + console.error("Failed to update update channel:", err); + } + }, []); + useEffect(() => { const handleKeyDown = async (e: KeyboardEvent) => { if (e.key === "Escape") { @@ -143,6 +163,8 @@ function Settings() { onLaunchOnStartupChange={handleLaunchOnStartupChange} onCareToneChange={handleCareToneChange} onCareSettingsChange={handleCareSettingsChange} + onAutoCheckUpdatesChange={handleAutoCheckUpdatesChange} + onUpdateChannelChange={handleUpdateChannelChange} /> ); } diff --git a/desktop/src/services/storage.ts b/desktop/src/services/storage.ts index 879ee44..8130a25 100644 --- a/desktop/src/services/storage.ts +++ b/desktop/src/services/storage.ts @@ -30,6 +30,12 @@ const defaultSettings: Settings = { eyeCareInterval: 20, weatherCareEnabled: true, weatherCity: "", + // ── App Update defaults ── + autoCheckUpdates: true, + updateChannel: "stable", + skippedVersion: "", + lastUpdateCheckAt: 0, + lastUpdatePromptedVersion: "", }; export async function getSettings(): Promise { @@ -65,6 +71,12 @@ export async function getSettings(): Promise { if (parsed.eyeCareInterval === undefined) parsed.eyeCareInterval = 20; if (parsed.weatherCareEnabled === undefined) parsed.weatherCareEnabled = true; if (parsed.weatherCity === undefined) parsed.weatherCity = ""; + // App Update backward compat + if (parsed.autoCheckUpdates === undefined) parsed.autoCheckUpdates = true; + if (!parsed.updateChannel) parsed.updateChannel = "stable"; + if (!parsed.skippedVersion) parsed.skippedVersion = ""; + if (parsed.lastUpdateCheckAt === undefined) parsed.lastUpdateCheckAt = 0; + if (!parsed.lastUpdatePromptedVersion) parsed.lastUpdatePromptedVersion = ""; return parsed; } catch { return { ...defaultSettings }; @@ -221,3 +233,40 @@ export async function updateCareSettings(partial: Partial): Promise { + const settings = await getSettings(); + settings.autoCheckUpdates = enabled; + await saveSettings(settings); + return settings; +} + +export async function updateUpdateChannel(channel: string): Promise { + const settings = await getSettings(); + settings.updateChannel = channel; + await saveSettings(settings); + return settings; +} + +export async function updateSkippedVersion(version: string): Promise { + const settings = await getSettings(); + settings.skippedVersion = version; + await saveSettings(settings); + return settings; +} + +export async function updateLastUpdateCheckAt(timestamp: number): Promise { + const settings = await getSettings(); + settings.lastUpdateCheckAt = timestamp; + await saveSettings(settings); + return settings; +} + +export async function updateLastUpdatePromptedVersion(version: string): Promise { + const settings = await getSettings(); + settings.lastUpdatePromptedVersion = version; + await saveSettings(settings); + return settings; +} diff --git a/desktop/src/services/updater/index.ts b/desktop/src/services/updater/index.ts new file mode 100644 index 0000000..7626cf5 --- /dev/null +++ b/desktop/src/services/updater/index.ts @@ -0,0 +1,232 @@ +import { check, type DownloadEvent } from "@tauri-apps/plugin-updater"; +import type { UpdateInfo, UpdateProgress, UpdateState, UpdateStatus } from "../../types"; +import { getSettings, updateLastUpdateCheckAt, updateSkippedVersion, updateLastUpdatePromptedVersion } from "../storage"; + +const RELEASE_URL = "https://github.com/rain9/pinwall/releases"; + +let currentState: UpdateState = { + status: "idle", + currentVersion: "__APP_VERSION__", +}; + +let stateListeners: Set<(state: UpdateState) => void> = new Set(); + +function notifyListeners() { + stateListeners.forEach((listener) => listener({ ...currentState })); +} + +function setStatus(status: UpdateStatus, error?: string) { + currentState.status = status; + if (error) { + currentState.error = error; + currentState.info = undefined; + currentState.progress = undefined; + } + notifyListeners(); +} + +function setInfo(info: UpdateInfo) { + currentState.info = info; + currentState.error = undefined; + notifyListeners(); +} + +function setProgress(progress: UpdateProgress) { + currentState.progress = progress; + notifyListeners(); +} + +export function initUpdater(version: string) { + currentState.currentVersion = version; +} + +export function getUpdateState(): UpdateState { + return { ...currentState }; +} + +export function subscribeToUpdates(listener: (state: UpdateState) => void): () => void { + stateListeners.add(listener); + listener({ ...currentState }); + return () => stateListeners.delete(listener); +} + +export async function checkForUpdates(manual = false): Promise { + try { + console.log("[Updater] Starting checkForUpdates, manual:", manual); + setStatus("checking"); + + const settings = await getSettings(); + + // 添加超时机制,避免长时间等待 + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error("Request timeout")), 10000); + }); + + const update = await Promise.race([ + check(), + timeoutPromise + ]); + + await updateLastUpdateCheckAt(Date.now()); + + if (update) { + const info: UpdateInfo = { + version: update.version, + date: update.date ?? "", + body: update.body ?? "", + size: update.rawJson.size as number || 0, + }; + + const skippedVersion = settings.skippedVersion; + if (skippedVersion && compareVersions(update.version, skippedVersion) <= 0) { + setStatus("up-to-date"); + return { ...currentState }; + } + + setInfo(info); + setStatus("available"); + + if (!manual) { + await updateLastUpdatePromptedVersion(update.version); + } + } else { + setStatus("up-to-date"); + } + } catch (error) { + console.error("Update check failed:", error); + let errorMessage = "Unknown error"; + if (error instanceof Error) { + if (error.message.includes("network") || error.message.includes("fetch")) { + errorMessage = "Network connection failed"; + } else if (error.message.includes("signature")) { + errorMessage = "Security verification failed"; + } else { + errorMessage = error.message; + } + } + setStatus("failed", errorMessage); + } + + return { ...currentState }; +} + +export async function downloadAndInstall(): Promise { + try { + setStatus("downloading"); + + const update = await check(); + + if (!update) { + setStatus("up-to-date"); + return { ...currentState }; + } + + let totalBytes = 0; + let downloadedBytes = 0; + + await update.downloadAndInstall((event: DownloadEvent) => { + if (event.event === "Started") { + totalBytes = event.data.contentLength || 0; + downloadedBytes = 0; + setProgress({ + downloadedBytes, + totalBytes, + percentage: 0, + }); + } else if (event.event === "Progress") { + downloadedBytes += event.data.chunkLength; + const percentage = totalBytes > 0 + ? Math.round((downloadedBytes / totalBytes) * 100) + : Math.min(99, downloadedBytes / 10000); + setProgress({ + downloadedBytes, + totalBytes, + percentage, + }); + } else if (event.event === "Finished") { + setStatus("installing"); + } + }); + + setStatus("pending-restart"); + } catch (error) { + console.error("Download/install failed:", error); + let errorMessage = "Unknown error"; + if (error instanceof Error) { + errorMessage = error.message; + } + setStatus("failed", errorMessage); + } + + return { ...currentState }; +} + +export async function skipVersion(version: string): Promise { + await updateSkippedVersion(version); + setStatus("up-to-date"); +} + +export async function dismissVersion(): Promise { + if (currentState.info) { + await updateLastUpdatePromptedVersion(currentState.info.version); + } +} + +export async function resetUpdateStatus(): Promise { + currentState.status = "idle"; + currentState.info = undefined; + currentState.progress = undefined; + currentState.error = undefined; + notifyListeners(); +} + +export function getReleaseUrl(): string { + return RELEASE_URL; +} + +export async function autoCheckForUpdates(): Promise { + try { + const settings = await getSettings(); + + if (!settings.autoCheckUpdates) { + return; + } + + const now = Date.now(); + const lastCheckAt = settings.lastUpdateCheckAt || 0; + const checkInterval = 24 * 60 * 60 * 1000; + + if (now - lastCheckAt < checkInterval) { + return; + } + + await checkForUpdates(false); + } catch (error) { + console.error("Auto update check failed:", error); + } +} + +function compareVersions(v1: string, v2: string): number { + const parts1 = v1.replace(/^v/, "").split(".").map(Number); + const parts2 = v2.replace(/^v/, "").split(".").map(Number); + + for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) { + const p1 = parts1[i] || 0; + const p2 = parts2[i] || 0; + if (p1 > p2) return 1; + if (p1 < p2) return -1; + } + return 0; +} + +export async function shouldPromptUpdate(): Promise { + const settings = await getSettings(); + if (!currentState.info) return false; + if (settings.skippedVersion && compareVersions(currentState.info.version, settings.skippedVersion) <= 0) { + return false; + } + if (settings.lastUpdatePromptedVersion === currentState.info.version) { + return false; + } + return true; +} \ No newline at end of file diff --git a/desktop/src/types/index.ts b/desktop/src/types/index.ts index 8cf10fe..547a8eb 100644 --- a/desktop/src/types/index.ts +++ b/desktop/src/types/index.ts @@ -54,8 +54,52 @@ export interface Settings { weatherCareEnabled?: boolean; // 天气关怀开关,默认 true weatherCity?: string; // 天气城市 lastWeatherCardDate?: string; // YYYY-MM-DD, tracks last weather card generation + // ── App Update features ── + autoCheckUpdates?: boolean; // 自动检测更新开关,默认 true + updateChannel?: string; // 更新通道,默认 "stable" + skippedVersion?: string; // 跳过的版本号 + lastUpdateCheckAt?: number; // 上次检查更新时间戳 + lastUpdatePromptedVersion?: string; // 上次提示的版本号 } +export type UpdateChannel = "stable" | "beta"; + +export type UpdateStatus = + | "idle" + | "checking" + | "up-to-date" + | "available" + | "downloading" + | "installing" + | "pending-restart" + | "failed"; + +export interface UpdateInfo { + version: string; + date: string; + body: string; + size?: number; +} + +export interface UpdateProgress { + downloadedBytes: number; + totalBytes: number; + percentage: number; +} + +export interface UpdateState { + status: UpdateStatus; + info?: UpdateInfo; + progress?: UpdateProgress; + error?: string; + currentVersion: string; +} + +export const UPDATE_CHANNELS: { value: UpdateChannel; label: string; desc: string }[] = [ + { value: "stable", label: "Stable", desc: "稳定版" }, + { value: "beta", label: "Beta", desc: "测试版" }, +]; + export const DEFAULT_GLOBAL_SHORTCUT = "CommandOrControl+Shift+Space"; export type CardType = "note" | "reminder" | "daily-checkin" | "hydration" | "mood";