Implement Driftwood AppImage manager - Phases 1 and 2
Phase 1 - Application scaffolding: - GTK4/libadwaita application window with AdwNavigationView - GSettings-backed window state persistence - GResource-compiled CSS and schema - Library view with grid/list toggle, search, sorting, filtering - Detail view with file info, desktop integration controls - Preferences window with scan directories, theme, behavior settings - CLI with list, scan, integrate, remove, clean, inspect commands - AppImage discovery, metadata extraction, desktop integration - Orphaned desktop entry detection and cleanup - AppImage packaging script Phase 2 - Intelligence layer: - Database schema v2 with migration for status tracking columns - FUSE detection engine (libfuse2/3, fusermount, /dev/fuse, AppImageLauncher) - Wayland awareness engine (session type, toolkit detection, XWayland) - Update info parsing from AppImage ELF sections (.upd_info) - GitHub/GitLab Releases API integration for update checking - Update download with progress tracking and atomic apply - Launch wrapper with FUSE auto-detection and usage tracking - Duplicate and multi-version detection with recommendations - Dashboard with system health, library stats, disk usage - Update check dialog (single and batch) - Duplicate resolution dialog - Status badges on library cards and detail view - Extended CLI: status, check-updates, duplicates, launch commands 49 tests passing across all modules.
This commit is contained in:
777
src/core/database.rs
Normal file
777
src/core/database.rs
Normal file
@@ -0,0 +1,777 @@
|
||||
use rusqlite::{params, Connection, Result as SqlResult};
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct Database {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppImageRecord {
|
||||
pub id: i64,
|
||||
pub path: String,
|
||||
pub filename: String,
|
||||
pub app_name: Option<String>,
|
||||
pub app_version: Option<String>,
|
||||
pub appimage_type: Option<i32>,
|
||||
pub size_bytes: i64,
|
||||
pub sha256: Option<String>,
|
||||
pub icon_path: Option<String>,
|
||||
pub desktop_file: Option<String>,
|
||||
pub integrated: bool,
|
||||
pub integrated_at: Option<String>,
|
||||
pub is_executable: bool,
|
||||
pub desktop_entry_content: Option<String>,
|
||||
pub categories: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub developer: Option<String>,
|
||||
pub architecture: Option<String>,
|
||||
pub first_seen: String,
|
||||
pub last_scanned: String,
|
||||
pub file_modified: Option<String>,
|
||||
// Phase 2 fields
|
||||
pub fuse_status: Option<String>,
|
||||
pub wayland_status: Option<String>,
|
||||
pub update_info: Option<String>,
|
||||
pub update_type: Option<String>,
|
||||
pub latest_version: Option<String>,
|
||||
pub update_checked: Option<String>,
|
||||
pub update_url: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OrphanedEntry {
|
||||
pub id: i64,
|
||||
pub desktop_file: String,
|
||||
pub original_path: Option<String>,
|
||||
pub app_name: Option<String>,
|
||||
pub detected_at: String,
|
||||
pub cleaned: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LaunchEvent {
|
||||
pub id: i64,
|
||||
pub appimage_id: i64,
|
||||
pub launched_at: String,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UpdateHistoryEntry {
|
||||
pub id: i64,
|
||||
pub appimage_id: i64,
|
||||
pub from_version: Option<String>,
|
||||
pub to_version: Option<String>,
|
||||
pub update_method: Option<String>,
|
||||
pub download_size: Option<i64>,
|
||||
pub updated_at: String,
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
fn db_path() -> PathBuf {
|
||||
let data_dir = dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
||||
.join("driftwood");
|
||||
std::fs::create_dir_all(&data_dir).ok();
|
||||
data_dir.join("driftwood.db")
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub fn open() -> SqlResult<Self> {
|
||||
let path = db_path();
|
||||
let conn = Connection::open(&path)?;
|
||||
let db = Self { conn };
|
||||
db.init_schema()?;
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
pub fn open_in_memory() -> SqlResult<Self> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
let db = Self { conn };
|
||||
db.init_schema()?;
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
fn init_schema(&self) -> SqlResult<()> {
|
||||
// Phase 1 base tables
|
||||
self.conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS appimages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
filename TEXT NOT NULL,
|
||||
app_name TEXT,
|
||||
app_version TEXT,
|
||||
appimage_type INTEGER,
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
sha256 TEXT,
|
||||
icon_path TEXT,
|
||||
desktop_file TEXT,
|
||||
integrated INTEGER NOT NULL DEFAULT 0,
|
||||
integrated_at TEXT,
|
||||
is_executable INTEGER NOT NULL DEFAULT 0,
|
||||
desktop_entry_content TEXT,
|
||||
categories TEXT,
|
||||
description TEXT,
|
||||
developer TEXT,
|
||||
architecture TEXT,
|
||||
first_seen TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_scanned TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
file_modified TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orphaned_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
desktop_file TEXT NOT NULL,
|
||||
original_path TEXT,
|
||||
app_name TEXT,
|
||||
detected_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
cleaned INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scan_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scan_type TEXT NOT NULL,
|
||||
directories TEXT,
|
||||
found INTEGER NOT NULL DEFAULT 0,
|
||||
new_count INTEGER NOT NULL DEFAULT 0,
|
||||
removed INTEGER NOT NULL DEFAULT 0,
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
scanned_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);"
|
||||
)?;
|
||||
|
||||
// Check current schema version and migrate
|
||||
let count: i32 = self.conn.query_row(
|
||||
"SELECT COUNT(*) FROM schema_version",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
|
||||
let current_version = if count == 0 {
|
||||
self.conn.execute(
|
||||
"INSERT INTO schema_version (version) VALUES (?1)",
|
||||
params![1],
|
||||
)?;
|
||||
1
|
||||
} else {
|
||||
self.conn.query_row(
|
||||
"SELECT version FROM schema_version LIMIT 1",
|
||||
[],
|
||||
|row| row.get::<_, i32>(0),
|
||||
)?
|
||||
};
|
||||
|
||||
if current_version < 2 {
|
||||
self.migrate_to_v2()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_to_v2(&self) -> SqlResult<()> {
|
||||
// Add Phase 2 columns to appimages table
|
||||
let phase2_columns = [
|
||||
"fuse_status TEXT",
|
||||
"wayland_status TEXT",
|
||||
"update_info TEXT",
|
||||
"update_type TEXT",
|
||||
"latest_version TEXT",
|
||||
"update_checked TEXT",
|
||||
"update_url TEXT",
|
||||
"notes TEXT",
|
||||
];
|
||||
for col in &phase2_columns {
|
||||
let sql = format!("ALTER TABLE appimages ADD COLUMN {}", col);
|
||||
// Ignore errors from columns that already exist
|
||||
self.conn.execute_batch(&sql).ok();
|
||||
}
|
||||
|
||||
// Phase 2 tables
|
||||
self.conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS launch_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
appimage_id INTEGER REFERENCES appimages(id) ON DELETE CASCADE,
|
||||
launched_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
source TEXT NOT NULL DEFAULT 'desktop_entry'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS update_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
appimage_id INTEGER REFERENCES appimages(id) ON DELETE CASCADE,
|
||||
from_version TEXT,
|
||||
to_version TEXT,
|
||||
update_method TEXT,
|
||||
download_size INTEGER,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
success INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS duplicate_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
canonical_name TEXT NOT NULL,
|
||||
duplicate_type TEXT,
|
||||
detected_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS duplicate_members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
group_id INTEGER REFERENCES duplicate_groups(id) ON DELETE CASCADE,
|
||||
appimage_id INTEGER REFERENCES appimages(id) ON DELETE CASCADE,
|
||||
is_recommended INTEGER NOT NULL DEFAULT 0
|
||||
);"
|
||||
)?;
|
||||
|
||||
// Update schema version
|
||||
self.conn.execute(
|
||||
"UPDATE schema_version SET version = ?1",
|
||||
params![2],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn upsert_appimage(
|
||||
&self,
|
||||
path: &str,
|
||||
filename: &str,
|
||||
appimage_type: Option<i32>,
|
||||
size_bytes: i64,
|
||||
is_executable: bool,
|
||||
file_modified: Option<&str>,
|
||||
) -> SqlResult<i64> {
|
||||
self.conn.execute(
|
||||
"INSERT INTO appimages (path, filename, appimage_type, size_bytes, is_executable, file_modified)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
ON CONFLICT(path) DO UPDATE SET
|
||||
filename = excluded.filename,
|
||||
appimage_type = excluded.appimage_type,
|
||||
size_bytes = excluded.size_bytes,
|
||||
is_executable = excluded.is_executable,
|
||||
file_modified = excluded.file_modified,
|
||||
last_scanned = datetime('now')",
|
||||
params![path, filename, appimage_type, size_bytes, is_executable, file_modified],
|
||||
)?;
|
||||
Ok(self.conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
pub fn update_metadata(
|
||||
&self,
|
||||
id: i64,
|
||||
app_name: Option<&str>,
|
||||
app_version: Option<&str>,
|
||||
description: Option<&str>,
|
||||
developer: Option<&str>,
|
||||
categories: Option<&str>,
|
||||
architecture: Option<&str>,
|
||||
icon_path: Option<&str>,
|
||||
desktop_entry_content: Option<&str>,
|
||||
) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE appimages SET
|
||||
app_name = ?2,
|
||||
app_version = ?3,
|
||||
description = ?4,
|
||||
developer = ?5,
|
||||
categories = ?6,
|
||||
architecture = ?7,
|
||||
icon_path = ?8,
|
||||
desktop_entry_content = ?9
|
||||
WHERE id = ?1",
|
||||
params![
|
||||
id, app_name, app_version, description, developer,
|
||||
categories, architecture, icon_path, desktop_entry_content,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_sha256(&self, id: i64, sha256: &str) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE appimages SET sha256 = ?2 WHERE id = ?1",
|
||||
params![id, sha256],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_integrated(
|
||||
&self,
|
||||
id: i64,
|
||||
integrated: bool,
|
||||
desktop_file: Option<&str>,
|
||||
) -> SqlResult<()> {
|
||||
let integrated_at = if integrated {
|
||||
Some(chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.conn.execute(
|
||||
"UPDATE appimages SET integrated = ?2, desktop_file = ?3, integrated_at = ?4 WHERE id = ?1",
|
||||
params![id, integrated, desktop_file, integrated_at],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const APPIMAGE_COLUMNS: &str =
|
||||
"id, path, filename, app_name, app_version, appimage_type,
|
||||
size_bytes, sha256, icon_path, desktop_file, integrated,
|
||||
integrated_at, is_executable, desktop_entry_content,
|
||||
categories, description, developer, architecture,
|
||||
first_seen, last_scanned, file_modified,
|
||||
fuse_status, wayland_status, update_info, update_type,
|
||||
latest_version, update_checked, update_url, notes";
|
||||
|
||||
fn row_to_record(row: &rusqlite::Row) -> rusqlite::Result<AppImageRecord> {
|
||||
Ok(AppImageRecord {
|
||||
id: row.get(0)?,
|
||||
path: row.get(1)?,
|
||||
filename: row.get(2)?,
|
||||
app_name: row.get(3)?,
|
||||
app_version: row.get(4)?,
|
||||
appimage_type: row.get(5)?,
|
||||
size_bytes: row.get(6)?,
|
||||
sha256: row.get(7)?,
|
||||
icon_path: row.get(8)?,
|
||||
desktop_file: row.get(9)?,
|
||||
integrated: row.get(10)?,
|
||||
integrated_at: row.get(11)?,
|
||||
is_executable: row.get(12)?,
|
||||
desktop_entry_content: row.get(13)?,
|
||||
categories: row.get(14)?,
|
||||
description: row.get(15)?,
|
||||
developer: row.get(16)?,
|
||||
architecture: row.get(17)?,
|
||||
first_seen: row.get(18)?,
|
||||
last_scanned: row.get(19)?,
|
||||
file_modified: row.get(20)?,
|
||||
fuse_status: row.get(21)?,
|
||||
wayland_status: row.get(22)?,
|
||||
update_info: row.get(23)?,
|
||||
update_type: row.get(24)?,
|
||||
latest_version: row.get(25)?,
|
||||
update_checked: row.get(26)?,
|
||||
update_url: row.get(27)?,
|
||||
notes: row.get(28)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_all_appimages(&self) -> SqlResult<Vec<AppImageRecord>> {
|
||||
let sql = format!(
|
||||
"SELECT {} FROM appimages ORDER BY app_name COLLATE NOCASE, filename",
|
||||
Self::APPIMAGE_COLUMNS
|
||||
);
|
||||
let mut stmt = self.conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map([], Self::row_to_record)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn get_appimage_by_id(&self, id: i64) -> SqlResult<Option<AppImageRecord>> {
|
||||
let sql = format!(
|
||||
"SELECT {} FROM appimages WHERE id = ?1",
|
||||
Self::APPIMAGE_COLUMNS
|
||||
);
|
||||
let mut stmt = self.conn.prepare(&sql)?;
|
||||
let mut rows = stmt.query_map(params![id], Self::row_to_record)?;
|
||||
Ok(rows.next().transpose()?)
|
||||
}
|
||||
|
||||
pub fn get_appimage_by_path(&self, path: &str) -> SqlResult<Option<AppImageRecord>> {
|
||||
let sql = format!(
|
||||
"SELECT {} FROM appimages WHERE path = ?1",
|
||||
Self::APPIMAGE_COLUMNS
|
||||
);
|
||||
let mut stmt = self.conn.prepare(&sql)?;
|
||||
let mut rows = stmt.query_map(params![path], Self::row_to_record)?;
|
||||
Ok(rows.next().transpose()?)
|
||||
}
|
||||
|
||||
pub fn remove_appimage(&self, id: i64) -> SqlResult<()> {
|
||||
self.conn.execute("DELETE FROM appimages WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_missing_appimages(&self) -> SqlResult<Vec<AppImageRecord>> {
|
||||
let all = self.get_all_appimages()?;
|
||||
let mut removed = Vec::new();
|
||||
for record in all {
|
||||
if !std::path::Path::new(&record.path).exists() {
|
||||
self.remove_appimage(record.id)?;
|
||||
removed.push(record);
|
||||
}
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
pub fn add_orphaned_entry(
|
||||
&self,
|
||||
desktop_file: &str,
|
||||
original_path: Option<&str>,
|
||||
app_name: Option<&str>,
|
||||
) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"INSERT INTO orphaned_entries (desktop_file, original_path, app_name) VALUES (?1, ?2, ?3)",
|
||||
params![desktop_file, original_path, app_name],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_orphaned_entries(&self) -> SqlResult<Vec<OrphanedEntry>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, desktop_file, original_path, app_name, detected_at, cleaned
|
||||
FROM orphaned_entries WHERE cleaned = 0"
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(OrphanedEntry {
|
||||
id: row.get(0)?,
|
||||
desktop_file: row.get(1)?,
|
||||
original_path: row.get(2)?,
|
||||
app_name: row.get(3)?,
|
||||
detected_at: row.get(4)?,
|
||||
cleaned: row.get(5)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn mark_orphan_cleaned(&self, id: i64) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE orphaned_entries SET cleaned = 1 WHERE id = ?1",
|
||||
params![id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn log_scan(
|
||||
&self,
|
||||
scan_type: &str,
|
||||
directories: &[String],
|
||||
found: i32,
|
||||
new_count: i32,
|
||||
removed: i32,
|
||||
duration_ms: i64,
|
||||
) -> SqlResult<()> {
|
||||
let dirs_joined = directories.join(";");
|
||||
self.conn.execute(
|
||||
"INSERT INTO scan_log (scan_type, directories, found, new_count, removed, duration_ms)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![scan_type, dirs_joined, found, new_count, removed, duration_ms],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn appimage_count(&self) -> SqlResult<i64> {
|
||||
self.conn.query_row("SELECT COUNT(*) FROM appimages", [], |row| row.get(0))
|
||||
}
|
||||
|
||||
// --- Phase 2: Status updates ---
|
||||
|
||||
pub fn update_fuse_status(&self, id: i64, status: &str) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE appimages SET fuse_status = ?2 WHERE id = ?1",
|
||||
params![id, status],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_wayland_status(&self, id: i64, status: &str) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE appimages SET wayland_status = ?2 WHERE id = ?1",
|
||||
params![id, status],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_update_info(
|
||||
&self,
|
||||
id: i64,
|
||||
update_info: Option<&str>,
|
||||
update_type: Option<&str>,
|
||||
) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE appimages SET update_info = ?2, update_type = ?3 WHERE id = ?1",
|
||||
params![id, update_info, update_type],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_update_available(
|
||||
&self,
|
||||
id: i64,
|
||||
latest_version: Option<&str>,
|
||||
update_url: Option<&str>,
|
||||
) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE appimages SET latest_version = ?2, update_url = ?3,
|
||||
update_checked = datetime('now') WHERE id = ?1",
|
||||
params![id, latest_version, update_url],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear_update_available(&self, id: i64) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE appimages SET latest_version = NULL, update_url = NULL,
|
||||
update_checked = datetime('now') WHERE id = ?1",
|
||||
params![id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_appimages_with_updates(&self) -> SqlResult<Vec<AppImageRecord>> {
|
||||
let sql = format!(
|
||||
"SELECT {} FROM appimages WHERE latest_version IS NOT NULL
|
||||
ORDER BY app_name COLLATE NOCASE, filename",
|
||||
Self::APPIMAGE_COLUMNS
|
||||
);
|
||||
let mut stmt = self.conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map([], Self::row_to_record)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
// --- Phase 2: Launch tracking ---
|
||||
|
||||
pub fn record_launch(&self, appimage_id: i64, source: &str) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"INSERT INTO launch_events (appimage_id, source) VALUES (?1, ?2)",
|
||||
params![appimage_id, source],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_launch_count(&self, appimage_id: i64) -> SqlResult<i64> {
|
||||
self.conn.query_row(
|
||||
"SELECT COUNT(*) FROM launch_events WHERE appimage_id = ?1",
|
||||
params![appimage_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_last_launched(&self, appimage_id: i64) -> SqlResult<Option<String>> {
|
||||
self.conn.query_row(
|
||||
"SELECT MAX(launched_at) FROM launch_events WHERE appimage_id = ?1",
|
||||
params![appimage_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_launch_events(&self, appimage_id: i64) -> SqlResult<Vec<LaunchEvent>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, appimage_id, launched_at, source
|
||||
FROM launch_events WHERE appimage_id = ?1
|
||||
ORDER BY launched_at DESC"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![appimage_id], |row| {
|
||||
Ok(LaunchEvent {
|
||||
id: row.get(0)?,
|
||||
appimage_id: row.get(1)?,
|
||||
launched_at: row.get(2)?,
|
||||
source: row.get(3)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
// --- Phase 2: Update history ---
|
||||
|
||||
pub fn record_update(
|
||||
&self,
|
||||
appimage_id: i64,
|
||||
from_version: Option<&str>,
|
||||
to_version: Option<&str>,
|
||||
update_method: Option<&str>,
|
||||
download_size: Option<i64>,
|
||||
success: bool,
|
||||
) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"INSERT INTO update_history
|
||||
(appimage_id, from_version, to_version, update_method, download_size, success)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![appimage_id, from_version, to_version, update_method, download_size, success],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_update_history(&self, appimage_id: i64) -> SqlResult<Vec<UpdateHistoryEntry>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, appimage_id, from_version, to_version, update_method,
|
||||
download_size, updated_at, success
|
||||
FROM update_history WHERE appimage_id = ?1
|
||||
ORDER BY updated_at DESC"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![appimage_id], |row| {
|
||||
Ok(UpdateHistoryEntry {
|
||||
id: row.get(0)?,
|
||||
appimage_id: row.get(1)?,
|
||||
from_version: row.get(2)?,
|
||||
to_version: row.get(3)?,
|
||||
update_method: row.get(4)?,
|
||||
download_size: row.get(5)?,
|
||||
updated_at: row.get(6)?,
|
||||
success: row.get(7)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_create_and_query() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
assert_eq!(db.appimage_count().unwrap(), 0);
|
||||
|
||||
db.upsert_appimage(
|
||||
"/home/user/Apps/test.AppImage",
|
||||
"test.AppImage",
|
||||
Some(2),
|
||||
1024000,
|
||||
true,
|
||||
None,
|
||||
).unwrap();
|
||||
|
||||
assert_eq!(db.appimage_count().unwrap(), 1);
|
||||
|
||||
let all = db.get_all_appimages().unwrap();
|
||||
assert_eq!(all.len(), 1);
|
||||
assert_eq!(all[0].filename, "test.AppImage");
|
||||
assert_eq!(all[0].size_bytes, 1024000);
|
||||
assert!(all[0].is_executable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_updates_existing() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.upsert_appimage("/path/test.AppImage", "test.AppImage", Some(2), 1000, true, None).unwrap();
|
||||
db.upsert_appimage("/path/test.AppImage", "test.AppImage", Some(2), 2000, true, None).unwrap();
|
||||
|
||||
assert_eq!(db.appimage_count().unwrap(), 1);
|
||||
let record = db.get_appimage_by_path("/path/test.AppImage").unwrap().unwrap();
|
||||
assert_eq!(record.size_bytes, 2000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metadata_update() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let id = db.upsert_appimage("/path/test.AppImage", "test.AppImage", Some(2), 1000, true, None).unwrap();
|
||||
|
||||
db.update_metadata(
|
||||
id,
|
||||
Some("Test App"),
|
||||
Some("1.0.0"),
|
||||
Some("A test application"),
|
||||
Some("Test Dev"),
|
||||
Some("Utility;Development"),
|
||||
Some("x86_64"),
|
||||
Some("/path/to/icon.png"),
|
||||
Some("[Desktop Entry]\nName=Test App"),
|
||||
).unwrap();
|
||||
|
||||
let record = db.get_appimage_by_id(id).unwrap().unwrap();
|
||||
assert_eq!(record.app_name.as_deref(), Some("Test App"));
|
||||
assert_eq!(record.app_version.as_deref(), Some("1.0.0"));
|
||||
assert_eq!(record.architecture.as_deref(), Some("x86_64"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_integration_toggle() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let id = db.upsert_appimage("/path/test.AppImage", "test.AppImage", Some(2), 1000, true, None).unwrap();
|
||||
|
||||
assert!(!db.get_appimage_by_id(id).unwrap().unwrap().integrated);
|
||||
|
||||
db.set_integrated(id, true, Some("/path/to/desktop")).unwrap();
|
||||
let record = db.get_appimage_by_id(id).unwrap().unwrap();
|
||||
assert!(record.integrated);
|
||||
assert!(record.integrated_at.is_some());
|
||||
|
||||
db.set_integrated(id, false, None).unwrap();
|
||||
assert!(!db.get_appimage_by_id(id).unwrap().unwrap().integrated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_orphaned_entries() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.add_orphaned_entry("/path/to/desktop", Some("/path/app.AppImage"), Some("App")).unwrap();
|
||||
|
||||
let orphans = db.get_orphaned_entries().unwrap();
|
||||
assert_eq!(orphans.len(), 1);
|
||||
assert_eq!(orphans[0].app_name.as_deref(), Some("App"));
|
||||
|
||||
db.mark_orphan_cleaned(orphans[0].id).unwrap();
|
||||
assert_eq!(db.get_orphaned_entries().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_log() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.log_scan("manual", &["~/Applications".into()], 5, 3, 0, 250).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_phase2_status_updates() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let id = db.upsert_appimage("/path/app.AppImage", "app.AppImage", Some(2), 1000, true, None).unwrap();
|
||||
|
||||
db.update_fuse_status(id, "fully_functional").unwrap();
|
||||
db.update_wayland_status(id, "native").unwrap();
|
||||
db.update_update_info(id, Some("gh-releases-zsync|user|repo|latest|*.zsync"), Some("github")).unwrap();
|
||||
db.set_update_available(id, Some("2.0.0"), Some("https://example.com/app-2.0.AppImage")).unwrap();
|
||||
|
||||
let record = db.get_appimage_by_id(id).unwrap().unwrap();
|
||||
assert_eq!(record.fuse_status.as_deref(), Some("fully_functional"));
|
||||
assert_eq!(record.wayland_status.as_deref(), Some("native"));
|
||||
assert_eq!(record.update_type.as_deref(), Some("github"));
|
||||
assert_eq!(record.latest_version.as_deref(), Some("2.0.0"));
|
||||
assert!(record.update_checked.is_some());
|
||||
|
||||
// Updates available query
|
||||
let with_updates = db.get_appimages_with_updates().unwrap();
|
||||
assert_eq!(with_updates.len(), 1);
|
||||
|
||||
db.clear_update_available(id).unwrap();
|
||||
let record = db.get_appimage_by_id(id).unwrap().unwrap();
|
||||
assert!(record.latest_version.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_launch_tracking() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let id = db.upsert_appimage("/path/app.AppImage", "app.AppImage", Some(2), 1000, true, None).unwrap();
|
||||
|
||||
assert_eq!(db.get_launch_count(id).unwrap(), 0);
|
||||
assert!(db.get_last_launched(id).unwrap().is_none());
|
||||
|
||||
db.record_launch(id, "desktop_entry").unwrap();
|
||||
db.record_launch(id, "cli").unwrap();
|
||||
|
||||
assert_eq!(db.get_launch_count(id).unwrap(), 2);
|
||||
assert!(db.get_last_launched(id).unwrap().is_some());
|
||||
|
||||
let events = db.get_launch_events(id).unwrap();
|
||||
assert_eq!(events.len(), 2);
|
||||
let sources: Vec<&str> = events.iter().map(|e| e.source.as_str()).collect();
|
||||
assert!(sources.contains(&"desktop_entry"));
|
||||
assert!(sources.contains(&"cli"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_history() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let id = db.upsert_appimage("/path/app.AppImage", "app.AppImage", Some(2), 1000, true, None).unwrap();
|
||||
|
||||
db.record_update(id, Some("1.0"), Some("2.0"), Some("full_download"), Some(50_000_000), true).unwrap();
|
||||
|
||||
let history = db.get_update_history(id).unwrap();
|
||||
assert_eq!(history.len(), 1);
|
||||
assert_eq!(history[0].from_version.as_deref(), Some("1.0"));
|
||||
assert_eq!(history[0].to_version.as_deref(), Some("2.0"));
|
||||
assert!(history[0].success);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user