feat: phase 5 distribution — tauri shell, pwa, desktop release artifacts

- apps/desktop: Tauri 2 thin shell; connect screen stores the deployment
  URL, webview renders the remote console, tray with Show/Change Server/
  Quit, close-to-tray, single-instance
- frontend: vite-plugin-pwa generates manifest + Workbox SW (shell
  precache, /api /auth /mailpit NetworkOnly), install banner on
  beforeinstallprompt, rasterized PWA icons
- release.yml: desktop bundle matrix (linux/macos/windows) attached to
  GitHub Releases
- workspace 0.5.0; Dockerfiles copy the desktop workspace manifest

Authored By: TDvorak <[email protected]>
This commit is contained in:
Tomas Dvorak committed 2026-09-16 19:59:53 +02:00
1 parent 23186e38b8
commit deb56d9a23
55 files changed
+9657 -155

No files matched your search

+1
View File
@@ -4,6 +4,7 @@ WORKDIR /workspace
COPY package.json package-lock.json tsconfig.base.json ./
COPY apps/auth/package.json ./apps/auth/package.json
COPY apps/cli/package.json ./apps/cli/package.json
COPY apps/desktop/package.json ./apps/desktop/package.json
COPY apps/frontend/package.json ./apps/frontend/package.json
COPY apps/mcp/package.json ./apps/mcp/package.json
COPY packages/api-client/package.json ./packages/api-client/package.json
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@primora/auth",
"version": "0.4.0",
"version": "0.5.0",
"private": true,
"type": "module",
"scripts": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@primora/cli",
"version": "0.4.0",
"version": "0.5.0",
"description": "Primora command-line client",
"type": "module",
"bin": {
@@ -23,7 +23,7 @@
"picocolors": "^1.1.1"
},
"devDependencies": {
"@primora/api-client": "0.4.0",
"@primora/api-client": "0.5.0",
"@types/node": "^24.0.0",
"esbuild": "^0.27.4",
"typescript": "^5.9.2"
+31
View File
@@ -0,0 +1,31 @@
# @primora/desktop
Tauri 2 shell around a self-hosted Primora deployment. On first run it asks for
the deployment URL; after that the webview renders the same SolidJS console the
server serves. Nothing is embedded — no backend, no database. The self-hosted
model is preserved.
## Prerequisites
- Rust (rustup) and Node.js.
- Linux webview deps:
`sudo apt install libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev`
## Run
```bash
npm run dev --workspace @primora/desktop # dev window (debug build)
npm run bundle --workspace @primora/desktop # release bundles → src-tauri/target/release/bundle
```
## Notes
- Config lives at `~/.config/dev.tdvorak.primora/config.json` — one field, `server_url`.
- System tray: **Show Primora**, **Change Server…** (clears the URL, back to the
connect screen), **Quit**. Closing the window hides to the tray; a second
launch focuses the existing window (single-instance).
- The connect screen only checks that the host answers; if the URL turns out not
to be a Primora deployment, use the tray's Change Server.
- macOS blocks plain-HTTP loads to non-localhost hosts (ATS). Use HTTPS, or
`http://localhost` for a local deployment.
- Deep links / push notifications: deliberately omitted — nothing needs them yet.
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@primora/desktop",
"version": "0.5.0",
"private": true,
"description": "Primora desktop shell — thin Tauri client for a self-hosted deployment",
"scripts": {
"tauri": "tauri",
"dev": "tauri dev",
"bundle": "tauri build",
"check": "cargo check --manifest-path src-tauri/Cargo.toml"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.4"
}
}
+5071
View File
File diff suppressed because it is too large. Load diff
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "primora-desktop"
version = "0.5.0"
edition = "2021"
description = "Primora desktop shell — thin client for a self-hosted Primora deployment"
license = "MIT"
[lib]
name = "primora_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-single-instance = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capabilities for the main window",
"windows": ["main"],
"permissions": ["core:default"]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 961 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+125
View File
@@ -0,0 +1,125 @@
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use tauri::{
menu::{Menu, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
AppHandle, Manager, WindowEvent,
};
/// Persisted shell state — one field, the deployment to load.
#[derive(Default, Serialize, Deserialize)]
struct DesktopConfig {
server_url: Option<String>,
}
fn config_path(app: &AppHandle) -> Result<PathBuf, String> {
app.path()
.app_config_dir()
.map(|dir| dir.join("config.json"))
.map_err(|e| e.to_string())
}
fn load_config(app: &AppHandle) -> DesktopConfig {
config_path(app)
.ok()
.and_then(|path| std::fs::read_to_string(path).ok())
.and_then(|raw| serde_json::from_str(&raw).ok())
.unwrap_or_default()
}
fn save_config(app: &AppHandle, config: &DesktopConfig) -> Result<(), String> {
let path = config_path(app)?;
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
let raw = serde_json::to_string_pretty(config).map_err(|e| e.to_string())?;
std::fs::write(path, raw).map_err(|e| e.to_string())
}
#[tauri::command]
fn get_server_url(app: AppHandle) -> Option<String> {
load_config(&app).server_url
}
#[tauri::command]
fn set_server_url(app: AppHandle, url: String) -> Result<(), String> {
let parsed = tauri::Url::parse(url.trim()).map_err(|_| "That is not a valid URL.".to_string())?;
match parsed.scheme() {
"http" | "https" => {}
_ => return Err("Only http:// and https:// URLs are supported.".to_string()),
}
if parsed.host_str().is_none() {
return Err("The URL needs a host.".to_string());
}
let mut normalized = parsed.to_string();
while normalized.ends_with('/') {
normalized.pop();
}
save_config(&app, &DesktopConfig { server_url: Some(normalized) })
}
fn show_main(app: &AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
show_main(app);
}))
.invoke_handler(tauri::generate_handler![get_server_url, set_server_url])
.setup(|app| {
let show = MenuItem::with_id(app, "show", "Show Primora", true, None::<&str>)?;
let change = MenuItem::with_id(app, "change_server", "Change Server…", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show, &change, &quit])?;
let mut tray = TrayIconBuilder::new()
.menu(&menu)
.tooltip("Primora")
.on_menu_event(|app, event| match event.id().as_ref() {
"show" => show_main(app),
"change_server" => {
// Clearing the URL and restarting lands back on the connect screen.
let _ = save_config(app, &DesktopConfig { server_url: None });
app.restart();
}
"quit" => app.exit(0),
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
show_main(tray.app_handle());
}
});
if let Some(icon) = app.default_window_icon() {
tray = tray.icon(icon.clone());
}
let _tray = tray.build(app)?;
// Close hides to the tray instead of quitting — Quit is on the menu.
if let Some(window) = app.get_webview_window("main") {
let handle = window.clone();
window.on_window_event(move |event| {
if let WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
let _ = handle.hide();
}
});
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running Primora desktop");
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
primora_desktop_lib::run();
}
+40
View File
@@ -0,0 +1,40 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Primora",
"version": "0.5.0",
"identifier": "dev.tdvorak.primora",
"build": {
"frontendDist": "../ui"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"label": "main",
"title": "Primora",
"width": 1280,
"height": 800,
"minWidth": 420,
"minHeight": 640,
"center": true
}
],
"security": {
"csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost https: http:; img-src 'self' data:; style-src 'self'"
}
},
"bundle": {
"active": true,
"targets": "all",
"category": "DeveloperTool",
"shortDescription": "Primora desktop client",
"longDescription": "Thin desktop shell for a self-hosted Primora deployment. Renders the console served by your own server.",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/[email protected]",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#f4693a"/>
<g fill="#ffffff">
<rect x="15" y="14" width="34" height="10" rx="5"/>
<rect x="15" y="27" width="23" height="10" rx="5" opacity="0.7"/>
<rect x="15" y="40" width="10" height="10" rx="5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 334 B

+84
View File
@@ -0,0 +1,84 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<title>Primora</title>
<link rel="icon" type="image/svg+xml" href="icon.svg" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="card">
<img src="icon.svg" class="logo" alt="" />
<h1>Primora</h1>
<p class="sub">Connect to your self-hosted deployment.</p>
<form id="form">
<input
id="url"
type="text"
inputmode="url"
placeholder="https://primora.example.com"
autocomplete="off"
spellcheck="false"
autofocus
/>
<button type="submit" id="go">Connect</button>
</form>
<p id="err" class="err" hidden></p>
<p class="hint">The desktop app renders the console served by your own server. Nothing is hosted by us.</p>
</main>
<script type="module">
const tauri = window.__TAURI__;
const form = document.getElementById("form");
const input = document.getElementById("url");
const button = document.getElementById("go");
const err = document.getElementById("err");
const fail = (msg) => {
err.textContent = msg;
err.hidden = false;
button.disabled = false;
button.textContent = "Connect";
};
if (!tauri) {
fail("This page is meant to run inside the Primora desktop app.");
} else {
// A saved deployment URL means "skip the form" on every launch.
const saved = await tauri.core.invoke("get_server_url").catch(() => null);
if (saved) location.replace(saved);
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
err.hidden = true;
button.disabled = true;
button.textContent = "Checking…";
let url = input.value.trim();
if (!/^https?:\/\//i.test(url)) url = `https://${url}`;
try {
await tauri.core.invoke("set_server_url", { url });
} catch (e) {
fail(String(e));
return;
}
// Reachability probe — opaque (no-cors), so it proves the host answers,
// not that it is a Primora deployment. A wrong-but-alive URL is recoverable
// via the tray menu's "Change server".
try {
await fetch(url, { mode: "no-cors", signal: AbortSignal.timeout(8000) });
} catch {
fail("Server did not respond. Check the URL and that the deployment is up.");
return;
}
location.replace(url);
});
</script>
</body>
</html>
+101
View File
@@ -0,0 +1,101 @@
* {
margin: 0;
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
display: grid;
place-items: center;
background: #0b0d11;
color: #e7e7ea;
font-family:
system-ui,
-apple-system,
"Segoe UI",
Roboto,
sans-serif;
-webkit-font-smoothing: antialiased;
}
.card {
width: min(24rem, calc(100vw - 3rem));
padding: 2.5rem 2rem;
background: #14161b;
border: 1px solid #26292f;
border-radius: 14px;
text-align: center;
}
.logo {
width: 56px;
height: 56px;
border-radius: 12px;
margin-bottom: 1rem;
}
h1 {
font-size: 1.4rem;
font-weight: 600;
letter-spacing: -0.01em;
}
.sub {
margin-top: 0.4rem;
color: #9a9aa3;
font-size: 0.9rem;
}
form {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: 1.75rem;
}
input {
padding: 0.65rem 0.8rem;
background: #0b0d11;
border: 1px solid #2e3239;
border-radius: 8px;
color: inherit;
font-size: 0.95rem;
outline: none;
}
input:focus {
border-color: #f4693a;
}
button {
padding: 0.65rem 0.8rem;
background: #f4693a;
border: none;
border-radius: 8px;
color: #fff;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
}
button:disabled {
opacity: 0.6;
cursor: default;
}
.err {
margin-top: 0.9rem;
color: #ef6b6b;
font-size: 0.85rem;
}
.hint {
margin-top: 1.5rem;
color: #6d6d76;
font-size: 0.78rem;
line-height: 1.5;
}
+1
View File
@@ -5,6 +5,7 @@ WORKDIR /app
COPY package.json package-lock.json tsconfig.base.json ./
COPY apps/auth/package.json ./apps/auth/package.json
COPY apps/cli/package.json ./apps/cli/package.json
COPY apps/desktop/package.json ./apps/desktop/package.json
COPY apps/frontend/package.json ./apps/frontend/package.json
COPY apps/mcp/package.json ./apps/mcp/package.json
COPY packages/api-client/package.json ./packages/api-client/package.json
+5
View File
@@ -10,6 +10,11 @@
<title>Primora</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Primora" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@primora/frontend",
"version": "0.4.0",
"version": "0.5.0",
"private": true,
"type": "module",
"scripts": {
@@ -10,8 +10,8 @@
"test": "vitest run"
},
"dependencies": {
"@primora/api-client": "0.4.0",
"@primora/shared-types": "0.4.0",
"@primora/api-client": "0.5.0",
"@primora/shared-types": "0.5.0",
"better-auth": "^1.5.6",
"solid-js": "^1.9.12"
},
@@ -26,6 +26,7 @@
"tailwindcss": "^3.4.17",
"typescript": "^5.9.2",
"vite": "^8.0.3",
"vite-plugin-pwa": "^1.3.0",
"vite-plugin-solid": "^2.11.8",
"vitest": "^3.2.4"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" fill="#f4693a"/>
<g fill="#ffffff" transform="translate(6.4 6.4) scale(0.8)">
<rect x="15" y="14" width="34" height="10" rx="5"/>
<rect x="15" y="27" width="23" height="10" rx="5" opacity="0.7"/>
<rect x="15" y="40" width="10" height="10" rx="5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 368 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+2
View File
@@ -31,6 +31,7 @@ import {
NetworkError,
OnboardingModal,
ProjectDashboard,
PwaInstallBanner,
type PaletteCommand,
type ViewType,
} from "./components";
@@ -1500,6 +1501,7 @@ export default function App() {
<Show when={platformMessage()}>
<div class="message message-neutral mb-5">{platformMessage()}</div>
</Show>
<PwaInstallBanner />
<Show
when={platform() || isDemo}
@@ -0,0 +1,58 @@
import { Show, createSignal, onCleanup, onMount } from "solid-js";
// Chromium fires `beforeinstallprompt` once manifest + service worker pass
// installability checks. Stash the event and let the banner trigger it —
// the native mini-infobar is easy to miss.
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
const DISMISS_KEY = "primora_pwa_install_dismissed";
export function PwaInstallBanner() {
const [deferred, setDeferred] = createSignal<BeforeInstallPromptEvent | null>(null);
const [dismissed, setDismissed] = createSignal(localStorage.getItem(DISMISS_KEY) === "1");
onMount(() => {
const handler = (event: Event) => {
event.preventDefault();
// SAFETY: the listener is only attached to "beforeinstallprompt", whose
// event always carries prompt()/userChoice.
setDeferred(event as BeforeInstallPromptEvent);
};
window.addEventListener("beforeinstallprompt", handler);
onCleanup(() => window.removeEventListener("beforeinstallprompt", handler));
});
const install = async () => {
const event = deferred();
if (!event) return;
await event.prompt();
if ((await event.userChoice).outcome === "accepted") setDeferred(null);
};
const dismiss = () => {
localStorage.setItem(DISMISS_KEY, "1");
setDismissed(true);
};
return (
<Show when={deferred() && !dismissed()}>
<div
class="message message-info mb-5"
style="display:flex;align-items:center;justify-content:space-between;gap:1rem"
>
<span>Install Primora as an app for quick access from your launcher.</span>
<span style="display:flex;gap:0.5rem;flex-shrink:0">
<button class="btn btn-primary btn-sm" onClick={install}>
Install
</button>
<button class="btn btn-ghost btn-sm" onClick={dismiss}>
Not now
</button>
</span>
</div>
</Show>
);
}
+1
View File
@@ -13,6 +13,7 @@ export { Modal, ModalFooter } from "./Modal";
export { NotificationCenter, addNotification, removeNotification, clearNotifications, notify } from "./NotificationCenter";
export { OnboardingModal } from "./OnboardingModal";
export { Progress, CircularProgress, Spinner } from "./Progress";
export { PwaInstallBanner } from "./PwaInstallBanner";
export { ProjectDashboard } from "./ProjectDashboard";
export { Table, DataTable, Pagination } from "./Table";
export { Tabs, TabPanel } from "./Tabs";
+49 -2
View File
@@ -1,8 +1,56 @@
import { defineConfig } from "vite";
import solidPlugin from "vite-plugin-solid";
import { VitePWA } from "vite-plugin-pwa";
export default defineConfig({
plugins: [solidPlugin()],
plugins: [
solidPlugin(),
VitePWA({
registerType: "autoUpdate",
injectRegister: "auto",
manifest: {
name: "Primora",
short_name: "Primora",
description:
"Self-hosted backend console: storage, collections, API keys, audit logs.",
theme_color: "#0b0d11",
background_color: "#0b0d11",
display: "standalone",
start_url: "/",
icons: [
{ src: "pwa-192x192.png", sizes: "192x192", type: "image/png" },
{ src: "pwa-512x512.png", sizes: "512x512", type: "image/png" },
{
src: "maskable-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
},
workbox: {
// Cache the app shell only. /api, /auth and /mailpit are live backends —
// never let a service worker answer for them.
navigateFallback: "/index.html",
navigateFallbackDenylist: [/^\/(api|auth|mailpit)(\/|$)/],
runtimeCaching: [
{
urlPattern: /\/(api|auth|mailpit)(\/|$)/,
handler: "NetworkOnly",
},
{
// Geist font files + CSS from Google Fonts — long-lived static assets.
urlPattern: /^https:\/\/fonts\.(googleapis|gstatic)\.com\//,
handler: "CacheFirst",
options: {
cacheName: "primora-fonts",
expiration: { maxEntries: 20, maxAgeSeconds: 60 * 60 * 24 * 365 },
},
},
],
},
}),
],
server: {
host: "0.0.0.0",
port: Number(process.env.FRONTEND_PORT ?? 3000),
@@ -15,4 +63,3 @@ export default defineConfig({
target: "esnext",
},
});
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@primora/mcp",
"version": "0.4.0",
"version": "0.5.0",
"publishConfig": {
"access": "public"
},
@@ -22,7 +22,7 @@
"zod": "^4.1.5"
},
"devDependencies": {
"@primora/api-client": "0.4.0",
"@primora/api-client": "0.5.0",
"@types/node": "^24.0.0",
"esbuild": "^0.27.4",
"typescript": "^5.9.2"