From 1bc4ac4bc944871ec8cba8c06e0085362b89042c Mon Sep 17 00:00:00 2001 From: Sebastian Palencsar Date: Mon, 23 Feb 2026 12:15:10 +0100 Subject: [PATCH] Persist app config, add list --verbose, and improve quick start/install --- Cargo.lock | 2 + Cargo.toml | 1 + README.md | 62 ++++++++- docs/reviewer-reply.md | 15 +++ docs/roadmap.md | 36 ++++++ packaging/PKGBUILD | 24 ++++ packaging/README.md | 18 +++ src/main.rs | 281 ++++++++++++++++++++++++++++++++++++----- 8 files changed, 399 insertions(+), 40 deletions(-) create mode 100755 docs/reviewer-reply.md create mode 100755 docs/roadmap.md create mode 100755 packaging/PKGBUILD create mode 100755 packaging/README.md diff --git a/Cargo.lock b/Cargo.lock index a66377d..eae9aa6 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -351,6 +351,7 @@ dependencies = [ "directories", "image", "regex", + "serde", "serde_json", "tempfile", "ureq", @@ -1334,6 +1335,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", + "serde_derive", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index c635d89..a6ce694 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ clap = { version = "4.5.60", features = ["derive"] } directories = "6.0.0" image = "0.25.9" regex = "1.12.3" +serde = { version = "1.0.218", features = ["derive"] } serde_json = "1.0.149" tempfile = "3.25.0" ureq = "2.9" diff --git a/README.md b/README.md index 63acf94..39190a2 100755 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ Written in Rust Tauri v2 Alpha status + Latest release + MIT license

@@ -25,6 +27,31 @@ --- +## Quick Start + +Two tracks, depending on what you want: + +### Option A: Chromium mode (fastest, best compatibility) + +Requirements: any Chromium-based browser installed (`chromium`, `google-chrome`, `brave`, etc.) + +```bash +mkdir -p ~/.local/bin +curl -L -o ~/.local/bin/deskify \ + https://github.com/spalencsar/deskify/releases/latest/download/deskify-linux-x86_64 +chmod +x ~/.local/bin/deskify + +deskify build --url "https://chat.com" --name "Chat" --backend chromium +``` + +### Option B: Native Tauri mode (system WebView, no Chromium dependency) + +Follow the full install section below (Tauri/WebKitGTK prerequisites), then: + +```bash +deskify build --url "https://chat.com" --name "Chat" +``` + ## Why Deskify Exists Web apps are now core tools, but Linux desktops still treat them like second-class citizens. @@ -145,6 +172,23 @@ The table above covers the technical tradeoffs in more detail. In practice, `des ## 🚀 Installation +### Option A: Install a prebuilt binary (recommended) + +Deskify provides Linux release binaries. Download the latest release and install it into `~/.local/bin`: + +```bash +mkdir -p ~/.local/bin +curl -L -o ~/.local/bin/deskify \ + https://github.com/spalencsar/deskify/releases/latest/download/deskify-linux-x86_64 +chmod +x ~/.local/bin/deskify +deskify --help +``` + +Notes: + +- The `tauri` backend still requires Tauri/WebKitGTK system dependencies to build wrappers locally. +- The `chromium` backend does not require Tauri prerequisites (but it requires an installed Chromium-based browser). + ### 1. System Dependencies Because `deskify` compiles Tauri applications natively on your machine, you need the standard Tauri prerequisites installed before using it. @@ -309,12 +353,18 @@ You can view all applications generated by `deskify`: ```bash deskify list ``` + +Verbose list (includes URL/backend when metadata is available): + +```bash +deskify list --verbose +``` *Output:* ```text Installed Deskify Apps: -- ChatGPT (Internal ID: chatgpt) -- Home Assistant (Internal ID: home-assistant) +- ChatGPT (Internal ID: chatgpt, Backend: tauri) +- Proton Mail (Internal ID: proton-mail, Backend: chromium) ``` To entirely uninstall an app (including the binary, desktop entry, and icons): @@ -332,7 +382,8 @@ Rebuild and reinstall an existing app ID without manually running `remove` + `bu deskify update chat --url "https://chat.com" --name "Chat" --no-decorations ``` -Alpha note: `update` currently requires `--url` because Deskify does not persist app URLs/config metadata yet. +Deskify persists app metadata under `~/.local/share/deskify/apps/.json`. With persisted metadata, `update` can be used without `--url`. +If an older app has no metadata yet, provide `--url` once (or rebuild once) to migrate it. `update ` keeps the existing internal ID stable even if you change the display name. You can also preview an update: @@ -382,10 +433,9 @@ Expected: clean validation error and no unintended file deletion. ## 🏷️ Versioning & GitHub Releases (MVP) - Recommended tag format during MVP: `v0.1.0-alpha.N` -- Start with **source-first** releases (repository + tags) -- Add automated binary releases later via GitHub Actions +- GitHub Releases publish a Linux `deskify` CLI binary on tags (`v*`) -For the current public alpha, use `v0.1.0-alpha.2` (and continue with `v0.1.0-alpha.N` for follow-up releases). +For the latest alpha, see GitHub Releases (and continue with `v0.1.0-alpha.N` for follow-up releases). --- diff --git a/docs/reviewer-reply.md b/docs/reviewer-reply.md new file mode 100755 index 0000000..df7519e --- /dev/null +++ b/docs/reviewer-reply.md @@ -0,0 +1,15 @@ +## Reply Draft (Copy/Paste) + +Thanks for the very concrete review - that matches the real friction points almost perfectly. + +A few things are intentionally built as Alpha/MVP tradeoffs, but I agree with your priorities: + +- The dual backend is deliberately framed as "tauri first, chromium when needed", not as a defeat. The README now includes a simple decision rule and concrete examples. +- Build time is currently the biggest hurdle for time-to-first-success. This is exactly why the Chromium backend exists: many users want "works now" instead of a 2-5 minute local build plus prerequisites. +- Config persistence is the next logical step: update without having to re-specify URL/config should be the default once metadata is stored under `~/.local/share/deskify/`. +- Distribution: CI and a tag-based GitHub Release workflow already produce a Linux binary asset. Next step is to make "install from releases" more prominent and enable community packaging (e.g. AUR `-bin`). + +Thanks also for calling out the WebKitGTK reality. That's why the backend story is explicit: system WebView where it works well, Chromium app mode when a site needs a newer engine. + +If you're up for it, I'd love to keep you as a sparring partner specifically on time-to-first-success (docs/install/release), since that's often more decisive than feature count in OSS adoption. + diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100755 index 0000000..ef0555b --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,36 @@ +# Roadmap (Alpha) + +This roadmap is intentionally pragmatic: it prioritizes "time-to-first-success" and UX gaps before deeper architecture work. + +## P0 - Config Persistence + +Goal: `deskify update ` works without `--url`, and `deskify list --verbose` can show URL/backend. + +- Store metadata under `~/.local/share/deskify/apps/.json` +- Persist (v1): + - `id`, `name`, `url` + - `backend`, `browser_bin` (optional), `profile_scope` + - `fullscreen`, `no_decorations`, `user_agent` (optional), `width`, `height`, `dark_mode` + - `schema_version` +- Migration: older installs without metadata should be migrated on first update/build + +## P1 - Distribution / Onboarding + +Goal: install `deskify` without a full Rust/Tauri toolchain. + +- Promote "install from GitHub Releases" in README +- Keep CI releases producing a Linux binary asset +- Enable community packaging (AUR `deskify-bin`, etc.) + +## P2 - Shared Runtime Model (only after P0/P1) + +Goal: reduce per-app build time and prerequisites for the `tauri` backend. + +- Concept: shared runtime/launcher + per-app configs (no per-app compile) + +## P3 - QoL + +- `deskify list --verbose` improvements (URLs/backend/profile scope) +- Better FAQ: WebView vs Chromium backend tradeoffs +- Optional DE integrations (later): KDE/GNOME widgets/extensions + diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD new file mode 100755 index 0000000..7f54fb7 --- /dev/null +++ b/packaging/PKGBUILD @@ -0,0 +1,24 @@ +pkgname=deskify-bin +pkgver=0.1.0_alpha.6 +pkgrel=1 +pkgdesc="Turn websites into Linux desktop apps (prebuilt binary package)" +arch=('x86_64') +url="https://github.com/spalencsar/deskify" +license=('MIT') +depends=('glibc') +provides=('deskify') +conflicts=('deskify') + +_tag="v0.1.0-alpha.6" +source=("deskify::https://github.com/spalencsar/deskify/releases/download/${_tag}/deskify-linux-x86_64" + "LICENSE::https://raw.githubusercontent.com/spalencsar/deskify/${_tag}/LICENSE") + +# TODO: replace SKIP with the real sha256 once the release asset is finalized. +sha256sums=('SKIP' + 'SKIP') + +package() { + install -Dm755 "${srcdir}/deskify" "${pkgdir}/usr/bin/deskify" + install -Dm644 "${srcdir}/LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" +} + diff --git a/packaging/README.md b/packaging/README.md new file mode 100755 index 0000000..ec07a0b --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,18 @@ +## AUR package template (manual) + +This folder contains a minimal `PKGBUILD` template for a future AUR package: + +- `deskify-bin` (prebuilt GitHub Releases binary) + +Notes: + +- `sha256sums` are currently set to `SKIP` and should be replaced with real checksums before publishing to AUR. +- Update `_tag` and `pkgver` when you publish a new release tag. + +Local build test: + +```bash +cd packaging +makepkg -sf +``` + diff --git a/src/main.rs b/src/main.rs index 562af13..a3444c3 100755 --- a/src/main.rs +++ b/src/main.rs @@ -12,13 +12,19 @@ use std::process::Command; use tempfile::tempdir; use url::Url; -#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)] +#[derive( + clap::ValueEnum, serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq, Eq, +)] +#[serde(rename_all = "lowercase")] enum Backend { Tauri, Chromium, } -#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)] +#[derive( + clap::ValueEnum, serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq, Eq, +)] +#[serde(rename_all = "lowercase")] enum ProfileScope { Isolated, Shared, @@ -110,7 +116,11 @@ enum Commands { dry_run: bool, }, /// List all installed apps created by deskify - List, + List { + /// Show additional metadata (URL, backend) when available + #[arg(long)] + verbose: bool, + }, /// Check local prerequisites and environment diagnostics Doctor, /// Remove a specific app by its internal ID @@ -118,14 +128,14 @@ enum Commands { /// The safe name/ID of the app (e.g., "youtube") id: String, }, - /// Rebuild and reinstall an existing app ID with new settings (alpha: URL is required) + /// Rebuild and reinstall an existing app ID with new settings (URL is optional when persisted) Update { /// The existing internal ID (e.g., "chatgpt") id: String, - /// The URL to wrap (required in current alpha because URLs are not persisted yet) + /// The URL to wrap (optional if Deskify has persisted app metadata for this ID) #[arg(short, long)] - url: String, + url: Option, /// Optional new display name (defaults to current desktop entry name or the ID) #[arg(short, long)] @@ -136,12 +146,12 @@ enum Commands { icon: Option, /// Launch the application in fullscreen (Kiosk) mode - #[arg(short, long)] - fullscreen: bool, + #[arg(short, long, action = clap::ArgAction::SetTrue)] + fullscreen: Option, /// Disable native window decorations (frameless window) - #[arg(long)] - no_decorations: bool, + #[arg(long, action = clap::ArgAction::SetTrue)] + no_decorations: Option, /// Set a custom User-Agent string for the webview #[arg(short = 'A', long)] @@ -156,20 +166,20 @@ enum Commands { height: Option, /// Force the webview into Dark Mode - #[arg(short, long)] - dark_mode: bool, + #[arg(short, long, action = clap::ArgAction::SetTrue)] + dark_mode: Option, /// Backend to use for the updated app - #[arg(long, value_enum, default_value_t = Backend::Tauri)] - backend: Backend, + #[arg(long, value_enum)] + backend: Option, /// Path to a Chromium-based browser binary (only for `--backend chromium`) #[arg(long)] browser_bin: Option, /// Profile isolation for Chromium backend (only for `--backend chromium`) - #[arg(long, value_enum, default_value_t = ProfileScope::Isolated)] - profile_scope: ProfileScope, + #[arg(long, value_enum)] + profile_scope: Option, /// Show planned update actions without rebuilding/installing #[arg(long)] @@ -181,6 +191,23 @@ enum Commands { }, } +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] +struct DeskifyAppConfig { + schema_version: u32, + id: String, + name: String, + url: String, + backend: Backend, + browser_bin: Option, + profile_scope: ProfileScope, + fullscreen: bool, + no_decorations: bool, + user_agent: Option, + width: Option, + height: Option, + dark_mode: bool, +} + fn sanitize_app_id(name: &str) -> String { let re = Regex::new(r"[^a-z0-9-]").unwrap(); let lower_name = name.to_lowercase().replace(' ', "-"); @@ -211,6 +238,75 @@ fn profile_scope_str(scope: ProfileScope) -> &'static str { } } +fn backend_str(backend: Backend) -> &'static str { + match backend { + Backend::Tauri => "tauri", + Backend::Chromium => "chromium", + } +} + +fn deskify_data_dir() -> Result { + let base_dirs = BaseDirs::new().ok_or_else(|| anyhow!("Could not find system BaseDirs"))?; + Ok(base_dirs.data_local_dir().join("deskify")) +} + +fn app_config_path(id: &str) -> Result { + validate_remove_id(id)?; + Ok(deskify_data_dir()? + .join("apps") + .join(format!("{}.json", id))) +} + +fn read_app_config(id: &str) -> Result> { + let path = app_config_path(id)?; + if !path.exists() { + return Ok(None); + } + let content = fs::read_to_string(&path) + .with_context(|| format!("Failed to read app config {}", path.display()))?; + let cfg: DeskifyAppConfig = + serde_json::from_str(&content).context("Failed to parse app config JSON")?; + Ok(Some(cfg)) +} + +fn write_app_config_from_args(args: &BuildArgs) -> Result<()> { + validate_remove_id(&args.internal_id)?; + let path = app_config_path(&args.internal_id)?; + if let Some(dir) = path.parent() { + fs::create_dir_all(dir).context("Failed to create app config directory")?; + } + let cfg = DeskifyAppConfig { + schema_version: 1, + id: args.internal_id.clone(), + name: args.name.clone(), + url: args.url.clone(), + backend: args.backend, + browser_bin: args.browser_bin.clone(), + profile_scope: args.profile_scope, + fullscreen: args.fullscreen, + no_decorations: args.no_decorations, + user_agent: args.user_agent.clone(), + width: args.width, + height: args.height, + dark_mode: args.dark_mode, + }; + fs::write( + &path, + serde_json::to_string_pretty(&cfg).context("Failed to serialize app config")?, + ) + .with_context(|| format!("Failed to write app config {}", path.display()))?; + Ok(()) +} + +fn remove_app_config(id: &str) -> Result<()> { + let path = app_config_path(id)?; + if path.exists() { + fs::remove_file(&path).with_context(|| format!("Failed to remove {}", path.display()))?; + println!("Removed app config: {:?}", path); + } + Ok(()) +} + fn build_tauri_config_value(args: &BuildArgs, safe_identifier: &str, bundle_active: bool) -> Value { let mut window_config = Map::::new(); window_config.insert("title".to_string(), json!(args.name)); @@ -838,7 +934,7 @@ X-Deskify-Browser={} Ok(()) } -fn list_apps() -> Result<()> { +fn list_apps_with_options(verbose: bool) -> Result<()> { let base_dirs = BaseDirs::new().ok_or_else(|| anyhow!("Could not find system BaseDirs"))?; let applications_dir = base_dirs.data_local_dir().join("applications"); @@ -871,10 +967,38 @@ fn list_apps() -> Result<()> { .and_then(|l| l.strip_prefix("X-Deskify-Backend=")) .unwrap_or("legacy"); - println!( - "- {} (Internal ID: {}, Backend: {})", - name, internal_id, backend - ); + if verbose && validate_remove_id(internal_id).is_ok() { + if let Some(cfg) = read_app_config(internal_id)? { + println!( + "- {} (Internal ID: {}, Backend: {}, URL: {})", + name, + internal_id, + backend_str(cfg.backend), + cfg.url + ); + } else { + let url = content + .lines() + .find(|l| l.starts_with("X-Deskify-URL=")) + .and_then(|l| l.strip_prefix("X-Deskify-URL=")); + if let Some(url) = url { + println!( + "- {} (Internal ID: {}, Backend: {}, URL: {})", + name, internal_id, backend, url + ); + } else { + println!( + "- {} (Internal ID: {}, Backend: {})", + name, internal_id, backend + ); + } + } + } else { + println!( + "- {} (Internal ID: {}, Backend: {})", + name, internal_id, backend + ); + } found = true; } } @@ -887,6 +1011,29 @@ fn list_apps() -> Result<()> { Ok(()) } +fn read_desktop_entry_value(id: &str, key: &str) -> Result> { + validate_remove_id(id)?; + let base_dirs = BaseDirs::new().ok_or_else(|| anyhow!("Could not find system BaseDirs"))?; + let desktop_path = base_dirs + .data_local_dir() + .join("applications") + .join(format!("{}.desktop", id)); + if !desktop_path.exists() { + return Ok(None); + } + let content = fs::read_to_string(&desktop_path) + .with_context(|| format!("Failed to read desktop entry {}", desktop_path.display()))?; + if !is_deskify_desktop_entry(&content) { + return Ok(None); + } + let prefix = format!("{}=", key); + Ok(content + .lines() + .find(|line| line.starts_with(&prefix)) + .and_then(|line| line.strip_prefix(&prefix)) + .map(str::to_string)) +} + fn read_installed_app_display_name(id: &str) -> Result> { validate_remove_id(id)?; let base_dirs = BaseDirs::new().ok_or_else(|| anyhow!("Could not find system BaseDirs"))?; @@ -984,6 +1131,9 @@ fn run_doctor() -> Result<()> { println!("[info] executable dir: {}", executable_dir.display()); println!("[info] applications dir: {}", applications_dir.display()); println!("[info] icons dir: {}", icons_dir.display()); + if let Ok(exe) = env::current_exe() { + println!("[info] deskify path: {}", exe.display()); + } if failed { Err(anyhow!("Doctor found critical issues")) @@ -1053,6 +1203,7 @@ fn remove_app_with_options(safe_name: &str, remove_profile: bool) -> Result<()> } println!("Successfully removed app '{}'.", safe_name); + let _ = remove_app_config(safe_name); Ok(()) } @@ -1087,6 +1238,7 @@ fn execute_build(args: &BuildArgs, dry_run: bool, print_config: bool) -> Result< }; install_tauri_app(args, &bin_path, dir.path())?; + write_app_config_from_args(args)?; Ok(()) } Backend::Chromium => { @@ -1094,7 +1246,9 @@ fn execute_build(args: &BuildArgs, dry_run: bool, print_config: bool) -> Result< "Installing Chromium app '{}' for URL: {}", args.name, args.url ); - install_chromium_app(args) + install_chromium_app(args)?; + write_app_config_from_args(args)?; + Ok(()) } } } @@ -1162,8 +1316,8 @@ fn main() -> Result<()> { }; execute_build(&args, dry_run, print_config)?; } - Commands::List => { - list_apps()?; + Commands::List { verbose } => { + list_apps_with_options(verbose)?; } Commands::Doctor => { run_doctor()?; @@ -1188,25 +1342,84 @@ fn main() -> Result<()> { dry_run, print_config, } => { + let existing_cfg = read_app_config(&id)?; + let resolved_url = if let Some(url) = url { + url + } else if let Some(cfg) = &existing_cfg { + cfg.url.clone() + } else if let Some(url) = read_desktop_entry_value(&id, "X-Deskify-URL")? { + url + } else { + return Err(anyhow!( + "Missing --url for update. Provide --url or rebuild once with a recent Deskify version so metadata can be persisted." + )); + }; + let resolved_name = if let Some(name) = name { name + } else if let Some(cfg) = &existing_cfg { + cfg.name.clone() } else { read_installed_app_display_name(&id)?.unwrap_or_else(|| id.clone()) }; + + let resolved_backend = if let Some(backend) = backend { + backend + } else if let Some(cfg) = &existing_cfg { + cfg.backend + } else if let Some(b) = read_desktop_entry_value(&id, "X-Deskify-Backend")? { + match b.as_str() { + "chromium" => Backend::Chromium, + "tauri" => Backend::Tauri, + _ => Backend::Tauri, + } + } else { + Backend::Tauri + }; + + let resolved_profile_scope = if let Some(scope) = profile_scope { + scope + } else if let Some(cfg) = &existing_cfg { + cfg.profile_scope + } else { + ProfileScope::Isolated + }; + + let resolved_user_agent = if user_agent.is_some() { + user_agent + } else { + existing_cfg.as_ref().and_then(|c| c.user_agent.clone()) + }; + let resolved_width = width.or_else(|| existing_cfg.as_ref().and_then(|c| c.width)); + let resolved_height = height.or_else(|| existing_cfg.as_ref().and_then(|c| c.height)); + let resolved_browser_bin = + browser_bin.or_else(|| existing_cfg.as_ref().and_then(|c| c.browser_bin.clone())); + + let resolved_fullscreen = + fullscreen.unwrap_or(existing_cfg.as_ref().map(|c| c.fullscreen).unwrap_or(false)); + let resolved_no_decorations = no_decorations.unwrap_or( + existing_cfg + .as_ref() + .map(|c| c.no_decorations) + .unwrap_or(false), + ); + let resolved_dark_mode = + dark_mode.unwrap_or(existing_cfg.as_ref().map(|c| c.dark_mode).unwrap_or(false)); + let args = BuildArgs { - url, + url: resolved_url, name: resolved_name, internal_id: id.clone(), icon, - fullscreen, - no_decorations, - user_agent, - width, - height, - dark_mode, - backend, - browser_bin, - profile_scope, + fullscreen: resolved_fullscreen, + no_decorations: resolved_no_decorations, + user_agent: resolved_user_agent, + width: resolved_width, + height: resolved_height, + dark_mode: resolved_dark_mode, + backend: resolved_backend, + browser_bin: resolved_browser_bin, + profile_scope: resolved_profile_scope, }; execute_update(&id, &args, dry_run, print_config)?; }