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