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

View File

@@ -0,0 +1,167 @@
# PSG Launcher — Release workflow
#
# Triggers on version tags: git tag v1.2.3 && git push --tags
#
# Required Gitea repository secrets:
# TAURI_SIGNING_PRIVATE_KEY — from: npm run tauri signer generate
# TAURI_SIGNING_PRIVATE_KEY_PASSWORD — (empty string is fine if no passphrase)
# MANIFEST_SIGNING_KEY — PEM content of manifest-private.pem
# GITEA_TOKEN — a personal access token with repo write scope
#
# The workflow:
# 1. Builds the Tauri Windows installer (signed with Tauri's ed25519 key)
# 2. Signs apps.json with the manifest key
# 3. Creates a Gitea release with the installer attached
# 4. Pushes updated latest.json and apps.json.sig to the `releases` branch
name: Release
on:
push:
tags:
- "v*"
jobs:
build-windows:
runs-on: windows-latest
permissions:
contents: write
steps:
# ── Checkout ───────────────────────────────────────────────────────────
- name: Checkout
uses: actions/checkout@v4
# ── Toolchains ─────────────────────────────────────────────────────────
- name: Set up Node.js 20
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Set up Rust (stable)
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- name: Cache Rust build
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-cargo-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
# ── Install dependencies ───────────────────────────────────────────────
- name: Install npm dependencies
run: npm ci
# ── Build Tauri release ────────────────────────────────────────────────
- name: Build Tauri app (Windows)
run: npm run tauri build -- --target x86_64-pc-windows-msvc
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
# ── Collect artefacts ──────────────────────────────────────────────────
- name: Locate installer and signature
id: artefacts
shell: pwsh
run: |
$installer = Get-ChildItem src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis `
-Filter "*-setup.exe" -Recurse | Select-Object -First 1
$sig = Get-Item "$($installer.FullName).sig"
echo "installer=$($installer.FullName)" >> $env:GITHUB_OUTPUT
echo "sig=$($sig.FullName)" >> $env:GITHUB_OUTPUT
echo "version=${GITHUB_REF_NAME#v}" >> $env:GITHUB_OUTPUT
$sigContent = Get-Content $sig.FullName -Raw
echo "tauri_sig=$sigContent" >> $env:GITHUB_OUTPUT
# ── Update latest.json ─────────────────────────────────────────────────
- name: Write latest.json
shell: pwsh
run: |
$version = "${{ steps.artefacts.outputs.version }}"
$tauriSig = "${{ steps.artefacts.outputs.tauri_sig }}"
$baseUrl = "${{ gitea.server_url }}/${{ gitea.repository }}/releases/download/v$version"
$filename = Split-Path "${{ steps.artefacts.outputs.installer }}" -Leaf
$latest = @{
version = $version
notes = "Release $version"
pub_date = (Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ")
platforms = @{
"windows-x86_64" = @{
signature = $tauriSig.Trim()
url = "$baseUrl/$filename"
}
}
}
$latest | ConvertTo-Json -Depth 5 | Set-Content manifests/latest.json
Write-Host "latest.json written"
# ── Sign apps.json ─────────────────────────────────────────────────────
- name: Sign apps manifest
shell: pwsh
env:
MANIFEST_SIGNING_KEY: ${{ secrets.MANIFEST_SIGNING_KEY }}
run: .\scripts\sign-manifest.ps1
# ── Create Gitea release ───────────────────────────────────────────────
- name: Create release and upload installer
shell: pwsh
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
$tag = "${{ github.ref_name }}"
$version = "${{ steps.artefacts.outputs.version }}"
$installer= "${{ steps.artefacts.outputs.installer }}"
$sig = "${{ steps.artefacts.outputs.sig }}"
$apiBase = "${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
$headers = @{ Authorization = "token $env:GITEA_TOKEN"; "Content-Type" = "application/json" }
# Create release
$body = @{ tag_name = $tag; name = "PSG Launcher $tag"; draft = $false; prerelease = $false } | ConvertTo-Json
$release = Invoke-RestMethod -Uri "$apiBase/releases" -Method Post -Headers $headers -Body $body
$uploadUrl = "$apiBase/releases/$($release.id)/assets"
# Upload installer
$uploadHeaders = @{ Authorization = "token $env:GITEA_TOKEN" }
Invoke-RestMethod -Uri "$uploadUrl`?name=$(Split-Path $installer -Leaf)" `
-Method Post -Headers $uploadHeaders `
-InFile $installer -ContentType "application/octet-stream"
# Upload .sig file
Invoke-RestMethod -Uri "$uploadUrl`?name=$(Split-Path $sig -Leaf)" `
-Method Post -Headers $uploadHeaders `
-InFile $sig -ContentType "text/plain"
Write-Host "Release $tag created ✓"
# ── Push manifests to releases branch ─────────────────────────────────
- name: Push manifests to releases branch
shell: pwsh
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
git config user.name "Gitea Actions"
git config user.email "actions@gitea"
$remote = "${{ gitea.server_url }}/${{ gitea.repository }}.git"
$authedRemote = $remote -replace "://", "://actions:$env:GITEA_TOKEN@"
# Fetch or create the releases branch
git fetch origin releases 2>$null
git checkout -B releases origin/releases 2>$null || git checkout --orphan releases
# Copy only the manifest files
git checkout ${{ github.sha }} -- manifests/
git add manifests/apps.json manifests/apps.json.sig manifests/latest.json
git commit -m "chore: update manifests for ${{ github.ref_name }}" --allow-empty
git push $authedRemote releases
Write-Host "Manifests pushed to releases branch ✓"

43
.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# Dependencies
node_modules/
# Build output
dist/
dist-ssr/
# Tauri build artifacts
src-tauri/target/
src-tauri/gen/
# Logs
*.log
npm-debug.log*
# Editor
.vscode/*
!.vscode/extensions.json
.idea/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# OS
.DS_Store
Thumbs.db
# Keys — NEVER commit private keys
keys/
*.key
*.pem
!manifests/*.json
# Env
.env
.env.local
.env.*.local
# Tauri signing env vars (set in CI secrets, not files)
# TAURI_SIGNING_PRIVATE_KEY
# TAURI_SIGNING_PRIVATE_KEY_PASSWORD

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

7
PSG-Conduit.slnx Normal file
View File

@@ -0,0 +1,7 @@
<Solution>
<Configurations>
<Platform Name="x64" />
<Platform Name="x86" />
</Configurations>
<Project Path="PSG-Conduit/PSG-Conduit.vcxproj" />
</Solution>

View File

@@ -0,0 +1,150 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup Label="ProjectConfigurations">
<ProjectConfiguration Include="Debug|Win32">
<Configuration>Debug</Configuration>
<Platform>Win32</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|Win32">
<Configuration>Release</Configuration>
<Platform>Win32</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Debug|x64">
<Configuration>Debug</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|x64">
<Configuration>Release</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
</ItemGroup>
<PropertyGroup Label="Globals">
<VCProjectVersion>18.0</VCProjectVersion>
<Keyword>Win32Proj</Keyword>
<ProjectGuid>{83b5cd7b-0f43-4e26-b53c-67789bed170b}</ProjectGuid>
<RootNamespace>PSGConduit</RootNamespace>
<WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries>
<PlatformToolset>v145</PlatformToolset>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<PlatformToolset>v145</PlatformToolset>
<WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries>
<PlatformToolset>v145</PlatformToolset>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<PlatformToolset>v145</PlatformToolset>
<WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
<ImportGroup Label="ExtensionSettings">
</ImportGroup>
<ImportGroup Label="Shared" >
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<PropertyGroup Label="UserMacros" />
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>WIN32;_DEBUG;_CONSOLE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
<LanguageStandard>stdcpp20</LanguageStandard>
</ClCompile>
<Link>
<SubSystem>Console</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
</Link>
<Manifest>
<EnableSegmentHeap>true</EnableSegmentHeap>
</Manifest>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<FunctionLevelLinking>true</FunctionLevelLinking>
<IntrinsicFunctions>true</IntrinsicFunctions>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>WIN32;NDEBUG;_CONSOLE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
<LanguageStandard>stdcpp20</LanguageStandard>
</ClCompile>
<Link>
<SubSystem>Console</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
</Link>
<Manifest>
<EnableSegmentHeap>true</EnableSegmentHeap>
</Manifest>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>_DEBUG;_CONSOLE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
<LanguageStandard>stdcpp20</LanguageStandard>
</ClCompile>
<Link>
<SubSystem>Console</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
</Link>
<Manifest>
<EnableSegmentHeap>true</EnableSegmentHeap>
</Manifest>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<FunctionLevelLinking>true</FunctionLevelLinking>
<IntrinsicFunctions>true</IntrinsicFunctions>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>NDEBUG;_CONSOLE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
<LanguageStandard>stdcpp20</LanguageStandard>
</ClCompile>
<Link>
<SubSystem>Console</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
</Link>
<Manifest>
<EnableSegmentHeap>true</EnableSegmentHeap>
</Manifest>
</ItemDefinitionGroup>
<ItemGroup></ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
<ImportGroup Label="ExtensionTargets">
</ImportGroup>
</Project>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup>
<Filter Include="Source Files">
<UniqueIdentifier>{4FC737F1-C7A5-4376-A066-2A32D752A2FF}</UniqueIdentifier>
<Extensions>cpp;c;cc;cxx;c++;cppm;ixx;def;odl;idl;hpj;bat;asm;asmx</Extensions>
</Filter>
<Filter Include="Header Files">
<UniqueIdentifier>{93995380-89BD-4b04-88EB-625FBE52EBFB}</UniqueIdentifier>
<Extensions>h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd</Extensions>
</Filter>
<Filter Include="Resource Files">
<UniqueIdentifier>{67DA6AB6-F800-4c08-8B7A-83BB121AAD01}</UniqueIdentifier>
<Extensions>rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms</Extensions>
</Filter>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup />
</Project>

198
README.md Normal file
View File

@@ -0,0 +1,198 @@
# PSG Launcher
An all-in-one launcher for PSG tools. Fetches a signed manifest from your Gitea instance, displays available apps, and handles install/update/launch — with OTA updates for both the launcher itself and every app it manages.
---
## Security model
| Threat | Mitigation |
|---|---|
| MITM on the wire | All fetches use `https_only(true)` — plain HTTP is rejected at the Rust layer |
| Tampered manifest | `apps.json` has a detached ed25519 signature (`apps.json.sig`); the launcher verifies it before parsing |
| Tampered package | Each app package has an inline SHA-256 hash **and** a separate ed25519 signature; both are verified before writing a single byte to disk |
| Rollback / replay | Tauri's self-updater rejects packages whose version ≤ the running version |
| Compromised CDN / server | The private key never touches the server; only signed content is accepted |
| Supply chain (npm) | Frontend is a thin React shell; all crypto lives in Rust with audited crates |
**Two separate keys are used:**
- `manifest-private.pem` — signs `apps.json` and each app package (your custom key)
- Tauri signer key — signs the launcher installer for self-updates (generated by Tauri CLI)
---
## Prerequisites
| Tool | Version |
|---|---|
| Rust | 1.77+ |
| Node.js | 20+ |
| OpenSSL | 3.x (`winget install ShiningLight.OpenSSL.Light`) |
| WebView2 | Bundled with Windows 11 |
---
## First-time setup
### 1 — Generate the manifest signing keypair
```powershell
.\scripts\keygen.ps1
```
This writes `keys\manifest-private.pem` and `keys\manifest-public.pem`.
Copy the printed base64 value into `src-tauri\src\config.rs` as `MANIFEST_PUBLIC_KEY_B64`.
> ⚠ `keys\` is in `.gitignore`. Never commit the private key.
### 2 — Generate the Tauri self-updater keypair
```powershell
npm run tauri signer generate -- -w keys\tauri.key
```
Copy the printed public key into `src-tauri\tauri.conf.json` under `plugins.updater.pubkey`.
### 3 — Update config.rs
Open `src-tauri\src\config.rs` and replace the three `YOURDOMAIN / OWNER` placeholders with your actual Gitea instance URL and repository owner name.
### 4 — Update tauri.conf.json
Replace the `YOURDOMAIN / OWNER` placeholders in `src-tauri\tauri.conf.json`.
### 5 — Install npm dependencies
```powershell
npm install
```
---
## Development
```powershell
npm run tauri dev
```
The Tauri dev server starts Vite at `http://localhost:1420` and hot-reloads the UI.
---
## Building a release locally
```powershell
npm run tauri build
```
Installer goes to `src-tauri\target\release\bundle\nsis\`.
---
## Adding an app to the manifest
1. Build your app and note its `.exe` / `.zip` path.
2. Sign the package and get its hash + signature:
```powershell
.\scripts\sign-package.ps1 -PackagePath .\path\to\your-app.exe
```
3. Copy the printed values into `manifests\apps.json` (add a new entry or update `current_version`).
4. Re-sign the manifest:
```powershell
.\scripts\sign-manifest.ps1
```
5. Commit, push, and tag:
```powershell
git add manifests\
git commit -m "feat: add your-app v1.0.0"
git tag v0.2.0
git push && git push --tags
```
The release workflow triggers automatically, builds the launcher, and pushes the updated manifests to the `releases` branch where the launcher can reach them.
---
## CI secrets (Gitea repository settings → Secrets)
| Secret | Value |
|---|---|
| `TAURI_SIGNING_PRIVATE_KEY` | Content of `keys\tauri.key` |
| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Passphrase (or empty string) |
| `MANIFEST_SIGNING_KEY` | Content of `keys\manifest-private.pem` |
| `GITEA_TOKEN` | Personal access token with `repo` write scope |
---
## Manifest structure
### `manifests/apps.json`
```jsonc
{
"schema_version": 1,
"generated_at": "ISO 8601 timestamp",
"minimum_launcher_version": "0.1.0",
"apps": [
{
"id": "my-app", // stable, URL-safe ID
"name": "My App",
"description": "...",
"category": "tools", // tools | utilities | games | media | development | network | other
"current_version": "1.0.0",
"icon_url": null, // optional HTTPS URL
"changelog": "...",
"tags": [],
"platforms": {
"windows-x86_64": {
"download_url": "https://...",
"hash_sha256": "lowercase hex",
"size_bytes": 1234567,
"install_type": "portable", // portable | zip | installer
"install_path": null, // null = default (%APPDATA%\PSG\<id>)
"signature": "base64 ed25519 sig of download bytes"
}
}
}
]
}
```
### `manifests/apps.json.sig`
Plain text file containing the base64-encoded ed25519 signature of the raw `apps.json` bytes.
### `manifests/latest.json`
Tauri's self-update format — updated automatically by the release workflow.
---
## Project layout
```
PSG-Conduit/
├── src/ React + TypeScript frontend
│ ├── components/ UI components (AppCard, Sidebar, TitleBar, …)
│ ├── hooks/ useAppManifest, useLauncherUpdate
│ ├── lib/commands.ts Type-safe Tauri invoke wrappers
│ └── types/manifest.ts Shared TypeScript types
├── src-tauri/ Rust backend
│ ├── src/
│ │ ├── commands/ Tauri commands (manifest, apps, updater)
│ │ ├── models/ Serde types (manifest, installed)
│ │ ├── config.rs Public key + manifest URLs
│ │ └── error.rs Serialisable error enum
│ ├── tauri.conf.json
│ └── capabilities/
├── manifests/ Cloud manifests (committed to `releases` branch via CI)
│ ├── apps.json
│ ├── apps.json.sig ← generated by sign-manifest.ps1
│ └── latest.json ← generated by release workflow
├── scripts/
│ ├── keygen.ps1 Generate ed25519 keypair
│ ├── sign-manifest.ps1 Sign apps.json → apps.json.sig
│ └── sign-package.ps1 Get hash + sig for a single app package
└── .gitea/workflows/
└── release.yml Build + sign + publish on tag push
```

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PSG Launcher</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

27
manifests/apps.json Normal file
View File

@@ -0,0 +1,27 @@
{
"schema_version": 1,
"generated_at": "2026-05-27T00:00:00Z",
"minimum_launcher_version": "0.1.0",
"apps": [
{
"id": "psg-conduit",
"name": "PSG Conduit",
"description": "High-performance network conduit and relay tool.",
"category": "network",
"current_version": "0.1.0",
"icon_url": null,
"changelog": "Initial release.",
"tags": ["network", "conduit", "relay"],
"platforms": {
"windows-x86_64": {
"download_url": "https://gitea.YOURDOMAIN.com/OWNER/psg-conduit/releases/download/v0.1.0/psg-conduit.exe",
"hash_sha256": "REPLACE_WITH_SHA256_OF_EXE",
"size_bytes": 0,
"install_type": "portable",
"install_path": null,
"signature": "REPLACE_WITH_BASE64_ED25519_SIG_OF_EXE_BYTES"
}
}
}
]
}

1
manifests/apps.json.sig Normal file
View File

@@ -0,0 +1 @@
hYpFWHlcLsJK/3npBxmBU2Ao7HBOO09hXzan27Vw2dZuivUCII7R4uBgA1no2rUKKqHaLWTDBldNXTOuiYX9CA==

11
manifests/latest.json Normal file
View File

@@ -0,0 +1,11 @@
{
"version": "0.1.0",
"notes": "Initial release of PSG Launcher.",
"pub_date": "2026-05-27T00:00:00Z",
"platforms": {
"windows-x86_64": {
"signature": "REPLACE_WITH_TAURI_SIGNER_SIG",
"url": "https://gitea.YOURDOMAIN.com/OWNER/psg-launcher/releases/download/v0.1.0/PSG-Launcher_0.1.0_x64-setup.exe"
}
}
}

1931
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "psg-launcher",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-process": "^2",
"@tauri-apps/plugin-shell": "^2",
"@tauri-apps/plugin-updater": "^2",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.3",
"vite": "^8.0.14"
}
}

54
scripts/_openssl.ps1 Normal file
View File

@@ -0,0 +1,54 @@
<#
.SYNOPSIS
Shared helper — locate OpenSSL and add its bin dir to the session PATH.
Dot-source this at the top of any script that needs openssl:
. "$PSScriptRoot\_openssl.ps1"
#>
function Find-OpenSSL {
# 1. Already on PATH?
if (Get-Command openssl -ErrorAction SilentlyContinue) {
return (Get-Command openssl).Source
}
# 2. Probe well-known Windows install locations
$candidates = @(
"$env:ProgramFiles\OpenSSL-Win64\bin\openssl.exe"
"$env:ProgramFiles\OpenSSL\bin\openssl.exe"
"${env:ProgramFiles(x86)}\OpenSSL-Win32\bin\openssl.exe"
"C:\Program Files\OpenSSL-Win64\bin\openssl.exe"
"C:\Program Files\OpenSSL\bin\openssl.exe"
"C:\tools\OpenSSL\bin\openssl.exe"
"$env:ChocolateyInstall\bin\openssl.exe"
"$env:ProgramFiles\Git\usr\bin\openssl.exe"
"$env:LocalAppData\Programs\Git\usr\bin\openssl.exe"
)
foreach ($candidate in $candidates) {
if (Test-Path $candidate) {
# Add the bin directory to the session PATH so subsequent
# `& openssl ...` calls work without a full path.
$binDir = Split-Path $candidate
$env:PATH = "$binDir;$env:PATH"
Write-Host "Found OpenSSL at: $candidate" -ForegroundColor Cyan
return $candidate
}
}
# 3. Nothing found — give actionable guidance
Write-Error @"
OpenSSL not found in PATH or any of the standard install locations.
If it is installed somewhere else, add its bin directory to your PATH:
`$env:PATH = 'C:\Path\To\OpenSSL\bin;' + `$env:PATH
Otherwise install it:
winget install ShiningLight.OpenSSL.Light
# then restart this terminal
"@
exit 1
}
# Run on dot-source — sets `$openssl` in the caller's scope
$openssl = Find-OpenSSL
Write-Host "OpenSSL ready: $(& $openssl version)" -ForegroundColor DarkGray

66
scripts/keygen.ps1 Normal file
View File

@@ -0,0 +1,66 @@
<#
.SYNOPSIS
Generate the ed25519 keypair used to sign the apps manifest and app packages.
.DESCRIPTION
Produces two files in .\keys\ :
manifest-private.pem — KEEP SECRET. Never commit, never put on the server.
manifest-public.pem — Safe to commit; goes in src-tauri/src/config.rs.
Also prints the raw 32-byte public key as base64 (the value for config.rs).
.NOTES
Requires OpenSSL 3.x. Install via: winget install ShiningLight.OpenSSL.Light
Verify: openssl version
#>
param(
[string]$OutputDir = ".\keys"
)
$ErrorActionPreference = "Stop"
# ── Locate OpenSSL (probes common Windows install paths if not on PATH) ───────
. "$PSScriptRoot\_openssl.ps1"
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
$privPath = Join-Path $OutputDir "manifest-private.pem"
$pubPath = Join-Path $OutputDir "manifest-public.pem"
# ── Generate ed25519 keypair ─────────────────────────────────────────────────
Write-Host "`nGenerating Ed25519 keypair..." -ForegroundColor Yellow
& openssl genpkey -algorithm ed25519 -out $privPath
& openssl pkey -in $privPath -pubout -out $pubPath
# ── Extract raw 32-byte public key as base64 ─────────────────────────────────
# OpenSSL DER-encodes the public key as SubjectPublicKeyInfo (SPKI).
# Ed25519 SPKI = 12-byte header + 32-byte raw key = 44 bytes total.
# Write to a temp file so we can read the raw bytes.
$tempDer = Join-Path $env:TEMP "psg-pubkey-$([System.Guid]::NewGuid()).der"
& openssl pkey -in $privPath -pubout -outform DER -out $tempDer
$derBytes = [IO.File]::ReadAllBytes($tempDer)
Remove-Item $tempDer -Force
# Skip the 12-byte SPKI header to get the raw 32-byte key
$rawKeyBytes = $derBytes[12..43]
$rawKeyB64 = [Convert]::ToBase64String($rawKeyBytes)
# ── Output ───────────────────────────────────────────────────────────────────
Write-Host ""
Write-Host "Keys written to $OutputDir" -ForegroundColor Green
Write-Host " Private : $privPath ← KEEP SECRET, never commit" -ForegroundColor Red
Write-Host " Public : $pubPath"
Write-Host ""
Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Yellow
Write-Host "Paste this value into src-tauri/src/config.rs as MANIFEST_PUBLIC_KEY_B64:" -ForegroundColor Yellow
Write-Host ""
Write-Host " $rawKeyB64" -ForegroundColor Cyan
Write-Host ""
Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Yellow
Write-Host ""
Write-Host "Next steps:"
Write-Host " 1. Copy the base64 value above into config.rs"
Write-Host " 2. Store manifest-private.pem as the Gitea CI secret MANIFEST_SIGNING_KEY"
Write-Host " 3. Run 'npm run tauri signer generate' for the Tauri self-updater key"
Write-Host " and store that in CI as TAURI_SIGNING_PRIVATE_KEY"

