Files
inkwell/desktop/src-tauri/src/integration.rs
T
bvandeusenandClaude Opus 5.5 fe6f0746b2 rename: the desktop is Inkwell — crates, Tauri identity, data move, packaging
Step 3 of milestone 481 (Scribe note 5071: a full rename).

- crates thoughtsync-{core,desktop,ffi,uniffi-bindgen} → inkwell-*, the
  Cargo.lock entries moved to match (checked with `cargo metadata --locked`)
- Tauri: productName "Inkwell", identifier com.fabledsword.inkwell, binary
  `inkwell`, updater feed on bvandeusen/inkwell, store file inkwell.db
- client agent inkwell-desktop, headers X-Inkwell-Client/-Protocol (the server
  reads neither), capture event inkwell://captured, display-version env
- .deb: conflicts + replaces thought-sync, so the updater's install retires the
  old package instead of colliding on it. kebab-case("Inkwell") is `inkwell`, so
  the package name finally matches the command and verify.sh now asserts it
- pacman: inkwell, conflicting with and replacing thoughtsync and
  thoughtsync-desktop
- AppImage ~/Applications/Inkwell.AppImage, menu entry inkwell.desktop,
  installer, release titles, desktop asset names in fetch-clients.sh

The one shim, chosen by the operator because it is the only copy of a
local-first user's notes: crossover.rs moves the old
com.fabledsword.thoughtsync app-data dir's contents into the new one on startup,
before the store opens, renaming thoughtsync.db and its -wal/-shm with it. It
skips when the new dir already has a store, and anything already in the new dir
wins (the installer writes its channel marker there first). Tested.

Android's Kotlin side (package, applicationId, uniffi class) is step 4. Its
release asset names stay thoughtsync.* until then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:27:26 -04:00

162 lines
5.9 KiB
Rust

