Add updater

This commit is contained in:
unicxrn committed 2026-10-10 22:35:31 +02:00
1 parent 65db70c9d2
commit fb790ec104
18 files changed
+654 -18

No files matched your search

+8
View File
@@ -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: |
+15 -1
View File
@@ -68,9 +68,23 @@ The ShadowMountPlus details:
- It ignores dot-files, which is why uploads use `.<TITLE>.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.
+24 -3
View File
@@ -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",
+3 -1
View File
@@ -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"
}
+300 -2
View File
@@ -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"
+4
View File
@@ -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"
+3 -1
View File
@@ -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"
]
}
+17 -1
View File
@@ -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");
+3
View File
@@ -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,
}
}
}
+14 -2
View File
@@ -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"
}
}
}
}
+21 -2
View File
@@ -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>
+19 -1
View File
@@ -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>
+4
View File
@@ -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>
+16 -1
View File
@@ -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; }
+85
View File
@@ -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>
+9 -2
View File
@@ -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),
+21 -1
View File
@@ -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,
+88
View File
@@ -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);
}