Prepare GitHub alpha launch (README, CI, tests, release workflow)

This commit is contained in:
Sebastian Palencsar
2026-02-23 07:44:41 +01:00
commit 5f66ae9e14
12 changed files with 3035 additions and 0 deletions

27
.github/pull_request_template.md vendored Executable file
View File

@@ -0,0 +1,27 @@
## Summary
Describe what changed and why.
## Type of Change
- [ ] Feature
- [ ] Fix
- [ ] Refactor
- [ ] Docs
- [ ] CI / Build
## What Was Tested
- [ ] `cargo fmt --check`
- [ ] `cargo clippy --all-targets --all-features -- -D warnings`
- [ ] `cargo test`
- [ ] `cargo check`
- [ ] Manual smoke test (`build` / `list` / `remove`) when behavior changed
## Behavior / Risk Notes
List any edge cases, compatibility impacts, or known limitations.
## Screenshots (if relevant)
Only needed when desktop integration behavior/UI-visible changes are involved.

20
.github/release.yml vendored Executable file
View File

@@ -0,0 +1,20 @@
changelog:
categories:
- title: Features
labels:
- feature
- enhancement
- title: Fixes
labels:
- fix
- bug
- title: Documentation
labels:
- docs
- title: Maintenance
labels:
- chore
- ci
exclude:
labels:
- skip-changelog

34
.github/workflows/ci.yml vendored Executable file
View File

@@ -0,0 +1,34 @@
name: CI
on:
push:
pull_request:
jobs:
checks:
name: Rust Checks
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Cache cargo artifacts
uses: Swatinem/rust-cache@v2
- name: Format check
run: cargo fmt --check
- name: Clippy
run: cargo clippy --all-targets --all-features -- -D warnings
- name: Unit tests
run: cargo test
- name: Cargo check
run: cargo check

38
.github/workflows/release.yml vendored Executable file
View File

@@ -0,0 +1,38 @@
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
build-linux:
name: Build Linux Binary
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo artifacts
uses: Swatinem/rust-cache@v2
- name: Build release binary
run: cargo build --release
- name: Prepare release asset
run: |
mkdir -p dist
cp target/release/deskify dist/deskify-linux-x86_64
- name: Upload GitHub Release asset
uses: softprops/action-gh-release@v2
with:
files: dist/deskify-linux-x86_64
generate_release_notes: true

1
.gitignore vendored Executable file
View File

@@ -0,0 +1 @@
/target

47
CHANGELOG.md Executable file
View File

@@ -0,0 +1,47 @@
# Changelog
All notable changes to `deskify` will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project follows early MVP/alpha versioning with tags like `v0.1.0-alpha.1`.
## [Unreleased]
## [v0.1.0-alpha.1] - 2026-02-23
### Added
- Initial public MVP/alpha release of `deskify` (Linux-only)
- CLI commands:
- `deskify build --url <URL> --name <NAME>`
- `deskify list`
- `deskify remove <internal-id>`
- `build` supports advanced options:
- `--icon`
- `--fullscreen`
- `--user-agent`
- `--dark-mode`
- `--width`
- `--height`
- Tauri-based wrapper generation in a temporary build directory
- XDG desktop integration:
- installs wrapper binary into local executable directory (`~/.local/bin` fallback)
- creates `.desktop` launcher in `~/.local/share/applications`
- installs app icon into `~/.local/share/icons/hicolor/128x128/apps`
- Automatic favicon download from Google Favicon API with dummy-icon fallback
- `.desktop` metadata marker (`X-Deskify-Managed=true`) for more reliable app discovery
- Unit tests for:
- app ID sanitization
- `remove` ID validation
- Deskify desktop entry detection (marker + legacy fallback)
- GitHub CI workflow (`cargo fmt --check`, `cargo clippy`, `cargo test`, `cargo check`)
- Tag-based GitHub Release workflow to upload a Linux `deskify` CLI binary
- GitHub PR template and release-note categorization config
### Changed
- `tauri.conf.json` generation now uses structured JSON serialization (`serde_json`) instead of manual string interpolation
- App discovery in `list` prefers the Deskify marker and keeps a legacy heuristic fallback for older installs
- Documentation updated for alpha/MVP positioning, known limitations, smoke tests, and release tagging (`v0.1.0-alpha.N`)
### Fixed
- `remove` now validates internal IDs and rejects unsafe values (for example path traversal inputs like `../foo`)
- Generated Tauri config creation is more robust for names/URLs/user agents containing special characters

