feat: initial PSG Launcher scaffold
6236
src-tauri/Cargo.lock
generated
Normal file
60
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,60 @@
|
||||
[package]
|
||||
name = "psg-launcher"
|
||||
version = "0.1.0"
|
||||
description = "PSG Launcher — all-in-one app launcher with signed OTA updates"
|
||||
authors = ["Bailey Taylor"]
|
||||
edition = "2021"
|
||||
rust-version = "1.77"
|
||||
|
||||
[lib]
|
||||
name = "psg_launcher_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
# Tauri core + plugins
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-http = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
|
||||
# Serialisation
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
# Cryptography — manifest + package verification
|
||||
ed25519-dalek = "2"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
base64 = "0.22"
|
||||
|
||||
# HTTP client (rustls so we get TLS without OpenSSL system lib dep)
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
|
||||
|
||||
# ZIP extraction for zip-type app installs
|
||||
zip = "2"
|
||||
|
||||
# Error handling
|
||||
thiserror = "1"
|
||||
|
||||
# Utilities
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
semver = { version = "1", features = ["serde"] }
|
||||
dirs = "5"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s" # optimise for binary size
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
panic = "abort"
|
||||
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
23
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities for the main launcher window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"updater:default",
|
||||
"updater:allow-check",
|
||||
"updater:allow-download-and-install",
|
||||
"dialog:default",
|
||||
"dialog:allow-message",
|
||||
"shell:default",
|
||||
"process:default",
|
||||
"process:allow-exit",
|
||||
"process:allow-restart",
|
||||
"http:default",
|
||||
"http:allow-fetch",
|
||||
"fs:default",
|
||||
"fs:allow-app-read-recursive",
|
||||
"fs:allow-app-write-recursive"
|
||||
]
|
||||
}
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 441 B |
BIN
src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 727 B |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 421 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 588 B |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 859 B |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 610 B |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 647 B |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 818 B |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 608 B |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 771 B |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 338 B |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 511 B |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 511 B |
BIN
src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 737 B |
BIN
src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 404 B |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 699 B |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 699 B |
BIN
src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 1011 B |
BIN
src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 511 B |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 918 B |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 918 B |
BIN
src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 906 B |
BIN
src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
259
src-tauri/src/commands/apps.rs
Normal file
@@ -0,0 +1,259 @@
|
||||
//! App install / launch / uninstall commands.
|
||||
//!
|
||||
//! Every downloaded package is verified with BOTH a SHA-256 hash (integrity)
|
||||
//! and an ed25519 signature (authenticity) before anything is written to disk.
|
||||
//! If either check fails the download is discarded and an error is returned.
|
||||
|
||||
use crate::commands::manifest::{build_https_client, verify_ed25519};
|
||||
use crate::config::PLATFORM;
|
||||
use crate::error::Error;
|
||||
use crate::models::installed::{InstalledApp, InstalledApps};
|
||||
use crate::models::manifest::{AppEntry, InstallType};
|
||||
use base64::Engine;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
// ── Queries ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Return the locally-installed apps state.
|
||||
#[tauri::command]
|
||||
pub async fn get_installed_apps(app: AppHandle) -> Result<InstalledApps, Error> {
|
||||
let content = tokio::fs::read_to_string(db_path(&app)?).await?;
|
||||
Ok(serde_json::from_str(&content)?)
|
||||
}
|
||||
|
||||
// ── Mutations ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Download, verify (hash + signature), and install an app entry from the manifest.
|
||||
#[tauri::command]
|
||||
pub async fn download_and_install_app(
|
||||
app: AppHandle,
|
||||
entry: AppEntry,
|
||||
) -> Result<InstalledApp, Error> {
|
||||
let platform = entry
|
||||
.platforms
|
||||
.get(PLATFORM)
|
||||
.ok_or_else(|| Error::PlatformNotSupported(PLATFORM.into()))?;
|
||||
|
||||
log::info!(
|
||||
"Downloading {} v{} from {}",
|
||||
entry.name,
|
||||
entry.current_version,
|
||||
platform.download_url
|
||||
);
|
||||
|
||||
// ── 1. Download ───────────────────────────────────────────────────────
|
||||
let client = build_https_client()?;
|
||||
let resp = client.get(&platform.download_url).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(Error::Network(format!(
|
||||
"Download returned HTTP {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
let bytes = resp.bytes().await?.to_vec();
|
||||
|
||||
// ── 2. SHA-256 integrity check ────────────────────────────────────────
|
||||
let actual_hash = {
|
||||
let mut h = Sha256::new();
|
||||
h.update(&bytes);
|
||||
hex::encode(h.finalize())
|
||||
};
|
||||
if actual_hash != platform.hash_sha256 {
|
||||
return Err(Error::HashMismatch {
|
||||
expected: platform.hash_sha256.clone(),
|
||||
actual: actual_hash,
|
||||
});
|
||||
}
|
||||
log::info!("SHA-256 hash verified ✓");
|
||||
|
||||
// ── 3. ed25519 authenticity check ─────────────────────────────────────
|
||||
let sig_bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(&platform.signature)
|
||||
.map_err(|_| Error::SignatureVerification("Package signature is not valid base64".into()))?;
|
||||
verify_ed25519(&bytes, &sig_bytes)?;
|
||||
log::info!("Package signature verified ✓");
|
||||
|
||||
// ── 4. Resolve install directory ──────────────────────────────────────
|
||||
let install_dir = resolve_install_dir(&app, &entry, platform.install_path.as_deref())?;
|
||||
tokio::fs::create_dir_all(&install_dir).await?;
|
||||
|
||||
// ── 5. Install ────────────────────────────────────────────────────────
|
||||
let executable = install_package(&bytes, &install_dir, &entry, platform.install_type.clone()).await?;
|
||||
|
||||
// ── 6. Persist install record ─────────────────────────────────────────
|
||||
let record = InstalledApp {
|
||||
id: entry.id.clone(),
|
||||
version: entry.current_version.clone(),
|
||||
install_path: install_dir.to_string_lossy().into_owned(),
|
||||
installed_at: chrono::Utc::now().to_rfc3339(),
|
||||
executable,
|
||||
};
|
||||
|
||||
let db = db_path(&app)?;
|
||||
let mut state: InstalledApps = serde_json::from_str(&tokio::fs::read_to_string(&db).await?)?;
|
||||
state.apps.insert(entry.id.clone(), record.clone());
|
||||
tokio::fs::write(&db, serde_json::to_string_pretty(&state)?).await?;
|
||||
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
/// Launch an already-installed app by ID.
|
||||
#[tauri::command]
|
||||
pub async fn launch_app(app: AppHandle, app_id: String) -> Result<(), Error> {
|
||||
let state: InstalledApps =
|
||||
serde_json::from_str(&tokio::fs::read_to_string(db_path(&app)?).await?)?;
|
||||
|
||||
let record = state
|
||||
.apps
|
||||
.get(&app_id)
|
||||
.ok_or_else(|| Error::AppNotFound(app_id.clone()))?;
|
||||
|
||||
let exe = match &record.executable {
|
||||
Some(rel) => PathBuf::from(&record.install_path).join(rel),
|
||||
None => PathBuf::from(&record.install_path),
|
||||
};
|
||||
|
||||
if !exe.exists() {
|
||||
return Err(Error::LaunchFailed(format!(
|
||||
"Executable not found at {}",
|
||||
exe.display()
|
||||
)));
|
||||
}
|
||||
|
||||
std::process::Command::new(&exe)
|
||||
.spawn()
|
||||
.map_err(|e| Error::LaunchFailed(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove an app's install record. Does NOT delete files — the user keeps
|
||||
/// their data; they can delete the folder manually if desired.
|
||||
#[tauri::command]
|
||||
pub async fn uninstall_app(app: AppHandle, app_id: String) -> Result<(), Error> {
|
||||
let db = db_path(&app)?;
|
||||
let mut state: InstalledApps =
|
||||
serde_json::from_str(&tokio::fs::read_to_string(&db).await?)?;
|
||||
|
||||
if state.apps.remove(&app_id).is_none() {
|
||||
return Err(Error::AppNotFound(app_id));
|
||||
}
|
||||
|
||||
tokio::fs::write(&db, serde_json::to_string_pretty(&state)?).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn db_path(app: &AppHandle) -> Result<PathBuf, Error> {
|
||||
Ok(app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| Error::Io(e.to_string()))?
|
||||
.join("installed.json"))
|
||||
}
|
||||
|
||||
fn resolve_install_dir(
|
||||
app: &AppHandle,
|
||||
entry: &AppEntry,
|
||||
custom: Option<&str>,
|
||||
) -> Result<PathBuf, Error> {
|
||||
if let Some(template) = custom {
|
||||
let expanded = template
|
||||
.replace(
|
||||
"%APPDATA%",
|
||||
&std::env::var("APPDATA").unwrap_or_default(),
|
||||
)
|
||||
.replace(
|
||||
"%LOCALAPPDATA%",
|
||||
&std::env::var("LOCALAPPDATA").unwrap_or_default(),
|
||||
)
|
||||
.replace(
|
||||
"%PROGRAMFILES%",
|
||||
&std::env::var("PROGRAMFILES").unwrap_or_default(),
|
||||
);
|
||||
return Ok(PathBuf::from(expanded));
|
||||
}
|
||||
|
||||
// Default: <app-data-dir>/../PSG/<app-id>
|
||||
// Resolves to %APPDATA%\PSG\<app-id> on Windows
|
||||
let base = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| Error::Io(e.to_string()))?
|
||||
.parent()
|
||||
.unwrap_or_else(|| std::path::Path::new("."))
|
||||
.join("PSG")
|
||||
.join(&entry.id);
|
||||
|
||||
Ok(base)
|
||||
}
|
||||
|
||||
async fn install_package(
|
||||
bytes: &[u8],
|
||||
install_dir: &PathBuf,
|
||||
entry: &AppEntry,
|
||||
install_type: InstallType,
|
||||
) -> Result<Option<String>, Error> {
|
||||
match install_type {
|
||||
InstallType::Portable => {
|
||||
let exe_name = format!("{}.exe", entry.id);
|
||||
tokio::fs::write(install_dir.join(&exe_name), bytes).await?;
|
||||
Ok(Some(exe_name))
|
||||
}
|
||||
|
||||
InstallType::Zip => {
|
||||
let tmp = std::env::temp_dir()
|
||||
.join(format!("{}-{}.zip", entry.id, entry.current_version));
|
||||
tokio::fs::write(&tmp, bytes).await?;
|
||||
extract_zip(&tmp, install_dir)?;
|
||||
tokio::fs::remove_file(&tmp).await.ok();
|
||||
Ok(None) // executable path comes from the manifest or user configuration
|
||||
}
|
||||
|
||||
InstallType::Installer => {
|
||||
let tmp = std::env::temp_dir()
|
||||
.join(format!("{}-{}-setup.exe", entry.id, entry.current_version));
|
||||
tokio::fs::write(&tmp, bytes).await?;
|
||||
// Run the installer silently (NSIS /S flag)
|
||||
let status = std::process::Command::new(&tmp)
|
||||
.arg("/S")
|
||||
.status()
|
||||
.map_err(|e| Error::LaunchFailed(e.to_string()))?;
|
||||
tokio::fs::remove_file(&tmp).await.ok();
|
||||
if !status.success() {
|
||||
return Err(Error::LaunchFailed(format!(
|
||||
"Installer exited with code {:?}",
|
||||
status.code()
|
||||
)));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_zip(zip_path: &PathBuf, dest: &PathBuf) -> Result<(), Error> {
|
||||
let file = std::fs::File::open(zip_path)?;
|
||||
let mut archive =
|
||||
zip::ZipArchive::new(file).map_err(|e| Error::Io(e.to_string()))?;
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut entry = archive
|
||||
.by_index(i)
|
||||
.map_err(|e| Error::Io(e.to_string()))?;
|
||||
let out = dest.join(entry.mangled_name());
|
||||
|
||||
if entry.is_dir() {
|
||||
std::fs::create_dir_all(&out)?;
|
||||
} else {
|
||||
if let Some(parent) = out.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut f = std::fs::File::create(&out)?;
|
||||
std::io::copy(&mut entry, &mut f)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
115
src-tauri/src/commands/manifest.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
//! Fetches, verifies, and returns the apps manifest.
|
||||
//!
|
||||
//! Security model
|
||||
//! ──────────────
|
||||
//! 1. Manifest bytes are fetched over HTTPS (TLS prevents MITM on transport).
|
||||
//! 2. A detached ed25519 signature is fetched from a second URL.
|
||||
//! 3. The signature is verified against the raw manifest bytes using the
|
||||
//! public key embedded at compile time. Signature failure is fatal —
|
||||
//! we never parse or return manifests that haven't been verified.
|
||||
//! 4. Only after successful verification is the JSON parsed into our types.
|
||||
//!
|
||||
//! This means a compromised Gitea server or CDN can serve garbage but the
|
||||
//! launcher will refuse to act on it, because the attacker doesn't have your
|
||||
//! private key.
|
||||
|
||||
use crate::config::{MANIFEST_PUBLIC_KEY_B64, MANIFEST_SIG_URL, MANIFEST_URL};
|
||||
use crate::error::Error;
|
||||
use crate::models::manifest::AppManifest;
|
||||
use base64::Engine;
|
||||
use ed25519_dalek::{Signature, VerifyingKey, PUBLIC_KEY_LENGTH};
|
||||
use tauri::AppHandle;
|
||||
|
||||
/// Fetch the apps manifest, verify its signature, and return the parsed data.
|
||||
/// All verification happens before any data is returned to the frontend.
|
||||
#[tauri::command]
|
||||
pub async fn fetch_app_manifest(_app: AppHandle) -> Result<AppManifest, Error> {
|
||||
let client = build_https_client()?;
|
||||
|
||||
// ── 1. Fetch manifest bytes ───────────────────────────────────────────
|
||||
let manifest_bytes = get_bytes(&client, MANIFEST_URL).await?;
|
||||
|
||||
// ── 2. Fetch detached signature ───────────────────────────────────────
|
||||
let sig_text = get_text(&client, MANIFEST_SIG_URL).await?;
|
||||
let sig_bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(sig_text.trim())
|
||||
.map_err(|_| Error::SignatureVerification("Signature is not valid base64".into()))?;
|
||||
|
||||
// ── 3. Verify signature (BEFORE parsing JSON) ─────────────────────────
|
||||
verify_ed25519(&manifest_bytes, &sig_bytes)?;
|
||||
log::info!("Manifest signature verified ✓");
|
||||
|
||||
// ── 4. Parse into typed model ─────────────────────────────────────────
|
||||
let manifest: AppManifest = serde_json::from_slice(&manifest_bytes)?;
|
||||
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
// ── Shared crypto helper ──────────────────────────────────────────────────────
|
||||
|
||||
/// Verify a raw ed25519 signature against `data` using the compile-time public key.
|
||||
pub(crate) fn verify_ed25519(data: &[u8], sig_bytes: &[u8]) -> Result<(), Error> {
|
||||
use ed25519_dalek::Verifier;
|
||||
|
||||
// Decode the embedded public key
|
||||
let pubkey_bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(MANIFEST_PUBLIC_KEY_B64)
|
||||
.map_err(|_| Error::Crypto("Embedded public key is not valid base64".into()))?;
|
||||
|
||||
if pubkey_bytes.len() != PUBLIC_KEY_LENGTH {
|
||||
return Err(Error::Crypto(format!(
|
||||
"Public key must be {PUBLIC_KEY_LENGTH} bytes, got {}",
|
||||
pubkey_bytes.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let key_array: [u8; PUBLIC_KEY_LENGTH] = pubkey_bytes
|
||||
.try_into()
|
||||
.map_err(|_| Error::Crypto("Key array conversion failed".into()))?;
|
||||
|
||||
let verifying_key = VerifyingKey::from_bytes(&key_array)
|
||||
.map_err(|e| Error::Crypto(e.to_string()))?;
|
||||
|
||||
let signature = Signature::from_slice(sig_bytes)
|
||||
.map_err(|e| Error::SignatureVerification(e.to_string()))?;
|
||||
|
||||
verifying_key
|
||||
.verify(data, &signature)
|
||||
.map_err(|_| {
|
||||
Error::SignatureVerification(
|
||||
"Signature verification failed — content may have been tampered with".into(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// ── HTTP helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
pub(crate) fn build_https_client() -> Result<reqwest::Client, Error> {
|
||||
reqwest::Client::builder()
|
||||
.https_only(true) // never fall back to plain HTTP
|
||||
.user_agent(concat!("PSG-Launcher/", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
.map_err(|e| Error::Network(e.to_string()))
|
||||
}
|
||||
|
||||
async fn get_bytes(client: &reqwest::Client, url: &str) -> Result<Vec<u8>, Error> {
|
||||
let resp = client.get(url).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(Error::Network(format!(
|
||||
"GET {url} returned HTTP {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
Ok(resp.bytes().await?.to_vec())
|
||||
}
|
||||
|
||||
async fn get_text(client: &reqwest::Client, url: &str) -> Result<String, Error> {
|
||||
let resp = client.get(url).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(Error::Network(format!(
|
||||
"GET {url} returned HTTP {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
Ok(resp.text().await?)
|
||||
}
|
||||
3
src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod apps;
|
||||
pub mod manifest;
|
||||
pub mod updater;
|
||||
75
src-tauri/src/commands/updater.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
//! Self-update commands for the launcher binary itself.
|
||||
//!
|
||||
//! Tauri's built-in updater plugin handles ALL the cryptography here — it
|
||||
//! fetches `latest.json` from the configured endpoint, verifies the ed25519
|
||||
//! signature on the installer package, and applies the update.
|
||||
//!
|
||||
//! The public key used here (in tauri.conf.json `plugins.updater.pubkey`) is
|
||||
//! generated separately from the manifest key via `npm run tauri signer generate`.
|
||||
|
||||
use crate::error::Error;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UpdateInfo {
|
||||
pub available: bool,
|
||||
pub version: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
pub date: Option<String>,
|
||||
}
|
||||
|
||||
/// Check whether a new version of the launcher is available.
|
||||
/// Returns immediately with cached/live data; does not block the UI.
|
||||
#[tauri::command]
|
||||
pub async fn check_for_launcher_update(app: AppHandle) -> Result<UpdateInfo, Error> {
|
||||
let updater = app
|
||||
.updater_builder()
|
||||
.build()
|
||||
.map_err(|e| Error::Update(e.to_string()))?;
|
||||
|
||||
match updater.check().await {
|
||||
Ok(Some(update)) => Ok(UpdateInfo {
|
||||
available: true,
|
||||
version: Some(update.version.clone()),
|
||||
notes: update.body.clone(),
|
||||
date: update.date.map(|d| d.to_string()),
|
||||
}),
|
||||
Ok(None) => Ok(UpdateInfo {
|
||||
available: false,
|
||||
version: None,
|
||||
notes: None,
|
||||
date: None,
|
||||
}),
|
||||
Err(e) => Err(Error::Update(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Download and install the pending launcher update.
|
||||
/// Tauri will restart the app automatically after installation completes.
|
||||
#[tauri::command]
|
||||
pub async fn install_launcher_update(app: AppHandle) -> Result<(), Error> {
|
||||
let updater = app
|
||||
.updater_builder()
|
||||
.build()
|
||||
.map_err(|e| Error::Update(e.to_string()))?;
|
||||
|
||||
let update = updater
|
||||
.check()
|
||||
.await
|
||||
.map_err(|e| Error::Update(e.to_string()))?
|
||||
.ok_or_else(|| Error::Update("No update available".into()))?;
|
||||
|
||||
update
|
||||
.download_and_install(
|
||||
// on_chunk: called for each downloaded chunk — could emit progress events
|
||||
|_chunk_len, _content_len| {},
|
||||
// on_finish: called when download completes
|
||||
|| {},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| Error::Update(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
31
src-tauri/src/config.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
//! Compile-time configuration constants.
|
||||
//!
|
||||
//! The public key and manifest URL are the two values you MUST update before
|
||||
//! building a production release:
|
||||
//!
|
||||
//! 1. Run `scripts/keygen.ps1` to generate your ed25519 keypair.
|
||||
//! 2. Paste the output "Raw public key (base64)" value into MANIFEST_PUBLIC_KEY.
|
||||
//! 3. Replace the YOURDOMAIN / OWNER placeholders in MANIFEST_URL and
|
||||
//! MANIFEST_SIG_URL with your actual Gitea instance details.
|
||||
|
||||
/// Raw 32-byte ed25519 public key, base64-encoded.
|
||||
/// Generated by scripts/keygen.ps1. The matching private key signs both the
|
||||
/// apps manifest and individual app packages in CI.
|
||||
///
|
||||
/// ⚠ Replace this placeholder before building!
|
||||
pub const MANIFEST_PUBLIC_KEY_B64: &str =
|
||||
"z217KMG/Z1uq1gRI2ZZMANA79KLt4TkCQtO5fWihYlI=";
|
||||
|
||||
/// URL of the apps manifest JSON on your Gitea instance.
|
||||
/// Hosted on the `releases` branch so the URL is stable across releases.
|
||||
pub const MANIFEST_URL: &str =
|
||||
"https://git.psg.net.au/sonderau/psg-conduit/raw/branch/releases/manifests/apps.json";
|
||||
|
||||
/// URL of the detached ed25519 signature for the apps manifest.
|
||||
/// Contains the base64-encoded signature of the raw apps.json bytes.
|
||||
pub const MANIFEST_SIG_URL: &str =
|
||||
"https://git.psg.net.au/sonderau/psg-conduit/raw/branch/releases/manifests/apps.json.sig";
|
||||
|
||||
/// Platform string used to look up platform-specific entries in the manifest.
|
||||
/// Only Windows x86-64 is supported for now.
|
||||
pub const PLATFORM: &str = "windows-x86_64";
|
||||
62
src-tauri/src/error.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
|
||||
/// All errors that can be surfaced to the frontend via Tauri's invoke bridge.
|
||||
/// `Serialize` is required so Tauri can serialise the error into JSON for TS.
|
||||
#[derive(Debug, Error, Serialize)]
|
||||
#[serde(tag = "kind", content = "message")]
|
||||
pub enum Error {
|
||||
#[error("Network error: {0}")]
|
||||
Network(String),
|
||||
|
||||
#[error("Manifest parse error: {0}")]
|
||||
ManifestParse(String),
|
||||
|
||||
/// Signature didn't match — treat this as a potential attack, not a soft error.
|
||||
#[error("Signature verification failed: {0}")]
|
||||
SignatureVerification(String),
|
||||
|
||||
#[error("Hash mismatch — expected {expected}, got {actual}")]
|
||||
HashMismatch { expected: String, actual: String },
|
||||
|
||||
#[error("Version downgrade rejected — current {current}, offered {offered}")]
|
||||
VersionDowngrade { current: String, offered: String },
|
||||
|
||||
#[error("App not found: {0}")]
|
||||
AppNotFound(String),
|
||||
|
||||
#[error("Platform not supported: {0}")]
|
||||
PlatformNotSupported(String),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(String),
|
||||
|
||||
#[error("Launch failed: {0}")]
|
||||
LaunchFailed(String),
|
||||
|
||||
#[error("Update check failed: {0}")]
|
||||
Update(String),
|
||||
|
||||
#[error("Invalid key material: {0}")]
|
||||
Crypto(String),
|
||||
}
|
||||
|
||||
// ── From impls ───────────────────────────────────────────────────────────────
|
||||
|
||||
impl From<std::io::Error> for Error {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
Error::Io(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for Error {
|
||||
fn from(e: reqwest::Error) -> Self {
|
||||
Error::Network(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for Error {
|
||||
fn from(e: serde_json::Error) -> Self {
|
||||
Error::ManifestParse(e.to_string())
|
||||
}
|
||||
}
|
||||
52
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use tauri::Manager;
|
||||
|
||||
mod commands;
|
||||
mod config;
|
||||
mod error;
|
||||
mod models;
|
||||
|
||||
pub use error::Error;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
env_logger::Builder::from_env(
|
||||
env_logger::Env::default().default_filter_or("warn,psg_launcher=info"),
|
||||
)
|
||||
.init();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.setup(|app| {
|
||||
// Ensure the app-data directory and initial installed.json exist
|
||||
let data_dir = app.path().app_data_dir()?;
|
||||
std::fs::create_dir_all(&data_dir)?;
|
||||
|
||||
let db = data_dir.join("installed.json");
|
||||
if !db.exists() {
|
||||
let empty = models::installed::InstalledApps::default();
|
||||
std::fs::write(&db, serde_json::to_string_pretty(&empty)?)?;
|
||||
}
|
||||
|
||||
log::info!("PSG Launcher starting — data dir: {}", data_dir.display());
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// Manifest
|
||||
commands::manifest::fetch_app_manifest,
|
||||
// Apps
|
||||
commands::apps::get_installed_apps,
|
||||
commands::apps::download_and_install_app,
|
||||
commands::apps::launch_app,
|
||||
commands::apps::uninstall_app,
|
||||
// Self-updater
|
||||
commands::updater::check_for_launcher_update,
|
||||
commands::updater::install_launcher_update,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running PSG Launcher");
|
||||
}
|
||||
6
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// Prevents a console window from appearing on Windows in release builds.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
psg_launcher_lib::run()
|
||||
}
|
||||
21
src-tauri/src/models/installed.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Local state file (`installed.json`) recording every app the launcher has
|
||||
/// installed on this machine.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct InstalledApps {
|
||||
pub apps: HashMap<String, InstalledApp>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InstalledApp {
|
||||
pub id: String,
|
||||
/// The version that is currently installed locally.
|
||||
pub version: String,
|
||||
/// Absolute path to the directory where the app is installed.
|
||||
pub install_path: String,
|
||||
pub installed_at: String,
|
||||
/// Relative path (from install_path) to the executable, if known.
|
||||
pub executable: Option<String>,
|
||||
}
|
||||
65
src-tauri/src/models/manifest.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Top-level structure of `apps.json`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppManifest {
|
||||
pub schema_version: u32,
|
||||
pub generated_at: String,
|
||||
/// The launcher must be at least this version to use this manifest.
|
||||
pub minimum_launcher_version: String,
|
||||
pub apps: Vec<AppEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppEntry {
|
||||
/// Stable, URL-safe identifier. Used as the key in installed.json.
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub category: AppCategory,
|
||||
/// Latest available version in the manifest.
|
||||
pub current_version: String,
|
||||
pub icon_url: Option<String>,
|
||||
pub changelog: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
/// Keyed by platform string, e.g. "windows-x86_64".
|
||||
pub platforms: HashMap<String, PlatformEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AppCategory {
|
||||
Tools,
|
||||
Utilities,
|
||||
Games,
|
||||
Media,
|
||||
Development,
|
||||
Network,
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlatformEntry {
|
||||
pub download_url: String,
|
||||
/// Lowercase hex SHA-256 of the download file.
|
||||
pub hash_sha256: String,
|
||||
pub size_bytes: u64,
|
||||
pub install_type: InstallType,
|
||||
/// Optional override install path. Supports %APPDATA%, %LOCALAPPDATA%.
|
||||
pub install_path: Option<String>,
|
||||
/// Base64-encoded ed25519 signature of the raw download bytes.
|
||||
/// Signed with the same key as the manifest.
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum InstallType {
|
||||
/// Single executable — copied directly to the install directory.
|
||||
Portable,
|
||||
/// ZIP archive — extracted into the install directory.
|
||||
Zip,
|
||||
/// NSIS/MSI installer — executed silently (/S flag).
|
||||
Installer,
|
||||
}
|
||||
2
src-tauri/src/models/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod installed;
|
||||
pub mod manifest;
|
||||
53
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"productName": "PSG Launcher",
|
||||
"version": "0.1.0",
|
||||
"identifier": "net.yeahnah.psg-launcher",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "PSG Launcher",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"minWidth": 960,
|
||||
"minHeight": 620,
|
||||
"resizable": true,
|
||||
"decorations": false,
|
||||
"shadow": true,
|
||||
"center": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self' ipc: http://ipc.localhost; img-src 'self' data: blob: https://gitea.YOURDOMAIN.com; style-src 'self' 'unsafe-inline'; connect-src ipc: http://ipc.localhost"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["nsis", "msi"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"windows": {
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": "http://timestamp.digicert.com"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEZCNjJFQzRBMEQzQjQ5RDgKUldUWVNUc05TdXhpKzB0RGZseGMvTm9XQ0plRDk2Qko4OVI2aGM1a3FtNjlxSjdCano4dzJYWjgK",
|
||||
"endpoints": [
|
||||
"https://git.psg.net.au/sonderau/psg-conduit/raw/branch/releases/manifests/latest.json"
|
||||
],
|
||||
"dialog": false
|
||||
}
|
||||
}
|
||||
}
|
||||