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]>
No files matched your search
@@ -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"
|
||||
@@ -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"]
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 961 B |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 21 KiB |
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
primora_desktop_lib::run();
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||