64
CONTRIBUTING.md Executable file
View File

@@ -0,0 +1,64 @@
# Contributing to Deskify
First off, thank you for considering contributing to Deskify! It's people like you that make Deskify such a great tool.
`deskify` is currently in an **Alpha / Early MVP** phase. Contributions that improve reliability, error handling, tests, and Linux compatibility are especially valuable.
## How Can I Contribute?
### Reporting Bugs
This section guides you through submitting a bug report for Deskify. Following these guidelines helps maintainers and the community understand your report, reproduce the behavior, and find related reports.
* Use a clear and descriptive title for the issue to identify the problem.
* Describe the exact steps which reproduce the problem in as many details as possible.
* Provide specific examples to demonstrate the steps.
### Suggesting Enhancements
This section guides you through submitting an enhancement suggestion for Deskify, including completely new features and minor improvements to existing functionality.
* Use a clear and descriptive title for the issue to identify the suggestion.
* Provide a step-by-step description of the suggested enhancement in as many details as possible.
* Explain why this enhancement would be useful to most Deskify users.
### Pull Requests
* Fill in the required template
* Do not include issue numbers in the PR title
* Include screenshots only when UI/desktop integration behavior is relevant.
* Follow the Rust styleguide (`cargo fmt`).
* Document new code based on the existing documentation style.
* End all files with a newline.
* Prefer small, focused PRs (one behavioral change per PR).
## Development Setup
1. Deskify relies on `tauri`. Ensure `tauri-cli` is installed via `cargo install tauri-cli`.
2. Ensure you have the standard Tauri dependencies for Linux (WebKit2GTK, etc.).
3. Clone the repository and run `cargo build` to test your changes.
4. Run `cargo clippy` to check for common mistakes and improve your Rust code.
## Local Checks Before Opening a PR
Run these checks locally before submitting a pull request:
```bash
cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test
cargo check
```
## Manual Smoke Test (Recommended for Behavior Changes)
If your change affects build/install/list/remove behavior, run a quick smoke test:
```bash
deskify build --url "https://example.com" --name "Example"
deskify list
deskify remove example
```
Also verify invalid IDs are rejected safely:
```bash
deskify remove ../foo
deskify remove FooBar
```

2026
Cargo.lock generated Executable file

File diff suppressed because it is too large Load Diff

15
Cargo.toml Executable file
View File

@@ -0,0 +1,15 @@
[package]
name = "deskify"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.102"
clap = { version = "4.5.60", features = ["derive"] }
directories = "6.0.0"
image = "0.25.9"
regex = "1.12.3"
serde_json = "1.0.149"
tempfile = "3.25.0"
ureq = "2.9"
url = "2.5.8"

21
LICENSE Executable file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Sebastian Palencsar
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

196
README.md Executable file
View File