//! Linux AppImage desktop integration.
//!
//! A bare AppImage is a portable file with no menu entry — deleting it uninstalls
//! the app. These commands let Inkwell install itself into the application menu
//! (a `.desktop` launcher + icon) and keep a stable copy in `~/Applications`, so it
//! behaves like an installed app, and remove that again. No external helper
//! (AppImageLauncher / Gear Lever) is required.
//!
//! Everything keys off the `$APPIMAGE` env var (set only when running from an
//! AppImage), so on a non-AppImage run — the dev build, a `.deb` install, or a
//! future Windows/macOS build — the status simply reports `is_appimage: false` and
//! the actions are never offered by the frontend.
use std::fs;
use std::path::{Path, PathBuf};
use serde::Serialize;
// The 1024px brand icon, embedded so integration never depends on the AppImage's
// internal layout. Written to the user icon dir on integrate.
const APP_ICON: &[u8] = include_bytes!("../app-icon.png");
const DESKTOP_ENTRY_NAME: &str = "inkwell.desktop";
const ICON_FILE_NAME: &str = "inkwell.png";
const INSTALLED_APPIMAGE_NAME: &str = "Inkwell.AppImage";
#[derive(Serialize)]
pub struct IntegrationStatus {
/// Running from an AppImage (`$APPIMAGE` is set).
is_appimage: bool,
/// Our `.desktop` launcher is present in the user applications dir.
is_integrated: bool,
/// Path of the currently-running AppImage, if any.
appimage_path: Option<String>,
}
fn home() -> Option<PathBuf> {
std::env::var_os("HOME").map(PathBuf::from)
}
fn appimage_path() -> Option<PathBuf> {
std::env::var_os("APPIMAGE").map(PathBuf::from)
}
fn desktop_entry_path() -> Option<PathBuf> {
home().map(|h| h.join(".local/share/applications").join(DESKTOP_ENTRY_NAME))
}
fn icon_path() -> Option<PathBuf> {
home().map(|h| h.join(".local/share/icons").join(ICON_FILE_NAME))
}
#[tauri::command]
pub fn integration_status() -> IntegrationStatus {
let ai = appimage_path();
let is_integrated = desktop_entry_path().map(|p| p.exists()).unwrap_or(false);
IntegrationStatus {
is_appimage: ai.is_some(),
is_integrated,
appimage_path: ai.map(|p| p.to_string_lossy().into_owned()),
}
}
#[tauri::command]
pub fn integrate_desktop() -> Result<IntegrationStatus, String> {
let current = appimage_path().ok_or("Not running as an AppImage.")?;
let home = home().ok_or("Cannot resolve the home directory.")?;
// Give the AppImage a stable home so it can't be lost from Downloads. Copy (not
// move): the running file is memory-mapped, so a copy is always safe.
let apps_dir = home.join("Applications");
fs::create_dir_all(&apps_dir).map_err(|e| format!("create ~/Applications: {e}"))?;
let installed = apps_dir.join(INSTALLED_APPIMAGE_NAME);
if current != installed {
fs::copy(&current, &installed).map_err(|e| format!("copy AppImage: {e}"))?;
set_executable(&installed)?;
}
// Icon (embedded) -> user icon dir.
let icon = icon_path().ok_or("Cannot resolve the icon path.")?;
if let Some(parent) = icon.parent() {
fs::create_dir_all(parent).map_err(|e| format!("create icon dir: {e}"))?;
}
fs::write(&icon, APP_ICON).map_err(|e| format!("write icon: {e}"))?;
// .desktop launcher -> user applications dir. Icon/Exec are absolute paths, so no
// icon-theme lookup is needed.
//
// StartupWMClass is the exception: it must be the BINARY name, because GTK
// derives the window's WM_CLASS from the executable and the desktop matches the
// two to group the taskbar icon. Not the product name, and not this AppImage's
// filename — AppRun execs `usr/bin/inkwell` inside it (issue 2075).
let entry = desktop_entry_path().ok_or("Cannot resolve the applications dir.")?;
if let Some(parent) = entry.parent() {
fs::create_dir_all(parent).map_err(|e| format!("create applications dir: {e}"))?;
}
let contents = format!(
"[Desktop Entry]\n\
Type=Application\n\
Name=Inkwell\n\
Comment=Capture a fleeting thought in a second\n\
Exec={exec} %U\n\
Icon={icon}\n\
Terminal=false\n\
Categories=Utility;Office;\n\
StartupWMClass=inkwell\n",
exec = installed.to_string_lossy(),
icon = icon.to_string_lossy(),
);
fs::write(&entry, contents).map_err(|e| format!("write .desktop: {e}"))?;
refresh_desktop_database(&home);
Ok(integration_status())
}
#[tauri::command]
pub fn unintegrate_desktop() -> Result<IntegrationStatus, String> {
let home = home().ok_or("Cannot resolve the home directory.")?;
if let Some(entry) = desktop_entry_path() {
remove_if_exists(&entry)?;
}
if let Some(icon) = icon_path() {
remove_if_exists(&icon)?;
}
// Intentionally keep ~/Applications/Inkwell.AppImage: it may be the running
// binary, and removing it would delete the app the user is using. Un-integrate
// only removes the menu entry.
refresh_desktop_database(&home);
Ok(integration_status())
}
fn remove_if_exists(p: &Path) -> Result<(), String> {
if p.exists() {
fs::remove_file(p).map_err(|e| format!("remove {}: {e}", p.display()))?;
}
Ok(())
}
#[cfg(unix)]
fn set_executable(p: &Path) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
let mut perm = fs::metadata(p)
.map_err(|e| format!("stat: {e}"))?
.permissions();
perm.set_mode(0o755);
fs::set_permissions(p, perm).map_err(|e| format!("chmod: {e}"))
}
#[cfg(not(unix))]
fn set_executable(_p: &Path) -> Result<(), String> {
Ok(())
}
// Best-effort: refresh the desktop database so the entry appears promptly. The entry
// still works on next login without it, so failures are ignored.
fn refresh_desktop_database(home: &Path) {
let apps = home.join(".local/share/applications");
let _ = std::process::Command::new("update-desktop-database")
.arg(&apps)
.output();
}