122
scripts/sign-manifest.ps1 Normal file
View File

@@ -0,0 +1,122 @@
<#
.SYNOPSIS
Sign (or re-sign) manifests/apps.json and produce apps.json.sig.
.DESCRIPTION
Reads the private key from either:
-KeyPath (a PEM file path, default .\keys\manifest-private.pem)
or the env var MANIFEST_SIGNING_KEY (base64-encoded DER private key, used in CI)
Produces manifests/apps.json.sig — a file containing the base64-encoded
ed25519 signature of the raw apps.json bytes.
This detached-signature approach means the JSON is never modified and there
are no canonicalisation issues.
.EXAMPLE
# Local development
.\scripts\sign-manifest.ps1
# CI (key stored as base64 DER in an env var)
$env:MANIFEST_SIGNING_KEY = "<base64-der-private-key>"
.\scripts\sign-manifest.ps1
#>
param(
[string]$KeyPath = ".\keys\manifest-private.pem",
[string]$ManifestPath = ".\manifests\apps.json",
[string]$SigPath = ".\manifests\apps.json.sig"
)
$ErrorActionPreference = "Stop"
# ── Locate OpenSSL (probes common Windows install paths if not on PATH) ───────
. "$PSScriptRoot\_openssl.ps1"
# ── Resolve all paths to absolute using PowerShell's CWD ─────────────────────
# [IO.File] uses .NET's Environment.CurrentDirectory, which differs from
# PowerShell's $PWD when the session was started from another location.
# GetUnresolvedProviderPathFromPSPath works even for paths that don't exist yet.
$ManifestPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($ManifestPath)
$SigPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($SigPath)
$KeyPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($KeyPath)
# ── Resolve private key ──────────────────────────────────────────────────────
$tempKeyFile = $null
if ($env:MANIFEST_SIGNING_KEY) {
Write-Host "Using MANIFEST_SIGNING_KEY from environment" -ForegroundColor Cyan
# CI provides the PEM content as an env var
$tempKeyFile = Join-Path $env:TEMP "psg-sign-key-$([System.Guid]::NewGuid()).pem"
[IO.File]::WriteAllText($tempKeyFile, $env:MANIFEST_SIGNING_KEY)
$resolvedKey = $tempKeyFile
} elseif (Test-Path $KeyPath) {
Write-Host "Using key file: $KeyPath" -ForegroundColor Cyan
$resolvedKey = Resolve-Path $KeyPath
} else {
Write-Error "No signing key found.`nRun scripts/keygen.ps1 first, or set the MANIFEST_SIGNING_KEY env var."
exit 1
}
try {
# ── Sign ─────────────────────────────────────────────────────────────────
$tempSig = Join-Path $env:TEMP "psg-manifest-sig-$([System.Guid]::NewGuid()).bin"
Write-Host "Signing $ManifestPath..." -ForegroundColor Yellow
& openssl pkeyutl `
-sign `
-inkey $resolvedKey `
-rawin `
-in (Resolve-Path $ManifestPath) `
-out $tempSig
if ($LASTEXITCODE -ne 0) {
Write-Error "OpenSSL signing failed (exit $LASTEXITCODE)"
exit 1
}
# Base64-encode the raw signature bytes
$sigBytes = [IO.File]::ReadAllBytes($tempSig)
$sigB64 = [Convert]::ToBase64String($sigBytes)
[IO.File]::WriteAllText($SigPath, $sigB64)
Remove-Item $tempSig -Force
Write-Host "Signature written to $SigPath" -ForegroundColor Green
Write-Host "Sig (base64): $sigB64" -ForegroundColor Gray
} finally {
# Clean up temp key file if we created one
if ($tempKeyFile -and (Test-Path $tempKeyFile)) {
Remove-Item $tempKeyFile -Force
}
}
# ── Verify round-trip ────────────────────────────────────────────────────────
Write-Host "`nVerifying round-trip..." -ForegroundColor Yellow
$tempVerifySig = Join-Path $env:TEMP "psg-verify-sig-$([System.Guid]::NewGuid()).bin"
$sigBytesBack = [Convert]::FromBase64String($sigB64)
[IO.File]::WriteAllBytes($tempVerifySig, $sigBytesBack)
# Use the public key for verification
$pubKeyPath = $KeyPath -replace '-private\.pem$', '-public.pem'
if (-not (Test-Path $pubKeyPath)) {
Write-Warning "Public key not found at $pubKeyPath — skipping verify step"
} else {
& openssl pkeyutl `
-verify `
-inkey $pubKeyPath `
-pubin `
-rawin `
-in (Resolve-Path $ManifestPath) `
-sigfile $tempVerifySig
if ($LASTEXITCODE -eq 0) {
Write-Host "Round-trip verification passed ✓" -ForegroundColor Green
} else {
Write-Error "Round-trip verification FAILED — signature is corrupt!"
}
}
Remove-Item $tempVerifySig -Force -ErrorAction SilentlyContinue

75
scripts/sign-package.ps1 Normal file
View File

@@ -0,0 +1,75 @@
<#
.SYNOPSIS
Sign a single app package file and output its hash + signature for the manifest.
.DESCRIPTION
Run this once per app package you want to add to apps.json.
Copy the output values into the appropriate platforms entry in manifests/apps.json,
then re-run sign-manifest.ps1 to re-sign the updated manifest.
.EXAMPLE
.\scripts\sign-package.ps1 -PackagePath .\dist\my-app.exe
#>
param(
[Parameter(Mandatory)]
[string]$PackagePath,
[string]$KeyPath = ".\keys\manifest-private.pem"
)
$ErrorActionPreference = "Stop"
# ── Locate OpenSSL (probes common Windows install paths if not on PATH) ───────
. "$PSScriptRoot\_openssl.ps1"
# Resolve to absolute paths so .NET IO methods use the correct CWD
$PackagePath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($PackagePath)
$KeyPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($KeyPath)
$resolvedPkg = Resolve-Path $PackagePath
$resolvedKey = $null
$tempKeyFile = $null
if ($env:MANIFEST_SIGNING_KEY) {
$tempKeyFile = Join-Path $env:TEMP "psg-sign-key-$([System.Guid]::NewGuid()).pem"
[IO.File]::WriteAllText($tempKeyFile, $env:MANIFEST_SIGNING_KEY)
$resolvedKey = $tempKeyFile
} else {
$resolvedKey = Resolve-Path $KeyPath
}
try {
# SHA-256 hash
$hashObj = Get-FileHash -Path $resolvedPkg -Algorithm SHA256
$hash = $hashObj.Hash.ToLower()
# File size
$sizeBytes = (Get-Item $resolvedPkg).Length
# ed25519 signature
$tempSig = Join-Path $env:TEMP "psg-pkg-sig-$([System.Guid]::NewGuid()).bin"
& openssl pkeyutl -sign -inkey $resolvedKey -rawin -in $resolvedPkg -out $tempSig
if ($LASTEXITCODE -ne 0) { throw "openssl signing failed" }
$sigB64 = [Convert]::ToBase64String([IO.File]::ReadAllBytes($tempSig))
Remove-Item $tempSig -Force
Write-Host ""
Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Yellow
Write-Host "Add this to the relevant platforms entry in manifests/apps.json:" -ForegroundColor Yellow
Write-Host ""
Write-Host @"
"hash_sha256": "$hash",
"size_bytes": $sizeBytes,
"signature": "$sigB64"
"@ -ForegroundColor Cyan
Write-Host ""
Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Yellow
Write-Host "Then re-run: .\scripts\sign-manifest.ps1"
} finally {
if ($tempKeyFile -and (Test-Path $tempKeyFile)) {
Remove-Item $tempKeyFile -Force
}
}

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

85
src/App.css Normal file
View File

@@ -0,0 +1,85 @@
.launcher {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.launcher__body {
display: flex;
flex: 1;
overflow: hidden;
}
.launcher__content {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
}
/* Loading state */
.launcher__status {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
flex: 1;
color: var(--text-2);
font-size: 14px;
}
.spinner {
display: inline-block;
width: 18px;
height: 18px;
border: 2px solid var(--surface-3);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Error state */
.launcher__error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
flex: 1;
padding: 40px;
text-align: center;
}
.launcher__error h2 {
margin: 0;
font-size: 16px;
color: var(--danger);
}
.launcher__error-detail {
margin: 0;
font-size: 12px;
color: var(--text-2);
max-width: 480px;
word-break: break-word;
}
.launcher__retry {
padding: 8px 20px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--surface-3);
color: var(--text);
font-size: 13px;
cursor: pointer;
transition: background 0.12s;
}
.launcher__retry:hover {
background: var(--surface-2);
}

75
src/App.tsx Normal file
View File

@@ -0,0 +1,75 @@
import { useState } from "react";
import { AppGrid } from "./components/AppGrid";
import { Sidebar } from "./components/Sidebar";
import { TitleBar } from "./components/TitleBar";
import { UpdateBanner } from "./components/UpdateBanner";
import { useAppManifest } from "./hooks/useAppManifest";
import { useLauncherUpdate } from "./hooks/useLauncherUpdate";
import type { AppCategory } from "./types/manifest";
import "./App.css";
export function App() {
const { manifest, installed, loading, error, refresh } = useAppManifest();
const launcherUpdate = useLauncherUpdate();
const [activeCategory, setActiveCategory] = useState<AppCategory | "all">("all");
const categories = manifest
? ([...new Set(manifest.apps.map((a) => a.category))] as AppCategory[])
: [];
const filteredApps =
manifest?.apps.filter(
(app) => activeCategory === "all" || app.category === activeCategory
) ?? [];
return (
<div className="launcher">
<TitleBar />
{launcherUpdate.update?.available && (
<UpdateBanner
version={launcherUpdate.update.version}
notes={launcherUpdate.update.notes}
installing={launcherUpdate.installing}
onInstall={launcherUpdate.install}
/>
)}
<div className="launcher__body">
<Sidebar
categories={categories}
active={activeCategory}
onSelect={setActiveCategory}
onRefresh={refresh}
/>
<main className="launcher__content">
{loading && (
<div className="launcher__status">
<span className="spinner" />
<span>Fetching manifest</span>
</div>
)}
{error && !loading && (
<div className="launcher__error">
<h2>Could not load manifest</h2>
<p className="launcher__error-detail">{error}</p>
<button className="launcher__retry" onClick={refresh}>
Retry
</button>
</div>
)}
{!loading && !error && (
<AppGrid
apps={filteredApps}
installed={installed}
onRefresh={refresh}
/>
)}
</main>
</div>
</div>
);
}

191
src/components/AppCard.css Normal file
View File

@@ -0,0 +1,191 @@
.app-card {
position: relative;
display: flex;
flex-direction: column;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
width: 200px;
min-height: 230px;
transition: border-color 0.15s, transform 0.1s, box-shadow 0.15s;
}
.app-card:hover {
border-color: var(--border-hover);
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
}
.app-card--has-update {
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
}
/* Update badge */
.app-card__update-badge {
position: absolute;
top: 10px;
right: 10px;
background: var(--accent);
color: #fff;
font-size: 11px;
font-weight: 700;
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
/* Icon area */
.app-card__icon {
width: 56px;
height: 56px;
border-radius: 12px;
overflow: hidden;
background: var(--surface-3);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12px;
flex-shrink: 0;
}
.app-card__icon img {
width: 100%;
height: 100%;
object-fit: cover;
}
.app-card__icon-fallback {
font-size: 22px;
font-weight: 700;
color: var(--accent);
}
/* Info */
.app-card__info {
flex: 1;
}
.app-card__name {
margin: 0 0 4px;
font-size: 14px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.app-card__desc {
margin: 0 0 8px;
font-size: 11px;
color: var(--text-2);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.45;
}
.app-card__meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
}
.app-card__version {
font-size: 11px;
color: var(--text-3);
font-variant-numeric: tabular-nums;
}
.app-card__installed-ver {
color: var(--success);
}
.app-card__available-ver {
color: var(--accent);
}
.app-card__category {
font-size: 10px;
color: var(--text-3);
background: var(--surface-3);
padding: 2px 6px;
border-radius: 4px;
text-transform: capitalize;
}
/* Error */
.app-card__error {
margin: 6px 0 0;
font-size: 11px;
color: var(--danger);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Actions */
.app-card__actions {
display: flex;
gap: 6px;
margin-top: 12px;
align-items: center;
}
.app-card__btn {
flex: 1;
padding: 7px 10px;
border-radius: 6px;
border: none;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background 0.12s, opacity 0.12s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.app-card__btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.app-card__btn--primary {
background: var(--accent);
color: #fff;
}
.app-card__btn--primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.app-card__btn--secondary {
background: var(--surface-3);
color: var(--accent);
border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
}
.app-card__btn--secondary:hover:not(:disabled) {
background: color-mix(in srgb, var(--accent) 15%, var(--surface-3));
}
.app-card__btn--ghost {
flex: 0;
padding: 7px 9px;
background: transparent;
color: var(--text-3);
font-size: 11px;
}
.app-card__btn--ghost:hover:not(:disabled) {
background: var(--surface-3);
color: var(--danger);
}

153
src/components/AppCard.tsx Normal file
View File

@@ -0,0 +1,153 @@
import { useState } from "react";
import {
downloadAndInstallApp,
launchApp,
uninstallApp,
} from "../lib/commands";
import type { AppEntry, InstalledApp } from "../types/manifest";
import "./AppCard.css";
interface Props {
app: AppEntry;
installed?: InstalledApp;
onRefresh: () => void;
}
type ActionState = "idle" | "downloading" | "launching" | "error";
export function AppCard({ app, installed, onRefresh }: Props) {
const [state, setState] = useState<ActionState>("idle");
const [errMsg, setErrMsg] = useState<string | null>(null);
const isInstalled = !!installed;
const hasUpdate = isInstalled && installed.version !== app.current_version;
const initial = app.name.charAt(0).toUpperCase();
async function handleInstallOrUpdate() {
setState("downloading");
setErrMsg(null);
try {
await downloadAndInstallApp(app);
onRefresh();
} catch (e: unknown) {
const msg =
e && typeof e === "object" && "message" in e
? String((e as { message: unknown }).message)
: String(e);
setErrMsg(msg);
setState("error");
return;
}
setState("idle");
}
async function handleLaunch() {
setState("launching");
setErrMsg(null);
try {
await launchApp(app.id);
} catch (e: unknown) {
const msg =
e && typeof e === "object" && "message" in e
? String((e as { message: unknown }).message)
: String(e);
setErrMsg(msg);
setState("error");
return;
}
setState("idle");
}
async function handleUninstall() {
if (!confirm(`Remove ${app.name} from the launcher? (Files are kept.)`)) return;
try {
await uninstallApp(app.id);
onRefresh();
} catch (e: unknown) {
console.error(e);
}
}
const busy = state === "downloading" || state === "launching";
return (
<article className={`app-card${hasUpdate ? " app-card--has-update" : ""}`}>
{hasUpdate && (
<span className="app-card__update-badge" title={`Update: v${app.current_version}`}>
</span>
)}
<div className="app-card__icon">
{app.icon_url ? (
<img src={app.icon_url} alt={app.name} />
) : (
<span className="app-card__icon-fallback">{initial}</span>
)}
</div>
<div className="app-card__info">
<h3 className="app-card__name">{app.name}</h3>
<p className="app-card__desc">{app.description}</p>
<div className="app-card__meta">
<span className="app-card__version">
{isInstalled ? (
<>
<span className="app-card__installed-ver">{installed.version}</span>
{hasUpdate && (
<span className="app-card__available-ver"> {app.current_version}</span>
)}
</>
) : (
`v${app.current_version}`
)}
</span>
<span className="app-card__category">{app.category}</span>
</div>
</div>
{state === "error" && errMsg && (
<p className="app-card__error" title={errMsg}>
{errMsg}
</p>
)}
<div className="app-card__actions">
{isInstalled && (
<button
className="app-card__btn app-card__btn--primary"
onClick={handleLaunch}
disabled={busy}
>
{state === "launching" ? "Launching…" : "Launch"}
</button>
)}
{(!isInstalled || hasUpdate) && (
<button
className={`app-card__btn${isInstalled ? " app-card__btn--secondary" : " app-card__btn--primary"}`}
onClick={handleInstallOrUpdate}
disabled={busy}
>
{state === "downloading"
? "Downloading…"
: hasUpdate
? `Update → v${app.current_version}`
: "Install"}
</button>
)}
{isInstalled && (
<button
className="app-card__btn app-card__btn--ghost"
onClick={handleUninstall}
disabled={busy}
title="Remove from launcher"
>
</button>
)}
</div>
</article>
);
}

View File

@@ -0,0 +1,16 @@
.app-grid {
display: flex;
flex-wrap: wrap;
gap: 14px;
padding: 20px;
align-content: flex-start;
}
.app-grid__empty {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
color: var(--text-3);
font-size: 14px;
}

View File

@@ -0,0 +1,32 @@
import { AppCard } from "./AppCard";
import type { AppEntry, InstalledApps } from "../types/manifest";
import "./AppGrid.css";
interface Props {
apps: AppEntry[];
installed: InstalledApps | null;
onRefresh: () => void;
}
export function AppGrid({ apps, installed, onRefresh }: Props) {
if (apps.length === 0) {
return (
<div className="app-grid__empty">
<p>No apps in this category.</p>
</div>
);
}
return (
<div className="app-grid">
{apps.map((app) => (
<AppCard
key={app.id}
app={app}
installed={installed?.apps[app.id]}
onRefresh={onRefresh}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,75 @@
.sidebar {
width: 192px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: var(--surface);
border-right: 1px solid var(--border);
padding: 12px 8px;
overflow-y: auto;
}
.sidebar__list {
list-style: none;
margin: 0;
padding: 0;
flex: 1;
}
.sidebar__item {
display: block;
width: 100%;
text-align: left;
padding: 8px 12px;
border-radius: 6px;
border: none;
background: transparent;
color: var(--text-2);
font-size: 13px;
cursor: pointer;
transition: background 0.12s, color 0.12s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar__item:hover {
background: var(--surface-3);
color: var(--text);
}
.sidebar__item--active {
background: color-mix(in srgb, var(--accent) 18%, transparent);
color: var(--accent);
font-weight: 600;
}
.sidebar__footer {
padding-top: 12px;
border-top: 1px solid var(--border);
margin-top: 8px;
}
.sidebar__refresh {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 7px 12px;
border-radius: 6px;
border: none;
background: transparent;
color: var(--text-3);
font-size: 12px;
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.sidebar__refresh:hover {
background: var(--surface-3);
color: var(--text-2);
}
.sidebar__refresh-icon {
font-size: 14px;
}

View File

@@ -0,0 +1,48 @@
import type { AppCategory } from "../types/manifest";
import "./Sidebar.css";
const CATEGORY_LABELS: Record<AppCategory | "all", string> = {
all: "All Apps",
tools: "Tools",
utilities: "Utilities",
games: "Games",
media: "Media",
development: "Development",
network: "Network",
other: "Other",
};
interface Props {
categories: AppCategory[];
active: AppCategory | "all";
onSelect: (cat: AppCategory | "all") => void;
onRefresh: () => void;
}
export function Sidebar({ categories, active, onSelect, onRefresh }: Props) {
const items: (AppCategory | "all")[] = ["all", ...categories];
return (
<nav className="sidebar">
<ul className="sidebar__list">
{items.map((cat) => (
<li key={cat}>
<button
className={`sidebar__item${active === cat ? " sidebar__item--active" : ""}`}
onClick={() => onSelect(cat)}
>
{CATEGORY_LABELS[cat] ?? cat}
</button>
</li>
))}
</ul>
<div className="sidebar__footer">
<button className="sidebar__refresh" onClick={onRefresh} title="Refresh manifest">
<span className="sidebar__refresh-icon"></span>
Refresh
</button>
</div>
</nav>
);
}

View File

@@ -0,0 +1,66 @@
.titlebar {
display: flex;
align-items: center;
justify-content: space-between;
height: 36px;
padding: 0 4px 0 14px;
background: var(--surface);
border-bottom: 1px solid var(--border);
user-select: none;
flex-shrink: 0;
}
.titlebar__brand {
display: flex;
align-items: center;
gap: 8px;
cursor: default;
}
.titlebar__logo {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
color: var(--accent);
background: color-mix(in srgb, var(--accent) 15%, transparent);
padding: 2px 7px;
border-radius: 4px;
}
.titlebar__name {
font-size: 12px;
font-weight: 500;
color: var(--text-2);
letter-spacing: 0.02em;
}
.titlebar__controls {
display: flex;
align-items: stretch;
height: 100%;
}
.titlebar__btn {
display: flex;
align-items: center;
justify-content: center;
width: 46px;
height: 100%;
border: none;
background: transparent;
color: var(--text-2);
font-family: "Segoe MDL2 Assets", "Segoe Fluent Icons", sans-serif;
font-size: 10px;
cursor: pointer;
transition: background 0.1s, color 0.1s;
}
.titlebar__btn:hover {
background: var(--surface-3);
color: var(--text);
}
.titlebar__btn--close:hover {
background: #c42b1c;
color: #fff;
}

View File

@@ -0,0 +1,39 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
import "./TitleBar.css";
export function TitleBar() {
const win = getCurrentWindow();
return (
<header className="titlebar" data-tauri-drag-region>
<div className="titlebar__brand" data-tauri-drag-region>
<span className="titlebar__logo">PSG</span>
<span className="titlebar__name">Launcher</span>
</div>
<div className="titlebar__controls">
<button
className="titlebar__btn"
title="Minimize"
onClick={() => win.minimize()}
>
&#xE921;
</button>
<button
className="titlebar__btn"
title="Maximize"
onClick={() => win.toggleMaximize()}
>
&#xE922;
</button>
<button
className="titlebar__btn titlebar__btn--close"
title="Close"
onClick={() => win.close()}
>
&#xE8BB;
</button>
</div>
</header>
);
}

View File

@@ -0,0 +1,51 @@
.update-banner {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 18px;
background: color-mix(in srgb, var(--accent) 12%, var(--surface));
border-bottom: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
flex-shrink: 0;
}
.update-banner__icon {
font-size: 18px;
color: var(--accent);
line-height: 1;
}
.update-banner__body {
flex: 1;
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
color: var(--text);
}
.update-banner__notes {
font-size: 12px;
color: var(--text-2);
}
.update-banner__btn {
padding: 6px 16px;
border-radius: 6px;
border: none;
background: var(--accent);
color: #fff;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
white-space: nowrap;
}
.update-banner__btn:hover:not(:disabled) {
background: var(--accent-hover);
}
.update-banner__btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}

Some files were not shown because too many files have changed in this diff Show More