@@ -0,0 +1,196 @@
<h1 align="center">deskify</h1>
<p align="center">
<b>A blazing fast, lightweight Nativefier alternative written in Rust & Tauri.</b><br>
Instantly turn any website or web app into a native, standalone Linux desktop application.
</p>
<p align="center">
<img src="https://img.shields.io/badge/OS-Linux-blue?style=flat-square" alt="Linux only" />
<img src="https://img.shields.io/badge/Language-Rust-orange?style=flat-square" alt="Written in Rust" />
<img src="https://img.shields.io/badge/Powered_by-Tauri_v2-grey?style=flat-square" alt="Tauri v2" />
<img src="https://img.shields.io/badge/Status-Alpha-yellow?style=flat-square" alt="Alpha status" />
</p>
---
## ⚡ Why deskify?
The original [Nativefier](https://github.com/nativefier/nativefier) project (Node.js/Electron) was an incredible tool, but it is now unmaintained and deprecated. Furthermore, packaging an entire Chromium V8 engine for *every single web app* you create consumes massive amounts of RAM and disk space.
**deskify** solves this by leveraging [Tauri](https://tauri.app/). Instead of bundling a heavy browser engine, it uses your native system webview (e.g., `webkit2gtk` on Linux).
## 🚧 Project Status (Alpha / Early MVP)
`deskify` is ready for public use and testing as an **early MVP**, but it is **not production-hardened yet**.
- **Platform scope:** Linux only
- **Build model:** `deskify` compiles a local Tauri wrapper app on your machine
- **First build can take a while:** Rust crates + Tauri build steps may need to compile
- **Test coverage:** currently limited (core behavior is implemented, but automated coverage is still growing)
### Known Limitations
- Requires Tauri/Linux system dependencies to be installed locally before `deskify build`
- Generated app build success can vary by distro/system setup (WebKitGTK/Tauri prerequisites)
- No official cross-platform support (Windows/macOS) yet
- GitHub Releases / binary automation for `deskify` itself may lag behind source updates during early MVP
### 🌟 Features
- **Extremely Lightweight:** Generated binaries are tiny (~4-6 MB) and RAM consumption is minimal.
- **Automatic Icon Fetching:** Scrapes high-quality 128x128 favicons automatically using the Google Favicon API.
- **XDG-Compliant System Integration:** Safely creates `.desktop` entries in `~/.local/share/applications` and manages application icons in `~/.local/share/icons/hicolor/`.
- **Wayland/X11 Ready:** Perfectly binds `StartupWMClass` to ensure your DE groups the app exactly to its custom icon (no generic gear icons in GNOME/KDE taskbars).
- **Kiosk / Fullscreen Mode:** Pin applications perfectly as dashboards.
- **User-Agent Spoofing:** Trick picky sites (like WhatsApp Web) into working within the webview.
- **Clean App Management:** Effortlessly list and remove created apps without leaving orphaned files behind.
---
## 🚀 Installation
### 1. System Dependencies
Because `deskify` compiles Tauri applications natively on your machine, you need the standard Tauri prerequisites installed before using it.
On **Ubuntu / Debian**:
```bash
sudo apt update
sudo apt install libwebkit2gtk-4.1-dev \
build-essential \
curl \
wget \
file \
libssl-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev
```
On **Arch Linux / Manjaro**:
```bash
sudo pacman -S webkit2gtk-4.1 \
base-devel \
curl \
wget \
file \
openssl \
appmenu-gtk-module \
gtk3 \
libappindicator-gtk3 \
librsvg \
libvips
```
*(For Fedora or other distros, refer to the [Tauri Prerequisites Guide](https://v2.tauri.app/start/prerequisites/).)*
### 2. Rust & Tauri CLI
You'll need the Rust compiler and the Tauri CLI to compile the generated apps.
```bash
# Ensure Rust is installed
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Install Tauri-CLI globally
cargo install tauri-cli --version "^2.0.0"
```
### 3. Install deskify
Clone the repository and install it directly via Cargo:
```bash
git clone https://github.com/spalencsar/deskify.git
cd deskify
cargo install --path .
```
---
## 🛠️ Usage
### Creating an App (`build`)
The `build` subcommand requires a `--url` and a `--name`.
```bash
deskify build --url "https://chatgpt.com" --name "ChatGPT"
```
Once the build finishes, `ChatGPT` will instantly appear in your system Application Launcher (e.g., Rofi, Wofi, GNOME Dash).
`deskify` derives a safe internal ID from the app name (for example, `ChatGPT` becomes `chatgpt`).
#### Advanced Build Options
```bash
deskify build \
--url "https://netflix.com" \
--name "Netflix" \
--fullscreen \
--user-agent "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36" \
--dark-mode \
--width 1280 \
--height 720
```
* `--icon <PATH>`: Provide a custom PNG icon instead of auto-downloading one.
* `--fullscreen`: Starts the app in Kiosk mode.
* `--user-agent <UA>`: Useful to bypass webview restrictions on certain platforms.
* `--dark-mode`: Forces the Tauri webview into a dark theme.
* `--width <PX>` / `--height <PX>`: Sets the startup resolution.
### Managing Apps (`list` & `remove`)
You can view all applications generated by `deskify`:
```bash
deskify list
```
*Output:*
```text
Installed Deskify Apps:
- ChatGPT (Internal ID: chatgpt)
- Netflix (Internal ID: netflix)
```
To entirely uninstall an app (including the binary, desktop entry, and icons):
```bash
deskify remove chatgpt
```
`remove` expects this **internal ID** (sanitized lowercase letters, numbers, `-`), not the display name.
---
## 🧪 Manual Smoke Test Checklist (Before a Public Release)
```bash
deskify build --url "https://example.com" --name "Example"
deskify list
deskify remove example
```
Also test invalid IDs:
```bash
deskify remove ../foo
deskify remove FooBar
```
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
For the first public launch, tagging `v0.1.0-alpha.1` is a sensible default.
---
## 📦 Open Source Acknowledgements
`deskify` is built on the shoulders of giants. It leverages the following fantastic open-source projects:
* **[Tauri](https://tauri.app/)** - The core framework driving the native wrap.
* **[Clap](https://crates.io/crates/clap)** - Command-Line Argument Parser for Rust.
* **[Anyhow](https://crates.io/crates/anyhow)** - Excellent error handling context.
* **[Ureq](https://crates.io/crates/ureq)** - Minimalist sync HTTP request library (used for icon fetching).
* **[Directories](https://crates.io/crates/directories)** - Abstractions for standard OS directories (XDG Base Dirs).
## 📝 License
This project is licensed under the [MIT License](LICENSE).

546
src/main.rs Executable file
View File

@@ -0,0 +1,546 @@
use anyhow::{Context, Result, anyhow};
use clap::{Parser, Subcommand};
use directories::BaseDirs;
use regex::Regex;
use serde_json::{Map, Value, json};
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::tempdir;
use url::Url;
/// Deskify - Turn any URL into a native Linux desktop application using Tauri.
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Build and install a new native application from a URL
Build {
/// The URL of the website to wrap (e.g., https://youtube.com)
#[arg(short, long)]
url: String,
/// The name of the application (e.g., "YouTube")
#[arg(short, long)]
name: String,
/// Optional path to a custom icon (PNG format is recommended)
#[arg(short, long)]
icon: Option<String>,
/// Launch the application in fullscreen (Kiosk) mode
#[arg(short, long)]
fullscreen: bool,
/// Set a custom User-Agent string for the webview
#[arg(short = 'A', long)]
user_agent: Option<String>,
/// Set the initial window width
#[arg(short = 'W', long)]
width: Option<f64>,
/// Set the initial window height
#[arg(short = 'H', long)]
height: Option<f64>,
/// Force the webview into Dark Mode
#[arg(short, long)]
dark_mode: bool,
},
/// List all installed apps created by deskify
List,
/// Remove a specific app by its internal ID
Remove {
/// The safe name/ID of the app (e.g., "youtube")
id: String,
},
}
fn sanitize_app_id(name: &str) -> String {
let re = Regex::new(r"[^a-z0-9-]").unwrap();
let lower_name = name.to_lowercase().replace(' ', "-");
let sanitized = re.replace_all(&lower_name, "");
if sanitized.is_empty() {
"app".to_string()
} else {
sanitized.to_string()
}
}
fn validate_remove_id(id: &str) -> Result<()> {
let re = Regex::new(r"^[a-z0-9-]+$").unwrap();
if re.is_match(id) {
Ok(())
} else {
Err(anyhow!(
"Invalid app ID '{}'. Allowed characters: lowercase letters, numbers, and '-'.",
id
))
}
}
fn is_deskify_desktop_entry(content: &str) -> bool {
content.contains("X-Deskify-Managed=true")
|| (content.contains("Categories=Network;WebBrowser;")
&& (content.contains(".local/bin/") || content.contains("deskify")))
}
fn fetch_or_create_icon(
website_url: &str,
custom_icon: Option<&String>,
output_path: &Path,
) -> Result<()> {
if let Some(icon_path) = custom_icon {
if Path::new(icon_path).exists() {
fs::copy(icon_path, output_path)
.with_context(|| format!("Failed to copy custom icon from {}", icon_path))?;
return Ok(());
} else {
eprintln!(
"Warning: Custom icon path '{}' does not exist, falling back to downloaded icon.",
icon_path
);
}
}
if let Ok(parsed_url) = Url::parse(website_url)
&& let Some(host) = parsed_url.host_str()
{
println!("Downloading icon for {}...", host);
let api_url = format!("https://www.google.com/s2/favicons?domain={}&sz=128", host);
if let Ok(response) = ureq::get(&api_url).call() {
let mut bytes = Vec::new();
if response.into_reader().read_to_end(&mut bytes).is_ok() && !bytes.is_empty() {
fs::write(output_path, bytes).context("Failed to write downloaded icon to disk")?;
return Ok(());
}
}
}
// Fallback to a transparent dummy icon
let dummy_png: [u8; 67] = [
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f,
0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00,
0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49,
0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
];
fs::write(output_path, dummy_png).context("Failed to write dummy icon fallback")?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn generate_project(
url: &str,
name: &str,
icon: Option<&String>,
fullscreen: bool,
user_agent: Option<&String>,
width: Option<f64>,
height: Option<f64>,
dark_mode: bool,
project_dir: &Path,
) -> Result<()> {
let src_dir = project_dir.join("src");
fs::create_dir_all(&src_dir).context("Failed to create src directory")?;
// 1. Cargo.toml
let cargo_toml = r#"
[package]
name = "deskify-app"
version = "0.1.0"
description = "A native application wrapper"
authors = ["deskify"]
edition = "2021"
[dependencies]
tauri = { version = "2.0.0", features = [] }
[build-dependencies]
tauri-build = "2.0.0"
"#;
fs::write(project_dir.join("Cargo.toml"), cargo_toml).context("Failed to write Cargo.toml")?;
// 2. Build script (build.rs)
let build_rs = r#"
fn main() {
tauri_build::build()
}
"#;
fs::write(project_dir.join("build.rs"), build_rs).context("Failed to write build.rs")?;
// 3. src/main.rs
let main_rs = r#"
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
fn main() {
tauri::Builder::default()
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
"#;
fs::write(src_dir.join("main.rs"), main_rs).context("Failed to write src/main.rs")?;
// 4. tauri.conf.json
let safe_identifier = sanitize_app_id(name);
let dist_dir = project_dir.join("dist");
fs::create_dir_all(&dist_dir).context("Failed to create dist directory")?;
fs::write(
dist_dir.join("index.html"),
"<!DOCTYPE html><html><body></body></html>",
)
.context("Failed to write dummy index.html")?;
let icons_dir = project_dir.join("icons");
fs::create_dir_all(&icons_dir).context("Failed to create icons directory")?;
fetch_or_create_icon(url, icon, &icons_dir.join("icon.png"))?;
let mut window_config = Map::<String, Value>::new();
window_config.insert("title".to_string(), json!(name));
window_config.insert("url".to_string(), json!(url));
if fullscreen {
window_config.insert("fullscreen".to_string(), json!(true));
}
if let Some(ua) = user_agent {
window_config.insert("userAgent".to_string(), json!(ua));
}
if let Some(w) = width {
window_config.insert("width".to_string(), json!(w));
}
if let Some(h) = height {
window_config.insert("height".to_string(), json!(h));
}
if dark_mode {
window_config.insert("theme".to_string(), json!("Dark"));
}
let tauri_conf = json!({
"$schema": "https://schema.tauri.app/config/2",
"productName": name,
"version": "0.1.0",
"identifier": format!("com.deskify.{}", safe_identifier),
"build": {
"frontendDist": "dist"
},
"app": {
"windows": [Value::Object(window_config)],
"security": {
"csp": null
}
},
"bundle": {
"active": false,
"targets": "all",
"icon": ["icons/icon.png"]
}
});
fs::write(
project_dir.join("tauri.conf.json"),
serde_json::to_string_pretty(&tauri_conf).context("Failed to serialize tauri.conf.json")?,
)
.context("Failed to write tauri.conf.json")?;
Ok(())
}
fn build_project(project_dir: &Path) -> Result<PathBuf> {
// 1. Check if tauri-cli is installed
let tauri_check = Command::new("cargo")
.arg("tauri")
.arg("--version")
.output()
.context("Failed to execute `cargo`. Make sure rust/cargo is installed.")?;
if !tauri_check.status.success() {
return Err(anyhow!(
"Tauri CLI is not installed or not working.\nPlease install it by running:\n cargo install tauri-cli --version \"^2.0.0\""
));
}
println!("Building native app (this may take a few minutes the first time)...");
let status = Command::new("cargo")
.arg("tauri")
.arg("build")
.current_dir(project_dir)
.status()
.context("Failed to execute `cargo tauri build`")?;
if !status.success() {
return Err(anyhow!("Tauri build completed with an error"));
}
let bin_path = project_dir.join("target/release/deskify-app");
if bin_path.exists() {
println!("Successfully built binary at {:?}", bin_path);
Ok(bin_path)
} else {
Err(anyhow!(
"Binary not found after build (expected at {:?})",
bin_path
))
}
}
fn install_app(name: &str, bin_path: &Path, project_dir: &Path) -> Result<()> {
let safe_name = sanitize_app_id(name);
let base_dirs =
BaseDirs::new().ok_or_else(|| anyhow!("Could not find system BaseDirs (e.g. $HOME)"))?;
let data_local_dir = base_dirs.data_local_dir(); // Usually ~/.local/share
let executable_dir = base_dirs
.executable_dir()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| {
// Fallback for some systems where executable_dir is none
base_dirs.home_dir().join(".local/bin")
});
// 1. Move binary
fs::create_dir_all(&executable_dir).context("Failed to create ~/.local/bin directory")?;
let target_bin = executable_dir.join(&safe_name);
fs::copy(bin_path, &target_bin).context("Failed to move binary to installation directory")?;
// 2. Install Icon
let icon_dir = data_local_dir.join("icons/hicolor/128x128/apps");
fs::create_dir_all(&icon_dir).context("Failed to create icons directory")?;
let target_icon = icon_dir.join(format!("{}.png", safe_name));
let source_icon = project_dir.join("icons/icon.png");
if source_icon.exists() {
let _ = fs::copy(&source_icon, &target_icon);
}
// 3. Create .desktop file
let applications_dir = data_local_dir.join("applications");
fs::create_dir_all(&applications_dir).context("Failed to create applications directory")?;
let desktop_entry = format!(
r#"
[Desktop Entry]
Version=1.0
Name={}
Exec={}
Icon={}
StartupWMClass={}
Terminal=false
Type=Application
Categories=Network;WebBrowser;
X-Deskify-Managed=true
"#,
name,
target_bin.to_string_lossy(),
safe_name, // Icon name
safe_name // Window class for wayland/x11 proper grouping
);
let desktop_file_path = applications_dir.join(format!("{}.desktop", safe_name));
fs::write(&desktop_file_path, desktop_entry.trim()).context("Failed to write .desktop file")?;
println!("App successfully installed!");
println!("You can now launch '{}' from your application menu.", name);
Ok(())
}
fn list_apps() -> 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");
println!("Installed Deskify Apps:");
let mut found = false;
if applications_dir.exists() {
for entry in
fs::read_dir(applications_dir).context("Failed to read applications directory")?
{
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("desktop")
&& let Ok(content) = fs::read_to_string(&path)
&& is_deskify_desktop_entry(&content)
{
let name = content
.lines()
.find(|l| l.starts_with("Name="))
.and_then(|l| l.strip_prefix("Name="))
.unwrap_or("Unknown");
let exec = content
.lines()
.find(|l| l.starts_with("Exec="))
.and_then(|l| l.strip_prefix("Exec="))
.unwrap_or("Unknown");
let bin_name = Path::new(exec)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("Unknown");
println!("- {} (Internal ID: {})", name, bin_name);
found = true;
}
}
}
if !found {
println!("No apps found.");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{is_deskify_desktop_entry, sanitize_app_id, validate_remove_id};
#[test]
fn sanitize_app_id_replaces_spaces_and_strips_symbols() {
assert_eq!(sanitize_app_id("Chat GPT!"), "chat-gpt");
}
#[test]
fn sanitize_app_id_falls_back_to_app_for_empty_result() {
assert_eq!(sanitize_app_id("!!!"), "app");
}
#[test]
fn validate_remove_id_accepts_safe_id() {
assert!(validate_remove_id("chatgpt").is_ok());
}
#[test]
fn validate_remove_id_rejects_path_traversal() {
assert!(validate_remove_id("../x").is_err());
}
#[test]
fn desktop_entry_detection_accepts_marker() {
let content = "[Desktop Entry]\nName=ChatGPT\nX-Deskify-Managed=true\n";
assert!(is_deskify_desktop_entry(content));
}
#[test]
fn desktop_entry_detection_accepts_legacy_entries() {
let content =
"[Desktop Entry]\nExec=/home/user/.local/bin/chatgpt\nCategories=Network;WebBrowser;\n";
assert!(is_deskify_desktop_entry(content));
}
#[test]
fn desktop_entry_detection_rejects_unrelated_entries() {
let content = "[Desktop Entry]\nExec=/usr/bin/firefox\nCategories=Network;WebBrowser;\n";
assert!(!is_deskify_desktop_entry(content));
}
}
fn remove_app(safe_name: &str) -> Result<()> {
validate_remove_id(safe_name)?;
let base_dirs = BaseDirs::new().ok_or_else(|| anyhow!("Could not find system BaseDirs"))?;
let executable_dir = base_dirs
.executable_dir()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| base_dirs.home_dir().join(".local/bin"));
let data_local_dir = base_dirs.data_local_dir();
// 1. Remove binary
let binary_path = executable_dir.join(safe_name);
if binary_path.exists() {
fs::remove_file(&binary_path).context("Failed to remove binary")?;
println!("Removed binary: {:?}", binary_path);
} else {
println!("Binary not found: {:?}", binary_path);
}
// 2. Remove .desktop file
let desktop_path = data_local_dir
.join("applications")
.join(format!("{}.desktop", safe_name));
if desktop_path.exists() {
fs::remove_file(&desktop_path).context("Failed to remove .desktop file")?;
println!("Removed desktop entry: {:?}", desktop_path);
} else {
println!("Desktop entry not found: {:?}", desktop_path);
}
// 3. Remove icon
let icon_path = data_local_dir
.join("icons/hicolor/128x128/apps")
.join(format!("{}.png", safe_name));
if icon_path.exists() {
fs::remove_file(&icon_path).context("Failed to remove icon")?;
println!("Removed icon: {:?}", icon_path);
} else {
println!("Icon not found: {:?}", icon_path);
}
println!("Successfully removed app '{}'.", safe_name);
Ok(())
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Build {
url,
name,
icon,
fullscreen,
user_agent,
width,
height,
dark_mode,
} => {
println!("Generating native app '{}' for URL: {}", name, url);
let dir = tempdir().context("Failed to create temporary directory for building")?;
println!("Scaffolding Tauri project in {:?}", dir.path());
generate_project(
&url,
&name,
icon.as_ref(),
fullscreen,
user_agent.as_ref(),
width,
height,
dark_mode,
dir.path(),
)?;
let bin_path = match build_project(dir.path()) {
Ok(path) => path,
Err(e) => {
let _ = dir.keep();
return Err(e.context("Project architecture failed to compile"));
}
};
install_app(&name, &bin_path, dir.path())?;
}
Commands::List => {
list_apps()?;
}
Commands::Remove { id } => {
remove_app(&id)?;
}
}
Ok(())
}