diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6d95b6e..ceab841 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,6 +12,8 @@ jobs: build: strategy: fail-fast: false + # One at a time, so the two jobs don't race to write latest.json (the updater feed). + max-parallel: 1 matrix: os: [ubuntu-22.04, windows-latest] runs-on: ${{ matrix.os }} @@ -65,7 +67,13 @@ jobs: - name: Build and draft the release uses: tauri-apps/tauri-action@v0 + env: + # Signs the update files. The public half is in src-tauri/tauri.conf.json. + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: "" with: + includeUpdaterJson: true + updaterJsonPreferNsis: true tagName: ${{ github.ref_name }} releaseName: Prospack ${{ github.ref_name }} releaseBody: | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 75128ff..8551fa3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -68,9 +68,23 @@ The ShadowMountPlus details: - It ignores dot-files, which is why uploads use `..ffpfsc.prospack-part`. - It only reliably notices a new image when `manual.lst` changes. +## Updates and signing + +Prospack updates itself with Tauri's updater. It reads `latest.json` from the latest +published GitHub release and only installs files signed with the project's private key. +The public key is in `src-tauri/tauri.conf.json`. + +- `npm run tauri dev` and `npx tauri build --no-bundle` don't need the key. +- A full `npx tauri build` makes update files, so it needs the key in the environment: + `TAURI_SIGNING_PRIVATE_KEY="$(cat ~/.tauri/prospack.key)"`. If you don't have the key, + add `--config '{"bundle":{"createUpdaterArtifacts":false}}'` to build without update files. +- The release workflow reads the key from the `TAURI_SIGNING_PRIVATE_KEY` repository secret. +- A `.deb` install can't replace itself. Those users are pointed at the release page instead. + ## Releases -Push a tag such as `v0.1.0`. `.github/workflows/release.yml` builds Linux and Windows, +Bump the version in `package.json`, `src-tauri/Cargo.toml` and `src-tauri/tauri.conf.json`, +then push a tag such as `v0.1.0`. `.github/workflows/release.yml` builds Linux and Windows, runs the tests (including the end-to-end tests with the frozen engine), builds Prospack Setup, smoke-tests an unattended install and uninstall on Windows, and creates a **draft** release for you to check and publish. diff --git a/package-lock.json b/package-lock.json index 33d1d10..6b6eeef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,21 @@ { - "name": "ps5-packer", + "name": "prospack", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "ps5-packer", + "name": "prospack", "version": "0.1.0", + "license": "GPL-3.0-only", "dependencies": { "@fontsource-variable/geist": "^5.3.0", "@fontsource-variable/geist-mono": "^5.3.0", "@tauri-apps/api": "^2.12.2", "@tauri-apps/plugin-dialog": "^2.8.1", - "@tauri-apps/plugin-opener": "^2.7.0" + "@tauri-apps/plugin-opener": "^2.7.0", + "@tauri-apps/plugin-process": "^2.4.0", + "@tauri-apps/plugin-updater": "^2.13.3" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^7.3.1", @@ -669,6 +672,24 @@ "@tauri-apps/api": "^2.12.0" } }, + "node_modules/@tauri-apps/plugin-process": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.4.0.tgz", + "integrity": "sha512-GddrmsSLAYrLa/yTXlXK/YRDzUgYeeOnUXTbiyvJWkmI424RmDPD3FVLeqcon7rv6O6ZfhBilcrHWnSo7o6hIQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.12.0" + } + }, + "node_modules/@tauri-apps/plugin-updater": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.13.3.tgz", + "integrity": "sha512-CtbA8CZ79WJry2HbRiFGx6WZN7uspho6A4jM+TxmuqwWL2EEfJEMw6BIYgRncIZyqp4vEXw3U/97ZjQ33dKUgg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.12.0" + } + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", diff --git a/package.json b/package.json index 2d42f19..529f84a 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,9 @@ "@fontsource-variable/geist-mono": "^5.3.0", "@tauri-apps/api": "^2.12.2", "@tauri-apps/plugin-dialog": "^2.8.1", - "@tauri-apps/plugin-opener": "^2.7.0" + "@tauri-apps/plugin-opener": "^2.7.0", + "@tauri-apps/plugin-process": "^2.4.0", + "@tauri-apps/plugin-updater": "^2.13.3" }, "license": "GPL-3.0-only" } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e12eddc..5bb00d2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -47,6 +47,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -532,7 +541,7 @@ dependencies = [ "bitflags 2.13.2", "core-foundation", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -704,6 +713,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -972,6 +992,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.14" @@ -1001,6 +1031,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1008,7 +1047,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1022,6 +1061,12 @@ dependencies = [ "syn 3.0.7", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1515,6 +1560,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.21" @@ -2052,6 +2113,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minisign-verify" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2114,6 +2181,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2327,6 +2411,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.13.2", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2401,6 +2497,49 @@ dependencies = [ "libc", ] +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -2417,6 +2556,20 @@ dependencies = [ "pin-project-lite", ] +[[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.21", +] + [[package]] name = "pango" version = "0.18.3" @@ -2719,6 +2872,8 @@ dependencies = [ "tauri-build", "tauri-plugin-dialog", "tauri-plugin-opener", + "tauri-plugin-process", + "tauri-plugin-updater", "tempfile", ] @@ -2846,15 +3001,19 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-tls", "hyper-util", "js-sys", "log", + "native-tls", "percent-encoding", "pin-project-lite", + "rustls-pki-types", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-util", "tower", "tower-http", @@ -2918,6 +3077,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -2933,6 +3101,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" @@ -2990,6 +3167,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.13.2", + "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.38.0" @@ -3450,6 +3650,17 @@ dependencies = [ "syn 2.0.119", ] +[[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" @@ -3649,6 +3860,49 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-process" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74ae3d8549ae564c0397377373e262f340908eb23a5b9fbe03f2a00007dc0038" +dependencies = [ + "tauri", + "tauri-plugin", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "855a336eb389d2c1502244fd48fa2d8cb487a430f691bbaed025195f99a04489" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "libc", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.21", + "time", + "tokio", + "url", + "windows-sys 0.61.2", + "zip", +] + [[package]] name = "tauri-runtime" version = "2.12.1" @@ -3871,6 +4125,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.20" @@ -4162,6 +4426,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" @@ -4827,6 +5097,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.3" @@ -4941,6 +5221,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + [[package]] name = "zerotrie" version = "0.2.5" @@ -4974,6 +5260,18 @@ dependencies = [ "syn 3.0.7", ] +[[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.2", + "memchr", +] + [[package]] name = "zlib-rs" version = "0.6.8" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 294472b..129d7d1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -17,6 +17,7 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = [] } tauri-plugin-dialog = "2" tauri-plugin-opener = "2" +tauri-plugin-process = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } @@ -24,6 +25,9 @@ base64 = "0.22" fs4 = "1" if-addrs = "0.15" +[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] +tauri-plugin-updater = { version = "2", default-features = false, features = ["native-tls", "zip"] } + [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index a307826..424a797 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -14,6 +14,8 @@ "dialog:default", "opener:default", "opener:allow-reveal-item-in-dir", - "opener:allow-open-url" + "opener:allow-open-url", + "updater:default", + "process:allow-restart" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ca6902e..5ce2e51 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -118,6 +118,19 @@ fn free_space(path: String) -> Option<u64> { pipeline::free_space(&p) } +/// How this copy was installed, which decides whether it can update itself: +/// "windows" and "appimage" can; "system" (a .deb or a source build) can't. +#[tauri::command] +fn install_kind() -> &'static str { + if cfg!(windows) { + "windows" + } else if std::env::var_os("APPIMAGE").is_some() { + "appimage" + } else { + "system" + } +} + #[tauri::command] async fn discover_ps5() -> Result<Vec<discover::Found>, String> { blocking(discover::discover).await @@ -128,6 +141,8 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_process::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) .setup(|app| { paths::init(app.path().app_config_dir()?, app.path().app_data_dir()?); settings::migrate_legacy(); @@ -163,7 +178,8 @@ pub fn run() { resume_job, ps5_status, discover_ps5, - free_space + free_space, + install_kind ]) .run(tauri::generate_context!()) .expect("error while running Prospack"); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index dfa9fa7..6b9e129 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -27,6 +27,8 @@ pub struct Settings { pub delete_local_after_upload: bool, /// The first-run wizard has been completed (or skipped). pub setup_done: bool, + /// Look for a new Prospack release at startup and every few hours. + pub check_updates: bool, } impl Default for Settings { @@ -45,6 +47,7 @@ impl Default for Settings { mark_gc_validated: true, delete_local_after_upload: false, setup_done: false, + check_updates: true, } } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2945ae4..09a846d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -61,7 +61,19 @@ "depends": [] } }, - "category": "Utility" + "category": "Utility", + "createUpdaterArtifacts": true }, - "mainBinaryName": "prospack" + "mainBinaryName": "prospack", + "plugins": { + "updater": { + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU0OUJGQkQ3MUQ5QTZGREIKUldUYmI1b2QxL3ViNU1TY1NXUnh6YkhZQXpPczgyd1hTUHlWdlpHN2JJeGNDdmRZZThrU3FzR0sK", + "endpoints": [ + "https://github.com/unicxrn/prospack/releases/latest/download/latest.json" + ], + "windows": { + "installMode": "passive" + } + } + } } diff --git a/src/App.svelte b/src/App.svelte index f279cbf..d8a56f7 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -12,6 +12,8 @@ import Settings from "./lib/Settings.svelte"; import Setup from "./lib/Setup.svelte"; import Help from "./lib/Help.svelte"; + import UpdateDialog from "./lib/UpdateDialog.svelte"; + import { updates, startAutoCheck, installUpdate } from "./lib/updates.svelte.js"; let jobs = $state([]); let selected = $state(null); @@ -21,6 +23,9 @@ let ps5 = $state(null); let showSettings = $state(false); let showHelp = $state(false); + let showUpdate = $state(false); + // Set when the user chose "Update when it's done" while a game was running. + let updateWhenIdle = $state(false); let dragging = $state(false); let installing = $state(false); let installLog = $state([]); @@ -84,6 +89,15 @@ } const inSetup = $derived(settings && !settings.setupDone); + const runningGame = $derived(jobs.find((j) => j.status === "running" || j.status === "queued")?.dump.titleName ?? null); + + $effect(() => { + if (updateWhenIdle && !runningGame && updates.available?.canInstall && !updates.installing) { + updateWhenIdle = false; + showUpdate = true; + installUpdate(); + } + }); async function setupDone(next, pickNow) { try { @@ -132,6 +146,7 @@ }) ); refreshPs5(); + offs.push(startAutoCheck(() => settings?.checkUpdates !== false)); })(); // A running upload already proves the link; poll less to keep FTP free. const t = setInterval(() => !jobs.some((j) => j.status === "running" && j.stage >= 2) && refreshPs5(), 20000); @@ -157,7 +172,7 @@ </script> <div class="app" class:empty={!jobs.length || inSetup} style:--accent={job?.dump.accent ?? "#9b7bff"}> - <TitleBar {ps5} host={settings?.ps5Host} onSettings={() => (showSettings = true)} onHelp={() => (showHelp = true)} /> + <TitleBar {ps5} host={settings?.ps5Host} onSettings={() => (showSettings = true)} onHelp={() => (showHelp = true)} onUpdate={() => (showUpdate = true)} /> {#if jobs.length && !inSetup} <Queue {jobs} {selected} {art} {ps5} onSelect={(id) => (selected = id)} onAdd={pick} /> @@ -212,8 +227,12 @@ <Settings {settings} onClose={() => (showSettings = false)} onSaved={(s) => { settings = s; refreshPs5(); }} onRerunSetup={rerunSetup} /> {/if} + {#if showUpdate && updates.available} + <UpdateDialog busyGame={runningGame} pending={updateWhenIdle} onPending={() => (updateWhenIdle = true)} onClose={() => (showUpdate = false)} /> + {/if} + {#if showHelp} - <Help onClose={() => (showHelp = false)} onRerunSetup={rerunSetup} /> + <Help onClose={() => (showHelp = false)} onRerunSetup={rerunSetup} onShowUpdate={() => { showHelp = false; showUpdate = true; }} /> {/if} </div> diff --git a/src/lib/Help.svelte b/src/lib/Help.svelte index 4c1da59..0066508 100644 --- a/src/lib/Help.svelte +++ b/src/lib/Help.svelte @@ -2,8 +2,16 @@ import { onMount } from "svelte"; import { fade, fly } from "svelte/transition"; import { api, isTauri } from "./api.js"; + import { updates, checkForUpdates } from "./updates.svelte.js"; - let { onClose, onRerunSetup } = $props(); + let { onClose, onRerunSetup, onShowUpdate } = $props(); + let checkedHere = $state(false); + + async function check() { + await checkForUpdates(); + checkedHere = true; + if (updates.available) onShowUpdate(); + } let version = $state("dev"); const REPO = "https://github.com/unicxrn/prospack"; @@ -128,6 +136,14 @@ <button class="link" onclick={() => open("https://github.com/juma-sayeh/PS5-Game-Compressor")}>Game Compressor</button> and <button class="link" onclick={() => open("https://github.com/ps5-payload-dev/ftpsrv")}>ftpsrv</button>. Thanks to their authors. </p> + <div class="upd"> + <button class="btn" onclick={check} disabled={updates.checking}>{updates.checking ? "Checking…" : "Check for updates"}</button> + {#if checkedHere && !updates.checking} + <span class:err={updates.lastError}> + {#if updates.lastError}Couldn't check: {updates.lastError}{:else if updates.available}Prospack {updates.available.version} is available.{:else}You're on the latest version.{/if} + </span> + {/if} + </div> <p class="credits">Only use dumps of games you own.</p> </section> </div> @@ -163,4 +179,6 @@ .link { background: none; border: 0; color: var(--text); text-decoration: underline; text-decoration-color: var(--line-strong); cursor: pointer; padding: 0; font: inherit; } .link:hover { text-decoration-color: currentColor; } .credits { font-size: 12.5px; } + .upd { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; font-size: 12.5px; } + .upd .err { color: var(--err); } </style> diff --git a/src/lib/Settings.svelte b/src/lib/Settings.svelte index 367b8d5..5560ffe 100644 --- a/src/lib/Settings.svelte +++ b/src/lib/Settings.svelte @@ -106,6 +106,10 @@ <input type="checkbox" bind:checked={draft.markGcValidated} /><span class="sw"></span> <span><b>Show as validated in Game Compressor</b><small>Writes Game Compressor's validation record after Prospack's own file-by-file check, with the original size and space saved.</small></span> </label> + <label class="toggle"> + <input type="checkbox" bind:checked={draft.checkUpdates} /><span class="sw"></span> + <span><b>Check for updates</b><small>Looks for a new Prospack release when it starts and every six hours. Nothing installs without you clicking it.</small></span> + </label> <label class="toggle"> <input type="checkbox" bind:checked={draft.deleteLocalAfterUpload} /><span class="sw"></span> <span><b>Delete the local image after it mounts</b><small>Only once ShadowMountPlus has confirmed the mount.</small></span> diff --git a/src/lib/TitleBar.svelte b/src/lib/TitleBar.svelte index 9ec7aee..4b35620 100644 --- a/src/lib/TitleBar.svelte +++ b/src/lib/TitleBar.svelte @@ -1,8 +1,9 @@ <script> import { api, isTauri } from "./api.js"; import { gb } from "./format.js"; + import { updates } from "./updates.svelte.js"; - let { ps5, host, onSettings, onHelp } = $props(); + let { ps5, host, onSettings, onHelp, onUpdate } = $props(); </script> <header class="bar" data-tauri-drag-region> @@ -29,6 +30,12 @@ {/if} </button> + {#if updates.available} + <button class="update" onclick={onUpdate} title="Prospack {updates.available.version} is available"> + <svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true"><path d="M6 1.5v6.5M3.2 5.4 6 8.2l2.8-2.8M2 10.5h8" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg> + Update to {updates.available.version} + </button> + {/if} <button class="iconbtn" aria-label="Help" title="Help (F1)" onclick={onHelp}> <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6.3" stroke="currentColor" stroke-width="1.3"/><path d="M6.2 6.3a1.9 1.9 0 0 1 3.7.4c0 1.2-1.9 1.5-1.9 2.6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="8" cy="11.4" r=".75" fill="currentColor"/></svg> </button> @@ -74,6 +81,14 @@ .unset .dot::after { display: none; } .off .dot::after { display: none; } @keyframes ping { 0% { transform: scale(0.4); opacity: 0.7; } 80%, 100% { transform: scale(1.4); opacity: 0; } } + .update { + display: inline-flex; align-items: center; gap: 6px; height: 26px; padding: 0 10px; border-radius: 99px; cursor: pointer; + border: 1px solid color-mix(in srgb, #9b7bff 45%, transparent); background: color-mix(in srgb, #9b7bff 14%, transparent); + color: #d9ccff; font-size: 12px; font-weight: 500; animation: arrive 0.5s var(--ease); + transition: background 0.2s; + } + .update:hover { background: color-mix(in srgb, #9b7bff 24%, transparent); } + @keyframes arrive { from { opacity: 0; transform: translateY(-4px); } } .sep { width: 1px; height: 14px; background: var(--line-strong); } .win { display: flex; gap: 2px; margin-left: 6px; padding-left: 10px; border-left: 1px solid var(--line); } .close:hover { background: var(--err); color: #fff; } diff --git a/src/lib/UpdateDialog.svelte b/src/lib/UpdateDialog.svelte new file mode 100644 index 0000000..6139b14 --- /dev/null +++ b/src/lib/UpdateDialog.svelte @@ -0,0 +1,85 @@ +<script> + import { fade, scale } from "svelte/transition"; + import { api } from "./api.js"; + import { updates, installUpdate, releasePage } from "./updates.svelte.js"; + + let { busyGame, pending, onPending, onClose } = $props(); + + const a = $derived(updates.available); + const pct = $derived(updates.progress?.total ? (updates.progress.got / updates.progress.total) * 100 : 0); + const mb = (b) => (b / 1e6).toFixed(1); + + function onKey(e) { + if (e.key === "Escape" && !updates.installing) onClose(); + } +</script> + +<svelte:window onkeydown={onKey} /> + +<div class="scrim" transition:fade={{ duration: 180 }} onclick={() => !updates.installing && onClose()} role="presentation"></div> +<div class="dialog" role="dialog" aria-label="Update Prospack" transition:scale={{ start: 0.96, duration: 260, opacity: 0 }}> + {#if a} + <div class="kicker">Update available</div> + <h2>Prospack {a.version}</h2> + <p class="sub">You have {updates.current}.</p> + + {#if a.notes.trim()} + <div class="notes"> + {#each a.notes.split("\n").map((l) => l.replace(/^[-*]\s*/, "").trim()).filter(Boolean) as line} + <p>{line}</p> + {/each} + </div> + {/if} + + {#if updates.installing} + <div class="bar"><i style:width="{pct}%"></i></div> + <p class="sub mono"> + {#if updates.progress?.total && updates.progress.got < updates.progress.total} + Downloading {mb(updates.progress.got)} of {mb(updates.progress.total)} MB + {:else} + Installing. Prospack will restart in a moment. + {/if} + </p> + {:else if updates.lastError} + <p class="err">The update didn't install: {updates.lastError}</p> + {/if} + + <footer> + {#if !a.canInstall} + <span class="hint">This copy was installed from a .deb, so it can't update itself.</span> + <button class="btn ghost" onclick={onClose}>Later</button> + <button class="btn primary" onclick={() => api.openUrl(releasePage(a.version))}>Download {a.version}</button> + {:else if updates.installing} + <span class="hint">Keep Prospack open until it restarts.</span> + {:else if busyGame} + <span class="hint">{busyGame} is still running. The update waits until it's done.</span> + <button class="btn ghost" onclick={onClose}>Later</button> + <button class="btn primary" disabled={pending} onclick={onPending}>{pending ? "Waiting for the game…" : "Update when it's done"}</button> + {:else} + <button class="btn ghost" onclick={() => api.openUrl(releasePage(a.version))}>Release notes</button> + <button class="btn ghost" onclick={onClose}>Later</button> + <button class="btn primary" onclick={installUpdate}>Update and restart</button> + {/if} + </footer> + {/if} +</div> + +<style> + .scrim { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.5); z-index: 60; backdrop-filter: blur(2px); } + .dialog { + position: fixed; z-index: 61; left: 50%; top: 50%; translate: -50% -50%; width: min(460px, calc(100vw - 32px)); + background: var(--surface); border: 1px solid var(--line-strong); border-radius: 16px; padding: 22px 24px 18px; + box-shadow: 0 40px 80px rgba(0, 0, 0, 0.45); user-select: text; + } + .kicker { color: var(--accent); } + h2 { font-weight: 500; font-size: 24px; letter-spacing: -0.03em; margin: 8px 0 2px; } + .sub { color: var(--muted); font-size: 12.5px; } + .notes { margin-top: 14px; border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; max-height: 180px; overflow-y: auto; display: flex; flex-direction: column; gap: 6px; color: var(--muted); font-size: 12.5px; } + .notes p { padding-left: 14px; position: relative; } + .notes p::before { content: ""; position: absolute; left: 2px; top: 8px; width: 4px; height: 4px; border-radius: 50%; background: var(--faint); } + .bar { height: 5px; border-radius: 5px; background: var(--line); margin: 18px 0 8px; overflow: hidden; } + .bar i { display: block; height: 100%; background: var(--accent); transition: width 0.2s var(--ease); } + .err { color: var(--err); font-size: 12.5px; margin-top: 12px; } + footer { display: flex; justify-content: flex-end; align-items: center; gap: 8px; margin-top: 20px; flex-wrap: wrap; } + .hint { margin-right: auto; color: var(--faint); font-size: 12px; max-width: 26ch; } +</style> diff --git a/src/lib/api.js b/src/lib/api.js index b42d608..b7b5392 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -6,15 +6,17 @@ export const isTauri = typeof window !== "undefined" && "__TAURI_INTERNALS__" in let real = null; async function tauri() { if (!real) { - const [core, event, dialog, opener, win, webview] = await Promise.all([ + const [core, event, dialog, opener, win, webview, updater, process] = await Promise.all([ import("@tauri-apps/api/core"), import("@tauri-apps/api/event"), import("@tauri-apps/plugin-dialog"), import("@tauri-apps/plugin-opener"), import("@tauri-apps/api/window"), import("@tauri-apps/api/webview"), + import("@tauri-apps/plugin-updater"), + import("@tauri-apps/plugin-process"), ]); - real = { core, event, dialog, opener, win, webview }; + real = { core, event, dialog, opener, win, webview, updater, process }; } return real; } @@ -37,6 +39,11 @@ export const api = isTauri ps5Status: (value) => call("ps5_status", { value: value ?? null }), discoverPs5: () => call("discover_ps5"), freeSpace: (path) => call("free_space", { path }), + installKind: () => call("install_kind"), + appVersion: async () => (await import("@tauri-apps/api/app")).getVersion(), + // Returns the updater's Update object (or null). Throws if the update feed can't be read. + checkUpdate: async () => (await tauri()).updater.check(), + relaunch: async () => (await tauri()).process.relaunch(), on: async (name, fn) => (await tauri()).event.listen(name, (e) => fn(e.payload)), pickFolder: async (title) => (await tauri()).dialog.open({ directory: true, multiple: false, title }), reveal: async (path) => (await tauri()).opener.revealItemInDir(path), diff --git a/src/lib/mock.js b/src/lib/mock.js index 1921ff3..8318299 100644 --- a/src/lib/mock.js +++ b/src/lib/mock.js @@ -9,7 +9,7 @@ let settings = { setupDone: new URLSearchParams(location.search).has("setup") ? false : true, ps5Host: "192.168.0.50", ftpPort: 2121, webPort: 8888, remoteDir: "/data/homebrew", outputDir: "/home/you/PS5 Images", compressionLevel: 7, cpuCount: 0, verifyWorkers: 0, - autoUpload: true, writeSmpHints: true, markGcValidated: true, deleteLocalAfterUpload: false, + autoUpload: true, writeSmpHints: true, markGcValidated: true, checkUpdates: true, deleteLocalAfterUpload: false, }; const sample = { @@ -125,6 +125,26 @@ export const mock = { resumeJob: async (id) => { const j = jobs.find((x) => x.id === id); if (j) { j.status = "running"; j.stages[j.stage].state = "live"; } emit("jobs", structuredClone(jobs)); }, discoverPs5: async () => { await delay(1600); return [{ ip: "192.168.0.50", port: 2121, label: "ftpsrv v1.6.22", banner: "" }]; }, freeSpace: async () => 529_000_000_000, + installKind: async () => new URLSearchParams(location.search).get("kind") ?? "appimage", + appVersion: async () => "0.1.0", + checkUpdate: async () => { + await delay(700); + if (!new URLSearchParams(location.search).has("update")) return null; + return { + version: "0.2.0", + body: "Faster verify on large dumps.\nThe wizard remembers manual IPs.\nFixes a crash when the image folder was on a removed drive.", + downloadAndInstall: async (onEvent) => { + const total = 42_000_000; + onEvent({ event: "Started", data: { contentLength: total } }); + for (let got = 0; got < total; got += 1_400_000) { + await delay(60); + onEvent({ event: "Progress", data: { chunkLength: 1_400_000 } }); + } + onEvent({ event: "Finished" }); + }, + }; + }, + relaunch: async () => location.reload(), ps5Status: async () => ({ online: true, latencyMs: 4, error: null, freeBytes: 631_629_479_936, totalBytes: 937_046_507_520, shadowmount: true, gameCompressor: true }), on: async (name, fn) => { (listeners[name] ||= []).push(fn); return () => {}; }, pickFolder: async () => null, diff --git a/src/lib/updates.svelte.js b/src/lib/updates.svelte.js new file mode 100644 index 0000000..a7a0315 --- /dev/null +++ b/src/lib/updates.svelte.js @@ -0,0 +1,88 @@ +// Update state shared by the title bar, the update dialog, Help and Settings. +import { api } from "./api.js"; + +const REPO = "unicxrn/prospack"; +const SIX_HOURS = 6 * 60 * 60 * 1000; + +export const updates = $state({ + current: null, + kind: null, // "windows" | "appimage" | "system" + available: null, // { version, notes, canInstall } + checking: false, + lastError: null, + lastChecked: null, + // While downloading or installing: { got, total } + progress: null, + installing: false, +}); + +let handle = null; // the updater's Update object, needed to install + +/** "0.10.2" > "0.9.9", ignoring a leading "v" and any "-suffix". */ +export function newer(a, b) { + const parts = (v) => String(v).replace(/^v/, "").split("-")[0].split(".").map((n) => parseInt(n, 10) || 0); + const [x, y] = [parts(a), parts(b)]; + for (let i = 0; i < Math.max(x.length, y.length); i++) { + if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0); + } + return false; +} + +/** A .deb or source build can't replace itself; ask GitHub what the latest release is instead. */ +async function latestFromGitHub() { + const r = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, { headers: { Accept: "application/vnd.github+json" } }); + if (!r.ok) throw new Error(`GitHub answered ${r.status}`); + const rel = await r.json(); + return { version: rel.tag_name.replace(/^v/, ""), notes: rel.body ?? "" }; +} + +export async function checkForUpdates() { + if (updates.checking || updates.installing) return; + updates.checking = true; + updates.lastError = null; + try { + updates.current ??= await api.appVersion(); + updates.kind ??= await api.installKind(); + if (updates.kind === "system") { + const rel = await latestFromGitHub(); + updates.available = newer(rel.version, updates.current) ? { ...rel, canInstall: false } : null; + } else { + handle = await api.checkUpdate(); + updates.available = handle ? { version: handle.version, notes: handle.body ?? "", canInstall: true } : null; + } + } catch (e) { + updates.lastError = String(e?.message ?? e); + } finally { + updates.checking = false; + updates.lastChecked = Date.now(); + } +} + +/** Downloads, installs and restarts. On Windows the installer closes the app itself. */ +export async function installUpdate() { + if (!handle) return; + updates.installing = true; + updates.progress = { got: 0, total: 0 }; + try { + await handle.downloadAndInstall((ev) => { + if (ev.event === "Started") updates.progress = { got: 0, total: ev.data.contentLength ?? 0 }; + else if (ev.event === "Progress") updates.progress = { ...updates.progress, got: updates.progress.got + ev.data.chunkLength }; + }); + await api.relaunch(); + } catch (e) { + updates.lastError = String(e?.message ?? e); + updates.installing = false; + updates.progress = null; + } +} + +export function releasePage(version) { + return `https://github.com/${REPO}/releases/tag/v${version}`; +} + +/** Checks now and then every six hours while `enabled()` stays true. Returns a stop function. */ +export function startAutoCheck(enabled) { + if (enabled()) checkForUpdates(); + const t = setInterval(() => enabled() && checkForUpdates(), SIX_HOURS); + return () => clearInterval(t); +}