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

+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"
]
}
}