feat: initial PSG Launcher scaffold

This commit is contained in:
2026-05-27 09:01:09 +08:00
commit 2c9b591c52
110 changed files with 11150 additions and 0 deletions

6236
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

60
src-tauri/Cargo.toml Normal file
View 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
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 B

BIN
src-tauri/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 727 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 588 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 B

View File

@@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 647 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 818 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 608 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 771 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -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

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 511 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 511 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 737 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 699 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 699 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1011 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 511 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 918 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 918 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 906 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View 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(())
}

View 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?)
}

View File

@@ -0,0 +1,3 @@
pub mod apps;
pub mod manifest;
pub mod updater;

View 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
View 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
View 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
View 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
View 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()
}

View 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>,
}

View 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,
}

View File

@@ -0,0 +1,2 @@
pub mod installed;
pub mod manifest;

53
src-tauri/tauri.conf.json Normal file
View 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
}
}
}