Add Phase 5 enhancements: security, i18n, analysis, backup, notifications
- Database v8 migration: tags, pinned, avg_startup_ms columns - Security scanning with CVE matching and batch scan - Bundled library extraction and vulnerability reports - Desktop notification system for security alerts - Backup/restore system for AppImage configurations - i18n framework with gettext support - Runtime analysis and Wayland compatibility detection - AppStream metadata and Flatpak-style build support - File watcher module for live directory monitoring - Preferences panel with GSettings integration - CLI interface for headless operation - Detail view: tabbed layout with ViewSwitcher in title bar, health score, sandbox controls, changelog links - Library view: sort dropdown, context menu enhancements - Dashboard: system status, disk usage, launch history - Security report page with scan and export - Packaging: meson build, PKGBUILD, metainfo
This commit is contained in:
137
CONTRIBUTING.md
Normal file
137
CONTRIBUTING.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# Contributing to Driftwood
|
||||||
|
|
||||||
|
## Building from source
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- Rust 1.75+ and Cargo
|
||||||
|
- GTK 4.16+ development headers (`libgtk-4-dev` or `gtk4-devel`)
|
||||||
|
- libadwaita 1.6+ development headers (`libadwaita-1-dev` or `libadwaita-devel`)
|
||||||
|
- SQLite 3 development headers
|
||||||
|
- gettext development headers
|
||||||
|
- `glib-compile-resources` and `glib-compile-schemas` (from `libglib2.0-dev-bin`)
|
||||||
|
|
||||||
|
### Quick start
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://github.com/driftwood-app/driftwood
|
||||||
|
cd driftwood
|
||||||
|
cargo build
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running tests
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```
|
||||||
|
driftwood/
|
||||||
|
Cargo.toml # Rust package manifest
|
||||||
|
build.rs # GResource and GSettings compilation
|
||||||
|
meson.build # Meson build system for installation
|
||||||
|
|
||||||
|
src/
|
||||||
|
main.rs # Entry point, GResource init, CLI dispatch
|
||||||
|
application.rs # GtkApplication subclass, CSS loading, app actions
|
||||||
|
window.rs # Main window, navigation, scanning orchestration
|
||||||
|
config.rs # App ID and version constants
|
||||||
|
cli.rs # Command-line interface (clap)
|
||||||
|
i18n.rs # Internationalization (gettext wrappers)
|
||||||
|
|
||||||
|
core/ # Backend logic (no GTK dependencies)
|
||||||
|
database.rs # SQLite database (rusqlite), all queries
|
||||||
|
discovery.rs # Filesystem scanning, AppImage detection, SHA256
|
||||||
|
inspector.rs # AppImage metadata extraction (icon, desktop entry)
|
||||||
|
integrator.rs # Desktop integration (.desktop files, icons)
|
||||||
|
launcher.rs # AppImage launching with FUSE/sandbox support
|
||||||
|
updater.rs # Update checking and applying (GitHub, zsync)
|
||||||
|
fuse.rs # FUSE status detection
|
||||||
|
wayland.rs # Wayland compatibility analysis
|
||||||
|
security.rs # CVE scanning via OSV.dev API
|
||||||
|
duplicates.rs # Duplicate and multi-version detection
|
||||||
|
footprint.rs # Disk footprint analysis (config/data/cache)
|
||||||
|
orphan.rs # Orphaned desktop entry detection and cleanup
|
||||||
|
|
||||||
|
ui/ # GTK4/libadwaita UI components
|
||||||
|
library_view.rs # Main grid/list view of AppImages
|
||||||
|
app_card.rs # Individual AppImage card widget
|
||||||
|
detail_view.rs # Full detail page for a single AppImage
|
||||||
|
dashboard.rs # System health dashboard
|
||||||
|
preferences.rs # Preferences dialog
|
||||||
|
update_dialog.rs # Update check and apply dialog
|
||||||
|
duplicate_dialog.rs # Duplicate resolution dialog
|
||||||
|
cleanup_wizard.rs # Disk space reclamation wizard
|
||||||
|
security_report.rs # Security scan results view
|
||||||
|
integration_dialog.rs # Desktop integration confirmation
|
||||||
|
widgets.rs # Shared utility widgets (badges, sections)
|
||||||
|
|
||||||
|
data/
|
||||||
|
app.driftwood.Driftwood.gschema.xml # GSettings schema
|
||||||
|
app.driftwood.Driftwood.desktop # Desktop entry for Driftwood itself
|
||||||
|
app.driftwood.Driftwood.metainfo.xml # AppStream metadata
|
||||||
|
resources.gresource.xml # GResource manifest
|
||||||
|
resources/style.css # Application CSS
|
||||||
|
|
||||||
|
po/ # Translation files
|
||||||
|
POTFILES.in # Files with translatable strings
|
||||||
|
LINGUAS # Available translations
|
||||||
|
|
||||||
|
build-aux/ # Build helpers
|
||||||
|
app.driftwood.Driftwood.json # Flatpak manifest
|
||||||
|
build-appimage.sh # AppImage build script
|
||||||
|
|
||||||
|
packaging/
|
||||||
|
PKGBUILD # Arch Linux AUR package
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The codebase is split into three layers:
|
||||||
|
|
||||||
|
1. **core/** - Pure Rust business logic. No GTK dependencies. Can be tested
|
||||||
|
independently. Each module handles one concern.
|
||||||
|
|
||||||
|
2. **ui/** - GTK4/libadwaita widgets. Each view is a function that builds a
|
||||||
|
widget tree. Uses `Rc<Database>` for shared database access.
|
||||||
|
|
||||||
|
3. **window.rs / application.rs** - Orchestration layer. Connects UI to core,
|
||||||
|
handles navigation, spawns background threads for scanning.
|
||||||
|
|
||||||
|
Background work (scanning, update checks, security scans) runs on
|
||||||
|
`gio::spawn_blocking` threads. Results are sent back to the main thread
|
||||||
|
via `glib::spawn_future_local`.
|
||||||
|
|
||||||
|
## Coding conventions
|
||||||
|
|
||||||
|
- Follow standard Rust formatting (`cargo fmt`)
|
||||||
|
- All new code must compile with zero warnings
|
||||||
|
- Add tests for core/ modules (81+ tests currently)
|
||||||
|
- Use `log::info!`, `log::warn!`, `log::error!` for diagnostics
|
||||||
|
- User-facing strings should be wrapped in `i18n()` for translation
|
||||||
|
- Use `adw::` widgets over raw `gtk::` when an Adwaita equivalent exists
|
||||||
|
- Status badges use CSS classes: `badge-success`, `badge-warning`, `badge-error`
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
SQLite database stored at `~/.local/share/driftwood/driftwood.db`. Schema
|
||||||
|
migrates automatically (v1 through v4). All queries are in `core/database.rs`.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Run all tests
|
||||||
|
cargo test
|
||||||
|
|
||||||
|
# Run tests for a specific module
|
||||||
|
cargo test core::database
|
||||||
|
cargo test core::updater
|
||||||
|
|
||||||
|
# Run with output
|
||||||
|
cargo test -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests use `Database::open_in_memory()` for isolation.
|
||||||
864
Cargo.lock
generated
864
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -42,5 +42,11 @@ env_logger = "0.11"
|
|||||||
# Temp directories (for AppImage extraction)
|
# Temp directories (for AppImage extraction)
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|
||||||
|
# Desktop notifications
|
||||||
|
notify-rust = "4"
|
||||||
|
|
||||||
|
# File system watching (inotify)
|
||||||
|
notify = "7"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
glib-build-tools = "0.22"
|
glib-build-tools = "0.22"
|
||||||
|
|||||||
100
README.md
Normal file
100
README.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# Driftwood
|
||||||
|
|
||||||
|
A modern GTK4/libadwaita AppImage manager for GNOME desktops.
|
||||||
|
|
||||||
|
Driftwood discovers, inspects, integrates, updates, and audits AppImage files
|
||||||
|
with a clean GNOME-native interface built for the Wayland era.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Library management** - Scan directories to discover AppImages, view them in
|
||||||
|
grid or list mode with status badges for FUSE, Wayland, and update status
|
||||||
|
- **Desktop integration** - Create .desktop files and install icons with one click
|
||||||
|
- **FUSE and Wayland detection** - Automatically detect compatibility and suggest
|
||||||
|
launch methods (direct, extract-and-run, or sandboxed)
|
||||||
|
- **Update checking** - Read embedded update information (GitHub Releases, GitLab,
|
||||||
|
zsync) and check for newer versions
|
||||||
|
- **Security scanning** - Extract bundled shared libraries and check them against
|
||||||
|
the OSV.dev vulnerability database
|
||||||
|
- **Duplicate detection** - Find AppImages that are different versions of the same
|
||||||
|
app or identical files in different locations
|
||||||
|
- **Disk footprint analysis** - Discover config, data, and cache files associated
|
||||||
|
with each AppImage
|
||||||
|
- **Sandboxing** - Optional Firejail sandbox support per-app
|
||||||
|
- **Orphan cleanup** - Detect and remove .desktop files for AppImages that no
|
||||||
|
longer exist
|
||||||
|
- **CLI interface** - Full command-line access to all core features
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- GTK 4.16+
|
||||||
|
- libadwaita 1.6+
|
||||||
|
- SQLite 3
|
||||||
|
- gettext
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
- firejail (for sandboxed launches)
|
||||||
|
- fuse2/fuse3 (for AppImage FUSE mounting)
|
||||||
|
- appimageupdate (for delta updates)
|
||||||
|
|
||||||
|
## Building from source
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Development build (uses cargo directly)
|
||||||
|
cargo build
|
||||||
|
cargo run
|
||||||
|
|
||||||
|
# System installation (uses meson)
|
||||||
|
meson setup build --prefix=/usr
|
||||||
|
meson compile -C build
|
||||||
|
sudo meson install -C build
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI usage
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Scan configured directories for AppImages
|
||||||
|
driftwood scan
|
||||||
|
|
||||||
|
# List all known AppImages
|
||||||
|
driftwood list
|
||||||
|
driftwood list --format json
|
||||||
|
|
||||||
|
# Inspect a specific AppImage
|
||||||
|
driftwood inspect ~/Applications/Firefox.AppImage
|
||||||
|
|
||||||
|
# Integrate into desktop menu
|
||||||
|
driftwood integrate ~/Applications/Firefox.AppImage
|
||||||
|
|
||||||
|
# Check for updates
|
||||||
|
driftwood check-updates
|
||||||
|
|
||||||
|
# Run a security scan
|
||||||
|
driftwood security
|
||||||
|
driftwood security ~/Applications/Firefox.AppImage
|
||||||
|
|
||||||
|
# Launch with tracking
|
||||||
|
driftwood launch ~/Applications/Firefox.AppImage
|
||||||
|
driftwood launch --sandbox ~/Applications/Firefox.AppImage
|
||||||
|
|
||||||
|
# Find duplicates
|
||||||
|
driftwood duplicates
|
||||||
|
|
||||||
|
# Show disk footprint
|
||||||
|
driftwood footprint ~/Applications/Firefox.AppImage
|
||||||
|
|
||||||
|
# System status
|
||||||
|
driftwood status
|
||||||
|
|
||||||
|
# Clean orphaned entries
|
||||||
|
driftwood clean-orphans
|
||||||
|
```
|
||||||
|
|
||||||
|
## Packaging
|
||||||
|
|
||||||
|
- **Flatpak**: See `build-aux/app.driftwood.Driftwood.json`
|
||||||
|
- **Arch Linux (AUR)**: See `packaging/PKGBUILD`
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
GPL-3.0-or-later
|
||||||
43
build-aux/app.driftwood.Driftwood.json
Normal file
43
build-aux/app.driftwood.Driftwood.json
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"app-id": "app.driftwood.Driftwood",
|
||||||
|
"runtime": "org.gnome.Platform",
|
||||||
|
"runtime-version": "48",
|
||||||
|
"sdk": "org.gnome.Sdk",
|
||||||
|
"sdk-extensions": [
|
||||||
|
"org.freedesktop.Sdk.Extension.rust-stable"
|
||||||
|
],
|
||||||
|
"command": "driftwood",
|
||||||
|
"finish-args": [
|
||||||
|
"--share=ipc",
|
||||||
|
"--socket=fallback-x11",
|
||||||
|
"--socket=wayland",
|
||||||
|
"--share=network",
|
||||||
|
"--filesystem=home:ro",
|
||||||
|
"--filesystem=xdg-data/applications:create",
|
||||||
|
"--filesystem=xdg-data/icons:create",
|
||||||
|
"--talk-name=org.freedesktop.DBus",
|
||||||
|
"--env=RUST_LOG=driftwood=info"
|
||||||
|
],
|
||||||
|
"build-options": {
|
||||||
|
"append-path": "/usr/lib/sdk/rust-stable/bin",
|
||||||
|
"env": {
|
||||||
|
"CARGO_HOME": "/run/build/driftwood/cargo",
|
||||||
|
"RUST_BACKTRACE": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"name": "driftwood",
|
||||||
|
"buildsystem": "meson",
|
||||||
|
"config-opts": [
|
||||||
|
"-Dbuildtype=release"
|
||||||
|
],
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"type": "dir",
|
||||||
|
"path": ".."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
8
build.rs
8
build.rs
@@ -33,4 +33,12 @@ fn main() {
|
|||||||
"cargo::rustc-env=GSETTINGS_SCHEMA_DIR={}",
|
"cargo::rustc-env=GSETTINGS_SCHEMA_DIR={}",
|
||||||
schema_dir.display()
|
schema_dir.display()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Set LOCALEDIR for i18n support (development builds use a local path)
|
||||||
|
let locale_dir = out_dir.join("locale");
|
||||||
|
std::fs::create_dir_all(&locale_dir).ok();
|
||||||
|
println!(
|
||||||
|
"cargo::rustc-env=LOCALEDIR={}",
|
||||||
|
locale_dir.display()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,5 +31,60 @@
|
|||||||
<summary>Color scheme</summary>
|
<summary>Color scheme</summary>
|
||||||
<description>Application color scheme: default (follow system), force-light, or force-dark.</description>
|
<description>Application color scheme: default (follow system), force-light, or force-dark.</description>
|
||||||
</key>
|
</key>
|
||||||
|
<key name="auto-scan-on-startup" type="b">
|
||||||
|
<default>false</default>
|
||||||
|
<summary>Auto scan on startup</summary>
|
||||||
|
<description>Whether to automatically scan for AppImages when the application starts.</description>
|
||||||
|
</key>
|
||||||
|
<key name="detail-tab" type="s">
|
||||||
|
<default>'overview'</default>
|
||||||
|
<summary>Last detail view tab</summary>
|
||||||
|
<description>The last selected tab in the detail view (overview, system, security, storage).</description>
|
||||||
|
</key>
|
||||||
|
<key name="auto-check-updates" type="b">
|
||||||
|
<default>false</default>
|
||||||
|
<summary>Auto check updates</summary>
|
||||||
|
<description>Automatically check for AppImage updates periodically.</description>
|
||||||
|
</key>
|
||||||
|
<key name="auto-integrate" type="b">
|
||||||
|
<default>false</default>
|
||||||
|
<summary>Auto integrate</summary>
|
||||||
|
<description>Automatically integrate newly discovered AppImages into the desktop menu.</description>
|
||||||
|
</key>
|
||||||
|
<key name="auto-backup-before-update" type="b">
|
||||||
|
<default>false</default>
|
||||||
|
<summary>Auto backup before update</summary>
|
||||||
|
<description>Create a config backup before applying an update.</description>
|
||||||
|
</key>
|
||||||
|
<key name="backup-retention-days" type="i">
|
||||||
|
<default>30</default>
|
||||||
|
<summary>Backup retention days</summary>
|
||||||
|
<description>Number of days to keep config backups before auto-cleanup.</description>
|
||||||
|
</key>
|
||||||
|
<key name="confirm-before-delete" type="b">
|
||||||
|
<default>true</default>
|
||||||
|
<summary>Confirm before delete</summary>
|
||||||
|
<description>Show a confirmation dialog before deleting AppImages or backups.</description>
|
||||||
|
</key>
|
||||||
|
<key name="update-cleanup" type="s">
|
||||||
|
<default>'ask'</default>
|
||||||
|
<summary>Update cleanup mode</summary>
|
||||||
|
<description>What to do with old versions after update: ask, keep, or delete.</description>
|
||||||
|
</key>
|
||||||
|
<key name="auto-security-scan" type="b">
|
||||||
|
<default>false</default>
|
||||||
|
<summary>Auto security scan</summary>
|
||||||
|
<description>Automatically scan AppImages for security vulnerabilities during scan.</description>
|
||||||
|
</key>
|
||||||
|
<key name="security-notifications" type="b">
|
||||||
|
<default>false</default>
|
||||||
|
<summary>Security notifications</summary>
|
||||||
|
<description>Send desktop notifications when new CVEs are found.</description>
|
||||||
|
</key>
|
||||||
|
<key name="security-notification-threshold" type="s">
|
||||||
|
<default>'high'</default>
|
||||||
|
<summary>Security notification threshold</summary>
|
||||||
|
<description>Minimum CVE severity for desktop notifications: critical, high, medium, or low.</description>
|
||||||
|
</key>
|
||||||
</schema>
|
</schema>
|
||||||
</schemalist>
|
</schemalist>
|
||||||
|
|||||||
88
data/app.driftwood.Driftwood.metainfo.xml
Normal file
88
data/app.driftwood.Driftwood.metainfo.xml
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<component type="desktop-application">
|
||||||
|
<id>app.driftwood.Driftwood</id>
|
||||||
|
<metadata_license>CC0-1.0</metadata_license>
|
||||||
|
<project_license>GPL-3.0-or-later</project_license>
|
||||||
|
|
||||||
|
<name>Driftwood</name>
|
||||||
|
<summary>Modern AppImage manager for GNOME desktops</summary>
|
||||||
|
|
||||||
|
<description>
|
||||||
|
<p>
|
||||||
|
Driftwood is a native GTK4/libadwaita application for managing AppImages
|
||||||
|
on Wayland-era Linux desktops. It discovers, inspects, integrates, updates,
|
||||||
|
and audits AppImage files with a clean GNOME-native interface.
|
||||||
|
</p>
|
||||||
|
<p>Key features:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Automatic discovery and scanning of AppImage files</li>
|
||||||
|
<li>Desktop integration with menu entries and icons</li>
|
||||||
|
<li>FUSE and Wayland compatibility detection</li>
|
||||||
|
<li>Update checking via embedded update information</li>
|
||||||
|
<li>Security scanning against the OSV vulnerability database</li>
|
||||||
|
<li>Duplicate detection and disk space analysis</li>
|
||||||
|
<li>Firejail sandboxing support</li>
|
||||||
|
<li>Orphaned configuration cleanup</li>
|
||||||
|
</ul>
|
||||||
|
</description>
|
||||||
|
|
||||||
|
<launchable type="desktop-id">app.driftwood.Driftwood.desktop</launchable>
|
||||||
|
|
||||||
|
<url type="homepage">https://github.com/driftwood-app/driftwood</url>
|
||||||
|
<url type="bugtracker">https://github.com/driftwood-app/driftwood/issues</url>
|
||||||
|
|
||||||
|
<developer id="app.driftwood">
|
||||||
|
<name>Driftwood Contributors</name>
|
||||||
|
</developer>
|
||||||
|
|
||||||
|
<branding>
|
||||||
|
<color type="primary" scheme_preference="light">#8ff0a4</color>
|
||||||
|
<color type="primary" scheme_preference="dark">#26a269</color>
|
||||||
|
</branding>
|
||||||
|
|
||||||
|
<content_rating type="oars-1.1" />
|
||||||
|
|
||||||
|
<requires>
|
||||||
|
<display_length compare="ge">360</display_length>
|
||||||
|
</requires>
|
||||||
|
|
||||||
|
<recommends>
|
||||||
|
<control>keyboard</control>
|
||||||
|
<control>pointing</control>
|
||||||
|
</recommends>
|
||||||
|
|
||||||
|
<categories>
|
||||||
|
<category>System</category>
|
||||||
|
<category>PackageManager</category>
|
||||||
|
<category>GTK</category>
|
||||||
|
</categories>
|
||||||
|
|
||||||
|
<keywords>
|
||||||
|
<keyword>AppImage</keyword>
|
||||||
|
<keyword>Application</keyword>
|
||||||
|
<keyword>Manager</keyword>
|
||||||
|
<keyword>Package</keyword>
|
||||||
|
<keyword>FUSE</keyword>
|
||||||
|
<keyword>Wayland</keyword>
|
||||||
|
<keyword>Security</keyword>
|
||||||
|
</keywords>
|
||||||
|
|
||||||
|
<releases>
|
||||||
|
<release version="0.1.0" date="2026-02-26">
|
||||||
|
<description>
|
||||||
|
<p>Initial release of Driftwood with core features:</p>
|
||||||
|
<ul>
|
||||||
|
<li>AppImage discovery, inspection, and library management</li>
|
||||||
|
<li>Desktop integration with .desktop files and icons</li>
|
||||||
|
<li>FUSE and Wayland compatibility analysis</li>
|
||||||
|
<li>Update checking via GitHub/GitLab/zsync</li>
|
||||||
|
<li>Security vulnerability scanning via OSV.dev</li>
|
||||||
|
<li>Duplicate detection and disk footprint analysis</li>
|
||||||
|
<li>Firejail sandbox support</li>
|
||||||
|
<li>Orphan cleanup and disk reclamation wizard</li>
|
||||||
|
<li>CLI interface with scan, list, launch, and inspect commands</li>
|
||||||
|
</ul>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
</releases>
|
||||||
|
</component>
|
||||||
1135
docs/PHASE-5-PLAN.md
Normal file
1135
docs/PHASE-5-PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
246
docs/USER-GUIDE.md
Normal file
246
docs/USER-GUIDE.md
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
# Driftwood User Guide
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
**From source:**
|
||||||
|
```sh
|
||||||
|
cargo build --release
|
||||||
|
sudo install -Dm755 target/release/driftwood /usr/local/bin/driftwood
|
||||||
|
```
|
||||||
|
|
||||||
|
**Arch Linux (AUR):**
|
||||||
|
```sh
|
||||||
|
yay -S driftwood
|
||||||
|
```
|
||||||
|
|
||||||
|
**Flatpak:**
|
||||||
|
```sh
|
||||||
|
flatpak install app.driftwood.Driftwood
|
||||||
|
```
|
||||||
|
|
||||||
|
### First launch
|
||||||
|
|
||||||
|
When you first open Driftwood, you'll see an empty state with two options:
|
||||||
|
|
||||||
|
- **Scan Now** - Immediately scan the default directories (`~/Applications` and
|
||||||
|
`~/Downloads`) for AppImage files
|
||||||
|
- **Preferences** - Configure which directories to scan and other settings
|
||||||
|
|
||||||
|
Driftwood will discover all AppImage files (both Type 1 and Type 2) in your
|
||||||
|
configured directories and add them to its library.
|
||||||
|
|
||||||
|
## Library view
|
||||||
|
|
||||||
|
The main screen shows all your discovered AppImages in either grid or list mode.
|
||||||
|
Toggle between views with the button in the header bar.
|
||||||
|
|
||||||
|
### Status badges
|
||||||
|
|
||||||
|
Each AppImage card shows colored badges indicating:
|
||||||
|
|
||||||
|
- **Wayland status** - Green (native), yellow (XWayland), red (X11 only)
|
||||||
|
- **FUSE status** - Green (native FUSE), yellow (extract-and-run), red (cannot launch)
|
||||||
|
- **Update available** - Blue badge when a newer version is detected
|
||||||
|
- **Security** - Red badge if known vulnerabilities are found
|
||||||
|
|
||||||
|
### Searching
|
||||||
|
|
||||||
|
Use the search bar to filter AppImages by name or file path. The search is
|
||||||
|
debounced - it waits 150ms after you stop typing before filtering.
|
||||||
|
|
||||||
|
### Keyboard shortcuts
|
||||||
|
|
||||||
|
- **Ctrl+Q** - Quit
|
||||||
|
- **Ctrl+D** - Open dashboard
|
||||||
|
- **Ctrl+U** - Check for updates
|
||||||
|
|
||||||
|
## Detail view
|
||||||
|
|
||||||
|
Click any AppImage card to see its full detail page. The detail view has
|
||||||
|
these sections:
|
||||||
|
|
||||||
|
### Identity
|
||||||
|
App name, version, developer, description, and categories extracted from
|
||||||
|
the AppImage's embedded .desktop file.
|
||||||
|
|
||||||
|
### Desktop integration
|
||||||
|
Shows whether the AppImage is integrated into your desktop menu. You can
|
||||||
|
integrate or remove integration from here.
|
||||||
|
|
||||||
|
### Runtime compatibility
|
||||||
|
FUSE status (how the AppImage can be mounted) and Wayland compatibility
|
||||||
|
(whether the app supports Wayland natively or needs XWayland).
|
||||||
|
|
||||||
|
### Sandboxing
|
||||||
|
Toggle Firejail sandboxing for this AppImage. When enabled, the app
|
||||||
|
launches inside a Firejail container with `--appimage` flag. Requires
|
||||||
|
firejail to be installed.
|
||||||
|
|
||||||
|
### Updates
|
||||||
|
Shows the update type (GitHub Releases, GitLab, zsync), current and latest
|
||||||
|
versions, and lets you check for and apply updates.
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
Launch count and last launched date.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
Results of CVE scanning against bundled libraries. Shows counts by severity
|
||||||
|
(critical, high, medium, low).
|
||||||
|
|
||||||
|
### Disk footprint
|
||||||
|
Config, data, and cache directories associated with this AppImage. Shows
|
||||||
|
estimated size and discovery confidence.
|
||||||
|
|
||||||
|
### File details
|
||||||
|
File path, size, SHA256 hash, AppImage type, architecture, and timestamps.
|
||||||
|
|
||||||
|
## Scanning
|
||||||
|
|
||||||
|
### Automatic scanning
|
||||||
|
By default, Driftwood scans on startup. Disable this in Preferences under
|
||||||
|
Behavior > "Scan on startup".
|
||||||
|
|
||||||
|
### Manual scanning
|
||||||
|
Use the "Scan for AppImages" option in the hamburger menu or run:
|
||||||
|
```sh
|
||||||
|
driftwood scan
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scan optimization
|
||||||
|
On subsequent scans, Driftwood skips files that haven't changed (same size
|
||||||
|
and modification time) and already have all analysis complete. This makes
|
||||||
|
re-scans much faster.
|
||||||
|
|
||||||
|
### Adding scan directories
|
||||||
|
Go to Preferences > General > Scan Locations to add or remove directories.
|
||||||
|
Subdirectories are not scanned recursively.
|
||||||
|
|
||||||
|
## Desktop integration
|
||||||
|
|
||||||
|
Driftwood creates standard .desktop files in `~/.local/share/applications/`
|
||||||
|
with the prefix `driftwood-`. Icons are installed to
|
||||||
|
`~/.local/share/icons/hicolor/`.
|
||||||
|
|
||||||
|
To integrate an AppImage:
|
||||||
|
1. Open its detail view
|
||||||
|
2. Click "Integrate" in the Desktop Integration section
|
||||||
|
3. Confirm in the integration dialog
|
||||||
|
|
||||||
|
To remove integration:
|
||||||
|
1. Open its detail view
|
||||||
|
2. Click "Remove Integration"
|
||||||
|
|
||||||
|
## Updates
|
||||||
|
|
||||||
|
### Checking for updates
|
||||||
|
- Single app: Open detail view and click "Check for Updates"
|
||||||
|
- All apps: Use the hamburger menu "Check for Updates" or `driftwood check-updates`
|
||||||
|
|
||||||
|
### Applying updates
|
||||||
|
When an update is available, click "Update Now" in the update dialog. Driftwood
|
||||||
|
downloads the new version and replaces the old file.
|
||||||
|
|
||||||
|
### Old version cleanup
|
||||||
|
After a successful update, Driftwood handles the old version based on your
|
||||||
|
preference (Preferences > Behavior > "After updating an AppImage"):
|
||||||
|
|
||||||
|
- **Ask each time** (default) - Shows a dialog asking whether to remove the backup
|
||||||
|
- **Remove old version** - Automatically deletes the backup
|
||||||
|
- **Keep backup** - Saves the old version with a `.old` extension
|
||||||
|
|
||||||
|
## Security scanning
|
||||||
|
|
||||||
|
Driftwood extracts the list of shared libraries (.so files) bundled inside each
|
||||||
|
AppImage and queries the OSV.dev vulnerability database for known CVEs.
|
||||||
|
|
||||||
|
### Running a scan
|
||||||
|
- Single app: Open detail view and click "Run Security Scan"
|
||||||
|
- All apps: `driftwood security`
|
||||||
|
- Single app CLI: `driftwood security ~/path/to/app.AppImage`
|
||||||
|
|
||||||
|
### Interpreting results
|
||||||
|
Results show CVE IDs grouped by severity. Each CVE includes:
|
||||||
|
- CVE identifier and severity level
|
||||||
|
- CVSS score (if available)
|
||||||
|
- Summary of the vulnerability
|
||||||
|
- Affected library and version
|
||||||
|
- Fixed version (if known)
|
||||||
|
|
||||||
|
### Limitations
|
||||||
|
- Not all bundled libraries can be identified
|
||||||
|
- Version detection uses heuristics and may be inaccurate
|
||||||
|
- Results should be treated as advisory, not definitive
|
||||||
|
|
||||||
|
## Duplicate detection
|
||||||
|
|
||||||
|
Driftwood detects:
|
||||||
|
- **Same app, different versions** - Multiple version files of the same application
|
||||||
|
- **Identical files** - Same SHA256 hash in different locations
|
||||||
|
|
||||||
|
Access via the hamburger menu "Find Duplicates" or `driftwood duplicates`.
|
||||||
|
|
||||||
|
## Disk cleanup
|
||||||
|
|
||||||
|
The cleanup wizard (hamburger menu > "Disk Cleanup") helps reclaim space by:
|
||||||
|
- Identifying orphaned desktop entries for deleted AppImages
|
||||||
|
- Finding associated config/data/cache directories
|
||||||
|
- Showing total reclaimable space
|
||||||
|
|
||||||
|
## Orphaned entries
|
||||||
|
|
||||||
|
When an AppImage is deleted outside of Driftwood, its .desktop file and icon
|
||||||
|
remain. Driftwood detects these orphans and offers to clean them up.
|
||||||
|
|
||||||
|
- Automatic detection on the dashboard
|
||||||
|
- Manual cleanup via hamburger menu or `driftwood clean-orphans`
|
||||||
|
|
||||||
|
## Dashboard
|
||||||
|
|
||||||
|
The dashboard (Ctrl+D or hamburger menu) shows system health:
|
||||||
|
- FUSE availability (fuse2, fuse3, or none)
|
||||||
|
- Wayland session information
|
||||||
|
- Total disk usage by AppImages
|
||||||
|
- Orphaned entry count
|
||||||
|
- Security alert summary
|
||||||
|
|
||||||
|
## CLI reference
|
||||||
|
|
||||||
|
```
|
||||||
|
driftwood # Launch the GUI
|
||||||
|
driftwood scan # Scan for AppImages
|
||||||
|
driftwood list # List all AppImages (table format)
|
||||||
|
driftwood list --format json # List as JSON
|
||||||
|
driftwood inspect <path> # Show AppImage metadata
|
||||||
|
driftwood integrate <path> # Create .desktop file and icon
|
||||||
|
driftwood remove <path> # Remove desktop integration
|
||||||
|
driftwood launch <path> # Launch with tracking
|
||||||
|
driftwood launch --sandbox <path> # Launch in Firejail
|
||||||
|
driftwood check-updates # Check all for updates
|
||||||
|
driftwood duplicates # Find duplicates
|
||||||
|
driftwood security # Scan all for CVEs
|
||||||
|
driftwood security <path> # Scan one for CVEs
|
||||||
|
driftwood footprint <path> # Show disk footprint
|
||||||
|
driftwood status # Show system status
|
||||||
|
driftwood clean-orphans # Remove orphaned entries
|
||||||
|
```
|
||||||
|
|
||||||
|
## Preferences
|
||||||
|
|
||||||
|
Access via hamburger menu > Preferences.
|
||||||
|
|
||||||
|
### General
|
||||||
|
- **Color Scheme** - Follow system, light, or dark
|
||||||
|
- **Default View** - Grid or list
|
||||||
|
- **Scan Locations** - Directories to scan
|
||||||
|
|
||||||
|
### Behavior
|
||||||
|
- **Scan on startup** - Auto-scan when the app opens
|
||||||
|
- **Check for updates** - Periodically check for newer versions
|
||||||
|
- **Auto-integrate new AppImages** - Automatically create .desktop files
|
||||||
|
- **Confirm before delete** - Show confirmation dialogs
|
||||||
|
- **After updating** - Old version cleanup policy (ask/always/never)
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- **Auto-scan new AppImages** - Run CVE scan on newly discovered AppImages
|
||||||
31
docs/plans/2026-02-27-20-improvements.md
Normal file
31
docs/plans/2026-02-27-20-improvements.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# 20 Improvements Plan
|
||||||
|
|
||||||
|
## Batch 1: Low-risk code quality (no behavior change)
|
||||||
|
1. Wrap all hardcoded English strings in i18n()
|
||||||
|
2. Replace OnceCell.get().expect() with safe getters
|
||||||
|
3. Extract common async-toast-refresh helper
|
||||||
|
4. Log silently swallowed errors
|
||||||
|
|
||||||
|
## Batch 2: Performance
|
||||||
|
6. Async database initialization with loading screen
|
||||||
|
7. Batch CSS provider registration for letter-circle icons
|
||||||
|
8. Lazy-load detail view tabs
|
||||||
|
18. Rate-limit background analysis spawns
|
||||||
|
|
||||||
|
## Batch 3: UX
|
||||||
|
9. Progress indicator during background analysis
|
||||||
|
10. Multi-file drop and file picker support
|
||||||
|
12. Sort options in library view
|
||||||
|
15. Keyboard shortcut Ctrl+O for Add app
|
||||||
|
17. Validate scan directories exist before scanning
|
||||||
|
|
||||||
|
## Batch 4: Robustness
|
||||||
|
5. Add database migration tests
|
||||||
|
13. Confirmation before closing during active analysis
|
||||||
|
16. Graceful handling of corrupt/locked database
|
||||||
|
|
||||||
|
## Batch 5: Accessibility & Features
|
||||||
|
11. Remember detail view active tab
|
||||||
|
14. Announce analysis completion to screen readers
|
||||||
|
19. Custom launch arguments
|
||||||
|
20. Export/import app library
|
||||||
1123
docs/plans/2026-02-27-wcag-aaa-implementation.md
Normal file
1123
docs/plans/2026-02-27-wcag-aaa-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
68
meson.build
Normal file
68
meson.build
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
project(
|
||||||
|
'driftwood',
|
||||||
|
'rust',
|
||||||
|
version: '0.1.0',
|
||||||
|
license: 'GPL-3.0-or-later',
|
||||||
|
meson_version: '>= 0.62.0',
|
||||||
|
)
|
||||||
|
|
||||||
|
i18n = import('i18n')
|
||||||
|
gnome = import('gnome')
|
||||||
|
|
||||||
|
app_id = 'app.driftwood.Driftwood'
|
||||||
|
prefix = get_option('prefix')
|
||||||
|
bindir = prefix / get_option('bindir')
|
||||||
|
datadir = prefix / get_option('datadir')
|
||||||
|
localedir = prefix / get_option('localedir')
|
||||||
|
iconsdir = datadir / 'icons'
|
||||||
|
|
||||||
|
# Install desktop file
|
||||||
|
install_data(
|
||||||
|
'data' / app_id + '.desktop',
|
||||||
|
install_dir: datadir / 'applications',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Install AppStream metainfo
|
||||||
|
install_data(
|
||||||
|
'data' / app_id + '.metainfo.xml',
|
||||||
|
install_dir: datadir / 'metainfo',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Compile and install GSettings schema
|
||||||
|
install_data(
|
||||||
|
'data' / app_id + '.gschema.xml',
|
||||||
|
install_dir: datadir / 'glib-2.0' / 'schemas',
|
||||||
|
)
|
||||||
|
gnome.post_install(glib_compile_schemas: true)
|
||||||
|
|
||||||
|
# Build the Rust binary via Cargo
|
||||||
|
cargo = find_program('cargo')
|
||||||
|
cargo_build_type = get_option('buildtype') == 'release' ? '--release' : ''
|
||||||
|
|
||||||
|
custom_target(
|
||||||
|
'driftwood-binary',
|
||||||
|
output: 'driftwood',
|
||||||
|
command: [
|
||||||
|
cargo, 'build',
|
||||||
|
cargo_build_type,
|
||||||
|
'--manifest-path', meson.project_source_root() / 'Cargo.toml',
|
||||||
|
'--target-dir', meson.project_build_root() / 'cargo-target',
|
||||||
|
],
|
||||||
|
env: {
|
||||||
|
'LOCALEDIR': localedir,
|
||||||
|
'GSETTINGS_SCHEMA_DIR': datadir / 'glib-2.0' / 'schemas',
|
||||||
|
},
|
||||||
|
build_by_default: true,
|
||||||
|
install: false,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Install the binary (from the cargo output directory)
|
||||||
|
cargo_profile = get_option('buildtype') == 'release' ? 'release' : 'debug'
|
||||||
|
install_data(
|
||||||
|
meson.project_build_root() / 'cargo-target' / cargo_profile / 'driftwood',
|
||||||
|
install_dir: bindir,
|
||||||
|
install_mode: 'rwxr-xr-x',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
subdir('po')
|
||||||
41
packaging/PKGBUILD
Normal file
41
packaging/PKGBUILD
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Maintainer: Driftwood Contributors
|
||||||
|
|
||||||
|
pkgname=driftwood
|
||||||
|
pkgver=0.1.0
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc='Modern AppImage manager for GNOME desktops'
|
||||||
|
arch=('x86_64')
|
||||||
|
url='https://github.com/driftwood-app/driftwood'
|
||||||
|
license=('GPL-3.0-or-later')
|
||||||
|
depends=(
|
||||||
|
'gtk4'
|
||||||
|
'libadwaita'
|
||||||
|
'sqlite'
|
||||||
|
'gettext'
|
||||||
|
)
|
||||||
|
makedepends=(
|
||||||
|
'rust'
|
||||||
|
'cargo'
|
||||||
|
'meson'
|
||||||
|
'ninja'
|
||||||
|
'glib2'
|
||||||
|
)
|
||||||
|
optdepends=(
|
||||||
|
'firejail: sandboxed AppImage launching'
|
||||||
|
'fuse2: FUSE mount support for Type 1 AppImages'
|
||||||
|
'fuse3: FUSE mount support for Type 2 AppImages'
|
||||||
|
'appimageupdate: delta updates for AppImages'
|
||||||
|
)
|
||||||
|
source=("$pkgname-$pkgver.tar.gz")
|
||||||
|
sha256sums=('SKIP')
|
||||||
|
|
||||||
|
build() {
|
||||||
|
cd "$pkgname-$pkgver"
|
||||||
|
arch-meson build
|
||||||
|
meson compile -C build
|
||||||
|
}
|
||||||
|
|
||||||
|
package() {
|
||||||
|
cd "$pkgname-$pkgver"
|
||||||
|
meson install -C build --destdir "$pkgdir"
|
||||||
|
}
|
||||||
6
po/LINGUAS
Normal file
6
po/LINGUAS
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# List of languages with translations
|
||||||
|
# Add language codes here as translations are contributed, e.g.:
|
||||||
|
# de
|
||||||
|
# es
|
||||||
|
# fr
|
||||||
|
# pt_BR
|
||||||
17
po/POTFILES.in
Normal file
17
po/POTFILES.in
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
src/main.rs
|
||||||
|
src/application.rs
|
||||||
|
src/window.rs
|
||||||
|
src/cli.rs
|
||||||
|
src/ui/library_view.rs
|
||||||
|
src/ui/detail_view.rs
|
||||||
|
src/ui/dashboard.rs
|
||||||
|
src/ui/preferences.rs
|
||||||
|
src/ui/app_card.rs
|
||||||
|
src/ui/cleanup_wizard.rs
|
||||||
|
src/ui/duplicate_dialog.rs
|
||||||
|
src/ui/integration_dialog.rs
|
||||||
|
src/ui/security_report.rs
|
||||||
|
src/ui/update_dialog.rs
|
||||||
|
src/ui/widgets.rs
|
||||||
|
data/app.driftwood.Driftwood.metainfo.xml
|
||||||
|
data/app.driftwood.Driftwood.desktop
|
||||||
1
po/meson.build
Normal file
1
po/meson.build
Normal file
@@ -0,0 +1 @@
|
|||||||
|
i18n.gettext('app.driftwood.Driftwood', preset: 'glib')
|
||||||
223
src/cli.rs
223
src/cli.rs
@@ -59,6 +59,17 @@ pub enum Commands {
|
|||||||
/// Path to the AppImage
|
/// Path to the AppImage
|
||||||
path: String,
|
path: String,
|
||||||
},
|
},
|
||||||
|
/// Export app library to a JSON file
|
||||||
|
Export {
|
||||||
|
/// Output file path (default: stdout)
|
||||||
|
#[arg(long)]
|
||||||
|
output: Option<String>,
|
||||||
|
},
|
||||||
|
/// Import app library from a JSON file
|
||||||
|
Import {
|
||||||
|
/// Path to the JSON file to import
|
||||||
|
file: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run_command(command: Commands) -> ExitCode {
|
pub fn run_command(command: Commands) -> ExitCode {
|
||||||
@@ -81,6 +92,8 @@ pub fn run_command(command: Commands) -> ExitCode {
|
|||||||
Commands::CheckUpdates => cmd_check_updates(&db),
|
Commands::CheckUpdates => cmd_check_updates(&db),
|
||||||
Commands::Duplicates => cmd_duplicates(&db),
|
Commands::Duplicates => cmd_duplicates(&db),
|
||||||
Commands::Launch { path } => cmd_launch(&db, &path),
|
Commands::Launch { path } => cmd_launch(&db, &path),
|
||||||
|
Commands::Export { output } => cmd_export(&db, output.as_deref()),
|
||||||
|
Commands::Import { file } => cmd_import(&db, &file),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -661,3 +674,213 @@ fn do_inspect(path: &std::path::Path, appimage_type: &discovery::AppImageType) -
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Export/Import library ---
|
||||||
|
|
||||||
|
fn cmd_export(db: &Database, output: Option<&str>) -> ExitCode {
|
||||||
|
let records = match db.get_all_appimages() {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Error: {}", e);
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let appimages: Vec<serde_json::Value> = records
|
||||||
|
.iter()
|
||||||
|
.map(|r| {
|
||||||
|
serde_json::json!({
|
||||||
|
"path": r.path,
|
||||||
|
"app_name": r.app_name,
|
||||||
|
"app_version": r.app_version,
|
||||||
|
"integrated": r.integrated,
|
||||||
|
"notes": r.notes,
|
||||||
|
"categories": r.categories,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let export_data = serde_json::json!({
|
||||||
|
"version": 1,
|
||||||
|
"exported_at": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
|
||||||
|
"appimages": appimages,
|
||||||
|
});
|
||||||
|
|
||||||
|
let json_str = match serde_json::to_string_pretty(&export_data) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Error serializing export data: {}", e);
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(path) = output {
|
||||||
|
if let Err(e) = std::fs::write(path, &json_str) {
|
||||||
|
eprintln!("Error writing to {}: {}", path, e);
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!("{}", json_str);
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!("Exported {} AppImages", records.len());
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_import(db: &Database, file: &str) -> ExitCode {
|
||||||
|
let content = match std::fs::read_to_string(file) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Error reading {}: {}", file, e);
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let data: serde_json::Value = match serde_json::from_str(&content) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Error parsing JSON: {}", e);
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let entries = match data.get("appimages").and_then(|a| a.as_array()) {
|
||||||
|
Some(arr) => arr,
|
||||||
|
None => {
|
||||||
|
eprintln!("Error: JSON missing 'appimages' array");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let total = entries.len();
|
||||||
|
let mut imported = 0u32;
|
||||||
|
let mut skipped = 0u32;
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let path_str = match entry.get("path").and_then(|p| p.as_str()) {
|
||||||
|
Some(p) => p,
|
||||||
|
None => {
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let file_path = std::path::Path::new(path_str);
|
||||||
|
if !file_path.exists() {
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that the file is actually an AppImage
|
||||||
|
let appimage_type = match discovery::detect_appimage(file_path) {
|
||||||
|
Some(t) => t,
|
||||||
|
None => {
|
||||||
|
eprintln!(" Skipping {} - not a valid AppImage", path_str);
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let metadata = std::fs::metadata(file_path);
|
||||||
|
let size_bytes = metadata.as_ref().map(|m| m.len() as i64).unwrap_or(0);
|
||||||
|
let is_executable = metadata
|
||||||
|
.as_ref()
|
||||||
|
.map(|m| {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
m.permissions().mode() & 0o111 != 0
|
||||||
|
})
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let filename = file_path
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let file_modified = metadata
|
||||||
|
.as_ref()
|
||||||
|
.ok()
|
||||||
|
.and_then(|m| m.modified().ok())
|
||||||
|
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||||
|
.and_then(|dur| {
|
||||||
|
chrono::DateTime::from_timestamp(dur.as_secs() as i64, 0)
|
||||||
|
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
|
||||||
|
});
|
||||||
|
|
||||||
|
let id = match db.upsert_appimage(
|
||||||
|
path_str,
|
||||||
|
&filename,
|
||||||
|
Some(appimage_type.as_i32()),
|
||||||
|
size_bytes,
|
||||||
|
is_executable,
|
||||||
|
file_modified.as_deref(),
|
||||||
|
) {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(" Error registering {}: {}", path_str, e);
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Restore metadata fields from the export
|
||||||
|
let app_name = entry.get("app_name").and_then(|v| v.as_str());
|
||||||
|
let app_version = entry.get("app_version").and_then(|v| v.as_str());
|
||||||
|
let categories = entry.get("categories").and_then(|v| v.as_str());
|
||||||
|
|
||||||
|
if app_name.is_some() || app_version.is_some() {
|
||||||
|
db.update_metadata(
|
||||||
|
id,
|
||||||
|
app_name,
|
||||||
|
app_version,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
categories,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore notes if present
|
||||||
|
if let Some(notes_str) = entry.get("notes").and_then(|v| v.as_str()) {
|
||||||
|
db.update_notes(id, Some(notes_str)).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it was integrated in the export, integrate it now
|
||||||
|
let was_integrated = entry
|
||||||
|
.get("integrated")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if was_integrated {
|
||||||
|
// Need the full record to integrate
|
||||||
|
if let Ok(Some(record)) = db.get_appimage_by_id(id) {
|
||||||
|
if !record.integrated {
|
||||||
|
match integrator::integrate(&record) {
|
||||||
|
Ok(result) => {
|
||||||
|
db.set_integrated(
|
||||||
|
id,
|
||||||
|
true,
|
||||||
|
Some(&result.desktop_file_path.to_string_lossy()),
|
||||||
|
).ok();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(" Warning: could not integrate {}: {}", path_str, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
imported += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"Imported {} of {} AppImages ({} skipped - file not found)",
|
||||||
|
imported,
|
||||||
|
total,
|
||||||
|
skipped,
|
||||||
|
);
|
||||||
|
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
pub const APP_ID: &str = "app.driftwood.Driftwood";
|
pub const APP_ID: &str = "app.driftwood.Driftwood";
|
||||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
pub const GSETTINGS_SCHEMA_DIR: &str = env!("GSETTINGS_SCHEMA_DIR");
|
pub const GSETTINGS_SCHEMA_DIR: &str = env!("GSETTINGS_SCHEMA_DIR");
|
||||||
|
pub const SYSTEM_APPIMAGE_DIR: &str = "/opt/appimages";
|
||||||
|
|||||||
133
src/core/analysis.rs
Normal file
133
src/core/analysis.rs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
|
use crate::core::database::Database;
|
||||||
|
use crate::core::discovery::AppImageType;
|
||||||
|
use crate::core::fuse;
|
||||||
|
use crate::core::inspector;
|
||||||
|
use crate::core::integrator;
|
||||||
|
use crate::core::wayland;
|
||||||
|
|
||||||
|
/// Maximum number of concurrent background analyses.
|
||||||
|
const MAX_CONCURRENT_ANALYSES: usize = 2;
|
||||||
|
|
||||||
|
/// Counter for currently running analyses.
|
||||||
|
static RUNNING_ANALYSES: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
|
/// Returns the number of currently running background analyses.
|
||||||
|
pub fn running_count() -> usize {
|
||||||
|
RUNNING_ANALYSES.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RAII guard that decrements the analysis counter on drop.
|
||||||
|
struct AnalysisGuard;
|
||||||
|
|
||||||
|
impl Drop for AnalysisGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
RUNNING_ANALYSES.fetch_sub(1, Ordering::Release);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the heavy analysis steps for a single AppImage on a background thread.
|
||||||
|
///
|
||||||
|
/// This opens its own database connection and updates results as they complete.
|
||||||
|
/// All errors are logged but non-fatal - fields stay `None`, which the UI
|
||||||
|
/// already handles gracefully.
|
||||||
|
///
|
||||||
|
/// Blocks until a slot is available if the concurrency limit is reached.
|
||||||
|
pub fn run_background_analysis(id: i64, path: PathBuf, appimage_type: AppImageType, integrate: bool) {
|
||||||
|
// Wait for a slot to become available
|
||||||
|
loop {
|
||||||
|
let current = RUNNING_ANALYSES.load(Ordering::Acquire);
|
||||||
|
if current < MAX_CONCURRENT_ANALYSES {
|
||||||
|
if RUNNING_ANALYSES.compare_exchange(current, current + 1, Ordering::AcqRel, Ordering::Relaxed).is_ok() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _guard = AnalysisGuard;
|
||||||
|
|
||||||
|
let db = match Database::open() {
|
||||||
|
Ok(db) => db,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Background analysis: failed to open database: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = db.update_analysis_status(id, "analyzing") {
|
||||||
|
log::warn!("Failed to set analysis status to 'analyzing' for id {}: {}", id, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inspect metadata (app name, version, icon, desktop entry, etc.)
|
||||||
|
if let Ok(meta) = inspector::inspect_appimage(&path, &appimage_type) {
|
||||||
|
let categories = if meta.categories.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(meta.categories.join(";"))
|
||||||
|
};
|
||||||
|
if let Err(e) = db.update_metadata(
|
||||||
|
id,
|
||||||
|
meta.app_name.as_deref(),
|
||||||
|
meta.app_version.as_deref(),
|
||||||
|
meta.description.as_deref(),
|
||||||
|
meta.developer.as_deref(),
|
||||||
|
categories.as_deref(),
|
||||||
|
meta.architecture.as_deref(),
|
||||||
|
meta.cached_icon_path
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.to_string_lossy())
|
||||||
|
.as_deref(),
|
||||||
|
Some(&meta.desktop_entry_content),
|
||||||
|
) {
|
||||||
|
log::warn!("Failed to update metadata for id {}: {}", id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FUSE status
|
||||||
|
let fuse_info = fuse::detect_system_fuse();
|
||||||
|
let app_fuse = fuse::determine_app_fuse_status(&fuse_info, &path);
|
||||||
|
if let Err(e) = db.update_fuse_status(id, app_fuse.as_str()) {
|
||||||
|
log::warn!("Failed to update FUSE status for id {}: {}", id, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wayland status
|
||||||
|
let analysis = wayland::analyze_appimage(&path);
|
||||||
|
if let Err(e) = db.update_wayland_status(id, analysis.status.as_str()) {
|
||||||
|
log::warn!("Failed to update Wayland status for id {}: {}", id, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SHA256 hash
|
||||||
|
if let Ok(hash) = crate::core::discovery::compute_sha256(&path) {
|
||||||
|
if let Err(e) = db.update_sha256(id, &hash) {
|
||||||
|
log::warn!("Failed to update SHA256 for id {}: {}", id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footprint discovery
|
||||||
|
if let Ok(Some(rec)) = db.get_appimage_by_id(id) {
|
||||||
|
crate::core::footprint::discover_and_store(&db, id, &rec);
|
||||||
|
|
||||||
|
// Integrate if requested
|
||||||
|
if integrate {
|
||||||
|
match integrator::integrate(&rec) {
|
||||||
|
Ok(result) => {
|
||||||
|
let desktop_path = result.desktop_file_path.to_string_lossy().to_string();
|
||||||
|
if let Err(e) = db.set_integrated(id, true, Some(&desktop_path)) {
|
||||||
|
log::warn!("Failed to set integration status for id {}: {}", id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Integration failed for id {}: {}", id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = db.update_analysis_status(id, "complete") {
|
||||||
|
log::warn!("Failed to set analysis status to 'complete' for id {}: {}", id, e);
|
||||||
|
}
|
||||||
|
// _guard dropped here, decrementing RUNNING_ANALYSES
|
||||||
|
}
|
||||||
209
src/core/appstream.rs
Normal file
209
src/core/appstream.rs
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use super::database::Database;
|
||||||
|
|
||||||
|
/// Generate an AppStream catalog XML from the Driftwood database.
|
||||||
|
/// This allows GNOME Software / KDE Discover to see locally managed AppImages.
|
||||||
|
pub fn generate_catalog(db: &Database) -> Result<String, AppStreamError> {
|
||||||
|
let records = db.get_all_appimages()
|
||||||
|
.map_err(|e| AppStreamError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut xml = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||||
|
xml.push_str("<components version=\"0.16\" origin=\"driftwood\">\n");
|
||||||
|
|
||||||
|
for record in &records {
|
||||||
|
let app_name = record.app_name.as_deref().unwrap_or(&record.filename);
|
||||||
|
let app_id = make_component_id(app_name);
|
||||||
|
let description = record.description.as_deref().unwrap_or("");
|
||||||
|
|
||||||
|
xml.push_str(" <component type=\"desktop-application\">\n");
|
||||||
|
xml.push_str(&format!(" <id>appimage.{}</id>\n", xml_escape(&app_id)));
|
||||||
|
xml.push_str(&format!(" <name>{}</name>\n", xml_escape(app_name)));
|
||||||
|
|
||||||
|
if !description.is_empty() {
|
||||||
|
xml.push_str(&format!(" <summary>{}</summary>\n", xml_escape(description)));
|
||||||
|
}
|
||||||
|
|
||||||
|
xml.push_str(&format!(" <pkgname>{}</pkgname>\n", xml_escape(&record.filename)));
|
||||||
|
|
||||||
|
if let Some(version) = &record.app_version {
|
||||||
|
xml.push_str(" <releases>\n");
|
||||||
|
xml.push_str(&format!(
|
||||||
|
" <release version=\"{}\" />\n",
|
||||||
|
xml_escape(version),
|
||||||
|
));
|
||||||
|
xml.push_str(" </releases>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(categories) = &record.categories {
|
||||||
|
xml.push_str(" <categories>\n");
|
||||||
|
for cat in categories.split(';').filter(|c| !c.is_empty()) {
|
||||||
|
xml.push_str(&format!(" <category>{}</category>\n", xml_escape(cat.trim())));
|
||||||
|
}
|
||||||
|
xml.push_str(" </categories>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provide hint about source
|
||||||
|
xml.push_str(" <metadata>\n");
|
||||||
|
xml.push_str(" <value key=\"managed-by\">driftwood</value>\n");
|
||||||
|
xml.push_str(&format!(
|
||||||
|
" <value key=\"appimage-path\">{}</value>\n",
|
||||||
|
xml_escape(&record.path),
|
||||||
|
));
|
||||||
|
xml.push_str(" </metadata>\n");
|
||||||
|
|
||||||
|
xml.push_str(" </component>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
xml.push_str("</components>\n");
|
||||||
|
Ok(xml)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install the AppStream catalog to the local swcatalog directory.
|
||||||
|
/// GNOME Software reads from `~/.local/share/swcatalog/xml/`.
|
||||||
|
pub fn install_catalog(db: &Database) -> Result<PathBuf, AppStreamError> {
|
||||||
|
let catalog_xml = generate_catalog(db)?;
|
||||||
|
|
||||||
|
let catalog_dir = dirs::data_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
||||||
|
.join("swcatalog")
|
||||||
|
.join("xml");
|
||||||
|
|
||||||
|
fs::create_dir_all(&catalog_dir)
|
||||||
|
.map_err(|e| AppStreamError::Io(e.to_string()))?;
|
||||||
|
|
||||||
|
let catalog_path = catalog_dir.join("driftwood.xml");
|
||||||
|
fs::write(&catalog_path, &catalog_xml)
|
||||||
|
.map_err(|e| AppStreamError::Io(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(catalog_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the AppStream catalog from the local swcatalog directory.
|
||||||
|
pub fn uninstall_catalog() -> Result<(), AppStreamError> {
|
||||||
|
let catalog_path = dirs::data_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
||||||
|
.join("swcatalog")
|
||||||
|
.join("xml")
|
||||||
|
.join("driftwood.xml");
|
||||||
|
|
||||||
|
if catalog_path.exists() {
|
||||||
|
fs::remove_file(&catalog_path)
|
||||||
|
.map_err(|e| AppStreamError::Io(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the AppStream catalog is currently installed.
|
||||||
|
pub fn is_catalog_installed() -> bool {
|
||||||
|
let catalog_path = dirs::data_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
||||||
|
.join("swcatalog")
|
||||||
|
.join("xml")
|
||||||
|
.join("driftwood.xml");
|
||||||
|
|
||||||
|
catalog_path.exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Utility functions ---
|
||||||
|
|
||||||
|
fn make_component_id(name: &str) -> String {
|
||||||
|
name.chars()
|
||||||
|
.map(|c| if c.is_alphanumeric() || c == '-' || c == '.' { c.to_ascii_lowercase() } else { '_' })
|
||||||
|
.collect::<String>()
|
||||||
|
.trim_matches('_')
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn xml_escape(s: &str) -> String {
|
||||||
|
s.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Error types ---
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum AppStreamError {
|
||||||
|
Database(String),
|
||||||
|
Io(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for AppStreamError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Database(e) => write!(f, "Database error: {}", e),
|
||||||
|
Self::Io(e) => write!(f, "I/O error: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_make_component_id() {
|
||||||
|
assert_eq!(make_component_id("Firefox"), "firefox");
|
||||||
|
assert_eq!(make_component_id("My App 2.0"), "my_app_2.0");
|
||||||
|
assert_eq!(make_component_id("GIMP"), "gimp");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_xml_escape() {
|
||||||
|
assert_eq!(xml_escape("hello & world"), "hello & world");
|
||||||
|
assert_eq!(xml_escape("<tag>"), "<tag>");
|
||||||
|
assert_eq!(xml_escape("it's \"quoted\""), "it's "quoted"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_generate_catalog_empty() {
|
||||||
|
let db = crate::core::database::Database::open_in_memory().unwrap();
|
||||||
|
let xml = generate_catalog(&db).unwrap();
|
||||||
|
assert!(xml.contains("<components"));
|
||||||
|
assert!(xml.contains("</components>"));
|
||||||
|
// No individual component entries in an empty DB
|
||||||
|
assert!(!xml.contains("<component "));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_generate_catalog_with_app() {
|
||||||
|
let db = crate::core::database::Database::open_in_memory().unwrap();
|
||||||
|
db.upsert_appimage(
|
||||||
|
"/tmp/test.AppImage",
|
||||||
|
"test.AppImage",
|
||||||
|
Some(2),
|
||||||
|
1024,
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
).unwrap();
|
||||||
|
db.update_metadata(
|
||||||
|
1,
|
||||||
|
Some("TestApp"),
|
||||||
|
Some("1.0"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some("Utility;"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
).ok();
|
||||||
|
|
||||||
|
let xml = generate_catalog(&db).unwrap();
|
||||||
|
assert!(xml.contains("appimage.testapp"));
|
||||||
|
assert!(xml.contains("<pkgname>test.AppImage</pkgname>"));
|
||||||
|
assert!(xml.contains("managed-by"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_appstream_error_display() {
|
||||||
|
let err = AppStreamError::Database("db error".to_string());
|
||||||
|
assert!(format!("{}", err).contains("db error"));
|
||||||
|
let err = AppStreamError::Io("write failed".to_string());
|
||||||
|
assert!(format!("{}", err).contains("write failed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
437
src/core/backup.rs
Normal file
437
src/core/backup.rs
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::io::Read;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use super::database::Database;
|
||||||
|
use super::footprint;
|
||||||
|
|
||||||
|
/// Manifest describing the contents of a config backup archive.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct BackupManifest {
|
||||||
|
pub app_name: String,
|
||||||
|
pub app_version: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub paths: Vec<BackupPathEntry>,
|
||||||
|
pub total_size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct BackupPathEntry {
|
||||||
|
pub original_path: String,
|
||||||
|
pub path_type: String,
|
||||||
|
pub relative_path: String,
|
||||||
|
pub size_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backups_dir() -> PathBuf {
|
||||||
|
let dir = dirs::data_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
||||||
|
.join("driftwood")
|
||||||
|
.join("backups");
|
||||||
|
fs::create_dir_all(&dir).ok();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a backup of an AppImage's config/data files.
|
||||||
|
/// Returns the path to the created archive.
|
||||||
|
pub fn create_backup(db: &Database, appimage_id: i64) -> Result<PathBuf, BackupError> {
|
||||||
|
let record = db.get_appimage_by_id(appimage_id)
|
||||||
|
.map_err(|e| BackupError::Database(e.to_string()))?
|
||||||
|
.ok_or(BackupError::NotFound)?;
|
||||||
|
|
||||||
|
let app_name = record.app_name.as_deref().unwrap_or(&record.filename);
|
||||||
|
let app_version = record.app_version.as_deref().unwrap_or("unknown");
|
||||||
|
|
||||||
|
// Discover data paths if not already done
|
||||||
|
let existing_paths = db.get_app_data_paths(appimage_id).unwrap_or_default();
|
||||||
|
if existing_paths.is_empty() {
|
||||||
|
footprint::discover_and_store(db, appimage_id, &record);
|
||||||
|
}
|
||||||
|
|
||||||
|
let data_paths = db.get_app_data_paths(appimage_id).unwrap_or_default();
|
||||||
|
if data_paths.is_empty() {
|
||||||
|
return Err(BackupError::NoPaths);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect files to back up (config and data paths that exist)
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
let mut total_size: u64 = 0;
|
||||||
|
|
||||||
|
for dp in &data_paths {
|
||||||
|
let path = Path::new(&dp.path);
|
||||||
|
if !path.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip cache paths by default (too large, easily regenerated)
|
||||||
|
if dp.path_type == "cache" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let size = dir_size(path);
|
||||||
|
total_size += size;
|
||||||
|
|
||||||
|
// Create a relative path for the archive
|
||||||
|
let relative = dp.path.replace('/', "_").trim_start_matches('_').to_string();
|
||||||
|
|
||||||
|
entries.push(BackupPathEntry {
|
||||||
|
original_path: dp.path.clone(),
|
||||||
|
path_type: dp.path_type.clone(),
|
||||||
|
relative_path: relative,
|
||||||
|
size_bytes: size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if entries.is_empty() {
|
||||||
|
return Err(BackupError::NoPaths);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create manifest
|
||||||
|
let timestamp = chrono::Utc::now().format("%Y%m%d-%H%M%S").to_string();
|
||||||
|
let manifest = BackupManifest {
|
||||||
|
app_name: app_name.to_string(),
|
||||||
|
app_version: app_version.to_string(),
|
||||||
|
created_at: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(),
|
||||||
|
paths: entries.clone(),
|
||||||
|
total_size,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create backup archive using tar
|
||||||
|
let app_id = sanitize_filename(app_name);
|
||||||
|
let archive_name = format!("{}-{}-{}.tar.gz", app_id, app_version, timestamp);
|
||||||
|
let archive_path = backups_dir().join(&archive_name);
|
||||||
|
|
||||||
|
// Write manifest to a temp file
|
||||||
|
let temp_dir = tempfile::tempdir().map_err(|e| BackupError::Io(e.to_string()))?;
|
||||||
|
let manifest_path = temp_dir.path().join("manifest.json");
|
||||||
|
let manifest_json = serde_json::to_string_pretty(&manifest)
|
||||||
|
.map_err(|e| BackupError::Io(e.to_string()))?;
|
||||||
|
fs::write(&manifest_path, &manifest_json)
|
||||||
|
.map_err(|e| BackupError::Io(e.to_string()))?;
|
||||||
|
|
||||||
|
// Build tar command
|
||||||
|
let mut tar_args = vec![
|
||||||
|
"czf".to_string(),
|
||||||
|
archive_path.to_string_lossy().to_string(),
|
||||||
|
"-C".to_string(),
|
||||||
|
temp_dir.path().to_string_lossy().to_string(),
|
||||||
|
"manifest.json".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
|
for entry in &entries {
|
||||||
|
let source = Path::new(&entry.original_path);
|
||||||
|
if source.exists() {
|
||||||
|
tar_args.push("-C".to_string());
|
||||||
|
tar_args.push(
|
||||||
|
source.parent().unwrap_or(Path::new("/")).to_string_lossy().to_string(),
|
||||||
|
);
|
||||||
|
tar_args.push(
|
||||||
|
source.file_name().unwrap_or_default().to_string_lossy().to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = Command::new("tar")
|
||||||
|
.args(&tar_args)
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.status()
|
||||||
|
.map_err(|e| BackupError::Io(format!("tar failed: {}", e)))?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
return Err(BackupError::Io("tar archive creation failed".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get archive size
|
||||||
|
let archive_size = fs::metadata(&archive_path)
|
||||||
|
.map(|m| m.len() as i64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
// Compute checksum
|
||||||
|
let checksum = compute_file_sha256(&archive_path);
|
||||||
|
|
||||||
|
// Record in database
|
||||||
|
db.insert_config_backup(
|
||||||
|
appimage_id,
|
||||||
|
Some(app_version),
|
||||||
|
&archive_path.to_string_lossy(),
|
||||||
|
archive_size,
|
||||||
|
checksum.as_deref(),
|
||||||
|
entries.len() as i32,
|
||||||
|
).ok();
|
||||||
|
|
||||||
|
Ok(archive_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore a backup from an archive.
|
||||||
|
pub fn restore_backup(archive_path: &Path) -> Result<RestoreResult, BackupError> {
|
||||||
|
if !archive_path.exists() {
|
||||||
|
return Err(BackupError::NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract manifest first
|
||||||
|
let manifest = read_manifest(archive_path)?;
|
||||||
|
|
||||||
|
// Extract all files
|
||||||
|
let temp_dir = tempfile::tempdir().map_err(|e| BackupError::Io(e.to_string()))?;
|
||||||
|
|
||||||
|
let status = Command::new("tar")
|
||||||
|
.args(["xzf", &archive_path.to_string_lossy(), "-C", &temp_dir.path().to_string_lossy()])
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.status()
|
||||||
|
.map_err(|e| BackupError::Io(format!("tar extract failed: {}", e)))?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
return Err(BackupError::Io("tar extraction failed".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore each path
|
||||||
|
let mut restored = 0u32;
|
||||||
|
let mut skipped = 0u32;
|
||||||
|
|
||||||
|
for entry in &manifest.paths {
|
||||||
|
let source_name = Path::new(&entry.original_path)
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let extracted = temp_dir.path().join(source_name);
|
||||||
|
let target = Path::new(&entry.original_path);
|
||||||
|
|
||||||
|
if !extracted.exists() {
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create parent directory
|
||||||
|
if let Some(parent) = target.parent() {
|
||||||
|
fs::create_dir_all(parent).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy files back
|
||||||
|
if extracted.is_dir() {
|
||||||
|
copy_dir_recursive(&extracted, target)
|
||||||
|
.map_err(|e| BackupError::Io(e.to_string()))?;
|
||||||
|
} else {
|
||||||
|
fs::copy(&extracted, target)
|
||||||
|
.map_err(|e| BackupError::Io(e.to_string()))?;
|
||||||
|
}
|
||||||
|
restored += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(RestoreResult {
|
||||||
|
manifest,
|
||||||
|
paths_restored: restored,
|
||||||
|
paths_skipped: skipped,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List available backups for an AppImage.
|
||||||
|
pub fn list_backups(db: &Database, appimage_id: Option<i64>) -> Vec<BackupInfo> {
|
||||||
|
let records = if let Some(id) = appimage_id {
|
||||||
|
db.get_config_backups(id).unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
db.get_all_config_backups().unwrap_or_default()
|
||||||
|
};
|
||||||
|
records.iter().map(|r| {
|
||||||
|
let exists = Path::new(&r.archive_path).exists();
|
||||||
|
BackupInfo {
|
||||||
|
id: r.id,
|
||||||
|
appimage_id: r.appimage_id,
|
||||||
|
app_version: r.app_version.clone(),
|
||||||
|
archive_path: r.archive_path.clone(),
|
||||||
|
archive_size: r.archive_size.unwrap_or(0),
|
||||||
|
created_at: r.created_at.clone(),
|
||||||
|
path_count: r.path_count.unwrap_or(0),
|
||||||
|
exists,
|
||||||
|
}
|
||||||
|
}).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a backup archive and its database record.
|
||||||
|
pub fn delete_backup(db: &Database, backup_id: i64) -> Result<(), BackupError> {
|
||||||
|
// Get backup info
|
||||||
|
let backups = db.get_all_config_backups().unwrap_or_default();
|
||||||
|
let backup = backups.iter().find(|b| b.id == backup_id)
|
||||||
|
.ok_or(BackupError::NotFound)?;
|
||||||
|
|
||||||
|
// Delete the file
|
||||||
|
let path = Path::new(&backup.archive_path);
|
||||||
|
if path.exists() {
|
||||||
|
fs::remove_file(path).map_err(|e| BackupError::Io(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the database record
|
||||||
|
db.delete_config_backup(backup_id)
|
||||||
|
.map_err(|e| BackupError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove backups older than the specified number of days.
|
||||||
|
pub fn auto_cleanup_old_backups(db: &Database, retention_days: u32) -> Result<u32, BackupError> {
|
||||||
|
let backups = db.get_all_config_backups().unwrap_or_default();
|
||||||
|
let cutoff = chrono::Utc::now() - chrono::Duration::days(retention_days as i64);
|
||||||
|
let cutoff_str = cutoff.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||||
|
|
||||||
|
let mut removed = 0u32;
|
||||||
|
for backup in &backups {
|
||||||
|
if backup.created_at < cutoff_str {
|
||||||
|
if let Ok(()) = delete_backup(db, backup.id) {
|
||||||
|
removed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(removed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helper types ---
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct BackupInfo {
|
||||||
|
pub id: i64,
|
||||||
|
pub appimage_id: i64,
|
||||||
|
pub app_version: Option<String>,
|
||||||
|
pub archive_path: String,
|
||||||
|
pub archive_size: i64,
|
||||||
|
pub created_at: String,
|
||||||
|
pub path_count: i32,
|
||||||
|
pub exists: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct RestoreResult {
|
||||||
|
pub manifest: BackupManifest,
|
||||||
|
pub paths_restored: u32,
|
||||||
|
pub paths_skipped: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum BackupError {
|
||||||
|
NotFound,
|
||||||
|
NoPaths,
|
||||||
|
Io(String),
|
||||||
|
Database(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for BackupError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::NotFound => write!(f, "Backup not found"),
|
||||||
|
Self::NoPaths => write!(f, "No config/data paths to back up"),
|
||||||
|
Self::Io(e) => write!(f, "I/O error: {}", e),
|
||||||
|
Self::Database(e) => write!(f, "Database error: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Utility functions ---
|
||||||
|
|
||||||
|
fn sanitize_filename(name: &str) -> String {
|
||||||
|
name.chars()
|
||||||
|
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c.to_ascii_lowercase() } else { '-' })
|
||||||
|
.collect::<String>()
|
||||||
|
.trim_matches('-')
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dir_size(path: &Path) -> u64 {
|
||||||
|
if path.is_file() {
|
||||||
|
return fs::metadata(path).map(|m| m.len()).unwrap_or(0);
|
||||||
|
}
|
||||||
|
let mut total = 0u64;
|
||||||
|
if let Ok(entries) = fs::read_dir(path) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let p = entry.path();
|
||||||
|
if p.is_dir() {
|
||||||
|
total += dir_size(&p);
|
||||||
|
} else {
|
||||||
|
total += fs::metadata(&p).map(|m| m.len()).unwrap_or(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_file_sha256(path: &Path) -> Option<String> {
|
||||||
|
let mut file = fs::File::open(path).ok()?;
|
||||||
|
use sha2::{Sha256, Digest};
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
let mut buf = [0u8; 8192];
|
||||||
|
loop {
|
||||||
|
let n = file.read(&mut buf).ok()?;
|
||||||
|
if n == 0 { break; }
|
||||||
|
hasher.update(&buf[..n]);
|
||||||
|
}
|
||||||
|
Some(format!("{:x}", hasher.finalize()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||||
|
fs::create_dir_all(dst)?;
|
||||||
|
for entry in fs::read_dir(src)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let src_path = entry.path();
|
||||||
|
let dst_path = dst.join(entry.file_name());
|
||||||
|
if src_path.is_dir() {
|
||||||
|
copy_dir_recursive(&src_path, &dst_path)?;
|
||||||
|
} else {
|
||||||
|
fs::copy(&src_path, &dst_path)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_manifest(archive_path: &Path) -> Result<BackupManifest, BackupError> {
|
||||||
|
let output = Command::new("tar")
|
||||||
|
.args(["xzf", &archive_path.to_string_lossy(), "-O", "manifest.json"])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| BackupError::Io(format!("tar extract manifest failed: {}", e)))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(BackupError::Io("Could not read manifest from archive".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::from_slice(&output.stdout)
|
||||||
|
.map_err(|e| BackupError::Io(format!("Invalid manifest: {}", e)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sanitize_filename() {
|
||||||
|
assert_eq!(sanitize_filename("Firefox"), "firefox");
|
||||||
|
assert_eq!(sanitize_filename("My Cool App"), "my-cool-app");
|
||||||
|
assert_eq!(sanitize_filename(" Spaces "), "spaces");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_backups_dir_path() {
|
||||||
|
let dir = backups_dir();
|
||||||
|
assert!(dir.to_string_lossy().contains("driftwood"));
|
||||||
|
assert!(dir.to_string_lossy().contains("backups"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_backup_error_display() {
|
||||||
|
assert_eq!(format!("{}", BackupError::NotFound), "Backup not found");
|
||||||
|
assert_eq!(format!("{}", BackupError::NoPaths), "No config/data paths to back up");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dir_size_empty() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
assert_eq!(dir_size(dir.path()), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dir_size_with_files() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let file = dir.path().join("test.txt");
|
||||||
|
fs::write(&file, "hello world").unwrap();
|
||||||
|
let size = dir_size(dir.path());
|
||||||
|
assert!(size > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
364
src/core/catalog.rs
Normal file
364
src/core/catalog.rs
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use super::database::Database;
|
||||||
|
|
||||||
|
/// A catalog source that can be synced to discover available AppImages.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CatalogSource {
|
||||||
|
pub id: Option<i64>,
|
||||||
|
pub name: String,
|
||||||
|
pub url: String,
|
||||||
|
pub source_type: CatalogType,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub last_synced: Option<String>,
|
||||||
|
pub app_count: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum CatalogType {
|
||||||
|
AppImageHub,
|
||||||
|
GitHubSearch,
|
||||||
|
Custom,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CatalogType {
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::AppImageHub => "appimage-hub",
|
||||||
|
Self::GitHubSearch => "github-search",
|
||||||
|
Self::Custom => "custom",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_str(s: &str) -> Self {
|
||||||
|
match s {
|
||||||
|
"appimage-hub" => Self::AppImageHub,
|
||||||
|
"github-search" => Self::GitHubSearch,
|
||||||
|
_ => Self::Custom,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An app entry from a catalog source.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CatalogApp {
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub categories: Vec<String>,
|
||||||
|
pub latest_version: Option<String>,
|
||||||
|
pub download_url: String,
|
||||||
|
pub icon_url: Option<String>,
|
||||||
|
pub homepage: Option<String>,
|
||||||
|
pub file_size: Option<u64>,
|
||||||
|
pub architecture: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default AppImageHub registry URL.
|
||||||
|
const APPIMAGEHUB_API_URL: &str = "https://appimage.github.io/feed.json";
|
||||||
|
|
||||||
|
/// Sync a catalog source - fetch the index and store entries in the database.
|
||||||
|
pub fn sync_catalog(db: &Database, source: &CatalogSource) -> Result<u32, CatalogError> {
|
||||||
|
let apps = match source.source_type {
|
||||||
|
CatalogType::AppImageHub => fetch_appimage_hub()?,
|
||||||
|
CatalogType::Custom => fetch_custom_catalog(&source.url)?,
|
||||||
|
CatalogType::GitHubSearch => {
|
||||||
|
// GitHub search requires a token and is more complex - stub for now
|
||||||
|
log::warn!("GitHub catalog search not yet implemented");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let source_id = source.id.ok_or(CatalogError::NoSourceId)?;
|
||||||
|
let mut count = 0u32;
|
||||||
|
|
||||||
|
for app in &apps {
|
||||||
|
db.insert_catalog_app(
|
||||||
|
source_id,
|
||||||
|
&app.name,
|
||||||
|
app.description.as_deref(),
|
||||||
|
Some(&app.categories.join(", ")),
|
||||||
|
app.latest_version.as_deref(),
|
||||||
|
&app.download_url,
|
||||||
|
app.icon_url.as_deref(),
|
||||||
|
app.homepage.as_deref(),
|
||||||
|
app.file_size.map(|s| s as i64),
|
||||||
|
app.architecture.as_deref(),
|
||||||
|
).ok();
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.update_catalog_source_sync(source_id, count as i32).ok();
|
||||||
|
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search the local catalog database for apps matching a query.
|
||||||
|
pub fn search_catalog(db: &Database, query: &str) -> Vec<CatalogApp> {
|
||||||
|
let records = db.search_catalog_apps(query).unwrap_or_default();
|
||||||
|
records.into_iter().map(|r| CatalogApp {
|
||||||
|
name: r.name,
|
||||||
|
description: r.description,
|
||||||
|
categories: r.categories
|
||||||
|
.map(|c| c.split(", ").map(String::from).collect())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
latest_version: r.latest_version,
|
||||||
|
download_url: r.download_url,
|
||||||
|
icon_url: r.icon_url,
|
||||||
|
homepage: r.homepage,
|
||||||
|
file_size: r.file_size.map(|s| s as u64),
|
||||||
|
architecture: r.architecture,
|
||||||
|
}).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download an AppImage from the catalog to a local directory.
|
||||||
|
pub fn install_from_catalog(app: &CatalogApp, install_dir: &Path) -> Result<PathBuf, CatalogError> {
|
||||||
|
fs::create_dir_all(install_dir).map_err(|e| CatalogError::Io(e.to_string()))?;
|
||||||
|
|
||||||
|
// Derive filename from URL
|
||||||
|
let filename = app.download_url
|
||||||
|
.rsplit('/')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("downloaded.AppImage");
|
||||||
|
|
||||||
|
let dest = install_dir.join(filename);
|
||||||
|
|
||||||
|
log::info!("Downloading {} to {}", app.download_url, dest.display());
|
||||||
|
|
||||||
|
let response = ureq::get(&app.download_url)
|
||||||
|
.call()
|
||||||
|
.map_err(|e| CatalogError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut file = fs::File::create(&dest)
|
||||||
|
.map_err(|e| CatalogError::Io(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut reader = response.into_body().into_reader();
|
||||||
|
let mut buf = [0u8; 65536];
|
||||||
|
loop {
|
||||||
|
let n = reader.read(&mut buf)
|
||||||
|
.map_err(|e| CatalogError::Network(e.to_string()))?;
|
||||||
|
if n == 0 { break; }
|
||||||
|
file.write_all(&buf[..n])
|
||||||
|
.map_err(|e| CatalogError::Io(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set executable permission
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let perms = fs::Permissions::from_mode(0o755);
|
||||||
|
fs::set_permissions(&dest, perms)
|
||||||
|
.map_err(|e| CatalogError::Io(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch the AppImageHub feed and parse it into CatalogApp entries.
|
||||||
|
fn fetch_appimage_hub() -> Result<Vec<CatalogApp>, CatalogError> {
|
||||||
|
let response = ureq::get(APPIMAGEHUB_API_URL)
|
||||||
|
.call()
|
||||||
|
.map_err(|e| CatalogError::Network(format!("AppImageHub fetch failed: {}", e)))?;
|
||||||
|
|
||||||
|
let body = response.into_body().read_to_string()
|
||||||
|
.map_err(|e| CatalogError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
let feed: AppImageHubFeed = serde_json::from_str(&body)
|
||||||
|
.map_err(|e| CatalogError::Parse(format!("AppImageHub JSON parse failed: {}", e)))?;
|
||||||
|
|
||||||
|
let apps: Vec<CatalogApp> = feed.items.into_iter().filter_map(|item| {
|
||||||
|
// AppImageHub items need at least a name and a link
|
||||||
|
let name = item.name?;
|
||||||
|
let download_url = item.links.into_iter()
|
||||||
|
.find(|l| l.r#type == "Download")
|
||||||
|
.map(|l| l.url)?;
|
||||||
|
|
||||||
|
Some(CatalogApp {
|
||||||
|
name,
|
||||||
|
description: item.description,
|
||||||
|
categories: item.categories.unwrap_or_default(),
|
||||||
|
latest_version: None,
|
||||||
|
download_url,
|
||||||
|
icon_url: item.icons.and_then(|icons| icons.into_iter().next()),
|
||||||
|
homepage: item.authors.and_then(|a| {
|
||||||
|
let first = a.into_iter().next()?;
|
||||||
|
if let Some(ref author_name) = first.name {
|
||||||
|
log::debug!("Catalog app author: {}", author_name);
|
||||||
|
}
|
||||||
|
first.url
|
||||||
|
}),
|
||||||
|
file_size: None,
|
||||||
|
architecture: None,
|
||||||
|
})
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
Ok(apps)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch a custom catalog from a URL (expects a JSON array of CatalogApp-like objects).
|
||||||
|
fn fetch_custom_catalog(url: &str) -> Result<Vec<CatalogApp>, CatalogError> {
|
||||||
|
let response = ureq::get(url)
|
||||||
|
.call()
|
||||||
|
.map_err(|e| CatalogError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
let body = response.into_body().read_to_string()
|
||||||
|
.map_err(|e| CatalogError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
let items: Vec<CustomCatalogEntry> = serde_json::from_str(&body)
|
||||||
|
.map_err(|e| CatalogError::Parse(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(items.into_iter().map(|item| CatalogApp {
|
||||||
|
name: item.name,
|
||||||
|
description: item.description,
|
||||||
|
categories: item.categories.unwrap_or_default(),
|
||||||
|
latest_version: item.version,
|
||||||
|
download_url: item.download_url,
|
||||||
|
icon_url: item.icon_url,
|
||||||
|
homepage: item.homepage,
|
||||||
|
file_size: item.file_size,
|
||||||
|
architecture: item.architecture,
|
||||||
|
}).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure the default AppImageHub source exists in the database.
|
||||||
|
pub fn ensure_default_sources(db: &Database) {
|
||||||
|
db.upsert_catalog_source(
|
||||||
|
"AppImageHub",
|
||||||
|
APPIMAGEHUB_API_URL,
|
||||||
|
"appimage-hub",
|
||||||
|
).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all catalog sources from the database.
|
||||||
|
pub fn get_sources(db: &Database) -> Vec<CatalogSource> {
|
||||||
|
let records = db.get_catalog_sources().unwrap_or_default();
|
||||||
|
records.into_iter().map(|r| CatalogSource {
|
||||||
|
id: Some(r.id),
|
||||||
|
name: r.name,
|
||||||
|
url: r.url,
|
||||||
|
source_type: CatalogType::from_str(&r.source_type),
|
||||||
|
enabled: r.enabled,
|
||||||
|
last_synced: r.last_synced,
|
||||||
|
app_count: r.app_count,
|
||||||
|
}).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- AppImageHub feed format ---
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct AppImageHubFeed {
|
||||||
|
items: Vec<AppImageHubItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct AppImageHubItem {
|
||||||
|
name: Option<String>,
|
||||||
|
description: Option<String>,
|
||||||
|
categories: Option<Vec<String>>,
|
||||||
|
authors: Option<Vec<AppImageHubAuthor>>,
|
||||||
|
links: Vec<AppImageHubLink>,
|
||||||
|
icons: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct AppImageHubAuthor {
|
||||||
|
name: Option<String>,
|
||||||
|
url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct AppImageHubLink {
|
||||||
|
r#type: String,
|
||||||
|
url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Custom catalog entry format ---
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct CustomCatalogEntry {
|
||||||
|
name: String,
|
||||||
|
description: Option<String>,
|
||||||
|
categories: Option<Vec<String>>,
|
||||||
|
version: Option<String>,
|
||||||
|
download_url: String,
|
||||||
|
icon_url: Option<String>,
|
||||||
|
homepage: Option<String>,
|
||||||
|
file_size: Option<u64>,
|
||||||
|
architecture: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Error types ---
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum CatalogError {
|
||||||
|
Network(String),
|
||||||
|
Parse(String),
|
||||||
|
Io(String),
|
||||||
|
NoSourceId,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for CatalogError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Network(e) => write!(f, "Network error: {}", e),
|
||||||
|
Self::Parse(e) => write!(f, "Parse error: {}", e),
|
||||||
|
Self::Io(e) => write!(f, "I/O error: {}", e),
|
||||||
|
Self::NoSourceId => write!(f, "Catalog source has no ID"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
use std::io::Read;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_catalog_type_roundtrip() {
|
||||||
|
assert_eq!(CatalogType::from_str("appimage-hub"), CatalogType::AppImageHub);
|
||||||
|
assert_eq!(CatalogType::from_str("github-search"), CatalogType::GitHubSearch);
|
||||||
|
assert_eq!(CatalogType::from_str("custom"), CatalogType::Custom);
|
||||||
|
assert_eq!(CatalogType::from_str("unknown"), CatalogType::Custom);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_catalog_type_as_str() {
|
||||||
|
assert_eq!(CatalogType::AppImageHub.as_str(), "appimage-hub");
|
||||||
|
assert_eq!(CatalogType::GitHubSearch.as_str(), "github-search");
|
||||||
|
assert_eq!(CatalogType::Custom.as_str(), "custom");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_catalog_error_display() {
|
||||||
|
let err = CatalogError::Network("timeout".to_string());
|
||||||
|
assert!(format!("{}", err).contains("timeout"));
|
||||||
|
let err = CatalogError::NoSourceId;
|
||||||
|
assert!(format!("{}", err).contains("no ID"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ensure_default_sources() {
|
||||||
|
let db = crate::core::database::Database::open_in_memory().unwrap();
|
||||||
|
ensure_default_sources(&db);
|
||||||
|
let sources = get_sources(&db);
|
||||||
|
assert_eq!(sources.len(), 1);
|
||||||
|
assert_eq!(sources[0].name, "AppImageHub");
|
||||||
|
assert_eq!(sources[0].source_type, CatalogType::AppImageHub);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_search_catalog_empty() {
|
||||||
|
let db = crate::core::database::Database::open_in_memory().unwrap();
|
||||||
|
let results = search_catalog(&db, "firefox");
|
||||||
|
assert!(results.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_sources_empty() {
|
||||||
|
let db = crate::core::database::Database::open_in_memory().unwrap();
|
||||||
|
let sources = get_sources(&db);
|
||||||
|
assert!(sources.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
1076
src/core/database.rs
1076
src/core/database.rs
File diff suppressed because it is too large
Load Diff
@@ -48,7 +48,7 @@ pub fn expand_tilde(path: &str) -> PathBuf {
|
|||||||
/// ELF magic at offset 0: 0x7F 'E' 'L' 'F'
|
/// ELF magic at offset 0: 0x7F 'E' 'L' 'F'
|
||||||
/// AppImage Type 2 at offset 8: 'A' 'I' 0x02
|
/// AppImage Type 2 at offset 8: 'A' 'I' 0x02
|
||||||
/// AppImage Type 1 at offset 8: 'A' 'I' 0x01
|
/// AppImage Type 1 at offset 8: 'A' 'I' 0x01
|
||||||
fn detect_appimage(path: &Path) -> Option<AppImageType> {
|
pub fn detect_appimage(path: &Path) -> Option<AppImageType> {
|
||||||
let mut file = File::open(path).ok()?;
|
let mut file = File::open(path).ok()?;
|
||||||
let mut header = [0u8; 16];
|
let mut header = [0u8; 16];
|
||||||
file.read_exact(&mut header).ok()?;
|
file.read_exact(&mut header).ok()?;
|
||||||
@@ -153,6 +153,15 @@ pub fn scan_directories(dirs: &[String]) -> Vec<DiscoveredAppImage> {
|
|||||||
results
|
results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compute the SHA-256 hash of a file, returned as a lowercase hex string.
|
||||||
|
pub fn compute_sha256(path: &Path) -> std::io::Result<String> {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let mut file = File::open(path)?;
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
std::io::copy(&mut file, &mut hasher)?;
|
||||||
|
Ok(format!("{:x}", hasher.finalize()))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -405,6 +405,14 @@ mod tests {
|
|||||||
update_checked: None,
|
update_checked: None,
|
||||||
update_url: None,
|
update_url: None,
|
||||||
notes: None,
|
notes: None,
|
||||||
|
sandbox_mode: None,
|
||||||
|
runtime_wayland_status: None,
|
||||||
|
runtime_wayland_checked: None,
|
||||||
|
analysis_status: None,
|
||||||
|
launch_args: None,
|
||||||
|
tags: None,
|
||||||
|
pinned: false,
|
||||||
|
avg_startup_ms: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
479
src/core/footprint.rs
Normal file
479
src/core/footprint.rs
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use super::database::Database;
|
||||||
|
|
||||||
|
/// A discovered data/config/cache path for an AppImage.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DiscoveredPath {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub path_type: PathType,
|
||||||
|
pub discovery_method: DiscoveryMethod,
|
||||||
|
pub confidence: Confidence,
|
||||||
|
pub size_bytes: u64,
|
||||||
|
pub exists: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum PathType {
|
||||||
|
Config,
|
||||||
|
Data,
|
||||||
|
Cache,
|
||||||
|
State,
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PathType {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
PathType::Config => "config",
|
||||||
|
PathType::Data => "data",
|
||||||
|
PathType::Cache => "cache",
|
||||||
|
PathType::State => "state",
|
||||||
|
PathType::Other => "other",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
PathType::Config => "Configuration",
|
||||||
|
PathType::Data => "Data",
|
||||||
|
PathType::Cache => "Cache",
|
||||||
|
PathType::State => "State",
|
||||||
|
PathType::Other => "Other",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn icon_name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
PathType::Config => "preferences-system-symbolic",
|
||||||
|
PathType::Data => "folder-documents-symbolic",
|
||||||
|
PathType::Cache => "user-trash-symbolic",
|
||||||
|
PathType::State => "document-properties-symbolic",
|
||||||
|
PathType::Other => "folder-symbolic",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum DiscoveryMethod {
|
||||||
|
/// Matched by desktop entry ID or WM class
|
||||||
|
DesktopId,
|
||||||
|
/// Matched by app name in XDG directory
|
||||||
|
NameMatch,
|
||||||
|
/// Matched by executable name
|
||||||
|
ExecMatch,
|
||||||
|
/// Matched by binary name extracted from AppImage
|
||||||
|
BinaryMatch,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiscoveryMethod {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
DiscoveryMethod::DesktopId => "desktop_id",
|
||||||
|
DiscoveryMethod::NameMatch => "name_match",
|
||||||
|
DiscoveryMethod::ExecMatch => "exec_match",
|
||||||
|
DiscoveryMethod::BinaryMatch => "binary_match",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum Confidence {
|
||||||
|
High,
|
||||||
|
Medium,
|
||||||
|
Low,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Confidence {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Confidence::High => "high",
|
||||||
|
Confidence::Medium => "medium",
|
||||||
|
Confidence::Low => "low",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn badge_class(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Confidence::High => "success",
|
||||||
|
Confidence::Medium => "warning",
|
||||||
|
Confidence::Low => "neutral",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Summary of an AppImage's disk footprint.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct FootprintSummary {
|
||||||
|
pub appimage_size: u64,
|
||||||
|
pub config_size: u64,
|
||||||
|
pub data_size: u64,
|
||||||
|
pub cache_size: u64,
|
||||||
|
pub state_size: u64,
|
||||||
|
pub other_size: u64,
|
||||||
|
pub paths: Vec<DiscoveredPath>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FootprintSummary {
|
||||||
|
pub fn total_size(&self) -> u64 {
|
||||||
|
self.appimage_size + self.config_size + self.data_size
|
||||||
|
+ self.cache_size + self.state_size + self.other_size
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn data_total(&self) -> u64 {
|
||||||
|
self.config_size + self.data_size + self.cache_size
|
||||||
|
+ self.state_size + self.other_size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discover config/data/cache paths for an AppImage by searching XDG directories
|
||||||
|
/// for name variations.
|
||||||
|
pub fn discover_app_paths(
|
||||||
|
app_name: Option<&str>,
|
||||||
|
filename: &str,
|
||||||
|
desktop_entry_content: Option<&str>,
|
||||||
|
) -> Vec<DiscoveredPath> {
|
||||||
|
let mut results = Vec::new();
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
|
||||||
|
// Build search terms from available identity information
|
||||||
|
let mut search_terms: Vec<(String, DiscoveryMethod, Confidence)> = Vec::new();
|
||||||
|
|
||||||
|
// From desktop entry: extract desktop file ID and WM class
|
||||||
|
if let Some(content) = desktop_entry_content {
|
||||||
|
if let Some(wm_class) = extract_desktop_key(content, "StartupWMClass") {
|
||||||
|
let lower = wm_class.to_lowercase();
|
||||||
|
search_terms.push((lower.clone(), DiscoveryMethod::DesktopId, Confidence::High));
|
||||||
|
search_terms.push((wm_class.clone(), DiscoveryMethod::DesktopId, Confidence::High));
|
||||||
|
}
|
||||||
|
if let Some(exec) = extract_desktop_key(content, "Exec") {
|
||||||
|
// Extract just the binary name from the Exec line
|
||||||
|
let binary = exec.split_whitespace().next().unwrap_or(&exec);
|
||||||
|
let binary_name = Path::new(binary)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or(binary);
|
||||||
|
if !binary_name.is_empty() && binary_name != "AppRun" {
|
||||||
|
let lower = binary_name.to_lowercase();
|
||||||
|
search_terms.push((lower, DiscoveryMethod::ExecMatch, Confidence::Medium));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// From app name
|
||||||
|
if let Some(name) = app_name {
|
||||||
|
let lower = name.to_lowercase();
|
||||||
|
// Remove spaces and special chars for directory matching
|
||||||
|
let sanitized = lower.replace(' ', "").replace('-', "");
|
||||||
|
search_terms.push((lower.clone(), DiscoveryMethod::NameMatch, Confidence::Medium));
|
||||||
|
if sanitized != lower {
|
||||||
|
search_terms.push((sanitized, DiscoveryMethod::NameMatch, Confidence::Low));
|
||||||
|
}
|
||||||
|
// Also try with hyphens
|
||||||
|
let hyphenated = lower.replace(' ', "-");
|
||||||
|
if hyphenated != lower {
|
||||||
|
search_terms.push((hyphenated, DiscoveryMethod::NameMatch, Confidence::Medium));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// From filename (strip .AppImage extension and version suffixes)
|
||||||
|
let stem = filename
|
||||||
|
.strip_suffix(".AppImage")
|
||||||
|
.or_else(|| filename.strip_suffix(".appimage"))
|
||||||
|
.unwrap_or(filename);
|
||||||
|
// Strip version suffix like -1.2.3 or _v1.2
|
||||||
|
let base = strip_version_suffix(stem);
|
||||||
|
let lower = base.to_lowercase();
|
||||||
|
search_terms.push((lower, DiscoveryMethod::BinaryMatch, Confidence::Low));
|
||||||
|
|
||||||
|
// XDG base directories
|
||||||
|
let home = match std::env::var("HOME") {
|
||||||
|
Ok(h) => PathBuf::from(h),
|
||||||
|
Err(_) => return results,
|
||||||
|
};
|
||||||
|
|
||||||
|
let xdg_config = std::env::var("XDG_CONFIG_HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| home.join(".config"));
|
||||||
|
let xdg_data = std::env::var("XDG_DATA_HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| home.join(".local/share"));
|
||||||
|
let xdg_cache = std::env::var("XDG_CACHE_HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| home.join(".cache"));
|
||||||
|
let xdg_state = std::env::var("XDG_STATE_HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| home.join(".local/state"));
|
||||||
|
|
||||||
|
let search_dirs = [
|
||||||
|
(&xdg_config, PathType::Config),
|
||||||
|
(&xdg_data, PathType::Data),
|
||||||
|
(&xdg_cache, PathType::Cache),
|
||||||
|
(&xdg_state, PathType::State),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Also search legacy dotfiles in $HOME
|
||||||
|
for (term, method, confidence) in &search_terms {
|
||||||
|
// Search XDG directories
|
||||||
|
for (base_dir, path_type) in &search_dirs {
|
||||||
|
if !base_dir.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try exact match and case-insensitive match
|
||||||
|
let entries = match std::fs::read_dir(base_dir) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let entry_name = entry.file_name();
|
||||||
|
let entry_str = entry_name.to_string_lossy();
|
||||||
|
let entry_lower = entry_str.to_lowercase();
|
||||||
|
|
||||||
|
if entry_lower == *term || entry_lower.starts_with(&format!("{}.", term))
|
||||||
|
|| entry_lower.starts_with(&format!("{}-", term))
|
||||||
|
{
|
||||||
|
let full_path = entry.path();
|
||||||
|
if seen.contains(&full_path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.insert(full_path.clone());
|
||||||
|
|
||||||
|
let size = dir_size(&full_path);
|
||||||
|
results.push(DiscoveredPath {
|
||||||
|
path: full_path,
|
||||||
|
path_type: *path_type,
|
||||||
|
discovery_method: *method,
|
||||||
|
confidence: *confidence,
|
||||||
|
size_bytes: size,
|
||||||
|
exists: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for legacy dotfiles/dotdirs in $HOME (e.g., ~/.appname)
|
||||||
|
let dotdir = home.join(format!(".{}", term));
|
||||||
|
if dotdir.exists() && !seen.contains(&dotdir) {
|
||||||
|
seen.insert(dotdir.clone());
|
||||||
|
let size = dir_size(&dotdir);
|
||||||
|
results.push(DiscoveredPath {
|
||||||
|
path: dotdir,
|
||||||
|
path_type: PathType::Config,
|
||||||
|
discovery_method: *method,
|
||||||
|
confidence: *confidence,
|
||||||
|
size_bytes: size,
|
||||||
|
exists: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: high confidence first, then by path type
|
||||||
|
results.sort_by(|a, b| {
|
||||||
|
let conf_ord = confidence_rank(&a.confidence).cmp(&confidence_rank(&b.confidence));
|
||||||
|
if conf_ord != std::cmp::Ordering::Equal {
|
||||||
|
return conf_ord;
|
||||||
|
}
|
||||||
|
a.path_type.as_str().cmp(b.path_type.as_str())
|
||||||
|
});
|
||||||
|
|
||||||
|
results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discover paths and store them in the database.
|
||||||
|
pub fn discover_and_store(db: &Database, appimage_id: i64, record: &crate::core::database::AppImageRecord) {
|
||||||
|
let paths = discover_app_paths(
|
||||||
|
record.app_name.as_deref(),
|
||||||
|
&record.filename,
|
||||||
|
record.desktop_entry_content.as_deref(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Err(e) = db.clear_app_data_paths(appimage_id) {
|
||||||
|
log::warn!("Failed to clear app data paths for id {}: {}", appimage_id, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
for dp in &paths {
|
||||||
|
if let Err(e) = db.insert_app_data_path(
|
||||||
|
appimage_id,
|
||||||
|
&dp.path.to_string_lossy(),
|
||||||
|
dp.path_type.as_str(),
|
||||||
|
dp.discovery_method.as_str(),
|
||||||
|
dp.confidence.as_str(),
|
||||||
|
dp.size_bytes as i64,
|
||||||
|
) {
|
||||||
|
log::warn!("Failed to insert app data path '{}' for id {}: {}", dp.path.display(), appimage_id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a complete footprint summary for an AppImage.
|
||||||
|
pub fn get_footprint(db: &Database, appimage_id: i64, appimage_size: u64) -> FootprintSummary {
|
||||||
|
let stored = db.get_app_data_paths(appimage_id).unwrap_or_default();
|
||||||
|
|
||||||
|
let mut summary = FootprintSummary {
|
||||||
|
appimage_size,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
for record in &stored {
|
||||||
|
let dp = DiscoveredPath {
|
||||||
|
path: PathBuf::from(&record.path),
|
||||||
|
path_type: match record.path_type.as_str() {
|
||||||
|
"config" => PathType::Config,
|
||||||
|
"data" => PathType::Data,
|
||||||
|
"cache" => PathType::Cache,
|
||||||
|
"state" => PathType::State,
|
||||||
|
_ => PathType::Other,
|
||||||
|
},
|
||||||
|
discovery_method: match record.discovery_method.as_str() {
|
||||||
|
"desktop_id" => DiscoveryMethod::DesktopId,
|
||||||
|
"name_match" => DiscoveryMethod::NameMatch,
|
||||||
|
"exec_match" => DiscoveryMethod::ExecMatch,
|
||||||
|
_ => DiscoveryMethod::BinaryMatch,
|
||||||
|
},
|
||||||
|
confidence: match record.confidence.as_str() {
|
||||||
|
"high" => Confidence::High,
|
||||||
|
"medium" => Confidence::Medium,
|
||||||
|
_ => Confidence::Low,
|
||||||
|
},
|
||||||
|
size_bytes: record.size_bytes as u64,
|
||||||
|
exists: Path::new(&record.path).exists(),
|
||||||
|
};
|
||||||
|
|
||||||
|
match dp.path_type {
|
||||||
|
PathType::Config => summary.config_size += dp.size_bytes,
|
||||||
|
PathType::Data => summary.data_size += dp.size_bytes,
|
||||||
|
PathType::Cache => summary.cache_size += dp.size_bytes,
|
||||||
|
PathType::State => summary.state_size += dp.size_bytes,
|
||||||
|
PathType::Other => summary.other_size += dp.size_bytes,
|
||||||
|
}
|
||||||
|
summary.paths.push(dp);
|
||||||
|
}
|
||||||
|
|
||||||
|
summary
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
fn extract_desktop_key<'a>(content: &'a str, key: &str) -> Option<String> {
|
||||||
|
for line in content.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.starts_with('[') && trimmed != "[Desktop Entry]" {
|
||||||
|
break; // Only look in [Desktop Entry] section
|
||||||
|
}
|
||||||
|
if let Some(rest) = trimmed.strip_prefix(key) {
|
||||||
|
let rest = rest.trim_start();
|
||||||
|
if let Some(value) = rest.strip_prefix('=') {
|
||||||
|
return Some(value.trim().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_version_suffix(name: &str) -> &str {
|
||||||
|
// Strip trailing version patterns like -1.2.3, _v2.0, -x86_64
|
||||||
|
// Check for known arch suffixes first (may contain underscores)
|
||||||
|
for suffix in &["-x86_64", "-aarch64", "-arm64", "-x86", "_x86_64", "_aarch64"] {
|
||||||
|
if let Some(stripped) = name.strip_suffix(suffix) {
|
||||||
|
return strip_version_suffix(stripped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Find last hyphen or underscore followed by a digit or 'v'
|
||||||
|
if let Some(pos) = name.rfind(|c: char| c == '-' || c == '_') {
|
||||||
|
let after = &name[pos + 1..];
|
||||||
|
if after.starts_with(|c: char| c.is_ascii_digit() || c == 'v') {
|
||||||
|
return &name[..pos];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
name
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate the total size of a file or directory recursively.
|
||||||
|
pub fn dir_size_pub(path: &Path) -> u64 {
|
||||||
|
dir_size(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dir_size(path: &Path) -> u64 {
|
||||||
|
if path.is_file() {
|
||||||
|
return path.metadata().map(|m| m.len()).unwrap_or(0);
|
||||||
|
}
|
||||||
|
let mut total = 0u64;
|
||||||
|
if let Ok(entries) = std::fs::read_dir(path) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let ft = match entry.file_type() {
|
||||||
|
Ok(ft) => ft,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if ft.is_file() {
|
||||||
|
total += entry.metadata().map(|m| m.len()).unwrap_or(0);
|
||||||
|
} else if ft.is_dir() {
|
||||||
|
total += dir_size(&entry.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total
|
||||||
|
}
|
||||||
|
|
||||||
|
fn confidence_rank(c: &Confidence) -> u8 {
|
||||||
|
match c {
|
||||||
|
Confidence::High => 0,
|
||||||
|
Confidence::Medium => 1,
|
||||||
|
Confidence::Low => 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_strip_version_suffix() {
|
||||||
|
assert_eq!(strip_version_suffix("MyApp-1.2.3"), "MyApp");
|
||||||
|
assert_eq!(strip_version_suffix("MyApp_v2.0"), "MyApp");
|
||||||
|
assert_eq!(strip_version_suffix("MyApp-x86_64"), "MyApp");
|
||||||
|
assert_eq!(strip_version_suffix("MyApp"), "MyApp");
|
||||||
|
assert_eq!(strip_version_suffix("My-App"), "My-App");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_desktop_key() {
|
||||||
|
let content = "[Desktop Entry]\nName=Test App\nExec=/usr/bin/test --flag\nStartupWMClass=testapp\n\n[Actions]\nNew=new";
|
||||||
|
assert_eq!(extract_desktop_key(content, "Name"), Some("Test App".into()));
|
||||||
|
assert_eq!(extract_desktop_key(content, "Exec"), Some("/usr/bin/test --flag".into()));
|
||||||
|
assert_eq!(extract_desktop_key(content, "StartupWMClass"), Some("testapp".into()));
|
||||||
|
// Should not find keys in other sections
|
||||||
|
assert_eq!(extract_desktop_key(content, "New"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_path_type_labels() {
|
||||||
|
assert_eq!(PathType::Config.as_str(), "config");
|
||||||
|
assert_eq!(PathType::Data.as_str(), "data");
|
||||||
|
assert_eq!(PathType::Cache.as_str(), "cache");
|
||||||
|
assert_eq!(PathType::Cache.label(), "Cache");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_confidence_badge() {
|
||||||
|
assert_eq!(Confidence::High.badge_class(), "success");
|
||||||
|
assert_eq!(Confidence::Medium.badge_class(), "warning");
|
||||||
|
assert_eq!(Confidence::Low.badge_class(), "neutral");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_footprint_summary_totals() {
|
||||||
|
let summary = FootprintSummary {
|
||||||
|
appimage_size: 100,
|
||||||
|
config_size: 10,
|
||||||
|
data_size: 20,
|
||||||
|
cache_size: 30,
|
||||||
|
state_size: 5,
|
||||||
|
other_size: 0,
|
||||||
|
paths: Vec::new(),
|
||||||
|
};
|
||||||
|
assert_eq!(summary.total_size(), 165);
|
||||||
|
assert_eq!(summary.data_total(), 65);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -261,6 +261,14 @@ mod tests {
|
|||||||
update_checked: None,
|
update_checked: None,
|
||||||
update_url: None,
|
update_url: None,
|
||||||
notes: None,
|
notes: None,
|
||||||
|
sandbox_mode: None,
|
||||||
|
runtime_wayland_status: None,
|
||||||
|
runtime_wayland_checked: None,
|
||||||
|
analysis_status: None,
|
||||||
|
launch_args: None,
|
||||||
|
tags: None,
|
||||||
|
pinned: false,
|
||||||
|
avg_startup_ms: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// We can't easily test the full integrate() without mocking dirs,
|
// We can't easily test the full integrate() without mocking dirs,
|
||||||
|
|||||||
@@ -4,6 +4,36 @@ use std::process::{Child, Command, Stdio};
|
|||||||
use super::database::Database;
|
use super::database::Database;
|
||||||
use super::fuse::{detect_system_fuse, determine_app_fuse_status, AppImageFuseStatus};
|
use super::fuse::{detect_system_fuse, determine_app_fuse_status, AppImageFuseStatus};
|
||||||
|
|
||||||
|
/// Sandbox mode for running AppImages.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum SandboxMode {
|
||||||
|
None,
|
||||||
|
Firejail,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SandboxMode {
|
||||||
|
pub fn from_str(s: &str) -> Self {
|
||||||
|
match s {
|
||||||
|
"firejail" => Self::Firejail,
|
||||||
|
_ => Self::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::None => "none",
|
||||||
|
Self::Firejail => "firejail",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn display_label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::None => "None",
|
||||||
|
Self::Firejail => "Firejail",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Launch method used for the AppImage.
|
/// Launch method used for the AppImage.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum LaunchMethod {
|
pub enum LaunchMethod {
|
||||||
@@ -137,6 +167,13 @@ fn execute_appimage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse a launch_args string from the database into a Vec of individual arguments.
|
||||||
|
/// Splits on whitespace; returns an empty Vec if the input is None or empty.
|
||||||
|
pub fn parse_launch_args(args: Option<&str>) -> Vec<String> {
|
||||||
|
args.map(|s| s.split_whitespace().map(String::from).collect())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if firejail is available for sandboxed launches.
|
/// Check if firejail is available for sandboxed launches.
|
||||||
pub fn has_firejail() -> bool {
|
pub fn has_firejail() -> bool {
|
||||||
Command::new("firejail")
|
Command::new("firejail")
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
|
pub mod analysis;
|
||||||
|
pub mod backup;
|
||||||
pub mod database;
|
pub mod database;
|
||||||
pub mod discovery;
|
pub mod discovery;
|
||||||
pub mod duplicates;
|
pub mod duplicates;
|
||||||
|
pub mod footprint;
|
||||||
pub mod fuse;
|
pub mod fuse;
|
||||||
pub mod inspector;
|
pub mod inspector;
|
||||||
pub mod integrator;
|
pub mod integrator;
|
||||||
pub mod launcher;
|
pub mod launcher;
|
||||||
|
pub mod notification;
|
||||||
pub mod orphan;
|
pub mod orphan;
|
||||||
|
pub mod report;
|
||||||
|
pub mod security;
|
||||||
pub mod updater;
|
pub mod updater;
|
||||||
|
pub mod watcher;
|
||||||
pub mod wayland;
|
pub mod wayland;
|
||||||
|
|||||||
203
src/core/notification.rs
Normal file
203
src/core/notification.rs
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
use super::database::Database;
|
||||||
|
use super::security;
|
||||||
|
|
||||||
|
/// A CVE notification to send to the user.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CveNotification {
|
||||||
|
pub app_name: String,
|
||||||
|
pub appimage_id: i64,
|
||||||
|
pub severity: String,
|
||||||
|
pub cve_count: usize,
|
||||||
|
pub affected_libraries: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check for new CVEs and send desktop notifications for any new findings.
|
||||||
|
/// Returns the list of notifications that were sent.
|
||||||
|
pub fn check_and_notify(db: &Database, threshold: &str) -> Vec<CveNotification> {
|
||||||
|
let records = match db.get_all_appimages() {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to get appimages for notification check: {}", e);
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let min_severity = severity_rank(threshold);
|
||||||
|
let mut notifications = Vec::new();
|
||||||
|
|
||||||
|
for record in &records {
|
||||||
|
let path = std::path::Path::new(&record.path);
|
||||||
|
if !path.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current CVE matches from database
|
||||||
|
let cve_matches = db.get_cve_matches(record.id).unwrap_or_default();
|
||||||
|
|
||||||
|
let mut new_cves = Vec::new();
|
||||||
|
let mut affected_libs = Vec::new();
|
||||||
|
let mut max_severity = String::new();
|
||||||
|
let mut max_severity_rank = 0u8;
|
||||||
|
|
||||||
|
for m in &cve_matches {
|
||||||
|
let sev = m.severity.as_deref().unwrap_or("MEDIUM");
|
||||||
|
let rank = severity_rank(sev);
|
||||||
|
|
||||||
|
// Skip if below threshold
|
||||||
|
if rank < min_severity {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already notified
|
||||||
|
if db.has_cve_been_notified(record.id, &m.cve_id).unwrap_or(true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
new_cves.push(m.cve_id.clone());
|
||||||
|
|
||||||
|
let lib_name = m.library_name.as_deref()
|
||||||
|
.unwrap_or(&m.library_soname);
|
||||||
|
if !affected_libs.contains(&lib_name.to_string()) {
|
||||||
|
affected_libs.push(lib_name.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if rank > max_severity_rank {
|
||||||
|
max_severity_rank = rank;
|
||||||
|
max_severity = sev.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if new_cves.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let app_name = record.app_name.as_deref()
|
||||||
|
.unwrap_or(&record.filename)
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let notif = CveNotification {
|
||||||
|
app_name: app_name.clone(),
|
||||||
|
appimage_id: record.id,
|
||||||
|
severity: max_severity,
|
||||||
|
cve_count: new_cves.len(),
|
||||||
|
affected_libraries: affected_libs,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send desktop notification
|
||||||
|
if send_desktop_notification(¬if).is_ok() {
|
||||||
|
// Mark all as notified
|
||||||
|
for cve_id in &new_cves {
|
||||||
|
let sev = cve_matches.iter()
|
||||||
|
.find(|m| m.cve_id == *cve_id)
|
||||||
|
.and_then(|m| m.severity.as_deref())
|
||||||
|
.unwrap_or("MEDIUM");
|
||||||
|
db.mark_cve_notified(record.id, cve_id, sev).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
notifications.push(notif);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notifications
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a desktop notification for a CVE finding.
|
||||||
|
fn send_desktop_notification(notif: &CveNotification) -> Result<(), NotificationError> {
|
||||||
|
let summary = format!(
|
||||||
|
"Security: {} new CVE{} in {}",
|
||||||
|
notif.cve_count,
|
||||||
|
if notif.cve_count == 1 { "" } else { "s" },
|
||||||
|
notif.app_name,
|
||||||
|
);
|
||||||
|
|
||||||
|
let body = format!(
|
||||||
|
"Severity: {} - Affected: {}",
|
||||||
|
notif.severity,
|
||||||
|
notif.affected_libraries.join(", "),
|
||||||
|
);
|
||||||
|
|
||||||
|
let urgency = match notif.severity.as_str() {
|
||||||
|
"CRITICAL" => notify_rust::Urgency::Critical,
|
||||||
|
"HIGH" => notify_rust::Urgency::Normal,
|
||||||
|
_ => notify_rust::Urgency::Low,
|
||||||
|
};
|
||||||
|
|
||||||
|
notify_rust::Notification::new()
|
||||||
|
.appname("Driftwood")
|
||||||
|
.summary(&summary)
|
||||||
|
.body(&body)
|
||||||
|
.icon("security-medium")
|
||||||
|
.urgency(urgency)
|
||||||
|
.timeout(notify_rust::Timeout::Milliseconds(10000))
|
||||||
|
.show()
|
||||||
|
.map_err(|e| NotificationError::SendFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a security scan and send notifications for any new findings.
|
||||||
|
/// This is the CLI entry point for `driftwood security --notify`.
|
||||||
|
pub fn scan_and_notify(db: &Database, threshold: &str) -> Vec<CveNotification> {
|
||||||
|
// First run a batch scan to get fresh data
|
||||||
|
let _results = security::batch_scan(db);
|
||||||
|
|
||||||
|
// Then check for new notifications
|
||||||
|
check_and_notify(db, threshold)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn severity_rank(severity: &str) -> u8 {
|
||||||
|
match severity.to_uppercase().as_str() {
|
||||||
|
"CRITICAL" => 4,
|
||||||
|
"HIGH" => 3,
|
||||||
|
"MEDIUM" => 2,
|
||||||
|
"LOW" => 1,
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum NotificationError {
|
||||||
|
SendFailed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for NotificationError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::SendFailed(e) => write!(f, "Failed to send notification: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_severity_rank() {
|
||||||
|
assert_eq!(severity_rank("CRITICAL"), 4);
|
||||||
|
assert_eq!(severity_rank("HIGH"), 3);
|
||||||
|
assert_eq!(severity_rank("MEDIUM"), 2);
|
||||||
|
assert_eq!(severity_rank("LOW"), 1);
|
||||||
|
assert_eq!(severity_rank("unknown"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_severity_rank_case_insensitive() {
|
||||||
|
assert_eq!(severity_rank("critical"), 4);
|
||||||
|
assert_eq!(severity_rank("High"), 3);
|
||||||
|
assert_eq!(severity_rank("medium"), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_notification_error_display() {
|
||||||
|
let err = NotificationError::SendFailed("D-Bus error".to_string());
|
||||||
|
assert!(format!("{}", err).contains("D-Bus error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_check_and_notify_empty_db() {
|
||||||
|
let db = crate::core::database::Database::open_in_memory().unwrap();
|
||||||
|
let notifications = check_and_notify(&db, "high");
|
||||||
|
assert!(notifications.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
448
src/core/repackager.rs
Normal file
448
src/core/repackager.rs
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use super::database::Database;
|
||||||
|
|
||||||
|
/// Information about an AppImage's runtime binary.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RuntimeInfo {
|
||||||
|
pub runtime_size: u64,
|
||||||
|
pub payload_offset: u64,
|
||||||
|
pub runtime_type: RuntimeType,
|
||||||
|
pub runtime_version: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The type of AppImage runtime.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum RuntimeType {
|
||||||
|
OldFuse2,
|
||||||
|
NewMulti,
|
||||||
|
Static,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RuntimeType {
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::OldFuse2 => "old-fuse2",
|
||||||
|
Self::NewMulti => "new-multi",
|
||||||
|
Self::Static => "static",
|
||||||
|
Self::Unknown => "unknown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::OldFuse2 => "Legacy FUSE 2 only",
|
||||||
|
Self::NewMulti => "Multi-runtime (FUSE 2/3 + static)",
|
||||||
|
Self::Static => "Static (no FUSE needed)",
|
||||||
|
Self::Unknown => "Unknown runtime",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of a runtime replacement operation.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct RepackageResult {
|
||||||
|
pub original_path: PathBuf,
|
||||||
|
pub backup_path: PathBuf,
|
||||||
|
pub old_runtime_type: RuntimeType,
|
||||||
|
pub new_runtime_type: String,
|
||||||
|
pub old_size: u64,
|
||||||
|
pub new_size: u64,
|
||||||
|
pub success: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect the runtime type and payload offset of an AppImage.
|
||||||
|
/// Type 2 AppImages store the SquashFS offset in the ELF section header.
|
||||||
|
pub fn detect_runtime(appimage_path: &Path) -> Result<RuntimeInfo, RepackageError> {
|
||||||
|
let mut file = fs::File::open(appimage_path)
|
||||||
|
.map_err(|e| RepackageError::Io(e.to_string()))?;
|
||||||
|
|
||||||
|
// Read ELF header to find section headers
|
||||||
|
let mut header = [0u8; 64];
|
||||||
|
file.read_exact(&mut header)
|
||||||
|
.map_err(|e| RepackageError::Io(e.to_string()))?;
|
||||||
|
|
||||||
|
// Verify ELF magic
|
||||||
|
if &header[0..4] != b"\x7fELF" {
|
||||||
|
return Err(RepackageError::NotAppImage("Not an ELF file".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the SquashFS payload by searching for the magic bytes
|
||||||
|
let payload_offset = find_squashfs_offset(appimage_path)?;
|
||||||
|
|
||||||
|
let runtime_size = payload_offset;
|
||||||
|
|
||||||
|
// Classify the runtime type based on size and content
|
||||||
|
let runtime_type = classify_runtime(appimage_path, runtime_size)?;
|
||||||
|
|
||||||
|
Ok(RuntimeInfo {
|
||||||
|
runtime_size,
|
||||||
|
payload_offset,
|
||||||
|
runtime_type,
|
||||||
|
runtime_version: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the offset where the SquashFS payload starts.
|
||||||
|
/// SquashFS magic is 'hsqs' (0x73717368) at the start of the payload.
|
||||||
|
fn find_squashfs_offset(appimage_path: &Path) -> Result<u64, RepackageError> {
|
||||||
|
let mut file = fs::File::open(appimage_path)
|
||||||
|
.map_err(|e| RepackageError::Io(e.to_string()))?;
|
||||||
|
|
||||||
|
let file_size = file.metadata()
|
||||||
|
.map(|m| m.len())
|
||||||
|
.map_err(|e| RepackageError::Io(e.to_string()))?;
|
||||||
|
|
||||||
|
// SquashFS magic: 'hsqs' = [0x68, 0x73, 0x71, 0x73]
|
||||||
|
let magic = b"hsqs";
|
||||||
|
|
||||||
|
// Search in chunks starting from reasonable offsets (runtime is typically 100-300KB)
|
||||||
|
let mut buf = [0u8; 65536];
|
||||||
|
let search_start = 4096u64; // Skip the ELF header
|
||||||
|
let search_end = std::cmp::min(file_size, 1_048_576); // Don't search beyond 1MB
|
||||||
|
|
||||||
|
let mut offset = search_start;
|
||||||
|
use std::io::Seek;
|
||||||
|
file.seek(std::io::SeekFrom::Start(offset))
|
||||||
|
.map_err(|e| RepackageError::Io(e.to_string()))?;
|
||||||
|
|
||||||
|
while offset < search_end {
|
||||||
|
let n = file.read(&mut buf)
|
||||||
|
.map_err(|e| RepackageError::Io(e.to_string()))?;
|
||||||
|
if n == 0 { break; }
|
||||||
|
|
||||||
|
// Search for magic in this chunk
|
||||||
|
for i in 0..n.saturating_sub(3) {
|
||||||
|
if &buf[i..i + 4] == magic {
|
||||||
|
return Ok(offset + i as u64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += n as u64 - 3; // Overlap by 3 to catch magic spanning chunks
|
||||||
|
file.seek(std::io::SeekFrom::Start(offset))
|
||||||
|
.map_err(|e| RepackageError::Io(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(RepackageError::NotAppImage("SquashFS payload not found".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify the runtime type based on its binary content.
|
||||||
|
fn classify_runtime(appimage_path: &Path, runtime_size: u64) -> Result<RuntimeType, RepackageError> {
|
||||||
|
let mut file = fs::File::open(appimage_path)
|
||||||
|
.map_err(|e| RepackageError::Io(e.to_string()))?;
|
||||||
|
|
||||||
|
let read_size = std::cmp::min(runtime_size, 65536) as usize;
|
||||||
|
let mut buf = vec![0u8; read_size];
|
||||||
|
file.read_exact(&mut buf)
|
||||||
|
.map_err(|e| RepackageError::Io(e.to_string()))?;
|
||||||
|
|
||||||
|
let content = String::from_utf8_lossy(&buf);
|
||||||
|
|
||||||
|
// Check for known strings in the runtime binary
|
||||||
|
if content.contains("libfuse3") || content.contains("fuse3") {
|
||||||
|
Ok(RuntimeType::NewMulti)
|
||||||
|
} else if content.contains("static-runtime") || content.contains("no-fuse") {
|
||||||
|
Ok(RuntimeType::Static)
|
||||||
|
} else if content.contains("libfuse") || content.contains("fuse2") {
|
||||||
|
Ok(RuntimeType::OldFuse2)
|
||||||
|
} else if runtime_size < 4096 {
|
||||||
|
// Suspiciously small runtime - probably not a valid AppImage runtime
|
||||||
|
Ok(RuntimeType::Unknown)
|
||||||
|
} else {
|
||||||
|
// Default: older runtimes are typically fuse2-only
|
||||||
|
Ok(RuntimeType::OldFuse2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the runtime of an AppImage with a new one.
|
||||||
|
/// Creates a backup of the original file before modifying.
|
||||||
|
pub fn replace_runtime(
|
||||||
|
appimage_path: &Path,
|
||||||
|
new_runtime_path: &Path,
|
||||||
|
keep_backup: bool,
|
||||||
|
) -> Result<RepackageResult, RepackageError> {
|
||||||
|
if !appimage_path.exists() {
|
||||||
|
return Err(RepackageError::NotAppImage("File not found".to_string()));
|
||||||
|
}
|
||||||
|
if !new_runtime_path.exists() {
|
||||||
|
return Err(RepackageError::Io("New runtime file not found".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let info = detect_runtime(appimage_path)?;
|
||||||
|
let old_size = fs::metadata(appimage_path)
|
||||||
|
.map(|m| m.len())
|
||||||
|
.map_err(|e| RepackageError::Io(e.to_string()))?;
|
||||||
|
|
||||||
|
// Create backup
|
||||||
|
let backup_path = appimage_path.with_extension("bak");
|
||||||
|
fs::copy(appimage_path, &backup_path)
|
||||||
|
.map_err(|e| RepackageError::Io(format!("Backup failed: {}", e)))?;
|
||||||
|
|
||||||
|
// Read new runtime
|
||||||
|
let new_runtime = fs::read(new_runtime_path)
|
||||||
|
.map_err(|e| RepackageError::Io(format!("Failed to read new runtime: {}", e)))?;
|
||||||
|
|
||||||
|
// Read the SquashFS payload from the original file
|
||||||
|
let mut original = fs::File::open(appimage_path)
|
||||||
|
.map_err(|e| RepackageError::Io(e.to_string()))?;
|
||||||
|
use std::io::Seek;
|
||||||
|
original.seek(std::io::SeekFrom::Start(info.payload_offset))
|
||||||
|
.map_err(|e| RepackageError::Io(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut payload = Vec::new();
|
||||||
|
original.read_to_end(&mut payload)
|
||||||
|
.map_err(|e| RepackageError::Io(e.to_string()))?;
|
||||||
|
drop(original);
|
||||||
|
|
||||||
|
// Write new AppImage: new_runtime + payload
|
||||||
|
let mut output = fs::File::create(appimage_path)
|
||||||
|
.map_err(|e| RepackageError::Io(e.to_string()))?;
|
||||||
|
output.write_all(&new_runtime)
|
||||||
|
.map_err(|e| RepackageError::Io(e.to_string()))?;
|
||||||
|
output.write_all(&payload)
|
||||||
|
.map_err(|e| RepackageError::Io(e.to_string()))?;
|
||||||
|
|
||||||
|
// Set executable permission
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let perms = fs::Permissions::from_mode(0o755);
|
||||||
|
fs::set_permissions(appimage_path, perms).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_size = fs::metadata(appimage_path)
|
||||||
|
.map(|m| m.len())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
// Verify the new file is a valid AppImage
|
||||||
|
let success = verify_appimage(appimage_path);
|
||||||
|
|
||||||
|
if !success {
|
||||||
|
// Rollback from backup
|
||||||
|
log::error!("Verification failed, rolling back from backup");
|
||||||
|
fs::copy(&backup_path, appimage_path).ok();
|
||||||
|
if !keep_backup {
|
||||||
|
fs::remove_file(&backup_path).ok();
|
||||||
|
}
|
||||||
|
return Err(RepackageError::VerificationFailed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !keep_backup {
|
||||||
|
fs::remove_file(&backup_path).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(RepackageResult {
|
||||||
|
original_path: appimage_path.to_path_buf(),
|
||||||
|
backup_path,
|
||||||
|
old_runtime_type: info.runtime_type,
|
||||||
|
new_runtime_type: "new".to_string(),
|
||||||
|
old_size,
|
||||||
|
new_size,
|
||||||
|
success: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Batch-replace runtimes for all AppImages in the database that use the old runtime.
|
||||||
|
pub fn batch_replace_runtimes(
|
||||||
|
db: &Database,
|
||||||
|
new_runtime_path: &Path,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> Vec<RepackageResult> {
|
||||||
|
let records = db.get_all_appimages().unwrap_or_default();
|
||||||
|
let mut results = Vec::new();
|
||||||
|
|
||||||
|
for record in &records {
|
||||||
|
let path = Path::new(&record.path);
|
||||||
|
if !path.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let info = match detect_runtime(path) {
|
||||||
|
Ok(i) => i,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Skipping {}: {}", record.filename, e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only repackage old fuse2 runtimes
|
||||||
|
if info.runtime_type != RuntimeType::OldFuse2 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if dry_run {
|
||||||
|
results.push(RepackageResult {
|
||||||
|
original_path: path.to_path_buf(),
|
||||||
|
backup_path: path.with_extension("bak"),
|
||||||
|
old_runtime_type: info.runtime_type,
|
||||||
|
new_runtime_type: "new".to_string(),
|
||||||
|
old_size: fs::metadata(path).map(|m| m.len()).unwrap_or(0),
|
||||||
|
new_size: 0,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match replace_runtime(path, new_runtime_path, true) {
|
||||||
|
Ok(result) => {
|
||||||
|
// Record in database
|
||||||
|
db.record_runtime_update(
|
||||||
|
record.id,
|
||||||
|
Some(info.runtime_type.as_str()),
|
||||||
|
Some("new"),
|
||||||
|
result.backup_path.to_str(),
|
||||||
|
true,
|
||||||
|
).ok();
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to repackage {}: {}", record.filename, e);
|
||||||
|
db.record_runtime_update(
|
||||||
|
record.id,
|
||||||
|
Some(info.runtime_type.as_str()),
|
||||||
|
Some("new"),
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download the latest AppImage runtime binary.
|
||||||
|
pub fn download_latest_runtime() -> Result<PathBuf, RepackageError> {
|
||||||
|
let url = "https://github.com/AppImage/type2-runtime/releases/latest/download/runtime-x86_64";
|
||||||
|
|
||||||
|
let dest = dirs::cache_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||||
|
.join("driftwood")
|
||||||
|
.join("runtime-x86_64");
|
||||||
|
|
||||||
|
fs::create_dir_all(dest.parent().unwrap()).ok();
|
||||||
|
|
||||||
|
let response = ureq::get(url)
|
||||||
|
.call()
|
||||||
|
.map_err(|e| RepackageError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut file = fs::File::create(&dest)
|
||||||
|
.map_err(|e| RepackageError::Io(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut reader = response.into_body().into_reader();
|
||||||
|
let mut buf = [0u8; 65536];
|
||||||
|
loop {
|
||||||
|
let n = reader.read(&mut buf)
|
||||||
|
.map_err(|e| RepackageError::Network(e.to_string()))?;
|
||||||
|
if n == 0 { break; }
|
||||||
|
file.write_all(&buf[..n])
|
||||||
|
.map_err(|e| RepackageError::Io(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
fs::set_permissions(&dest, fs::Permissions::from_mode(0o755)).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Basic verification that a file is still a valid AppImage.
|
||||||
|
fn verify_appimage(path: &Path) -> bool {
|
||||||
|
// Check ELF magic
|
||||||
|
let mut file = match fs::File::open(path) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut magic = [0u8; 4];
|
||||||
|
if file.read_exact(&mut magic).is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if &magic != b"\x7fELF" {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that SquashFS payload exists
|
||||||
|
find_squashfs_offset(path).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Error types ---
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum RepackageError {
|
||||||
|
NotAppImage(String),
|
||||||
|
Io(String),
|
||||||
|
Network(String),
|
||||||
|
VerificationFailed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for RepackageError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::NotAppImage(e) => write!(f, "Not a valid AppImage: {}", e),
|
||||||
|
Self::Io(e) => write!(f, "I/O error: {}", e),
|
||||||
|
Self::Network(e) => write!(f, "Network error: {}", e),
|
||||||
|
Self::VerificationFailed => write!(f, "Verification failed after repackaging"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_runtime_type_as_str() {
|
||||||
|
assert_eq!(RuntimeType::OldFuse2.as_str(), "old-fuse2");
|
||||||
|
assert_eq!(RuntimeType::NewMulti.as_str(), "new-multi");
|
||||||
|
assert_eq!(RuntimeType::Static.as_str(), "static");
|
||||||
|
assert_eq!(RuntimeType::Unknown.as_str(), "unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_runtime_type_label() {
|
||||||
|
assert!(RuntimeType::OldFuse2.label().contains("Legacy"));
|
||||||
|
assert!(RuntimeType::NewMulti.label().contains("Multi"));
|
||||||
|
assert!(RuntimeType::Static.label().contains("no FUSE"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_repackage_error_display() {
|
||||||
|
let err = RepackageError::NotAppImage("bad magic".to_string());
|
||||||
|
assert!(format!("{}", err).contains("bad magic"));
|
||||||
|
let err = RepackageError::VerificationFailed;
|
||||||
|
assert!(format!("{}", err).contains("Verification failed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_runtime_nonexistent() {
|
||||||
|
let result = detect_runtime(Path::new("/nonexistent.AppImage"));
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_runtime_not_elf() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("not-an-elf");
|
||||||
|
fs::write(&path, "This is not an ELF file").unwrap();
|
||||||
|
let result = detect_runtime(&path);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_verify_appimage_nonexistent() {
|
||||||
|
assert!(!verify_appimage(Path::new("/nonexistent")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_verify_appimage_not_elf() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("not-elf");
|
||||||
|
fs::write(&path, "hello").unwrap();
|
||||||
|
assert!(!verify_appimage(&path));
|
||||||
|
}
|
||||||
|
}
|
||||||
322
src/core/report.rs
Normal file
322
src/core/report.rs
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
use super::database::{CveSummary, Database};
|
||||||
|
use crate::config::VERSION;
|
||||||
|
|
||||||
|
/// Export format for security reports.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum ReportFormat {
|
||||||
|
Json,
|
||||||
|
Html,
|
||||||
|
Csv,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReportFormat {
|
||||||
|
pub fn from_str(s: &str) -> Option<Self> {
|
||||||
|
match s.to_lowercase().as_str() {
|
||||||
|
"json" => Some(Self::Json),
|
||||||
|
"html" => Some(Self::Html),
|
||||||
|
"csv" => Some(Self::Csv),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extension(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Json => "json",
|
||||||
|
Self::Html => "html",
|
||||||
|
Self::Csv => "csv",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single CVE finding in a report.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct ReportCveFinding {
|
||||||
|
pub cve_id: String,
|
||||||
|
pub severity: String,
|
||||||
|
pub cvss_score: Option<f64>,
|
||||||
|
pub summary: String,
|
||||||
|
pub library_name: String,
|
||||||
|
pub library_version: String,
|
||||||
|
pub fixed_version: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-app entry in a report.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct ReportAppEntry {
|
||||||
|
pub name: String,
|
||||||
|
pub version: Option<String>,
|
||||||
|
pub path: String,
|
||||||
|
pub libraries_scanned: usize,
|
||||||
|
pub cve_summary: ReportCveSummaryData,
|
||||||
|
pub findings: Vec<ReportCveFinding>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serializable CVE summary counts.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct ReportCveSummaryData {
|
||||||
|
pub critical: i64,
|
||||||
|
pub high: i64,
|
||||||
|
pub medium: i64,
|
||||||
|
pub low: i64,
|
||||||
|
pub total: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&CveSummary> for ReportCveSummaryData {
|
||||||
|
fn from(s: &CveSummary) -> Self {
|
||||||
|
Self {
|
||||||
|
critical: s.critical,
|
||||||
|
high: s.high,
|
||||||
|
medium: s.medium,
|
||||||
|
low: s.low,
|
||||||
|
total: s.total(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete security report.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct SecurityReport {
|
||||||
|
pub generated_at: String,
|
||||||
|
pub driftwood_version: String,
|
||||||
|
pub apps: Vec<ReportAppEntry>,
|
||||||
|
pub totals: ReportCveSummaryData,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a security report from the database.
|
||||||
|
pub fn build_report(db: &Database, single_app_id: Option<i64>) -> SecurityReport {
|
||||||
|
let records = if let Some(id) = single_app_id {
|
||||||
|
db.get_appimage_by_id(id).ok().flatten().into_iter().collect()
|
||||||
|
} else {
|
||||||
|
db.get_all_appimages().unwrap_or_default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut apps = Vec::new();
|
||||||
|
let mut total_summary = CveSummary::default();
|
||||||
|
|
||||||
|
for record in &records {
|
||||||
|
let libs = db.get_bundled_libraries(record.id).unwrap_or_default();
|
||||||
|
let cve_matches = db.get_cve_matches(record.id).unwrap_or_default();
|
||||||
|
let summary = db.get_cve_summary(record.id).unwrap_or_default();
|
||||||
|
|
||||||
|
let findings: Vec<ReportCveFinding> = cve_matches.iter().map(|m| {
|
||||||
|
ReportCveFinding {
|
||||||
|
cve_id: m.cve_id.clone(),
|
||||||
|
severity: m.severity.clone().unwrap_or_default(),
|
||||||
|
cvss_score: m.cvss_score,
|
||||||
|
summary: m.summary.clone().unwrap_or_default(),
|
||||||
|
library_name: m.library_name.clone().unwrap_or_else(|| m.library_soname.clone()),
|
||||||
|
library_version: m.library_version.clone().unwrap_or_default(),
|
||||||
|
fixed_version: m.fixed_version.clone(),
|
||||||
|
}
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
total_summary.critical += summary.critical;
|
||||||
|
total_summary.high += summary.high;
|
||||||
|
total_summary.medium += summary.medium;
|
||||||
|
total_summary.low += summary.low;
|
||||||
|
|
||||||
|
apps.push(ReportAppEntry {
|
||||||
|
name: record.app_name.clone().unwrap_or_else(|| record.filename.clone()),
|
||||||
|
version: record.app_version.clone(),
|
||||||
|
path: record.path.clone(),
|
||||||
|
libraries_scanned: libs.len(),
|
||||||
|
cve_summary: ReportCveSummaryData::from(&summary),
|
||||||
|
findings,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
SecurityReport {
|
||||||
|
generated_at: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(),
|
||||||
|
driftwood_version: VERSION.to_string(),
|
||||||
|
apps,
|
||||||
|
totals: ReportCveSummaryData::from(&total_summary),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the report to JSON.
|
||||||
|
pub fn render_json(report: &SecurityReport) -> String {
|
||||||
|
serde_json::to_string_pretty(report).unwrap_or_else(|_| "{}".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the report to CSV.
|
||||||
|
pub fn render_csv(report: &SecurityReport) -> String {
|
||||||
|
let mut out = String::from("App,Version,Path,CVE ID,Severity,CVSS,Library,Library Version,Fixed Version,Summary\n");
|
||||||
|
|
||||||
|
for app in &report.apps {
|
||||||
|
if app.findings.is_empty() {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"\"{}\",\"{}\",\"{}\",,,,,,,No CVEs found\n",
|
||||||
|
csv_escape(&app.name),
|
||||||
|
csv_escape(app.version.as_deref().unwrap_or("")),
|
||||||
|
csv_escape(&app.path),
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
for f in &app.findings {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"\"{}\",\"{}\",\"{}\",\"{}\",\"{}\",{},\"{}\",\"{}\",\"{}\",\"{}\"\n",
|
||||||
|
csv_escape(&app.name),
|
||||||
|
csv_escape(app.version.as_deref().unwrap_or("")),
|
||||||
|
csv_escape(&app.path),
|
||||||
|
csv_escape(&f.cve_id),
|
||||||
|
csv_escape(&f.severity),
|
||||||
|
f.cvss_score.map(|s| format!("{:.1}", s)).unwrap_or_default(),
|
||||||
|
csv_escape(&f.library_name),
|
||||||
|
csv_escape(&f.library_version),
|
||||||
|
csv_escape(f.fixed_version.as_deref().unwrap_or("")),
|
||||||
|
csv_escape(&f.summary),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn csv_escape(s: &str) -> String {
|
||||||
|
s.replace('"', "\"\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the report to a standalone HTML document.
|
||||||
|
pub fn render_html(report: &SecurityReport) -> String {
|
||||||
|
let mut html = String::new();
|
||||||
|
|
||||||
|
html.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n");
|
||||||
|
html.push_str("<meta charset=\"UTF-8\">\n");
|
||||||
|
html.push_str("<title>Driftwood Security Report</title>\n");
|
||||||
|
html.push_str("<style>\n");
|
||||||
|
html.push_str("body { font-family: system-ui, -apple-system, sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1em; color: #333; }\n");
|
||||||
|
html.push_str("h1 { border-bottom: 2px solid #333; padding-bottom: 0.3em; }\n");
|
||||||
|
html.push_str("h2 { margin-top: 2em; }\n");
|
||||||
|
html.push_str("table { border-collapse: collapse; width: 100%; margin: 1em 0; }\n");
|
||||||
|
html.push_str("th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }\n");
|
||||||
|
html.push_str("th { background: #f5f5f5; }\n");
|
||||||
|
html.push_str(".critical { color: #d32f2f; font-weight: bold; }\n");
|
||||||
|
html.push_str(".high { color: #e65100; font-weight: bold; }\n");
|
||||||
|
html.push_str(".medium { color: #f9a825; }\n");
|
||||||
|
html.push_str(".low { color: #666; }\n");
|
||||||
|
html.push_str(".summary-box { background: #f5f5f5; border-radius: 8px; padding: 1em; margin: 1em 0; }\n");
|
||||||
|
html.push_str("footer { margin-top: 3em; padding-top: 1em; border-top: 1px solid #ddd; font-size: 0.85em; color: #666; }\n");
|
||||||
|
html.push_str("</style>\n</head>\n<body>\n");
|
||||||
|
|
||||||
|
html.push_str("<h1>Driftwood Security Report</h1>\n");
|
||||||
|
html.push_str(&format!("<p>Generated: {} | Driftwood v{}</p>\n",
|
||||||
|
report.generated_at, report.driftwood_version));
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
html.push_str("<div class=\"summary-box\">\n");
|
||||||
|
html.push_str("<h2>Summary</h2>\n");
|
||||||
|
html.push_str(&format!("<p>Apps scanned: {} | Total CVEs: {}</p>\n",
|
||||||
|
report.apps.len(), report.totals.total));
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<p><span class=\"critical\">Critical: {}</span> | <span class=\"high\">High: {}</span> | <span class=\"medium\">Medium: {}</span> | <span class=\"low\">Low: {}</span></p>\n",
|
||||||
|
report.totals.critical, report.totals.high, report.totals.medium, report.totals.low));
|
||||||
|
html.push_str("</div>\n");
|
||||||
|
|
||||||
|
// Per-app sections
|
||||||
|
for app in &report.apps {
|
||||||
|
html.push_str(&format!("<h2>{}", html_escape(&app.name)));
|
||||||
|
if let Some(ref ver) = app.version {
|
||||||
|
html.push_str(&format!(" v{}", html_escape(ver)));
|
||||||
|
}
|
||||||
|
html.push_str("</h2>\n");
|
||||||
|
html.push_str(&format!("<p>Path: <code>{}</code> | Libraries scanned: {}</p>\n",
|
||||||
|
html_escape(&app.path), app.libraries_scanned));
|
||||||
|
|
||||||
|
if app.findings.is_empty() {
|
||||||
|
html.push_str("<p>No known vulnerabilities found.</p>\n");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.push_str("<table>\n<tr><th>CVE</th><th>Severity</th><th>CVSS</th><th>Library</th><th>Fixed In</th><th>Summary</th></tr>\n");
|
||||||
|
for f in &app.findings {
|
||||||
|
let sev_class = f.severity.to_lowercase();
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<tr><td>{}</td><td class=\"{}\">{}</td><td>{}</td><td>{} {}</td><td>{}</td><td>{}</td></tr>\n",
|
||||||
|
html_escape(&f.cve_id),
|
||||||
|
sev_class, html_escape(&f.severity),
|
||||||
|
f.cvss_score.map(|s| format!("{:.1}", s)).unwrap_or_default(),
|
||||||
|
html_escape(&f.library_name), html_escape(&f.library_version),
|
||||||
|
html_escape(f.fixed_version.as_deref().unwrap_or("-")),
|
||||||
|
html_escape(&f.summary),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
html.push_str("</table>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
html.push_str("<footer>\n");
|
||||||
|
html.push_str("<p>This report was generated by Driftwood using the OSV.dev vulnerability database. ");
|
||||||
|
html.push_str("Library detection uses heuristics and may not identify all bundled components. ");
|
||||||
|
html.push_str("Results should be treated as advisory, not definitive.</p>\n");
|
||||||
|
html.push_str("</footer>\n");
|
||||||
|
html.push_str("</body>\n</html>\n");
|
||||||
|
|
||||||
|
html
|
||||||
|
}
|
||||||
|
|
||||||
|
fn html_escape(s: &str) -> String {
|
||||||
|
s.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the report in the given format.
|
||||||
|
pub fn render(report: &SecurityReport, format: ReportFormat) -> String {
|
||||||
|
match format {
|
||||||
|
ReportFormat::Json => render_json(report),
|
||||||
|
ReportFormat::Html => render_html(report),
|
||||||
|
ReportFormat::Csv => render_csv(report),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::core::database::Database;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_json_empty() {
|
||||||
|
let db = Database::open_in_memory().unwrap();
|
||||||
|
let report = build_report(&db, None);
|
||||||
|
let json = render_json(&report);
|
||||||
|
assert!(json.contains("\"apps\""));
|
||||||
|
assert!(json.contains("\"totals\""));
|
||||||
|
assert!(json.contains("\"driftwood_version\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_csv_header() {
|
||||||
|
let db = Database::open_in_memory().unwrap();
|
||||||
|
let report = build_report(&db, None);
|
||||||
|
let csv = render_csv(&report);
|
||||||
|
assert!(csv.starts_with("App,Version,Path,CVE ID"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_html_structure() {
|
||||||
|
let db = Database::open_in_memory().unwrap();
|
||||||
|
let report = build_report(&db, None);
|
||||||
|
let html = render_html(&report);
|
||||||
|
assert!(html.contains("<!DOCTYPE html>"));
|
||||||
|
assert!(html.contains("Driftwood Security Report"));
|
||||||
|
assert!(html.contains("</html>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_report_format_from_str() {
|
||||||
|
assert!(matches!(ReportFormat::from_str("json"), Some(ReportFormat::Json)));
|
||||||
|
assert!(matches!(ReportFormat::from_str("HTML"), Some(ReportFormat::Html)));
|
||||||
|
assert!(matches!(ReportFormat::from_str("csv"), Some(ReportFormat::Csv)));
|
||||||
|
assert!(ReportFormat::from_str("xml").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_csv_escape() {
|
||||||
|
assert_eq!(csv_escape("hello \"world\""), "hello \"\"world\"\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_html_escape() {
|
||||||
|
assert_eq!(html_escape("<script>&"), "<script>&");
|
||||||
|
}
|
||||||
|
}
|
||||||
405
src/core/sandbox.rs
Normal file
405
src/core/sandbox.rs
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use super::database::Database;
|
||||||
|
|
||||||
|
/// A sandbox profile that can be applied to an AppImage when launching with Firejail.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SandboxProfile {
|
||||||
|
pub id: Option<i64>,
|
||||||
|
pub app_name: String,
|
||||||
|
pub profile_version: Option<String>,
|
||||||
|
pub author: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub content: String,
|
||||||
|
pub source: ProfileSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum ProfileSource {
|
||||||
|
Local,
|
||||||
|
Community { registry_id: String },
|
||||||
|
FirejailDefault,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProfileSource {
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::Local => "local",
|
||||||
|
Self::Community { .. } => "community",
|
||||||
|
Self::FirejailDefault => "firejail-default",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_record(source: &str, registry_id: Option<&str>) -> Self {
|
||||||
|
match source {
|
||||||
|
"community" => Self::Community {
|
||||||
|
registry_id: registry_id.unwrap_or("").to_string(),
|
||||||
|
},
|
||||||
|
"firejail-default" => Self::FirejailDefault,
|
||||||
|
_ => Self::Local,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Directory where local sandbox profiles are stored.
|
||||||
|
fn profiles_dir() -> PathBuf {
|
||||||
|
let dir = dirs::config_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("~/.config"))
|
||||||
|
.join("driftwood")
|
||||||
|
.join("sandbox");
|
||||||
|
fs::create_dir_all(&dir).ok();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save a sandbox profile to local storage and the database.
|
||||||
|
pub fn save_profile(db: &Database, profile: &SandboxProfile) -> Result<PathBuf, SandboxError> {
|
||||||
|
let filename = sanitize_profile_name(&profile.app_name);
|
||||||
|
let path = profiles_dir().join(format!("{}.profile", filename));
|
||||||
|
|
||||||
|
// Write profile content with metadata header
|
||||||
|
let full_content = format_profile_with_header(profile);
|
||||||
|
fs::write(&path, &full_content).map_err(|e| SandboxError::Io(e.to_string()))?;
|
||||||
|
|
||||||
|
// Store in database
|
||||||
|
db.insert_sandbox_profile(
|
||||||
|
&profile.app_name,
|
||||||
|
profile.profile_version.as_deref(),
|
||||||
|
profile.author.as_deref(),
|
||||||
|
profile.description.as_deref(),
|
||||||
|
&profile.content,
|
||||||
|
profile.source.as_str(),
|
||||||
|
match &profile.source {
|
||||||
|
ProfileSource::Community { registry_id } => Some(registry_id.as_str()),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
).map_err(|e| SandboxError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the most recent sandbox profile for an app from the database.
|
||||||
|
pub fn load_profile(db: &Database, app_name: &str) -> Result<Option<SandboxProfile>, SandboxError> {
|
||||||
|
let record = db.get_sandbox_profile_for_app(app_name)
|
||||||
|
.map_err(|e| SandboxError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(record.map(|r| SandboxProfile {
|
||||||
|
id: Some(r.id),
|
||||||
|
app_name: r.app_name,
|
||||||
|
profile_version: r.profile_version,
|
||||||
|
author: r.author,
|
||||||
|
description: r.description,
|
||||||
|
content: r.content.clone(),
|
||||||
|
source: ProfileSource::from_record(&r.source, r.registry_id.as_deref()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a sandbox profile by ID.
|
||||||
|
pub fn delete_profile(db: &Database, profile_id: i64) -> Result<(), SandboxError> {
|
||||||
|
db.delete_sandbox_profile(profile_id)
|
||||||
|
.map_err(|e| SandboxError::Database(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all local sandbox profiles.
|
||||||
|
pub fn list_profiles(db: &Database) -> Vec<SandboxProfile> {
|
||||||
|
let records = db.get_all_sandbox_profiles().unwrap_or_default();
|
||||||
|
records.into_iter().map(|r| SandboxProfile {
|
||||||
|
id: Some(r.id),
|
||||||
|
app_name: r.app_name,
|
||||||
|
profile_version: r.profile_version,
|
||||||
|
author: r.author,
|
||||||
|
description: r.description,
|
||||||
|
content: r.content.clone(),
|
||||||
|
source: ProfileSource::from_record(&r.source, r.registry_id.as_deref()),
|
||||||
|
}).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search the community registry for sandbox profiles matching an app name.
|
||||||
|
/// Uses the GitHub-based registry approach (fetches a JSON index).
|
||||||
|
pub fn search_community_profiles(registry_url: &str, app_name: &str) -> Result<Vec<CommunityProfileEntry>, SanboxError> {
|
||||||
|
let index_url = format!("{}/index.json", registry_url.trim_end_matches('/'));
|
||||||
|
|
||||||
|
let response = ureq::get(&index_url)
|
||||||
|
.call()
|
||||||
|
.map_err(|e| SanboxError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
let body = response.into_body().read_to_string()
|
||||||
|
.map_err(|e| SanboxError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
let index: CommunityIndex = serde_json::from_str(&body)
|
||||||
|
.map_err(|e| SanboxError::Parse(e.to_string()))?;
|
||||||
|
|
||||||
|
let query = app_name.to_lowercase();
|
||||||
|
let matches: Vec<CommunityProfileEntry> = index.profiles
|
||||||
|
.into_iter()
|
||||||
|
.filter(|p| p.app_name.to_lowercase().contains(&query))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(matches)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download a community profile by its URL and save it locally.
|
||||||
|
pub fn download_community_profile(
|
||||||
|
db: &Database,
|
||||||
|
entry: &CommunityProfileEntry,
|
||||||
|
) -> Result<SandboxProfile, SanboxError> {
|
||||||
|
let response = ureq::get(&entry.url)
|
||||||
|
.call()
|
||||||
|
.map_err(|e| SanboxError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
let content = response.into_body().read_to_string()
|
||||||
|
.map_err(|e| SanboxError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
let profile = SandboxProfile {
|
||||||
|
id: None,
|
||||||
|
app_name: entry.app_name.clone(),
|
||||||
|
profile_version: Some(entry.version.clone()),
|
||||||
|
author: Some(entry.author.clone()),
|
||||||
|
description: Some(entry.description.clone()),
|
||||||
|
content,
|
||||||
|
source: ProfileSource::Community {
|
||||||
|
registry_id: entry.id.clone(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
save_profile(db, &profile)
|
||||||
|
.map_err(|e| SanboxError::Io(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a default restrictive sandbox profile for an app.
|
||||||
|
pub fn generate_default_profile(app_name: &str) -> SandboxProfile {
|
||||||
|
let content = format!(
|
||||||
|
"# Default Driftwood sandbox profile for {}\n\
|
||||||
|
# Generated automatically - review and customize before use\n\
|
||||||
|
\n\
|
||||||
|
include disable-common.inc\n\
|
||||||
|
include disable-devel.inc\n\
|
||||||
|
include disable-exec.inc\n\
|
||||||
|
include disable-interpreters.inc\n\
|
||||||
|
include disable-programs.inc\n\
|
||||||
|
\n\
|
||||||
|
whitelist ${{HOME}}/Documents\n\
|
||||||
|
whitelist ${{HOME}}/Downloads\n\
|
||||||
|
\n\
|
||||||
|
caps.drop all\n\
|
||||||
|
ipc-namespace\n\
|
||||||
|
netfilter\n\
|
||||||
|
no3d\n\
|
||||||
|
nodvd\n\
|
||||||
|
nogroups\n\
|
||||||
|
noinput\n\
|
||||||
|
nonewprivs\n\
|
||||||
|
noroot\n\
|
||||||
|
nosound\n\
|
||||||
|
notv\n\
|
||||||
|
nou2f\n\
|
||||||
|
novideo\n\
|
||||||
|
seccomp\n\
|
||||||
|
tracelog\n",
|
||||||
|
app_name,
|
||||||
|
);
|
||||||
|
|
||||||
|
SandboxProfile {
|
||||||
|
id: None,
|
||||||
|
app_name: app_name.to_string(),
|
||||||
|
profile_version: Some("1.0".to_string()),
|
||||||
|
author: Some("driftwood".to_string()),
|
||||||
|
description: Some(format!("Default restrictive profile for {}", app_name)),
|
||||||
|
content,
|
||||||
|
source: ProfileSource::Local,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the path to the profile file for an app (for passing to firejail --profile=).
|
||||||
|
pub fn profile_path_for_app(app_name: &str) -> Option<PathBuf> {
|
||||||
|
let filename = sanitize_profile_name(app_name);
|
||||||
|
let path = profiles_dir().join(format!("{}.profile", filename));
|
||||||
|
if path.exists() { Some(path) } else { None }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Community registry types ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Deserialize)]
|
||||||
|
pub struct CommunityIndex {
|
||||||
|
pub profiles: Vec<CommunityProfileEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Deserialize)]
|
||||||
|
pub struct CommunityProfileEntry {
|
||||||
|
pub id: String,
|
||||||
|
pub app_name: String,
|
||||||
|
pub author: String,
|
||||||
|
pub version: String,
|
||||||
|
pub description: String,
|
||||||
|
pub url: String,
|
||||||
|
pub downloads: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Error types ---
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum SandboxError {
|
||||||
|
Io(String),
|
||||||
|
Database(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum SanboxError {
|
||||||
|
Network(String),
|
||||||
|
Parse(String),
|
||||||
|
Io(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for SandboxError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Io(e) => write!(f, "I/O error: {}", e),
|
||||||
|
Self::Database(e) => write!(f, "Database error: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for SanboxError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Network(e) => write!(f, "Network error: {}", e),
|
||||||
|
Self::Parse(e) => write!(f, "Parse error: {}", e),
|
||||||
|
Self::Io(e) => write!(f, "I/O error: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Utility functions ---
|
||||||
|
|
||||||
|
fn sanitize_profile_name(name: &str) -> String {
|
||||||
|
name.chars()
|
||||||
|
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c.to_ascii_lowercase() } else { '-' })
|
||||||
|
.collect::<String>()
|
||||||
|
.trim_matches('-')
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_profile_with_header(profile: &SandboxProfile) -> String {
|
||||||
|
let mut header = String::new();
|
||||||
|
header.push_str("# Driftwood Sandbox Profile\n");
|
||||||
|
header.push_str(&format!("# App: {}\n", profile.app_name));
|
||||||
|
if let Some(v) = &profile.profile_version {
|
||||||
|
header.push_str(&format!("# Version: {}\n", v));
|
||||||
|
}
|
||||||
|
if let Some(a) = &profile.author {
|
||||||
|
header.push_str(&format!("# Author: {}\n", a));
|
||||||
|
}
|
||||||
|
if let Some(d) = &profile.description {
|
||||||
|
header.push_str(&format!("# Description: {}\n", d));
|
||||||
|
}
|
||||||
|
header.push_str(&format!("# Source: {}\n", profile.source.as_str()));
|
||||||
|
header.push('\n');
|
||||||
|
header.push_str(&profile.content);
|
||||||
|
header
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sanitize_profile_name() {
|
||||||
|
assert_eq!(sanitize_profile_name("Firefox"), "firefox");
|
||||||
|
assert_eq!(sanitize_profile_name("My Cool App"), "my-cool-app");
|
||||||
|
assert_eq!(sanitize_profile_name("GIMP 2.10"), "gimp-2-10");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_profile_source_as_str() {
|
||||||
|
assert_eq!(ProfileSource::Local.as_str(), "local");
|
||||||
|
assert_eq!(ProfileSource::FirejailDefault.as_str(), "firejail-default");
|
||||||
|
assert_eq!(
|
||||||
|
ProfileSource::Community { registry_id: "test".to_string() }.as_str(),
|
||||||
|
"community"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_profile_source_from_record() {
|
||||||
|
assert_eq!(
|
||||||
|
ProfileSource::from_record("local", None),
|
||||||
|
ProfileSource::Local
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ProfileSource::from_record("firejail-default", None),
|
||||||
|
ProfileSource::FirejailDefault
|
||||||
|
);
|
||||||
|
match ProfileSource::from_record("community", Some("firefox-strict")) {
|
||||||
|
ProfileSource::Community { registry_id } => assert_eq!(registry_id, "firefox-strict"),
|
||||||
|
other => panic!("Expected Community, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_generate_default_profile() {
|
||||||
|
let profile = generate_default_profile("Firefox");
|
||||||
|
assert_eq!(profile.app_name, "Firefox");
|
||||||
|
assert!(profile.content.contains("disable-common.inc"));
|
||||||
|
assert!(profile.content.contains("seccomp"));
|
||||||
|
assert!(profile.content.contains("nonewprivs"));
|
||||||
|
assert!(profile.content.contains("Downloads"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_format_profile_with_header() {
|
||||||
|
let profile = SandboxProfile {
|
||||||
|
id: None,
|
||||||
|
app_name: "TestApp".to_string(),
|
||||||
|
profile_version: Some("1.0".to_string()),
|
||||||
|
author: Some("tester".to_string()),
|
||||||
|
description: Some("Test profile".to_string()),
|
||||||
|
content: "include disable-common.inc\n".to_string(),
|
||||||
|
source: ProfileSource::Local,
|
||||||
|
};
|
||||||
|
let output = format_profile_with_header(&profile);
|
||||||
|
assert!(output.starts_with("# Driftwood Sandbox Profile\n"));
|
||||||
|
assert!(output.contains("# App: TestApp"));
|
||||||
|
assert!(output.contains("# Version: 1.0"));
|
||||||
|
assert!(output.contains("# Author: tester"));
|
||||||
|
assert!(output.contains("include disable-common.inc"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_profiles_dir_path() {
|
||||||
|
let dir = profiles_dir();
|
||||||
|
assert!(dir.to_string_lossy().contains("driftwood"));
|
||||||
|
assert!(dir.to_string_lossy().contains("sandbox"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sandbox_error_display() {
|
||||||
|
let err = SandboxError::Io("permission denied".to_string());
|
||||||
|
assert!(format!("{}", err).contains("permission denied"));
|
||||||
|
let err = SandboxError::Database("db locked".to_string());
|
||||||
|
assert!(format!("{}", err).contains("db locked"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_save_and_load_profile() {
|
||||||
|
let db = crate::core::database::Database::open_in_memory().unwrap();
|
||||||
|
let profile = generate_default_profile("TestSaveApp");
|
||||||
|
let result = save_profile(&db, &profile);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
let loaded = load_profile(&db, "TestSaveApp").unwrap();
|
||||||
|
assert!(loaded.is_some());
|
||||||
|
let loaded = loaded.unwrap();
|
||||||
|
assert_eq!(loaded.app_name, "TestSaveApp");
|
||||||
|
assert!(loaded.content.contains("seccomp"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_list_profiles_empty() {
|
||||||
|
let db = crate::core::database::Database::open_in_memory().unwrap();
|
||||||
|
let profiles = list_profiles(&db);
|
||||||
|
assert!(profiles.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
728
src/core/security.rs
Normal file
728
src/core/security.rs
Normal file
@@ -0,0 +1,728 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use super::database::Database;
|
||||||
|
|
||||||
|
/// A bundled shared library detected inside an AppImage.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct BundledLibrary {
|
||||||
|
pub soname: String,
|
||||||
|
pub detected_name: Option<String>,
|
||||||
|
pub detected_version: Option<String>,
|
||||||
|
pub file_path: String,
|
||||||
|
pub file_size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A CVE match found for a bundled library.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CveMatch {
|
||||||
|
pub cve_id: String,
|
||||||
|
pub severity: String,
|
||||||
|
pub cvss_score: Option<f64>,
|
||||||
|
pub summary: String,
|
||||||
|
pub affected_versions: Option<String>,
|
||||||
|
pub fixed_version: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of a security scan for a single AppImage.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SecurityScanResult {
|
||||||
|
pub appimage_id: i64,
|
||||||
|
pub libraries: Vec<BundledLibrary>,
|
||||||
|
pub cve_matches: Vec<(BundledLibrary, Vec<CveMatch>)>,
|
||||||
|
pub critical_count: usize,
|
||||||
|
pub high_count: usize,
|
||||||
|
pub medium_count: usize,
|
||||||
|
pub low_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecurityScanResult {
|
||||||
|
pub fn total_cves(&self) -> usize {
|
||||||
|
self.critical_count + self.high_count + self.medium_count + self.low_count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Library name to CPE product mapping ---
|
||||||
|
|
||||||
|
/// Map a shared library soname to a known product name for CVE lookup.
|
||||||
|
fn soname_to_product(soname: &str) -> Option<(&'static str, &'static str)> {
|
||||||
|
// Returns (ecosystem, package_name) for OSV API query
|
||||||
|
let lower = soname.to_lowercase();
|
||||||
|
|
||||||
|
// OpenSSL / LibreSSL
|
||||||
|
if lower.starts_with("libssl") || lower.starts_with("libcrypto") {
|
||||||
|
return Some(("OSS-Fuzz", "openssl"));
|
||||||
|
}
|
||||||
|
// curl
|
||||||
|
if lower.starts_with("libcurl") {
|
||||||
|
return Some(("OSS-Fuzz", "curl"));
|
||||||
|
}
|
||||||
|
// zlib
|
||||||
|
if lower.starts_with("libz.so") {
|
||||||
|
return Some(("OSS-Fuzz", "zlib"));
|
||||||
|
}
|
||||||
|
// libpng
|
||||||
|
if lower.starts_with("libpng") {
|
||||||
|
return Some(("OSS-Fuzz", "libpng"));
|
||||||
|
}
|
||||||
|
// libjpeg
|
||||||
|
if lower.starts_with("libjpeg") || lower.starts_with("libturbojpeg") {
|
||||||
|
return Some(("OSS-Fuzz", "libjpeg-turbo"));
|
||||||
|
}
|
||||||
|
// libwebp
|
||||||
|
if lower.starts_with("libwebp") || lower.starts_with("libsharpyuv") {
|
||||||
|
return Some(("OSS-Fuzz", "libwebp"));
|
||||||
|
}
|
||||||
|
// SQLite
|
||||||
|
if lower.starts_with("libsqlite3") {
|
||||||
|
return Some(("OSS-Fuzz", "sqlite3"));
|
||||||
|
}
|
||||||
|
// libxml2
|
||||||
|
if lower.starts_with("libxml2") {
|
||||||
|
return Some(("OSS-Fuzz", "libxml2"));
|
||||||
|
}
|
||||||
|
// libxslt
|
||||||
|
if lower.starts_with("libxslt") || lower.starts_with("libexslt") {
|
||||||
|
return Some(("OSS-Fuzz", "libxslt"));
|
||||||
|
}
|
||||||
|
// GnuTLS
|
||||||
|
if lower.starts_with("libgnutls") {
|
||||||
|
return Some(("OSS-Fuzz", "gnutls"));
|
||||||
|
}
|
||||||
|
// FFmpeg
|
||||||
|
if lower.starts_with("libavcodec") || lower.starts_with("libavformat")
|
||||||
|
|| lower.starts_with("libavutil") || lower.starts_with("libswscale")
|
||||||
|
|| lower.starts_with("libswresample") || lower.starts_with("libavfilter")
|
||||||
|
{
|
||||||
|
return Some(("OSS-Fuzz", "ffmpeg"));
|
||||||
|
}
|
||||||
|
// GLib
|
||||||
|
if lower.starts_with("libglib-2") || lower.starts_with("libgio-2") || lower.starts_with("libgobject-2") {
|
||||||
|
return Some(("OSS-Fuzz", "glib"));
|
||||||
|
}
|
||||||
|
// freetype
|
||||||
|
if lower.starts_with("libfreetype") {
|
||||||
|
return Some(("OSS-Fuzz", "freetype2"));
|
||||||
|
}
|
||||||
|
// harfbuzz
|
||||||
|
if lower.starts_with("libharfbuzz") {
|
||||||
|
return Some(("OSS-Fuzz", "harfbuzz"));
|
||||||
|
}
|
||||||
|
// fontconfig
|
||||||
|
if lower.starts_with("libfontconfig") {
|
||||||
|
return Some(("OSS-Fuzz", "fontconfig"));
|
||||||
|
}
|
||||||
|
// expat
|
||||||
|
if lower.starts_with("libexpat") {
|
||||||
|
return Some(("OSS-Fuzz", "expat"));
|
||||||
|
}
|
||||||
|
// libtiff
|
||||||
|
if lower.starts_with("libtiff") {
|
||||||
|
return Some(("OSS-Fuzz", "libtiff"));
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract a human-readable library name from the soname.
|
||||||
|
fn soname_to_name(soname: &str) -> String {
|
||||||
|
// Strip .so and version suffix
|
||||||
|
if let Some(pos) = soname.find(".so") {
|
||||||
|
let base = &soname[..pos];
|
||||||
|
// Strip "lib" prefix
|
||||||
|
if let Some(name) = base.strip_prefix("lib") {
|
||||||
|
return name.to_string();
|
||||||
|
}
|
||||||
|
return base.to_string();
|
||||||
|
}
|
||||||
|
soname.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to detect library version from soname suffix.
|
||||||
|
fn version_from_soname(soname: &str) -> Option<String> {
|
||||||
|
// Pattern: libfoo.so.X.Y.Z
|
||||||
|
if let Some(pos) = soname.find(".so.") {
|
||||||
|
let ver_part = &soname[pos + 4..];
|
||||||
|
if !ver_part.is_empty() && ver_part.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) {
|
||||||
|
return Some(ver_part.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Library inventory extraction ---
|
||||||
|
|
||||||
|
/// Extract the list of shared libraries bundled inside an AppImage.
|
||||||
|
pub fn inventory_bundled_libraries(appimage_path: &Path) -> Vec<BundledLibrary> {
|
||||||
|
// Get squashfs offset
|
||||||
|
let offset_output = Command::new(appimage_path)
|
||||||
|
.arg("--appimage-offset")
|
||||||
|
.env("APPIMAGE_EXTRACT_AND_RUN", "1")
|
||||||
|
.output();
|
||||||
|
|
||||||
|
let offset = match offset_output {
|
||||||
|
Ok(out) if out.status.success() => {
|
||||||
|
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||||
|
}
|
||||||
|
_ => return Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use unsquashfs to list all files with details
|
||||||
|
let output = Command::new("unsquashfs")
|
||||||
|
.args(["-o", &offset, "-ll", "-no-progress"])
|
||||||
|
.arg(appimage_path)
|
||||||
|
.output();
|
||||||
|
|
||||||
|
let listing = match output {
|
||||||
|
Ok(out) if out.status.success() => {
|
||||||
|
String::from_utf8_lossy(&out.stdout).to_string()
|
||||||
|
}
|
||||||
|
_ => return Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut libraries = Vec::new();
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
|
||||||
|
for line in listing.lines() {
|
||||||
|
// unsquashfs -ll format: "-rwxr-xr-x user/group 12345 2024-01-15 10:30 squashfs-root/usr/lib/libfoo.so.1"
|
||||||
|
// We want lines containing .so files
|
||||||
|
if !line.contains(".so") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the file path (last field)
|
||||||
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if parts.len() < 6 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_path = parts[parts.len() - 1];
|
||||||
|
|
||||||
|
// Must be in a lib-like directory or be a .so file
|
||||||
|
if !file_path.contains(".so") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract just the filename
|
||||||
|
let filename = file_path.rsplit('/').next().unwrap_or(file_path);
|
||||||
|
|
||||||
|
// Skip non-library files that happen to contain .so in name
|
||||||
|
if !filename.contains(".so") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip symlinks (they have -> in the line)
|
||||||
|
if line.contains(" -> ") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if we already have this soname
|
||||||
|
let soname = filename.to_string();
|
||||||
|
if !seen.insert(soname.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse file size from the listing
|
||||||
|
let file_size: u64 = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||||
|
|
||||||
|
let detected_name = soname_to_product(&soname)
|
||||||
|
.map(|(_, name)| name.to_string())
|
||||||
|
.or_else(|| Some(soname_to_name(&soname)));
|
||||||
|
|
||||||
|
let detected_version = version_from_soname(&soname);
|
||||||
|
|
||||||
|
libraries.push(BundledLibrary {
|
||||||
|
soname,
|
||||||
|
detected_name,
|
||||||
|
detected_version,
|
||||||
|
file_path: file_path.to_string(),
|
||||||
|
file_size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
libraries
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to detect version strings from the binary data of a library.
|
||||||
|
/// This scans .rodata sections for version patterns.
|
||||||
|
pub fn detect_version_from_binary(
|
||||||
|
appimage_path: &Path,
|
||||||
|
lib_file_path: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
// Get squashfs offset
|
||||||
|
let offset_output = Command::new(appimage_path)
|
||||||
|
.arg("--appimage-offset")
|
||||||
|
.env("APPIMAGE_EXTRACT_AND_RUN", "1")
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
if !offset_output.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let offset = String::from_utf8_lossy(&offset_output.stdout).trim().to_string();
|
||||||
|
|
||||||
|
// Extract the specific library to a temp file
|
||||||
|
let temp_dir = tempfile::tempdir().ok()?;
|
||||||
|
let extract_output = Command::new("unsquashfs")
|
||||||
|
.args(["-o", &offset, "-f", "-d"])
|
||||||
|
.arg(temp_dir.path())
|
||||||
|
.arg("-e")
|
||||||
|
.arg(lib_file_path.trim_start_matches("squashfs-root/"))
|
||||||
|
.arg("-no-progress")
|
||||||
|
.arg(appimage_path)
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
if !extract_output.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the extracted file
|
||||||
|
let extracted = temp_dir.path().join(
|
||||||
|
lib_file_path.trim_start_matches("squashfs-root/")
|
||||||
|
);
|
||||||
|
|
||||||
|
if !extracted.exists() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use strings to find version patterns
|
||||||
|
let strings_output = Command::new("strings")
|
||||||
|
.arg(&extracted)
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
if !strings_output.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let strings = String::from_utf8_lossy(&strings_output.stdout);
|
||||||
|
|
||||||
|
// Look for common version patterns
|
||||||
|
for line in strings.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
|
||||||
|
// OpenSSL: "OpenSSL 1.1.1k 25 Mar 2021"
|
||||||
|
if line.starts_with("OpenSSL ") && line.len() < 60 {
|
||||||
|
if let Some(ver) = line.strip_prefix("OpenSSL ") {
|
||||||
|
let ver_part = ver.split_whitespace().next()?;
|
||||||
|
return Some(ver_part.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// curl: "libcurl/7.81.0"
|
||||||
|
if line.starts_with("libcurl/") {
|
||||||
|
if let Some(ver) = line.strip_prefix("libcurl/") {
|
||||||
|
let ver_part = ver.split_whitespace().next()?;
|
||||||
|
return Some(ver_part.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SQLite: "3.39.4"
|
||||||
|
if line.starts_with("3.") && line.len() < 20 && line.chars().all(|c| c.is_ascii_digit() || c == '.') {
|
||||||
|
// Check it looks like a SQLite version
|
||||||
|
let parts: Vec<&str> = line.split('.').collect();
|
||||||
|
if parts.len() >= 3 {
|
||||||
|
return Some(line.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CVE checking via OSV API ---
|
||||||
|
|
||||||
|
/// Query the OSV.dev API for vulnerabilities affecting a specific package version.
|
||||||
|
fn query_osv(ecosystem: &str, package: &str, version: &str) -> Vec<CveMatch> {
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"version": version,
|
||||||
|
"package": {
|
||||||
|
"name": package,
|
||||||
|
"ecosystem": ecosystem
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = ureq::post("https://api.osv.dev/v1/query")
|
||||||
|
.send_json(&body);
|
||||||
|
|
||||||
|
let mut response = match result {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!("OSV query failed for {}/{}: {}", ecosystem, package, e);
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let json: serde_json::Value = match response.body_mut().read_json() {
|
||||||
|
Ok(j) => j,
|
||||||
|
Err(_) => return Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut matches = Vec::new();
|
||||||
|
|
||||||
|
if let Some(vulns) = json.get("vulns").and_then(|v| v.as_array()) {
|
||||||
|
for vuln in vulns {
|
||||||
|
let vuln_id = vuln.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
|
|
||||||
|
// Skip non-CVE entries unless they reference a CVE
|
||||||
|
let cve_id = if vuln_id.starts_with("CVE-") {
|
||||||
|
vuln_id.clone()
|
||||||
|
} else {
|
||||||
|
// Check aliases for a CVE
|
||||||
|
vuln.get("aliases")
|
||||||
|
.and_then(|a| a.as_array())
|
||||||
|
.and_then(|aliases| {
|
||||||
|
aliases.iter()
|
||||||
|
.filter_map(|a| a.as_str())
|
||||||
|
.find(|a| a.starts_with("CVE-"))
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
})
|
||||||
|
.unwrap_or(vuln_id)
|
||||||
|
};
|
||||||
|
|
||||||
|
let summary = vuln.get("summary")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Extract severity from database_specific or severity array
|
||||||
|
let (severity, cvss_score) = extract_severity(vuln);
|
||||||
|
|
||||||
|
// Extract fixed version
|
||||||
|
let fixed_version = extract_fixed_version(vuln);
|
||||||
|
|
||||||
|
matches.push(CveMatch {
|
||||||
|
cve_id,
|
||||||
|
severity,
|
||||||
|
cvss_score,
|
||||||
|
summary,
|
||||||
|
affected_versions: None,
|
||||||
|
fixed_version,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
matches
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_severity(vuln: &serde_json::Value) -> (String, Option<f64>) {
|
||||||
|
// Try severity array first
|
||||||
|
if let Some(severities) = vuln.get("severity").and_then(|s| s.as_array()) {
|
||||||
|
for sev in severities {
|
||||||
|
if let Some(score_str) = sev.get("score").and_then(|s| s.as_str()) {
|
||||||
|
// CVSS vector string - extract score
|
||||||
|
if score_str.starts_with("CVSS:") {
|
||||||
|
// Parse CVSS score from vector if available
|
||||||
|
// For now, just classify by the type
|
||||||
|
let score_type = sev.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||||
|
if score_type == "CVSS_V3" || score_type == "CVSS_V4" {
|
||||||
|
// Try to extract numerical score from database_specific
|
||||||
|
if let Some(db_spec) = vuln.get("database_specific") {
|
||||||
|
if let Some(score) = db_spec.get("severity").and_then(|s| s.as_str()) {
|
||||||
|
return (score.to_uppercase(), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try database_specific
|
||||||
|
if let Some(db_spec) = vuln.get("database_specific") {
|
||||||
|
if let Some(severity) = db_spec.get("severity").and_then(|s| s.as_str()) {
|
||||||
|
return (severity.to_uppercase(), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: classify as MEDIUM if we have a CVE but can't determine severity
|
||||||
|
("MEDIUM".to_string(), None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_fixed_version(vuln: &serde_json::Value) -> Option<String> {
|
||||||
|
if let Some(affected) = vuln.get("affected").and_then(|a| a.as_array()) {
|
||||||
|
for entry in affected {
|
||||||
|
if let Some(ranges) = entry.get("ranges").and_then(|r| r.as_array()) {
|
||||||
|
for range in ranges {
|
||||||
|
if let Some(events) = range.get("events").and_then(|e| e.as_array()) {
|
||||||
|
for event in events {
|
||||||
|
if let Some(fixed) = event.get("fixed").and_then(|f| f.as_str()) {
|
||||||
|
return Some(fixed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main scanning entry point ---
|
||||||
|
|
||||||
|
/// Scan a single AppImage for security vulnerabilities.
|
||||||
|
/// Returns the scan result with all findings.
|
||||||
|
pub fn scan_appimage(appimage_path: &Path, appimage_id: i64) -> SecurityScanResult {
|
||||||
|
let libraries = inventory_bundled_libraries(appimage_path);
|
||||||
|
|
||||||
|
let mut cve_matches: Vec<(BundledLibrary, Vec<CveMatch>)> = Vec::new();
|
||||||
|
let mut critical_count = 0;
|
||||||
|
let mut high_count = 0;
|
||||||
|
let mut medium_count = 0;
|
||||||
|
let mut low_count = 0;
|
||||||
|
|
||||||
|
for lib in &libraries {
|
||||||
|
// Only query CVEs for libraries we can map to a product
|
||||||
|
if let Some((ecosystem, package)) = soname_to_product(&lib.soname) {
|
||||||
|
// Use detected version, or try to extract from soname,
|
||||||
|
// then fall back to binary analysis for accurate version detection
|
||||||
|
let soname_ver = lib.detected_version.clone()
|
||||||
|
.or_else(|| version_from_soname(&lib.soname));
|
||||||
|
|
||||||
|
let version_string = soname_ver.or_else(|| {
|
||||||
|
detect_version_from_binary(appimage_path, &lib.file_path)
|
||||||
|
});
|
||||||
|
|
||||||
|
let version = match version_string.as_deref() {
|
||||||
|
Some(v) if !v.is_empty() => v,
|
||||||
|
_ => {
|
||||||
|
// Last resort: check system version for comparison logging
|
||||||
|
for sys_pkg in product_to_system_packages(package) {
|
||||||
|
if let Some(sys_ver) = get_system_library_version(sys_pkg) {
|
||||||
|
log::debug!("System {} version: {} (bundled version unknown)", sys_pkg, sys_ver);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let matches = query_osv(ecosystem, package, version);
|
||||||
|
|
||||||
|
if !matches.is_empty() {
|
||||||
|
for m in &matches {
|
||||||
|
match m.severity.as_str() {
|
||||||
|
"CRITICAL" => critical_count += 1,
|
||||||
|
"HIGH" => high_count += 1,
|
||||||
|
"MEDIUM" => medium_count += 1,
|
||||||
|
"LOW" => low_count += 1,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cve_matches.push((lib.clone(), matches));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SecurityScanResult {
|
||||||
|
appimage_id,
|
||||||
|
libraries,
|
||||||
|
cve_matches,
|
||||||
|
critical_count,
|
||||||
|
high_count,
|
||||||
|
medium_count,
|
||||||
|
low_count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan an AppImage and store results in the database.
|
||||||
|
pub fn scan_and_store(db: &Database, appimage_id: i64, appimage_path: &Path) -> SecurityScanResult {
|
||||||
|
let result = scan_appimage(appimage_path, appimage_id);
|
||||||
|
|
||||||
|
// Clear old data
|
||||||
|
db.clear_bundled_libraries(appimage_id).ok();
|
||||||
|
db.clear_cve_matches(appimage_id).ok();
|
||||||
|
|
||||||
|
// Store library inventory
|
||||||
|
let mut lib_id_map: HashMap<String, i64> = HashMap::new();
|
||||||
|
for lib in &result.libraries {
|
||||||
|
if let Ok(lib_id) = db.insert_bundled_library(
|
||||||
|
appimage_id,
|
||||||
|
&lib.soname,
|
||||||
|
lib.detected_name.as_deref(),
|
||||||
|
lib.detected_version.as_deref(),
|
||||||
|
Some(&lib.file_path),
|
||||||
|
lib.file_size as i64,
|
||||||
|
) {
|
||||||
|
lib_id_map.insert(lib.soname.clone(), lib_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store CVE matches
|
||||||
|
for (lib, matches) in &result.cve_matches {
|
||||||
|
if let Some(&lib_id) = lib_id_map.get(&lib.soname) {
|
||||||
|
for m in matches {
|
||||||
|
db.insert_cve_match(
|
||||||
|
appimage_id,
|
||||||
|
lib_id,
|
||||||
|
&m.cve_id,
|
||||||
|
Some(&m.severity),
|
||||||
|
m.cvss_score,
|
||||||
|
Some(&m.summary),
|
||||||
|
m.affected_versions.as_deref(),
|
||||||
|
m.fixed_version.as_deref(),
|
||||||
|
).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Batch scan all AppImages in the database.
|
||||||
|
pub fn batch_scan(db: &Database) -> Vec<SecurityScanResult> {
|
||||||
|
let records = match db.get_all_appimages() {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to get appimages for security scan: {}", e);
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut results = Vec::new();
|
||||||
|
|
||||||
|
for record in &records {
|
||||||
|
let path = Path::new(&record.path);
|
||||||
|
if !path.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = scan_and_store(db, record.id, path);
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get system library version by running dpkg or rpm.
|
||||||
|
pub fn get_system_library_version(package_name: &str) -> Option<String> {
|
||||||
|
// Try dpkg first (Debian/Ubuntu)
|
||||||
|
let output = Command::new("dpkg-query")
|
||||||
|
.args(["-W", "-f", "${Version}", package_name])
|
||||||
|
.output();
|
||||||
|
|
||||||
|
if let Ok(output) = output {
|
||||||
|
if output.status.success() {
|
||||||
|
let ver = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
if !ver.is_empty() {
|
||||||
|
return Some(ver);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try rpm (Fedora/RHEL)
|
||||||
|
let output = Command::new("rpm")
|
||||||
|
.args(["-q", "--queryformat", "%{VERSION}", package_name])
|
||||||
|
.output();
|
||||||
|
|
||||||
|
if let Ok(output) = output {
|
||||||
|
if output.status.success() {
|
||||||
|
let ver = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
if !ver.is_empty() {
|
||||||
|
return Some(ver);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a library product name to system package names to check.
|
||||||
|
pub fn product_to_system_packages(product: &str) -> Vec<&'static str> {
|
||||||
|
match product {
|
||||||
|
"openssl" => vec!["libssl3", "libssl3t64", "openssl"],
|
||||||
|
"curl" => vec!["libcurl4", "libcurl4t64", "curl"],
|
||||||
|
"zlib" => vec!["zlib1g", "zlib"],
|
||||||
|
"libpng" => vec!["libpng16-16", "libpng16-16t64", "libpng"],
|
||||||
|
"libjpeg-turbo" => vec!["libjpeg-turbo8", "libjpeg62-turbo", "libjpeg-turbo"],
|
||||||
|
"libwebp" => vec!["libwebp7", "libwebp"],
|
||||||
|
"sqlite3" => vec!["libsqlite3-0", "sqlite"],
|
||||||
|
"libxml2" => vec!["libxml2", "libxml2"],
|
||||||
|
"gnutls" => vec!["libgnutls30", "libgnutls30t64", "gnutls"],
|
||||||
|
"ffmpeg" => vec!["libavcodec-extra60", "libavcodec60", "ffmpeg-libs"],
|
||||||
|
"freetype2" => vec!["libfreetype6", "libfreetype6t64", "freetype"],
|
||||||
|
"harfbuzz" => vec!["libharfbuzz0b", "harfbuzz"],
|
||||||
|
"expat" => vec!["libexpat1", "expat"],
|
||||||
|
_ => vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_soname_to_product() {
|
||||||
|
assert_eq!(
|
||||||
|
soname_to_product("libssl.so.1.1"),
|
||||||
|
Some(("OSS-Fuzz", "openssl"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
soname_to_product("libcurl.so.4"),
|
||||||
|
Some(("OSS-Fuzz", "curl"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
soname_to_product("libz.so.1"),
|
||||||
|
Some(("OSS-Fuzz", "zlib"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
soname_to_product("libwebp.so.7"),
|
||||||
|
Some(("OSS-Fuzz", "libwebp"))
|
||||||
|
);
|
||||||
|
assert_eq!(soname_to_product("libfoo.so.1"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_soname_to_name() {
|
||||||
|
assert_eq!(soname_to_name("libssl.so.1.1"), "ssl");
|
||||||
|
assert_eq!(soname_to_name("libcurl.so.4"), "curl");
|
||||||
|
assert_eq!(soname_to_name("libz.so.1"), "z");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_version_from_soname() {
|
||||||
|
assert_eq!(version_from_soname("libssl.so.1.1"), Some("1.1".to_string()));
|
||||||
|
assert_eq!(version_from_soname("libz.so.1"), Some("1".to_string()));
|
||||||
|
assert_eq!(version_from_soname("libfoo.so"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_product_to_system_packages() {
|
||||||
|
let pkgs = product_to_system_packages("openssl");
|
||||||
|
assert!(pkgs.contains(&"libssl3"));
|
||||||
|
assert!(!pkgs.is_empty());
|
||||||
|
|
||||||
|
let unknown = product_to_system_packages("unknown_lib");
|
||||||
|
assert!(unknown.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_severity_default() {
|
||||||
|
let vuln = serde_json::json!({});
|
||||||
|
let (severity, score) = extract_severity(&vuln);
|
||||||
|
assert_eq!(severity, "MEDIUM");
|
||||||
|
assert!(score.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_fixed_version() {
|
||||||
|
let vuln = serde_json::json!({
|
||||||
|
"affected": [{
|
||||||
|
"ranges": [{
|
||||||
|
"type": "SEMVER",
|
||||||
|
"events": [
|
||||||
|
{"introduced": "0"},
|
||||||
|
{"fixed": "1.2.3"}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
assert_eq!(extract_fixed_version(&vuln), Some("1.2.3".to_string()));
|
||||||
|
|
||||||
|
let no_fix = serde_json::json!({});
|
||||||
|
assert_eq!(extract_fixed_version(&no_fix), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/core/watcher.rs
Normal file
79
src/core/watcher.rs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||||
|
|
||||||
|
/// Events sent from the file watcher to the UI thread.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum WatchEvent {
|
||||||
|
/// One or more AppImage files were created, modified, or deleted.
|
||||||
|
Changed(Vec<PathBuf>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start watching the given directories for AppImage file changes.
|
||||||
|
/// Returns the watcher handle (must be kept alive).
|
||||||
|
/// The callback `on_event` is invoked on the background debounce thread.
|
||||||
|
pub fn start_watcher<F: Fn(WatchEvent) + Send + 'static>(
|
||||||
|
dirs: Vec<PathBuf>,
|
||||||
|
on_event: F,
|
||||||
|
) -> Option<RecommendedWatcher> {
|
||||||
|
let (notify_tx, notify_rx) = mpsc::channel::<Result<Event, notify::Error>>();
|
||||||
|
|
||||||
|
let mut watcher = RecommendedWatcher::new(
|
||||||
|
move |res| {
|
||||||
|
notify_tx.send(res).ok();
|
||||||
|
},
|
||||||
|
Config::default().with_poll_interval(Duration::from_secs(2)),
|
||||||
|
).ok()?;
|
||||||
|
|
||||||
|
for dir in &dirs {
|
||||||
|
if dir.is_dir() {
|
||||||
|
watcher.watch(dir, RecursiveMode::NonRecursive).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn a thread to debounce and forward events
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut pending: Vec<PathBuf> = Vec::new();
|
||||||
|
let debounce = Duration::from_millis(500);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match notify_rx.recv_timeout(debounce) {
|
||||||
|
Ok(Ok(event)) => {
|
||||||
|
if is_appimage_event(&event) {
|
||||||
|
for path in event.paths {
|
||||||
|
if !pending.contains(&path) {
|
||||||
|
pending.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Err(_)) => {}
|
||||||
|
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||||
|
if !pending.is_empty() {
|
||||||
|
let paths = std::mem::take(&mut pending);
|
||||||
|
on_event(WatchEvent::Changed(paths));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Some(watcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_appimage_event(event: &Event) -> bool {
|
||||||
|
match event.kind {
|
||||||
|
EventKind::Create(_) | EventKind::Remove(_) | EventKind::Modify(_) => {
|
||||||
|
event.paths.iter().any(|p| {
|
||||||
|
p.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.map(|e| e.eq_ignore_ascii_case("appimage"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -310,6 +310,103 @@ pub fn detect_desktop_environment() -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Result of analyzing a running process for Wayland usage.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RuntimeAnalysis {
|
||||||
|
pub pid: u32,
|
||||||
|
pub has_wayland_socket: bool,
|
||||||
|
pub has_x11_connection: bool,
|
||||||
|
pub env_vars: Vec<(String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RuntimeAnalysis {
|
||||||
|
/// Human-readable status label.
|
||||||
|
pub fn status_label(&self) -> &'static str {
|
||||||
|
match (self.has_wayland_socket, self.has_x11_connection) {
|
||||||
|
(true, false) => "Native Wayland",
|
||||||
|
(true, true) => "Wayland + X11 fallback",
|
||||||
|
(false, true) => "X11 / XWayland",
|
||||||
|
(false, false) => "Unknown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Machine-readable status string for database storage.
|
||||||
|
pub fn as_status_str(&self) -> &'static str {
|
||||||
|
match (self.has_wayland_socket, self.has_x11_connection) {
|
||||||
|
(true, false) => "native",
|
||||||
|
(true, true) => "native",
|
||||||
|
(false, true) => "xwayland",
|
||||||
|
(false, false) => "unknown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Analyze a running process to determine its actual Wayland/X11 usage.
|
||||||
|
/// Inspects /proc/<pid>/fd for Wayland and X11 sockets, and reads
|
||||||
|
/// relevant environment variables from /proc/<pid>/environ.
|
||||||
|
pub fn analyze_running_process(pid: u32) -> Result<RuntimeAnalysis, String> {
|
||||||
|
let proc_path = format!("/proc/{}", pid);
|
||||||
|
if !std::path::Path::new(&proc_path).exists() {
|
||||||
|
return Err(format!("Process {} not found", pid));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file descriptors for Wayland and X11 sockets
|
||||||
|
let fd_dir = format!("{}/fd", proc_path);
|
||||||
|
let mut has_wayland_socket = false;
|
||||||
|
let mut has_x11_connection = false;
|
||||||
|
|
||||||
|
if let Ok(entries) = std::fs::read_dir(&fd_dir) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
if let Ok(target) = std::fs::read_link(entry.path()) {
|
||||||
|
let target_str = target.to_string_lossy();
|
||||||
|
if target_str.contains("wayland") {
|
||||||
|
has_wayland_socket = true;
|
||||||
|
}
|
||||||
|
if target_str.contains("/tmp/.X11-unix/") || target_str.contains("@/tmp/.X11") {
|
||||||
|
has_x11_connection = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read relevant environment variables
|
||||||
|
let environ_path = format!("{}/environ", proc_path);
|
||||||
|
let mut env_vars = Vec::new();
|
||||||
|
|
||||||
|
let relevant_vars = [
|
||||||
|
"WAYLAND_DISPLAY", "DISPLAY", "GDK_BACKEND", "QT_QPA_PLATFORM",
|
||||||
|
"XDG_SESSION_TYPE", "SDL_VIDEODRIVER", "CLUTTER_BACKEND",
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Ok(data) = std::fs::read(&environ_path) {
|
||||||
|
for entry in data.split(|&b| b == 0) {
|
||||||
|
if let Ok(s) = std::str::from_utf8(entry) {
|
||||||
|
if let Some((key, value)) = s.split_once('=') {
|
||||||
|
if relevant_vars.contains(&key) {
|
||||||
|
env_vars.push((key.to_string(), value.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check env vars for hints if fd inspection was inconclusive
|
||||||
|
if !has_wayland_socket {
|
||||||
|
has_wayland_socket = env_vars.iter().any(|(k, v)| {
|
||||||
|
(k == "GDK_BACKEND" && v.contains("wayland"))
|
||||||
|
|| (k == "QT_QPA_PLATFORM" && v.contains("wayland"))
|
||||||
|
|| (k == "WAYLAND_DISPLAY" && !v.is_empty())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(RuntimeAnalysis {
|
||||||
|
pid,
|
||||||
|
has_wayland_socket,
|
||||||
|
has_x11_connection,
|
||||||
|
env_vars,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if XWayland is available on the system.
|
/// Check if XWayland is available on the system.
|
||||||
pub fn has_xwayland() -> bool {
|
pub fn has_xwayland() -> bool {
|
||||||
// Check if Xwayland process is running
|
// Check if Xwayland process is running
|
||||||
|
|||||||
34
src/i18n.rs
Normal file
34
src/i18n.rs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/// Mark a string for translation.
|
||||||
|
/// Currently a passthrough; will use gettext when locale support is wired up.
|
||||||
|
pub fn i18n(msgid: &str) -> String {
|
||||||
|
msgid.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translate a string with singular/plural forms.
|
||||||
|
pub fn ni18n(singular: &str, plural: &str, n: u32) -> String {
|
||||||
|
if n == 1 {
|
||||||
|
singular.to_string()
|
||||||
|
} else {
|
||||||
|
plural.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translate a string and replace named placeholders.
|
||||||
|
pub fn i18n_f(msgid: &str, args: &[(&str, &str)]) -> String {
|
||||||
|
let mut result = msgid.to_string();
|
||||||
|
for (key, value) in args {
|
||||||
|
result = result.replace(key, value);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translate a string with singular/plural forms and named placeholders.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn ni18n_f(singular: &str, plural: &str, n: u32, args: &[(&str, &str)]) -> String {
|
||||||
|
let base = if n == 1 { singular } else { plural };
|
||||||
|
let mut result = base.to_string();
|
||||||
|
for (key, value) in args {
|
||||||
|
result = result.replace(key, value);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ mod application;
|
|||||||
mod cli;
|
mod cli;
|
||||||
mod config;
|
mod config;
|
||||||
mod core;
|
mod core;
|
||||||
|
mod i18n;
|
||||||
mod ui;
|
mod ui;
|
||||||
mod window;
|
mod window;
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ use gtk::prelude::*;
|
|||||||
use gtk::accessible::Property as AccessibleProperty;
|
use gtk::accessible::Property as AccessibleProperty;
|
||||||
|
|
||||||
use crate::core::database::AppImageRecord;
|
use crate::core::database::AppImageRecord;
|
||||||
use crate::core::fuse::FuseStatus;
|
|
||||||
use crate::core::wayland::WaylandStatus;
|
use crate::core::wayland::WaylandStatus;
|
||||||
use super::widgets;
|
use super::widgets;
|
||||||
|
|
||||||
@@ -11,25 +10,20 @@ pub fn build_app_card(record: &AppImageRecord) -> gtk::FlowBoxChild {
|
|||||||
let card = gtk::Box::builder()
|
let card = gtk::Box::builder()
|
||||||
.orientation(gtk::Orientation::Vertical)
|
.orientation(gtk::Orientation::Vertical)
|
||||||
.spacing(6)
|
.spacing(6)
|
||||||
.margin_top(14)
|
.halign(gtk::Align::Fill)
|
||||||
.margin_bottom(14)
|
|
||||||
.margin_start(14)
|
|
||||||
.margin_end(14)
|
|
||||||
.halign(gtk::Align::Center)
|
|
||||||
.build();
|
.build();
|
||||||
card.add_css_class("card");
|
card.add_css_class("card");
|
||||||
card.set_size_request(200, -1);
|
|
||||||
|
|
||||||
// Icon (72x72) with integration emblem overlay
|
|
||||||
let name = record.app_name.as_deref().unwrap_or(&record.filename);
|
let name = record.app_name.as_deref().unwrap_or(&record.filename);
|
||||||
|
|
||||||
|
// Icon (64x64) with integration emblem overlay
|
||||||
let icon_widget = widgets::app_icon(
|
let icon_widget = widgets::app_icon(
|
||||||
record.icon_path.as_deref(),
|
record.icon_path.as_deref(),
|
||||||
name,
|
name,
|
||||||
72,
|
64,
|
||||||
);
|
);
|
||||||
icon_widget.add_css_class("icon-dropshadow");
|
icon_widget.add_css_class("icon-dropshadow");
|
||||||
|
|
||||||
// If integrated, overlay a small checkmark emblem
|
|
||||||
if record.integrated {
|
if record.integrated {
|
||||||
let overlay = gtk::Overlay::new();
|
let overlay = gtk::Overlay::new();
|
||||||
overlay.set_child(Some(&icon_widget));
|
overlay.set_child(Some(&icon_widget));
|
||||||
@@ -47,13 +41,14 @@ pub fn build_app_card(record: &AppImageRecord) -> gtk::FlowBoxChild {
|
|||||||
card.append(&icon_widget);
|
card.append(&icon_widget);
|
||||||
}
|
}
|
||||||
|
|
||||||
// App name - .title-3 for more visual weight
|
// App name
|
||||||
let name_label = gtk::Label::builder()
|
let name_label = gtk::Label::builder()
|
||||||
.label(name)
|
.label(name)
|
||||||
.css_classes(["title-3"])
|
.css_classes(["title-4"])
|
||||||
.ellipsize(gtk::pango::EllipsizeMode::End)
|
.ellipsize(gtk::pango::EllipsizeMode::End)
|
||||||
.max_width_chars(20)
|
.max_width_chars(20)
|
||||||
.build();
|
.build();
|
||||||
|
card.append(&name_label);
|
||||||
|
|
||||||
// Version + size combined on one line
|
// Version + size combined on one line
|
||||||
let version_text = record.app_version.as_deref().unwrap_or("");
|
let version_text = record.app_version.as_deref().unwrap_or("");
|
||||||
@@ -61,28 +56,56 @@ pub fn build_app_card(record: &AppImageRecord) -> gtk::FlowBoxChild {
|
|||||||
let meta_text = if version_text.is_empty() {
|
let meta_text = if version_text.is_empty() {
|
||||||
size_text
|
size_text
|
||||||
} else {
|
} else {
|
||||||
format!("{} - {}", version_text, size_text)
|
format!("{} - {}", version_text, size_text)
|
||||||
};
|
};
|
||||||
let meta_label = gtk::Label::builder()
|
let meta_label = gtk::Label::builder()
|
||||||
.label(&meta_text)
|
.label(&meta_text)
|
||||||
.css_classes(["caption", "dimmed", "numeric"])
|
.css_classes(["caption", "dimmed", "numeric"])
|
||||||
.ellipsize(gtk::pango::EllipsizeMode::End)
|
.ellipsize(gtk::pango::EllipsizeMode::End)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
card.append(&name_label);
|
|
||||||
card.append(&meta_label);
|
card.append(&meta_label);
|
||||||
|
|
||||||
|
// Description snippet (if available)
|
||||||
|
if let Some(ref desc) = record.description {
|
||||||
|
if !desc.is_empty() {
|
||||||
|
let snippet = if desc.len() > 60 {
|
||||||
|
format!("{}...", &desc[..desc.char_indices().take_while(|&(i, _)| i < 57).last().map(|(i, c)| i + c.len_utf8()).unwrap_or(57)])
|
||||||
|
} else {
|
||||||
|
desc.clone()
|
||||||
|
};
|
||||||
|
let desc_label = gtk::Label::builder()
|
||||||
|
.label(&snippet)
|
||||||
|
.css_classes(["caption", "dimmed"])
|
||||||
|
.ellipsize(gtk::pango::EllipsizeMode::End)
|
||||||
|
.lines(2)
|
||||||
|
.wrap(true)
|
||||||
|
.xalign(0.5)
|
||||||
|
.max_width_chars(25)
|
||||||
|
.build();
|
||||||
|
card.append(&desc_label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Single most important badge (priority: Update > FUSE issue > Wayland issue)
|
// Single most important badge (priority: Update > FUSE issue > Wayland issue)
|
||||||
if let Some(badge) = build_priority_badge(record) {
|
let badge = build_priority_badge(record);
|
||||||
|
let has_badge = badge.is_some();
|
||||||
|
if let Some(badge) = badge {
|
||||||
let badge_box = gtk::Box::builder()
|
let badge_box = gtk::Box::builder()
|
||||||
.orientation(gtk::Orientation::Horizontal)
|
.orientation(gtk::Orientation::Horizontal)
|
||||||
.halign(gtk::Align::Center)
|
.halign(gtk::Align::Center)
|
||||||
.margin_top(4)
|
.margin_top(2)
|
||||||
.build();
|
.build();
|
||||||
badge_box.append(&badge);
|
badge_box.append(&badge);
|
||||||
card.append(&badge_box);
|
card.append(&badge_box);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Status border: green for healthy, amber for attention needed
|
||||||
|
if record.integrated && !has_badge {
|
||||||
|
card.add_css_class("status-ok");
|
||||||
|
} else if has_badge {
|
||||||
|
card.add_css_class("status-attention");
|
||||||
|
}
|
||||||
|
|
||||||
let child = gtk::FlowBoxChild::builder()
|
let child = gtk::FlowBoxChild::builder()
|
||||||
.child(&card)
|
.child(&card)
|
||||||
.build();
|
.build();
|
||||||
@@ -95,9 +118,14 @@ pub fn build_app_card(record: &AppImageRecord) -> gtk::FlowBoxChild {
|
|||||||
child
|
child
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the single most important badge for a card.
|
/// Return the single most important badge for a record.
|
||||||
/// Priority: Update available > FUSE issue > Wayland issue.
|
/// Priority: Analyzing > Update available > FUSE issue > Wayland issue.
|
||||||
pub fn build_priority_badge(record: &AppImageRecord) -> Option<gtk::Label> {
|
pub fn build_priority_badge(record: &AppImageRecord) -> Option<gtk::Label> {
|
||||||
|
// 0. Analysis in progress (highest priority)
|
||||||
|
if record.app_name.is_none() && record.analysis_status.as_deref() != Some("complete") {
|
||||||
|
return Some(widgets::status_badge("Analyzing...", "info"));
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Update available (highest priority)
|
// 1. Update available (highest priority)
|
||||||
if let (Some(ref latest), Some(ref current)) = (&record.latest_version, &record.app_version) {
|
if let (Some(ref latest), Some(ref current)) = (&record.latest_version, &record.app_version) {
|
||||||
if crate::core::updater::version_is_newer(latest, current) {
|
if crate::core::updater::version_is_newer(latest, current) {
|
||||||
@@ -106,10 +134,18 @@ pub fn build_priority_badge(record: &AppImageRecord) -> Option<gtk::Label> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. FUSE issue
|
// 2. FUSE issue
|
||||||
|
// The database stores AppImageFuseStatus values (per-app), not FuseStatus (system).
|
||||||
|
// Check both: per-app statuses like "native_fuse"/"static_runtime" are fine,
|
||||||
|
// "extract_and_run" is slow but works, "cannot_launch" is a real problem.
|
||||||
|
// System statuses like "fully_functional" are also fine.
|
||||||
if let Some(ref fs) = record.fuse_status {
|
if let Some(ref fs) = record.fuse_status {
|
||||||
let status = FuseStatus::from_str(fs);
|
let is_ok = matches!(
|
||||||
if !status.is_functional() {
|
fs.as_str(),
|
||||||
return Some(widgets::status_badge(status.label(), status.badge_class()));
|
"native_fuse" | "static_runtime" | "fully_functional"
|
||||||
|
);
|
||||||
|
let is_slow = fs.as_str() == "extract_and_run";
|
||||||
|
if !is_ok && !is_slow {
|
||||||
|
return Some(widgets::status_badge("Needs setup", "warning"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +153,7 @@ pub fn build_priority_badge(record: &AppImageRecord) -> Option<gtk::Label> {
|
|||||||
if let Some(ref ws) = record.wayland_status {
|
if let Some(ref ws) = record.wayland_status {
|
||||||
let status = WaylandStatus::from_str(ws);
|
let status = WaylandStatus::from_str(ws);
|
||||||
if status != WaylandStatus::Unknown && status != WaylandStatus::Native {
|
if status != WaylandStatus::Unknown && status != WaylandStatus::Native {
|
||||||
return Some(widgets::status_badge(status.label(), status.badge_class()));
|
return Some(widgets::status_badge("May look blurry", "neutral"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use crate::core::database::Database;
|
|||||||
use crate::core::duplicates;
|
use crate::core::duplicates;
|
||||||
use crate::core::footprint;
|
use crate::core::footprint;
|
||||||
use crate::core::orphan;
|
use crate::core::orphan;
|
||||||
|
use crate::i18n::{i18n, i18n_f};
|
||||||
use super::widgets;
|
use super::widgets;
|
||||||
|
|
||||||
/// A reclaimable item discovered during analysis.
|
/// A reclaimable item discovered during analysis.
|
||||||
@@ -28,11 +29,11 @@ enum ReclaimCategory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ReclaimCategory {
|
impl ReclaimCategory {
|
||||||
fn label(&self) -> &'static str {
|
fn label(&self) -> String {
|
||||||
match self {
|
match self {
|
||||||
ReclaimCategory::OrphanedDesktopEntry => "Orphaned desktop entries",
|
ReclaimCategory::OrphanedDesktopEntry => i18n("Orphaned desktop entries"),
|
||||||
ReclaimCategory::CacheData => "Cache data",
|
ReclaimCategory::CacheData => i18n("Cache data"),
|
||||||
ReclaimCategory::DuplicateAppImage => "Duplicate AppImages",
|
ReclaimCategory::DuplicateAppImage => i18n("Duplicate AppImages"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +49,7 @@ impl ReclaimCategory {
|
|||||||
/// Show the disk space reclamation wizard as an AdwDialog.
|
/// Show the disk space reclamation wizard as an AdwDialog.
|
||||||
pub fn show_cleanup_wizard(parent: &impl IsA<gtk::Widget>, _db: &Rc<Database>) {
|
pub fn show_cleanup_wizard(parent: &impl IsA<gtk::Widget>, _db: &Rc<Database>) {
|
||||||
let dialog = adw::Dialog::builder()
|
let dialog = adw::Dialog::builder()
|
||||||
.title("Disk Space Cleanup")
|
.title(&i18n("Disk Space Cleanup"))
|
||||||
.content_width(500)
|
.content_width(500)
|
||||||
.content_height(550)
|
.content_height(550)
|
||||||
.build();
|
.build();
|
||||||
@@ -118,8 +119,8 @@ pub fn show_cleanup_wizard(parent: &impl IsA<gtk::Widget>, _db: &Rc<Database>) {
|
|||||||
Err(_) => {
|
Err(_) => {
|
||||||
let error_page = adw::StatusPage::builder()
|
let error_page = adw::StatusPage::builder()
|
||||||
.icon_name("dialog-error-symbolic")
|
.icon_name("dialog-error-symbolic")
|
||||||
.title("Analysis Failed")
|
.title(&i18n("Analysis Failed"))
|
||||||
.description("Could not analyze disk usage.")
|
.description(&i18n("Could not analyze disk usage."))
|
||||||
.build();
|
.build();
|
||||||
if let Some(child) = stack_ref.child_by_name("review") {
|
if let Some(child) = stack_ref.child_by_name("review") {
|
||||||
stack_ref.remove(&child);
|
stack_ref.remove(&child);
|
||||||
@@ -148,13 +149,13 @@ fn build_analysis_step() -> gtk::Box {
|
|||||||
page.append(&spinner);
|
page.append(&spinner);
|
||||||
|
|
||||||
let label = gtk::Label::builder()
|
let label = gtk::Label::builder()
|
||||||
.label("Analyzing disk usage...")
|
.label(&i18n("Analyzing disk usage..."))
|
||||||
.css_classes(["title-3"])
|
.css_classes(["title-3"])
|
||||||
.build();
|
.build();
|
||||||
page.append(&label);
|
page.append(&label);
|
||||||
|
|
||||||
let subtitle = gtk::Label::builder()
|
let subtitle = gtk::Label::builder()
|
||||||
.label("Checking for orphaned files, cache data, and duplicates")
|
.label(&i18n("Checking for orphaned files, cache data, and duplicates"))
|
||||||
.css_classes(["dimmed"])
|
.css_classes(["dimmed"])
|
||||||
.build();
|
.build();
|
||||||
page.append(&subtitle);
|
page.append(&subtitle);
|
||||||
@@ -178,8 +179,8 @@ fn build_review_step(
|
|||||||
if items_ref.is_empty() {
|
if items_ref.is_empty() {
|
||||||
let empty = adw::StatusPage::builder()
|
let empty = adw::StatusPage::builder()
|
||||||
.icon_name("emblem-ok-symbolic")
|
.icon_name("emblem-ok-symbolic")
|
||||||
.title("All Clean")
|
.title(&i18n("All Clean"))
|
||||||
.description("No reclaimable disk space found.")
|
.description(&i18n("No reclaimable disk space found."))
|
||||||
.vexpand(true)
|
.vexpand(true)
|
||||||
.build();
|
.build();
|
||||||
page.append(&empty);
|
page.append(&empty);
|
||||||
@@ -189,7 +190,7 @@ fn build_review_step(
|
|||||||
// Summary header
|
// Summary header
|
||||||
let total_size: u64 = items_ref.iter().map(|i| i.size_bytes).sum();
|
let total_size: u64 = items_ref.iter().map(|i| i.size_bytes).sum();
|
||||||
let summary_label = gtk::Label::builder()
|
let summary_label = gtk::Label::builder()
|
||||||
.label(&format!("Found {} reclaimable", widgets::format_size(total_size as i64)))
|
.label(&i18n_f("Found {} reclaimable", &[("{}", &widgets::format_size(total_size as i64))]))
|
||||||
.css_classes(["title-3"])
|
.css_classes(["title-3"])
|
||||||
.margin_top(12)
|
.margin_top(12)
|
||||||
.margin_start(18)
|
.margin_start(18)
|
||||||
@@ -199,7 +200,7 @@ fn build_review_step(
|
|||||||
page.append(&summary_label);
|
page.append(&summary_label);
|
||||||
|
|
||||||
let desc_label = gtk::Label::builder()
|
let desc_label = gtk::Label::builder()
|
||||||
.label("Select items to remove")
|
.label(&i18n("Select items to remove"))
|
||||||
.css_classes(["dimmed"])
|
.css_classes(["dimmed"])
|
||||||
.margin_start(18)
|
.margin_start(18)
|
||||||
.margin_end(18)
|
.margin_end(18)
|
||||||
@@ -251,8 +252,9 @@ fn build_review_step(
|
|||||||
let cat_icon = gtk::Image::from_icon_name(cat.icon_name());
|
let cat_icon = gtk::Image::from_icon_name(cat.icon_name());
|
||||||
cat_icon.set_pixel_size(16);
|
cat_icon.set_pixel_size(16);
|
||||||
cat_header.append(&cat_icon);
|
cat_header.append(&cat_icon);
|
||||||
|
let cat_label_text = cat.label();
|
||||||
let cat_label = gtk::Label::builder()
|
let cat_label = gtk::Label::builder()
|
||||||
.label(&format!("{} ({})", cat.label(), widgets::format_size(cat_size as i64)))
|
.label(&format!("{} ({})", cat_label_text, widgets::format_size(cat_size as i64)))
|
||||||
.css_classes(["title-4"])
|
.css_classes(["title-4"])
|
||||||
.halign(gtk::Align::Start)
|
.halign(gtk::Align::Start)
|
||||||
.build();
|
.build();
|
||||||
@@ -263,7 +265,7 @@ fn build_review_step(
|
|||||||
list_box.add_css_class("boxed-list");
|
list_box.add_css_class("boxed-list");
|
||||||
list_box.set_selection_mode(gtk::SelectionMode::None);
|
list_box.set_selection_mode(gtk::SelectionMode::None);
|
||||||
list_box.update_property(&[
|
list_box.update_property(&[
|
||||||
gtk::accessible::Property::Label(cat.label()),
|
gtk::accessible::Property::Label(&cat_label_text),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
for (idx, item) in &cat_items {
|
for (idx, item) in &cat_items {
|
||||||
@@ -305,11 +307,11 @@ fn build_review_step(
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
let clean_button = gtk::Button::builder()
|
let clean_button = gtk::Button::builder()
|
||||||
.label("Clean Selected")
|
.label(&i18n("Clean Selected"))
|
||||||
.build();
|
.build();
|
||||||
clean_button.add_css_class("destructive-action");
|
clean_button.add_css_class("destructive-action");
|
||||||
clean_button.update_property(&[
|
clean_button.update_property(&[
|
||||||
gtk::accessible::Property::Label("Clean selected items"),
|
gtk::accessible::Property::Label(&i18n("Clean selected items")),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let items_clone = items.clone();
|
let items_clone = items.clone();
|
||||||
@@ -337,17 +339,16 @@ fn build_review_step(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let confirm = adw::AlertDialog::builder()
|
let confirm = adw::AlertDialog::builder()
|
||||||
.heading("Confirm Cleanup")
|
.heading(&i18n("Confirm Cleanup"))
|
||||||
.body(&format!(
|
.body(&i18n_f(
|
||||||
"Remove {} item{}?",
|
"Remove {} items?",
|
||||||
count,
|
&[("{}", &count.to_string())],
|
||||||
if count == 1 { "" } else { "s" }
|
|
||||||
))
|
))
|
||||||
.close_response("cancel")
|
.close_response("cancel")
|
||||||
.default_response("clean")
|
.default_response("clean")
|
||||||
.build();
|
.build();
|
||||||
confirm.add_response("cancel", "Cancel");
|
confirm.add_response("cancel", &i18n("Cancel"));
|
||||||
confirm.add_response("clean", "Clean");
|
confirm.add_response("clean", &i18n("Clean"));
|
||||||
confirm.set_response_appearance("clean", adw::ResponseAppearance::Destructive);
|
confirm.set_response_appearance("clean", adw::ResponseAppearance::Destructive);
|
||||||
|
|
||||||
let on_confirm_inner = on_confirm_ref.clone();
|
let on_confirm_inner = on_confirm_ref.clone();
|
||||||
@@ -416,31 +417,32 @@ fn build_complete_step(count: usize, size: u64, dialog: &adw::Dialog) -> gtk::Bo
|
|||||||
if count == 0 {
|
if count == 0 {
|
||||||
let status = adw::StatusPage::builder()
|
let status = adw::StatusPage::builder()
|
||||||
.icon_name("emblem-ok-symbolic")
|
.icon_name("emblem-ok-symbolic")
|
||||||
.title("Nothing Selected")
|
.title(&i18n("Nothing Selected"))
|
||||||
.description("No items were selected for cleanup.")
|
.description(&i18n("No items were selected for cleanup."))
|
||||||
.build();
|
.build();
|
||||||
page.append(&status);
|
page.append(&status);
|
||||||
} else {
|
} else {
|
||||||
let status = adw::StatusPage::builder()
|
let status = adw::StatusPage::builder()
|
||||||
.icon_name("user-trash-symbolic")
|
.icon_name("user-trash-symbolic")
|
||||||
.title("Cleanup Complete")
|
.title(&i18n("Cleanup Complete"))
|
||||||
.description(&format!(
|
.description(&i18n_f(
|
||||||
"Removed {} item{}, freeing {}",
|
"Removed {count} items, freeing {size}",
|
||||||
count,
|
&[
|
||||||
if count == 1 { "" } else { "s" },
|
("{count}", &count.to_string()),
|
||||||
widgets::format_size(size as i64),
|
("{size}", &widgets::format_size(size as i64)),
|
||||||
|
],
|
||||||
))
|
))
|
||||||
.build();
|
.build();
|
||||||
page.append(&status);
|
page.append(&status);
|
||||||
}
|
}
|
||||||
|
|
||||||
let close_button = gtk::Button::builder()
|
let close_button = gtk::Button::builder()
|
||||||
.label("Close")
|
.label(&i18n("Close"))
|
||||||
.halign(gtk::Align::Center)
|
.halign(gtk::Align::Center)
|
||||||
.build();
|
.build();
|
||||||
close_button.add_css_class("pill");
|
close_button.add_css_class("pill");
|
||||||
close_button.update_property(&[
|
close_button.update_property(&[
|
||||||
gtk::accessible::Property::Label("Close cleanup dialog"),
|
gtk::accessible::Property::Label(&i18n("Close cleanup dialog")),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let dialog_ref = dialog.clone();
|
let dialog_ref = dialog.clone();
|
||||||
|
|||||||
@@ -22,32 +22,15 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::Nav
|
|||||||
// Toast overlay for copy actions
|
// Toast overlay for copy actions
|
||||||
let toast_overlay = adw::ToastOverlay::new();
|
let toast_overlay = adw::ToastOverlay::new();
|
||||||
|
|
||||||
// Main content container
|
// ViewStack for tabbed content
|
||||||
let content = gtk::Box::builder()
|
|
||||||
.orientation(gtk::Orientation::Vertical)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
// Hero banner (always visible at top)
|
|
||||||
let banner = build_banner(record);
|
|
||||||
content.append(&banner);
|
|
||||||
|
|
||||||
// ViewSwitcher (tab bar) - inline style, between banner and tab content
|
|
||||||
let view_stack = adw::ViewStack::new();
|
let view_stack = adw::ViewStack::new();
|
||||||
|
|
||||||
let switcher = adw::ViewSwitcher::builder()
|
|
||||||
.stack(&view_stack)
|
|
||||||
.policy(adw::ViewSwitcherPolicy::Wide)
|
|
||||||
.build();
|
|
||||||
switcher.add_css_class("inline");
|
|
||||||
switcher.add_css_class("detail-view-switcher");
|
|
||||||
content.append(&switcher);
|
|
||||||
|
|
||||||
// Build tab pages
|
// Build tab pages
|
||||||
let overview_page = build_overview_tab(record, db);
|
let overview_page = build_overview_tab(record, db);
|
||||||
view_stack.add_titled(&overview_page, Some("overview"), "Overview");
|
view_stack.add_titled(&overview_page, Some("overview"), "Overview");
|
||||||
view_stack.page(&overview_page).set_icon_name(Some("info-symbolic"));
|
view_stack.page(&overview_page).set_icon_name(Some("info-symbolic"));
|
||||||
|
|
||||||
let system_page = build_system_tab(record, db);
|
let system_page = build_system_tab(record, db, &toast_overlay);
|
||||||
view_stack.add_titled(&system_page, Some("system"), "System");
|
view_stack.add_titled(&system_page, Some("system"), "System");
|
||||||
view_stack.page(&system_page).set_icon_name(Some("system-run-symbolic"));
|
view_stack.page(&system_page).set_icon_name(Some("system-run-symbolic"));
|
||||||
|
|
||||||
@@ -59,18 +42,31 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::Nav
|
|||||||
view_stack.add_titled(&storage_page, Some("storage"), "Storage");
|
view_stack.add_titled(&storage_page, Some("storage"), "Storage");
|
||||||
view_stack.page(&storage_page).set_icon_name(Some("drive-harddisk-symbolic"));
|
view_stack.page(&storage_page).set_icon_name(Some("drive-harddisk-symbolic"));
|
||||||
|
|
||||||
// Scrollable area for tab content
|
// Scrollable view stack
|
||||||
let scrolled = gtk::ScrolledWindow::builder()
|
let scrolled = gtk::ScrolledWindow::builder()
|
||||||
.child(&view_stack)
|
.child(&view_stack)
|
||||||
.vexpand(true)
|
.vexpand(true)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
// Main vertical layout: banner + scrolled tabs
|
||||||
|
let content = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.build();
|
||||||
|
content.append(&build_banner(record));
|
||||||
content.append(&scrolled);
|
content.append(&scrolled);
|
||||||
|
|
||||||
toast_overlay.set_child(Some(&content));
|
toast_overlay.set_child(Some(&content));
|
||||||
|
|
||||||
// Header bar with per-app actions
|
// Header bar with ViewSwitcher as title widget (standard GNOME pattern)
|
||||||
let header = adw::HeaderBar::new();
|
let header = adw::HeaderBar::new();
|
||||||
|
|
||||||
|
let switcher = adw::ViewSwitcher::builder()
|
||||||
|
.stack(&view_stack)
|
||||||
|
.policy(adw::ViewSwitcherPolicy::Wide)
|
||||||
|
.build();
|
||||||
|
header.set_title_widget(Some(&switcher));
|
||||||
|
|
||||||
|
// Launch button
|
||||||
let launch_button = gtk::Button::builder()
|
let launch_button = gtk::Button::builder()
|
||||||
.label("Launch")
|
.label("Launch")
|
||||||
.tooltip_text("Launch this AppImage")
|
.tooltip_text("Launch this AppImage")
|
||||||
@@ -97,11 +93,9 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::Nav
|
|||||||
let pid = child.id();
|
let pid = child.id();
|
||||||
log::info!("Launched AppImage: {} (PID: {}, method: {})", path, pid, method.as_str());
|
log::info!("Launched AppImage: {} (PID: {}, method: {})", path, pid, method.as_str());
|
||||||
|
|
||||||
// Run post-launch Wayland runtime analysis after a short delay
|
|
||||||
let db_wayland = db_launch.clone();
|
let db_wayland = db_launch.clone();
|
||||||
let path_clone = path.clone();
|
let path_clone = path.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
// Wait 3 seconds for the process to initialize
|
|
||||||
glib::timeout_future(std::time::Duration::from_secs(3)).await;
|
glib::timeout_future(std::time::Duration::from_secs(3)).await;
|
||||||
|
|
||||||
let analysis_result = gio::spawn_blocking(move || {
|
let analysis_result = gio::spawn_blocking(move || {
|
||||||
@@ -165,7 +159,10 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::Nav
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rich banner at top: large icon + app name + version + badges
|
// ---------------------------------------------------------------------------
|
||||||
|
// Banner
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
fn build_banner(record: &AppImageRecord) -> gtk::Box {
|
fn build_banner(record: &AppImageRecord) -> gtk::Box {
|
||||||
let banner = gtk::Box::builder()
|
let banner = gtk::Box::builder()
|
||||||
.orientation(gtk::Orientation::Horizontal)
|
.orientation(gtk::Orientation::Horizontal)
|
||||||
@@ -178,7 +175,7 @@ fn build_banner(record: &AppImageRecord) -> gtk::Box {
|
|||||||
|
|
||||||
let name = record.app_name.as_deref().unwrap_or(&record.filename);
|
let name = record.app_name.as_deref().unwrap_or(&record.filename);
|
||||||
|
|
||||||
// Large icon (96x96) with drop shadow
|
// Large icon with drop shadow
|
||||||
let icon = widgets::app_icon(record.icon_path.as_deref(), name, 96);
|
let icon = widgets::app_icon(record.icon_path.as_deref(), name, 96);
|
||||||
icon.set_valign(gtk::Align::Start);
|
icon.set_valign(gtk::Align::Start);
|
||||||
icon.add_css_class("icon-dropshadow");
|
icon.add_css_class("icon-dropshadow");
|
||||||
@@ -259,7 +256,10 @@ fn build_banner(record: &AppImageRecord) -> gtk::Box {
|
|||||||
banner
|
banner
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tab 1: Overview - most commonly needed info at a glance
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tab 1: Overview - updates, usage, basic file info
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
||||||
let tab = gtk::Box::builder()
|
let tab = gtk::Box::builder()
|
||||||
.orientation(gtk::Orientation::Vertical)
|
.orientation(gtk::Orientation::Vertical)
|
||||||
@@ -283,6 +283,7 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
|||||||
// Updates section
|
// Updates section
|
||||||
let updates_group = adw::PreferencesGroup::builder()
|
let updates_group = adw::PreferencesGroup::builder()
|
||||||
.title("Updates")
|
.title("Updates")
|
||||||
|
.description("Keep this app up to date by checking for new versions.")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
if let Some(ref update_type) = record.update_type {
|
if let Some(ref update_type) = record.update_type {
|
||||||
@@ -291,15 +292,31 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
|||||||
.unwrap_or("Unknown format");
|
.unwrap_or("Unknown format");
|
||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
.title("Update method")
|
.title("Update method")
|
||||||
.subtitle(display_label)
|
.subtitle(&format!(
|
||||||
|
"This app checks for updates using: {}",
|
||||||
|
display_label
|
||||||
|
))
|
||||||
|
.tooltip_text(
|
||||||
|
"AppImages can include built-in update information that tells Driftwood \
|
||||||
|
where to check for newer versions. Common methods include GitHub releases, \
|
||||||
|
zsync (efficient delta updates), and direct download URLs."
|
||||||
|
)
|
||||||
.build();
|
.build();
|
||||||
updates_group.add(&row);
|
updates_group.add(&row);
|
||||||
} else {
|
} else {
|
||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
.title("Update method")
|
.title("Update method")
|
||||||
.subtitle("This app cannot check for updates automatically")
|
.subtitle(
|
||||||
|
"This app does not include update information. \
|
||||||
|
You will need to check for new versions manually."
|
||||||
|
)
|
||||||
|
.tooltip_text(
|
||||||
|
"AppImages can include built-in update information that tells Driftwood \
|
||||||
|
where to check for newer versions. This one doesn't have any, so you'll \
|
||||||
|
need to download updates yourself from wherever you got the app."
|
||||||
|
)
|
||||||
.build();
|
.build();
|
||||||
let badge = widgets::status_badge("None", "neutral");
|
let badge = widgets::status_badge("Manual only", "neutral");
|
||||||
badge.set_valign(gtk::Align::Center);
|
badge.set_valign(gtk::Align::Center);
|
||||||
row.add_suffix(&badge);
|
row.add_suffix(&badge);
|
||||||
updates_group.add(&row);
|
updates_group.add(&row);
|
||||||
@@ -314,9 +331,9 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
|||||||
|
|
||||||
if is_newer {
|
if is_newer {
|
||||||
let subtitle = format!(
|
let subtitle = format!(
|
||||||
"{} -> {}",
|
"A newer version is available: {} (you have {})",
|
||||||
|
latest,
|
||||||
record.app_version.as_deref().unwrap_or("unknown"),
|
record.app_version.as_deref().unwrap_or("unknown"),
|
||||||
latest
|
|
||||||
);
|
);
|
||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
.title("Update available")
|
.title("Update available")
|
||||||
@@ -328,8 +345,8 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
|||||||
updates_group.add(&row);
|
updates_group.add(&row);
|
||||||
} else {
|
} else {
|
||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
.title("Status")
|
.title("Version status")
|
||||||
.subtitle("Up to date")
|
.subtitle("You are running the latest version.")
|
||||||
.build();
|
.build();
|
||||||
let badge = widgets::status_badge("Latest", "success");
|
let badge = widgets::status_badge("Latest", "success");
|
||||||
badge.set_valign(gtk::Align::Center);
|
badge.set_valign(gtk::Align::Center);
|
||||||
@@ -375,20 +392,29 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
let type_str = match record.appimage_type {
|
let type_str = match record.appimage_type {
|
||||||
Some(1) => "Type 1",
|
Some(1) => "Type 1 (ISO 9660) - older format, still widely supported",
|
||||||
Some(2) => "Type 2",
|
Some(2) => "Type 2 (SquashFS) - modern format, most common today",
|
||||||
_ => "Unknown",
|
_ => "Unknown type",
|
||||||
};
|
};
|
||||||
let type_row = adw::ActionRow::builder()
|
let type_row = adw::ActionRow::builder()
|
||||||
.title("AppImage type")
|
.title("AppImage format")
|
||||||
.subtitle(type_str)
|
.subtitle(type_str)
|
||||||
.tooltip_text("Type 1 uses ISO9660, Type 2 uses SquashFS")
|
.tooltip_text(
|
||||||
|
"AppImages come in two formats. Type 1 uses an ISO 9660 filesystem \
|
||||||
|
(older, simpler). Type 2 uses SquashFS (modern, compressed, smaller \
|
||||||
|
files). Type 2 is the standard today and is what most AppImage tools \
|
||||||
|
produce."
|
||||||
|
)
|
||||||
.build();
|
.build();
|
||||||
info_group.add(&type_row);
|
info_group.add(&type_row);
|
||||||
|
|
||||||
let exec_row = adw::ActionRow::builder()
|
let exec_row = adw::ActionRow::builder()
|
||||||
.title("Executable")
|
.title("Executable")
|
||||||
.subtitle(if record.is_executable { "Yes" } else { "No" })
|
.subtitle(if record.is_executable {
|
||||||
|
"Yes - this file has execute permission"
|
||||||
|
} else {
|
||||||
|
"No - execute permission is missing. It will be set automatically when launched."
|
||||||
|
})
|
||||||
.build();
|
.build();
|
||||||
info_group.add(&exec_row);
|
info_group.add(&exec_row);
|
||||||
|
|
||||||
@@ -420,8 +446,11 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
|||||||
tab
|
tab
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tab 2: System - integration, compatibility, sandboxing
|
// ---------------------------------------------------------------------------
|
||||||
fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
// Tab 2: System - integration, compatibility, sandboxing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &adw::ToastOverlay) -> gtk::Box {
|
||||||
let tab = gtk::Box::builder()
|
let tab = gtk::Box::builder()
|
||||||
.orientation(gtk::Orientation::Vertical)
|
.orientation(gtk::Orientation::Vertical)
|
||||||
.spacing(24)
|
.spacing(24)
|
||||||
@@ -444,13 +473,21 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
|||||||
// Desktop Integration group
|
// Desktop Integration group
|
||||||
let integration_group = adw::PreferencesGroup::builder()
|
let integration_group = adw::PreferencesGroup::builder()
|
||||||
.title("Desktop Integration")
|
.title("Desktop Integration")
|
||||||
.description("Add this app to your application menu")
|
.description(
|
||||||
|
"Show this app in your Activities menu and app launcher, \
|
||||||
|
just like a regular installed application."
|
||||||
|
)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let switch_row = adw::SwitchRow::builder()
|
let switch_row = adw::SwitchRow::builder()
|
||||||
.title("Add to application menu")
|
.title("Add to application menu")
|
||||||
.subtitle("Creates a .desktop file and installs the icon")
|
.subtitle("Creates a .desktop entry and installs the app icon")
|
||||||
.active(record.integrated)
|
.active(record.integrated)
|
||||||
|
.tooltip_text(
|
||||||
|
"Desktop integration makes this AppImage appear in your Activities menu \
|
||||||
|
and app launcher, just like a regular installed app. It creates a .desktop \
|
||||||
|
file (a shortcut) and copies the app's icon to your system icon folder."
|
||||||
|
)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let record_id = record.id;
|
let record_id = record.id;
|
||||||
@@ -501,8 +538,11 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
|||||||
|
|
||||||
// Runtime Compatibility group
|
// Runtime Compatibility group
|
||||||
let compat_group = adw::PreferencesGroup::builder()
|
let compat_group = adw::PreferencesGroup::builder()
|
||||||
.title("Runtime Compatibility")
|
.title("Compatibility")
|
||||||
.description("Wayland support and FUSE status")
|
.description(
|
||||||
|
"How well this app works with your display server and filesystem. \
|
||||||
|
Most issues here can be resolved with a small package install."
|
||||||
|
)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let wayland_status = record
|
let wayland_status = record
|
||||||
@@ -512,20 +552,31 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
|||||||
.unwrap_or(WaylandStatus::Unknown);
|
.unwrap_or(WaylandStatus::Unknown);
|
||||||
|
|
||||||
let wayland_row = adw::ActionRow::builder()
|
let wayland_row = adw::ActionRow::builder()
|
||||||
.title("Wayland")
|
.title("Wayland display")
|
||||||
.subtitle(wayland_description(&wayland_status))
|
.subtitle(wayland_user_explanation(&wayland_status))
|
||||||
.tooltip_text("Display protocol for Linux desktops")
|
.tooltip_text(
|
||||||
|
"Wayland is the modern display system used by GNOME and most Linux desktops. \
|
||||||
|
It replaced the older X11 system. Apps built for X11 still work through \
|
||||||
|
a compatibility layer called XWayland, but native Wayland apps look \
|
||||||
|
sharper and perform better, especially on high-resolution screens."
|
||||||
|
)
|
||||||
.build();
|
.build();
|
||||||
let wayland_badge = widgets::status_badge(wayland_status.label(), wayland_status.badge_class());
|
let wayland_badge = widgets::status_badge(wayland_status.label(), wayland_status.badge_class());
|
||||||
wayland_badge.set_valign(gtk::Align::Center);
|
wayland_badge.set_valign(gtk::Align::Center);
|
||||||
wayland_row.add_suffix(&wayland_badge);
|
wayland_row.add_suffix(&wayland_badge);
|
||||||
compat_group.add(&wayland_row);
|
compat_group.add(&wayland_row);
|
||||||
|
|
||||||
// Wayland analyze button
|
// Analyze toolkit button
|
||||||
let analyze_row = adw::ActionRow::builder()
|
let analyze_row = adw::ActionRow::builder()
|
||||||
.title("Analyze toolkit")
|
.title("Analyze toolkit")
|
||||||
.subtitle("Inspect bundled libraries to detect UI toolkit")
|
.subtitle("Inspect bundled libraries to detect which UI toolkit this app uses")
|
||||||
.activatable(true)
|
.activatable(true)
|
||||||
|
.tooltip_text(
|
||||||
|
"UI toolkits (like GTK, Qt, Electron) are the frameworks apps use to \
|
||||||
|
draw their windows and buttons. Knowing the toolkit helps predict Wayland \
|
||||||
|
compatibility - GTK4 apps are natively Wayland, while older Qt or Electron \
|
||||||
|
apps may need XWayland."
|
||||||
|
)
|
||||||
.build();
|
.build();
|
||||||
let analyze_icon = gtk::Image::from_icon_name("system-search-symbolic");
|
let analyze_icon = gtk::Image::from_icon_name("system-search-symbolic");
|
||||||
analyze_icon.set_valign(gtk::Align::Center);
|
analyze_icon.set_valign(gtk::Align::Center);
|
||||||
@@ -552,12 +603,12 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
|||||||
let toolkit_label = analysis.toolkit.label();
|
let toolkit_label = analysis.toolkit.label();
|
||||||
let lib_count = analysis.libraries_found.len();
|
let lib_count = analysis.libraries_found.len();
|
||||||
row_clone.set_subtitle(&format!(
|
row_clone.set_subtitle(&format!(
|
||||||
"Toolkit: {} ({} libraries scanned)",
|
"Detected: {} ({} libraries scanned)",
|
||||||
toolkit_label, lib_count,
|
toolkit_label, lib_count,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
row_clone.set_subtitle("Analysis failed");
|
row_clone.set_subtitle("Analysis failed - the AppImage may not be mountable");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -567,8 +618,11 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
|||||||
// Runtime Wayland status (from post-launch analysis)
|
// Runtime Wayland status (from post-launch analysis)
|
||||||
if let Some(ref runtime_status) = record.runtime_wayland_status {
|
if let Some(ref runtime_status) = record.runtime_wayland_status {
|
||||||
let runtime_row = adw::ActionRow::builder()
|
let runtime_row = adw::ActionRow::builder()
|
||||||
.title("Runtime display protocol")
|
.title("Last observed protocol")
|
||||||
.subtitle(runtime_status)
|
.subtitle(&format!(
|
||||||
|
"When this app was last launched, it used: {}",
|
||||||
|
runtime_status
|
||||||
|
))
|
||||||
.build();
|
.build();
|
||||||
if let Some(ref checked) = record.runtime_wayland_checked {
|
if let Some(ref checked) = record.runtime_wayland_checked {
|
||||||
let info = gtk::Label::builder()
|
let info = gtk::Label::builder()
|
||||||
@@ -581,6 +635,7 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
|||||||
compat_group.add(&runtime_row);
|
compat_group.add(&runtime_row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FUSE status
|
||||||
let fuse_system = fuse::detect_system_fuse();
|
let fuse_system = fuse::detect_system_fuse();
|
||||||
let fuse_status = record
|
let fuse_status = record
|
||||||
.fuse_status
|
.fuse_status
|
||||||
@@ -589,9 +644,14 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
|||||||
.unwrap_or(fuse_system.status.clone());
|
.unwrap_or(fuse_system.status.clone());
|
||||||
|
|
||||||
let fuse_row = adw::ActionRow::builder()
|
let fuse_row = adw::ActionRow::builder()
|
||||||
.title("FUSE")
|
.title("FUSE (filesystem)")
|
||||||
.subtitle(fuse_description(&fuse_status))
|
.subtitle(fuse_user_explanation(&fuse_status))
|
||||||
.tooltip_text("Filesystem in Userspace - required for mounting AppImages")
|
.tooltip_text(
|
||||||
|
"FUSE (Filesystem in Userspace) lets AppImages mount themselves as \
|
||||||
|
virtual drives so they can run directly without extracting. Without it, \
|
||||||
|
AppImages still work but need to extract to a temp folder first, which \
|
||||||
|
is slower. Most systems have FUSE already, but some need libfuse2 installed."
|
||||||
|
)
|
||||||
.build();
|
.build();
|
||||||
let fuse_badge = widgets::status_badge_with_icon(
|
let fuse_badge = widgets::status_badge_with_icon(
|
||||||
if fuse_status.is_functional() { "emblem-ok-symbolic" } else { "dialog-warning-symbolic" },
|
if fuse_status.is_functional() { "emblem-ok-symbolic" } else { "dialog-warning-symbolic" },
|
||||||
@@ -600,14 +660,29 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
|||||||
);
|
);
|
||||||
fuse_badge.set_valign(gtk::Align::Center);
|
fuse_badge.set_valign(gtk::Align::Center);
|
||||||
fuse_row.add_suffix(&fuse_badge);
|
fuse_row.add_suffix(&fuse_badge);
|
||||||
|
if let Some(cmd) = fuse_install_command(&fuse_status) {
|
||||||
|
let copy_btn = widgets::copy_button(cmd, Some(toast_overlay));
|
||||||
|
copy_btn.set_valign(gtk::Align::Center);
|
||||||
|
copy_btn.set_tooltip_text(Some(&format!("Copy '{}' to clipboard", cmd)));
|
||||||
|
fuse_row.add_suffix(©_btn);
|
||||||
|
}
|
||||||
compat_group.add(&fuse_row);
|
compat_group.add(&fuse_row);
|
||||||
|
|
||||||
// Per-app FUSE launch method
|
// Per-app launch method
|
||||||
let appimage_path = std::path::Path::new(&record.path);
|
let appimage_path = std::path::Path::new(&record.path);
|
||||||
let app_fuse_status = fuse::determine_app_fuse_status(&fuse_system, appimage_path);
|
let app_fuse_status = fuse::determine_app_fuse_status(&fuse_system, appimage_path);
|
||||||
let launch_method_row = adw::ActionRow::builder()
|
let launch_method_row = adw::ActionRow::builder()
|
||||||
.title("Launch method")
|
.title("Launch method")
|
||||||
.subtitle(app_fuse_status.label())
|
.subtitle(&format!(
|
||||||
|
"This app will launch using: {}",
|
||||||
|
app_fuse_status.label()
|
||||||
|
))
|
||||||
|
.tooltip_text(
|
||||||
|
"AppImages can launch two ways: 'FUSE mount' mounts the image as a \
|
||||||
|
virtual drive (fast, instant startup), or 'extract' unpacks to a temp \
|
||||||
|
folder first (slower, but works everywhere). The method is chosen \
|
||||||
|
automatically based on your system's FUSE support."
|
||||||
|
)
|
||||||
.build();
|
.build();
|
||||||
let launch_badge = widgets::status_badge(
|
let launch_badge = widgets::status_badge(
|
||||||
fuse_system.status.as_str(),
|
fuse_system.status.as_str(),
|
||||||
@@ -621,7 +696,10 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
|||||||
// Sandboxing group
|
// Sandboxing group
|
||||||
let sandbox_group = adw::PreferencesGroup::builder()
|
let sandbox_group = adw::PreferencesGroup::builder()
|
||||||
.title("Sandboxing")
|
.title("Sandboxing")
|
||||||
.description("Isolate this app with Firejail")
|
.description(
|
||||||
|
"Isolate this app for extra security. Sandboxing limits what \
|
||||||
|
the app can access on your system."
|
||||||
|
)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let current_mode = record
|
let current_mode = record
|
||||||
@@ -633,17 +711,25 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
|||||||
let firejail_available = launcher::has_firejail();
|
let firejail_available = launcher::has_firejail();
|
||||||
|
|
||||||
let sandbox_subtitle = if firejail_available {
|
let sandbox_subtitle = if firejail_available {
|
||||||
format!("Current mode: {}", current_mode.label())
|
format!(
|
||||||
|
"Isolate this app using Firejail. Current mode: {}",
|
||||||
|
current_mode.display_label()
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
"Firejail is not installed".to_string()
|
"Firejail is not installed. Use the row below to copy the install command.".to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
let firejail_row = adw::SwitchRow::builder()
|
let firejail_row = adw::SwitchRow::builder()
|
||||||
.title("Firejail sandbox")
|
.title("Firejail sandbox")
|
||||||
.subtitle(&sandbox_subtitle)
|
.subtitle(&sandbox_subtitle)
|
||||||
.tooltip_text("Linux application sandboxing tool")
|
|
||||||
.active(current_mode == SandboxMode::Firejail)
|
.active(current_mode == SandboxMode::Firejail)
|
||||||
.sensitive(firejail_available)
|
.sensitive(firejail_available)
|
||||||
|
.tooltip_text(
|
||||||
|
"Sandboxing restricts what an app can access on your system - files, \
|
||||||
|
network, devices, etc. This adds a security layer so that even if an \
|
||||||
|
app is compromised, it cannot freely access your personal data. Firejail \
|
||||||
|
is a lightweight Linux sandboxing tool."
|
||||||
|
)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let record_id = record.id;
|
let record_id = record.id;
|
||||||
@@ -661,13 +747,18 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
|||||||
sandbox_group.add(&firejail_row);
|
sandbox_group.add(&firejail_row);
|
||||||
|
|
||||||
if !firejail_available {
|
if !firejail_available {
|
||||||
|
let firejail_cmd = "sudo apt install firejail";
|
||||||
let info_row = adw::ActionRow::builder()
|
let info_row = adw::ActionRow::builder()
|
||||||
.title("Install Firejail")
|
.title("Install Firejail")
|
||||||
.subtitle("sudo apt install firejail")
|
.subtitle(firejail_cmd)
|
||||||
.build();
|
.build();
|
||||||
let badge = widgets::status_badge("Missing", "warning");
|
let badge = widgets::status_badge("Missing", "warning");
|
||||||
badge.set_valign(gtk::Align::Center);
|
badge.set_valign(gtk::Align::Center);
|
||||||
info_row.add_suffix(&badge);
|
info_row.add_suffix(&badge);
|
||||||
|
let copy_btn = widgets::copy_button(firejail_cmd, Some(toast_overlay));
|
||||||
|
copy_btn.set_valign(gtk::Align::Center);
|
||||||
|
copy_btn.set_tooltip_text(Some(&format!("Copy '{}' to clipboard", firejail_cmd)));
|
||||||
|
info_row.add_suffix(©_btn);
|
||||||
sandbox_group.add(&info_row);
|
sandbox_group.add(&info_row);
|
||||||
}
|
}
|
||||||
inner.append(&sandbox_group);
|
inner.append(&sandbox_group);
|
||||||
@@ -677,7 +768,10 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
|||||||
tab
|
tab
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tab 3: Security - vulnerability scanning and integrity
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tab 3: Security - vulnerability scanning and integrity
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
||||||
let tab = gtk::Box::builder()
|
let tab = gtk::Box::builder()
|
||||||
.orientation(gtk::Orientation::Vertical)
|
.orientation(gtk::Orientation::Vertical)
|
||||||
@@ -700,7 +794,10 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
|||||||
|
|
||||||
let group = adw::PreferencesGroup::builder()
|
let group = adw::PreferencesGroup::builder()
|
||||||
.title("Vulnerability Scanning")
|
.title("Vulnerability Scanning")
|
||||||
.description("Check bundled libraries for known CVEs")
|
.description(
|
||||||
|
"Scan the libraries bundled inside this AppImage for known \
|
||||||
|
security vulnerabilities (CVEs)."
|
||||||
|
)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let libs = db.get_bundled_libraries(record.id).unwrap_or_default();
|
let libs = db.get_bundled_libraries(record.id).unwrap_or_default();
|
||||||
@@ -709,7 +806,10 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
|||||||
if libs.is_empty() {
|
if libs.is_empty() {
|
||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
.title("Security scan")
|
.title("Security scan")
|
||||||
.subtitle("Not yet scanned for vulnerabilities")
|
.subtitle(
|
||||||
|
"This app has not been scanned yet. Use the button below \
|
||||||
|
to check for known vulnerabilities."
|
||||||
|
)
|
||||||
.build();
|
.build();
|
||||||
let badge = widgets::status_badge("Not scanned", "neutral");
|
let badge = widgets::status_badge("Not scanned", "neutral");
|
||||||
badge.set_valign(gtk::Align::Center);
|
badge.set_valign(gtk::Align::Center);
|
||||||
@@ -718,14 +818,17 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
|||||||
} else {
|
} else {
|
||||||
let lib_row = adw::ActionRow::builder()
|
let lib_row = adw::ActionRow::builder()
|
||||||
.title("Bundled libraries")
|
.title("Bundled libraries")
|
||||||
.subtitle(&libs.len().to_string())
|
.subtitle(&format!(
|
||||||
|
"{} libraries detected inside this AppImage",
|
||||||
|
libs.len()
|
||||||
|
))
|
||||||
.build();
|
.build();
|
||||||
group.add(&lib_row);
|
group.add(&lib_row);
|
||||||
|
|
||||||
if summary.total() == 0 {
|
if summary.total() == 0 {
|
||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
.title("Vulnerabilities")
|
.title("Vulnerabilities")
|
||||||
.subtitle("No known vulnerabilities")
|
.subtitle("No known security issues found in the bundled libraries.")
|
||||||
.build();
|
.build();
|
||||||
let badge = widgets::status_badge("Clean", "success");
|
let badge = widgets::status_badge("Clean", "success");
|
||||||
badge.set_valign(gtk::Align::Center);
|
badge.set_valign(gtk::Align::Center);
|
||||||
@@ -734,7 +837,11 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
|||||||
} else {
|
} else {
|
||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
.title("Vulnerabilities")
|
.title("Vulnerabilities")
|
||||||
.subtitle(&format!("{} found", summary.total()))
|
.subtitle(&format!(
|
||||||
|
"{} known issue{} found. Consider updating this app if a newer version is available.",
|
||||||
|
summary.total(),
|
||||||
|
if summary.total() == 1 { "" } else { "s" },
|
||||||
|
))
|
||||||
.build();
|
.build();
|
||||||
let badge = widgets::status_badge(summary.max_severity(), summary.badge_class());
|
let badge = widgets::status_badge(summary.max_severity(), summary.badge_class());
|
||||||
badge.set_valign(gtk::Align::Center);
|
badge.set_valign(gtk::Align::Center);
|
||||||
@@ -745,9 +852,16 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
|||||||
|
|
||||||
// Scan button
|
// Scan button
|
||||||
let scan_row = adw::ActionRow::builder()
|
let scan_row = adw::ActionRow::builder()
|
||||||
.title("Scan this AppImage")
|
.title("Run security scan")
|
||||||
.subtitle("Check bundled libraries for known CVEs")
|
.subtitle("Check bundled libraries against known CVE databases")
|
||||||
.activatable(true)
|
.activatable(true)
|
||||||
|
.tooltip_text(
|
||||||
|
"CVE stands for Common Vulnerabilities and Exposures - a public list \
|
||||||
|
of known security bugs in software. AppImages bundle their own copies \
|
||||||
|
of system libraries, which may contain outdated versions with known \
|
||||||
|
vulnerabilities. This scan checks those bundled libraries against the \
|
||||||
|
OSV.dev database to find any known issues."
|
||||||
|
)
|
||||||
.build();
|
.build();
|
||||||
let scan_icon = gtk::Image::from_icon_name("security-medium-symbolic");
|
let scan_icon = gtk::Image::from_icon_name("security-medium-symbolic");
|
||||||
scan_icon.set_valign(gtk::Align::Center);
|
scan_icon.set_valign(gtk::Align::Center);
|
||||||
@@ -758,7 +872,7 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
|||||||
scan_row.connect_activated(move |row| {
|
scan_row.connect_activated(move |row| {
|
||||||
row.set_sensitive(false);
|
row.set_sensitive(false);
|
||||||
row.update_state(&[gtk::accessible::State::Busy(true)]);
|
row.update_state(&[gtk::accessible::State::Busy(true)]);
|
||||||
row.set_subtitle("Scanning...");
|
row.set_subtitle("Scanning - this may take a moment...");
|
||||||
let row_clone = row.clone();
|
let row_clone = row.clone();
|
||||||
let path = record_path.clone();
|
let path = record_path.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
@@ -775,15 +889,17 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
|||||||
Ok(scan_result) => {
|
Ok(scan_result) => {
|
||||||
let total = scan_result.total_cves();
|
let total = scan_result.total_cves();
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
row_clone.set_subtitle("No vulnerabilities found");
|
row_clone.set_subtitle("No vulnerabilities found - looking good!");
|
||||||
} else {
|
} else {
|
||||||
row_clone.set_subtitle(&format!(
|
row_clone.set_subtitle(&format!(
|
||||||
"Found {} CVE{}", total, if total == 1 { "" } else { "s" }
|
"Found {} known issue{}. Check for app updates.",
|
||||||
|
total,
|
||||||
|
if total == 1 { "" } else { "s" },
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
row_clone.set_subtitle("Scan failed");
|
row_clone.set_subtitle("Scan failed - the AppImage may not be mountable");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -795,6 +911,7 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
|||||||
if record.sha256.is_some() {
|
if record.sha256.is_some() {
|
||||||
let integrity_group = adw::PreferencesGroup::builder()
|
let integrity_group = adw::PreferencesGroup::builder()
|
||||||
.title("Integrity")
|
.title("Integrity")
|
||||||
|
.description("Verify that the file has not been modified or corrupted.")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
if let Some(ref hash) = record.sha256 {
|
if let Some(ref hash) = record.sha256 {
|
||||||
@@ -802,7 +919,12 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
|||||||
.title("SHA256 checksum")
|
.title("SHA256 checksum")
|
||||||
.subtitle(hash)
|
.subtitle(hash)
|
||||||
.subtitle_selectable(true)
|
.subtitle_selectable(true)
|
||||||
.tooltip_text("Cryptographic hash for verifying file integrity")
|
.tooltip_text(
|
||||||
|
"A SHA256 checksum is a unique fingerprint of the file. If even one \
|
||||||
|
byte changes, the checksum changes completely. You can compare this \
|
||||||
|
against the developer's published checksum to verify the file hasn't \
|
||||||
|
been tampered with or corrupted during download."
|
||||||
|
)
|
||||||
.build();
|
.build();
|
||||||
hash_row.add_css_class("property");
|
hash_row.add_css_class("property");
|
||||||
integrity_group.add(&hash_row);
|
integrity_group.add(&hash_row);
|
||||||
@@ -815,7 +937,10 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
|||||||
tab
|
tab
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tab 4: Storage - disk usage and data discovery
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tab 4: Storage - disk usage, data paths, file location
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
fn build_storage_tab(
|
fn build_storage_tab(
|
||||||
record: &AppImageRecord,
|
record: &AppImageRecord,
|
||||||
db: &Rc<Database>,
|
db: &Rc<Database>,
|
||||||
@@ -843,12 +968,16 @@ fn build_storage_tab(
|
|||||||
// Disk usage group
|
// Disk usage group
|
||||||
let size_group = adw::PreferencesGroup::builder()
|
let size_group = adw::PreferencesGroup::builder()
|
||||||
.title("Disk Usage")
|
.title("Disk Usage")
|
||||||
|
.description(
|
||||||
|
"Disk space used by this app, including any configuration, \
|
||||||
|
cache, or data files it may have created."
|
||||||
|
)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let fp = footprint::get_footprint(db, record.id, record.size_bytes as u64);
|
let fp = footprint::get_footprint(db, record.id, record.size_bytes as u64);
|
||||||
|
|
||||||
let appimage_row = adw::ActionRow::builder()
|
let appimage_row = adw::ActionRow::builder()
|
||||||
.title("AppImage file size")
|
.title("AppImage file")
|
||||||
.subtitle(&widgets::format_size(record.size_bytes))
|
.subtitle(&widgets::format_size(record.size_bytes))
|
||||||
.build();
|
.build();
|
||||||
size_group.add(&appimage_row);
|
size_group.add(&appimage_row);
|
||||||
@@ -857,9 +986,9 @@ fn build_storage_tab(
|
|||||||
let data_total = fp.data_total();
|
let data_total = fp.data_total();
|
||||||
if data_total > 0 {
|
if data_total > 0 {
|
||||||
let total_row = adw::ActionRow::builder()
|
let total_row = adw::ActionRow::builder()
|
||||||
.title("Total disk footprint")
|
.title("Total disk usage")
|
||||||
.subtitle(&format!(
|
.subtitle(&format!(
|
||||||
"{} (AppImage) + {} (data) = {}",
|
"{} (AppImage) + {} (app data) = {}",
|
||||||
widgets::format_size(record.size_bytes),
|
widgets::format_size(record.size_bytes),
|
||||||
widgets::format_size(data_total as i64),
|
widgets::format_size(data_total as i64),
|
||||||
widgets::format_size(fp.total_size() as i64),
|
widgets::format_size(fp.total_size() as i64),
|
||||||
@@ -872,14 +1001,14 @@ fn build_storage_tab(
|
|||||||
|
|
||||||
// Data paths group
|
// Data paths group
|
||||||
let paths_group = adw::PreferencesGroup::builder()
|
let paths_group = adw::PreferencesGroup::builder()
|
||||||
.title("Data Paths")
|
.title("App Data")
|
||||||
.description("Config, data, and cache directories for this app")
|
.description("Config, cache, and data directories this app may have created.")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// Discover button
|
// Discover button
|
||||||
let discover_row = adw::ActionRow::builder()
|
let discover_row = adw::ActionRow::builder()
|
||||||
.title("Discover data paths")
|
.title("Find app data")
|
||||||
.subtitle("Search for config, data, and cache directories")
|
.subtitle("Search for config, cache, and data directories")
|
||||||
.activatable(true)
|
.activatable(true)
|
||||||
.build();
|
.build();
|
||||||
let discover_icon = gtk::Image::from_icon_name("folder-saved-search-symbolic");
|
let discover_icon = gtk::Image::from_icon_name("folder-saved-search-symbolic");
|
||||||
@@ -890,7 +1019,7 @@ fn build_storage_tab(
|
|||||||
let record_id = record.id;
|
let record_id = record.id;
|
||||||
discover_row.connect_activated(move |row| {
|
discover_row.connect_activated(move |row| {
|
||||||
row.set_sensitive(false);
|
row.set_sensitive(false);
|
||||||
row.set_subtitle("Discovering...");
|
row.set_subtitle("Searching...");
|
||||||
let row_clone = row.clone();
|
let row_clone = row.clone();
|
||||||
let rec = record_clone.clone();
|
let rec = record_clone.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
@@ -906,10 +1035,10 @@ fn build_storage_tab(
|
|||||||
Ok(fp) => {
|
Ok(fp) => {
|
||||||
let count = fp.paths.len();
|
let count = fp.paths.len();
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
row_clone.set_subtitle("No associated paths found");
|
row_clone.set_subtitle("No associated data directories found");
|
||||||
} else {
|
} else {
|
||||||
row_clone.set_subtitle(&format!(
|
row_clone.set_subtitle(&format!(
|
||||||
"Found {} path{} ({})",
|
"Found {} path{} using {}",
|
||||||
count,
|
count,
|
||||||
if count == 1 { "" } else { "s" },
|
if count == 1 { "" } else { "s" },
|
||||||
widgets::format_size(fp.data_total() as i64),
|
widgets::format_size(fp.data_total() as i64),
|
||||||
@@ -917,14 +1046,14 @@ fn build_storage_tab(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
row_clone.set_subtitle("Discovery failed");
|
row_clone.set_subtitle("Search failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
paths_group.add(&discover_row);
|
paths_group.add(&discover_row);
|
||||||
|
|
||||||
// Individual discovered paths with type icons and confidence badges
|
// Individual discovered paths
|
||||||
for dp in &fp.paths {
|
for dp in &fp.paths {
|
||||||
if dp.exists {
|
if dp.exists {
|
||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
@@ -998,22 +1127,51 @@ fn build_storage_tab(
|
|||||||
tab
|
tab
|
||||||
}
|
}
|
||||||
|
|
||||||
fn wayland_description(status: &WaylandStatus) -> &'static str {
|
// ---------------------------------------------------------------------------
|
||||||
|
// User-friendly explanations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn wayland_user_explanation(status: &WaylandStatus) -> &'static str {
|
||||||
match status {
|
match status {
|
||||||
WaylandStatus::Native => "Runs natively on Wayland",
|
WaylandStatus::Native =>
|
||||||
WaylandStatus::XWayland => "Runs via XWayland compatibility layer",
|
"Runs natively on Wayland - the best experience on modern Linux desktops.",
|
||||||
WaylandStatus::Possible => "May run on Wayland with additional flags",
|
WaylandStatus::XWayland =>
|
||||||
WaylandStatus::X11Only => "X11 only - no Wayland support",
|
"Uses XWayland for display. Works fine, but may appear slightly \
|
||||||
WaylandStatus::Unknown => "Could not determine Wayland compatibility",
|
blurry on high-resolution screens.",
|
||||||
|
WaylandStatus::Possible =>
|
||||||
|
"Might work on Wayland with the right settings. Try launching it to find out.",
|
||||||
|
WaylandStatus::X11Only =>
|
||||||
|
"Designed for X11 only. It will run through XWayland automatically, \
|
||||||
|
but you may notice minor display quirks.",
|
||||||
|
WaylandStatus::Unknown =>
|
||||||
|
"Not yet determined. Launch the app or use 'Analyze toolkit' below to check.",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fuse_description(status: &FuseStatus) -> &'static str {
|
fn fuse_user_explanation(status: &FuseStatus) -> &'static str {
|
||||||
match status {
|
match status {
|
||||||
FuseStatus::FullyFunctional => "FUSE mount available - native AppImage launch",
|
FuseStatus::FullyFunctional =>
|
||||||
FuseStatus::Fuse3Only => "Only FUSE3 installed - may need libfuse2",
|
"FUSE is working - AppImages mount directly for fast startup.",
|
||||||
FuseStatus::NoFusermount => "fusermount binary not found",
|
FuseStatus::Fuse3Only =>
|
||||||
FuseStatus::NoDevFuse => "/dev/fuse device not available",
|
"Only FUSE 3 found. Some AppImages need FUSE 2. \
|
||||||
FuseStatus::MissingLibfuse2 => "libfuse2 not installed",
|
Click the copy button to get the install command.",
|
||||||
|
FuseStatus::NoFusermount =>
|
||||||
|
"FUSE tools not found. The app will still work by extracting to a \
|
||||||
|
temporary folder, but startup will be slower.",
|
||||||
|
FuseStatus::NoDevFuse =>
|
||||||
|
"/dev/fuse not available. FUSE may not be configured on your system. \
|
||||||
|
Apps will extract to a temp folder instead.",
|
||||||
|
FuseStatus::MissingLibfuse2 =>
|
||||||
|
"libfuse2 is missing. Click the copy button to get the install command.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return an install command for a FUSE status that needs one, or None.
|
||||||
|
fn fuse_install_command(status: &FuseStatus) -> Option<&'static str> {
|
||||||
|
match status {
|
||||||
|
FuseStatus::Fuse3Only | FuseStatus::MissingLibfuse2 => {
|
||||||
|
Some("sudo apt install libfuse2")
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
305
src/ui/drop_dialog.rs
Normal file
305
src/ui/drop_dialog.rs
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
use adw::prelude::*;
|
||||||
|
use gtk::gio;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::core::analysis;
|
||||||
|
use crate::core::database::Database;
|
||||||
|
use crate::core::discovery;
|
||||||
|
use crate::i18n::{i18n, ni18n_f};
|
||||||
|
|
||||||
|
/// Registered file info returned by the fast registration phase.
|
||||||
|
struct RegisteredFile {
|
||||||
|
id: i64,
|
||||||
|
path: PathBuf,
|
||||||
|
appimage_type: discovery::AppImageType,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show a dialog offering to add dropped AppImage files to the library.
|
||||||
|
///
|
||||||
|
/// `files` should already be validated as AppImages (magic bytes checked).
|
||||||
|
/// `toast_overlay` is used to show result toasts.
|
||||||
|
/// `on_complete` is called after files are registered to refresh the UI.
|
||||||
|
pub fn show_drop_dialog(
|
||||||
|
parent: &impl IsA<gtk::Widget>,
|
||||||
|
files: Vec<PathBuf>,
|
||||||
|
toast_overlay: &adw::ToastOverlay,
|
||||||
|
on_complete: impl Fn() + 'static,
|
||||||
|
) {
|
||||||
|
if files.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = files.len();
|
||||||
|
|
||||||
|
// Build heading and body
|
||||||
|
let heading = if count == 1 {
|
||||||
|
let name = files[0]
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_else(|| "AppImage".to_string());
|
||||||
|
crate::i18n::i18n_f("Add {name}?", &[("{name}", &name)])
|
||||||
|
} else {
|
||||||
|
ni18n_f(
|
||||||
|
"Add {} app?",
|
||||||
|
"Add {} apps?",
|
||||||
|
count as u32,
|
||||||
|
&[("{}", &count.to_string())],
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = if count == 1 {
|
||||||
|
files[0].to_string_lossy().to_string()
|
||||||
|
} else {
|
||||||
|
files
|
||||||
|
.iter()
|
||||||
|
.filter_map(|f| f.file_name().map(|n| n.to_string_lossy().into_owned()))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
};
|
||||||
|
|
||||||
|
let dialog = adw::AlertDialog::builder()
|
||||||
|
.heading(&heading)
|
||||||
|
.body(&body)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
dialog.add_response("cancel", &i18n("Cancel"));
|
||||||
|
dialog.add_response("add-only", &i18n("Just add"));
|
||||||
|
dialog.add_response("add-and-integrate", &i18n("Add to app menu"));
|
||||||
|
|
||||||
|
dialog.set_response_appearance("add-and-integrate", adw::ResponseAppearance::Suggested);
|
||||||
|
dialog.set_default_response(Some("add-and-integrate"));
|
||||||
|
dialog.set_close_response("cancel");
|
||||||
|
|
||||||
|
let toast_ref = toast_overlay.clone();
|
||||||
|
let on_complete = Rc::new(on_complete);
|
||||||
|
dialog.connect_response(None, move |_dialog, response| {
|
||||||
|
if response == "cancel" {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let integrate = response == "add-and-integrate";
|
||||||
|
let files = files.clone();
|
||||||
|
let toast_ref = toast_ref.clone();
|
||||||
|
let on_complete_ref = on_complete.clone();
|
||||||
|
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
// Phase 1: Fast registration (copy + DB upsert only)
|
||||||
|
let result = gio::spawn_blocking(move || {
|
||||||
|
register_dropped_files(&files)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(Ok(registered)) => {
|
||||||
|
let added = registered.len();
|
||||||
|
|
||||||
|
// Refresh UI immediately - apps appear with "Analyzing..." badge
|
||||||
|
on_complete_ref();
|
||||||
|
|
||||||
|
// Show toast
|
||||||
|
if added == 1 {
|
||||||
|
toast_ref.add_toast(adw::Toast::new(&i18n("Added to your apps")));
|
||||||
|
} else if added > 0 {
|
||||||
|
let msg = ni18n_f(
|
||||||
|
"Added {} app",
|
||||||
|
"Added {} apps",
|
||||||
|
added as u32,
|
||||||
|
&[("{}", &added.to_string())],
|
||||||
|
);
|
||||||
|
toast_ref.add_toast(adw::Toast::new(&msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Background analysis for each file
|
||||||
|
let on_complete_bg = on_complete_ref.clone();
|
||||||
|
for reg in registered {
|
||||||
|
let on_complete_inner = on_complete_bg.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let _ = gio::spawn_blocking(move || {
|
||||||
|
analysis::run_background_analysis(
|
||||||
|
reg.id,
|
||||||
|
reg.path,
|
||||||
|
reg.appimage_type,
|
||||||
|
integrate,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Refresh UI when each analysis completes
|
||||||
|
on_complete_inner();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
log::error!("Drop processing failed: {}", e);
|
||||||
|
toast_ref.add_toast(adw::Toast::new(&i18n("Failed to add app")));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Drop task failed: {:?}", e);
|
||||||
|
toast_ref.add_toast(adw::Toast::new(&i18n("Failed to add app")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
dialog.present(Some(parent));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fast registration of dropped files - copies to target dir and inserts into DB.
|
||||||
|
/// Returns a list of registered files for background analysis.
|
||||||
|
fn register_dropped_files(
|
||||||
|
files: &[PathBuf],
|
||||||
|
) -> Result<Vec<RegisteredFile>, String> {
|
||||||
|
let db = Database::open().map_err(|e| format!("Failed to open database: {}", e))?;
|
||||||
|
|
||||||
|
let settings = gio::Settings::new(crate::config::APP_ID);
|
||||||
|
let scan_dirs: Vec<String> = settings
|
||||||
|
.strv("scan-directories")
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Target directory: first scan directory (default ~/Applications)
|
||||||
|
let target_dir = scan_dirs
|
||||||
|
.first()
|
||||||
|
.map(|d| discovery::expand_tilde(d))
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||||
|
.join("Applications")
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure target directory exists
|
||||||
|
std::fs::create_dir_all(&target_dir)
|
||||||
|
.map_err(|e| format!("Failed to create {}: {}", target_dir.display(), e))?;
|
||||||
|
|
||||||
|
// Expand scan dirs for checking if file is already in a scan location
|
||||||
|
let expanded_scan_dirs: Vec<PathBuf> = scan_dirs
|
||||||
|
.iter()
|
||||||
|
.map(|d| discovery::expand_tilde(d))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut registered = Vec::new();
|
||||||
|
|
||||||
|
for file in files {
|
||||||
|
// Determine if the file is already in a scan directory
|
||||||
|
let in_scan_dir = expanded_scan_dirs.iter().any(|scan_dir| {
|
||||||
|
file.parent()
|
||||||
|
.and_then(|p| p.canonicalize().ok())
|
||||||
|
.and_then(|parent| {
|
||||||
|
scan_dir.canonicalize().ok().map(|sd| parent == sd)
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
});
|
||||||
|
|
||||||
|
let final_path = if in_scan_dir {
|
||||||
|
file.clone()
|
||||||
|
} else {
|
||||||
|
let filename = file
|
||||||
|
.file_name()
|
||||||
|
.ok_or_else(|| "No filename".to_string())?;
|
||||||
|
let dest = target_dir.join(filename);
|
||||||
|
|
||||||
|
// Don't overwrite existing files - generate a unique name
|
||||||
|
let dest = if dest.exists() && dest != *file {
|
||||||
|
let stem = dest
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("app");
|
||||||
|
let ext = dest
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.unwrap_or("AppImage");
|
||||||
|
let mut counter = 1;
|
||||||
|
loop {
|
||||||
|
let candidate = target_dir.join(format!("{}-{}.{}", stem, counter, ext));
|
||||||
|
if !candidate.exists() {
|
||||||
|
break candidate;
|
||||||
|
}
|
||||||
|
counter += 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dest
|
||||||
|
};
|
||||||
|
|
||||||
|
std::fs::copy(file, &dest)
|
||||||
|
.map_err(|e| format!("Failed to copy {}: {}", file.display(), e))?;
|
||||||
|
|
||||||
|
// Make executable
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let perms = std::fs::Permissions::from_mode(0o755);
|
||||||
|
if let Err(e) = std::fs::set_permissions(&dest, perms) {
|
||||||
|
log::warn!("Failed to set executable permissions on {}: {}", dest.display(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dest
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate it's actually an AppImage
|
||||||
|
let appimage_type = match discovery::detect_appimage(&final_path) {
|
||||||
|
Some(t) => t,
|
||||||
|
None => {
|
||||||
|
log::warn!("Not a valid AppImage: {}", final_path.display());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let filename = final_path
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let metadata = std::fs::metadata(&final_path).ok();
|
||||||
|
let size_bytes = metadata.as_ref().map(|m| m.len() as i64).unwrap_or(0);
|
||||||
|
let modified = metadata
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|m| m.modified().ok())
|
||||||
|
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||||
|
.and_then(|dur| {
|
||||||
|
chrono::DateTime::from_timestamp(dur.as_secs() as i64, 0)
|
||||||
|
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
|
||||||
|
});
|
||||||
|
|
||||||
|
let is_executable = {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
metadata
|
||||||
|
.as_ref()
|
||||||
|
.map(|m| m.permissions().mode() & 0o111 != 0)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register in database with pending analysis status
|
||||||
|
let id = db
|
||||||
|
.upsert_appimage(
|
||||||
|
&final_path.to_string_lossy(),
|
||||||
|
&filename,
|
||||||
|
Some(appimage_type.as_i32()),
|
||||||
|
size_bytes,
|
||||||
|
is_executable,
|
||||||
|
modified.as_deref(),
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Database error: {}", e))?;
|
||||||
|
|
||||||
|
if let Err(e) = db.update_analysis_status(id, "pending") {
|
||||||
|
log::warn!("Failed to set analysis status to 'pending' for id {}: {}", id, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
registered.push(RegisteredFile {
|
||||||
|
id,
|
||||||
|
path: final_path,
|
||||||
|
appimage_type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(registered)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ use std::rc::Rc;
|
|||||||
use crate::core::database::Database;
|
use crate::core::database::Database;
|
||||||
use crate::core::duplicates::{self, DuplicateGroup, MatchReason, MemberRecommendation};
|
use crate::core::duplicates::{self, DuplicateGroup, MatchReason, MemberRecommendation};
|
||||||
use crate::core::integrator;
|
use crate::core::integrator;
|
||||||
|
use crate::i18n::{i18n, i18n_f, ni18n_f};
|
||||||
use super::widgets;
|
use super::widgets;
|
||||||
|
|
||||||
/// Show a dialog listing duplicate/multi-version AppImages with resolution options.
|
/// Show a dialog listing duplicate/multi-version AppImages with resolution options.
|
||||||
@@ -17,10 +18,10 @@ pub fn show_duplicate_dialog(
|
|||||||
|
|
||||||
if groups.is_empty() {
|
if groups.is_empty() {
|
||||||
let dialog = adw::AlertDialog::builder()
|
let dialog = adw::AlertDialog::builder()
|
||||||
.heading("No Duplicates Found")
|
.heading(&i18n("No Duplicates Found"))
|
||||||
.body("No duplicate or multi-version AppImages were detected.")
|
.body(&i18n("No duplicate or multi-version AppImages were detected."))
|
||||||
.build();
|
.build();
|
||||||
dialog.add_response("ok", "OK");
|
dialog.add_response("ok", &i18n("OK"));
|
||||||
dialog.set_default_response(Some("ok"));
|
dialog.set_default_response(Some("ok"));
|
||||||
dialog.present(Some(parent));
|
dialog.present(Some(parent));
|
||||||
return;
|
return;
|
||||||
@@ -29,7 +30,7 @@ pub fn show_duplicate_dialog(
|
|||||||
let summary = duplicates::summarize_duplicates(&groups);
|
let summary = duplicates::summarize_duplicates(&groups);
|
||||||
|
|
||||||
let dialog = adw::Dialog::builder()
|
let dialog = adw::Dialog::builder()
|
||||||
.title("Duplicates & Old Versions")
|
.title(&i18n("Duplicates & Old Versions"))
|
||||||
.content_width(600)
|
.content_width(600)
|
||||||
.content_height(500)
|
.content_height(500)
|
||||||
.build();
|
.build();
|
||||||
@@ -39,12 +40,12 @@ pub fn show_duplicate_dialog(
|
|||||||
|
|
||||||
// "Remove All Suggested" bulk action button
|
// "Remove All Suggested" bulk action button
|
||||||
let bulk_btn = gtk::Button::builder()
|
let bulk_btn = gtk::Button::builder()
|
||||||
.label("Remove All Suggested")
|
.label(&i18n("Remove All Suggested"))
|
||||||
.tooltip_text("Delete all items recommended for removal")
|
.tooltip_text(&i18n("Delete all items recommended for removal"))
|
||||||
.build();
|
.build();
|
||||||
bulk_btn.add_css_class("destructive-action");
|
bulk_btn.add_css_class("destructive-action");
|
||||||
bulk_btn.update_property(&[
|
bulk_btn.update_property(&[
|
||||||
gtk::accessible::Property::Label("Remove all suggested duplicates"),
|
gtk::accessible::Property::Label(&i18n("Remove all suggested duplicates")),
|
||||||
]);
|
]);
|
||||||
header.pack_end(&bulk_btn);
|
header.pack_end(&bulk_btn);
|
||||||
|
|
||||||
@@ -64,13 +65,14 @@ pub fn show_duplicate_dialog(
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
// Summary banner
|
// Summary banner
|
||||||
let summary_text = format!(
|
let summary_text = i18n_f(
|
||||||
"{} groups found ({} exact duplicates, {} with multiple versions). \
|
"{groups} groups found ({exact} exact duplicates, {multi} with multiple versions). Potential savings: {savings}",
|
||||||
Potential savings: {}",
|
&[
|
||||||
summary.total_groups,
|
("{groups}", &summary.total_groups.to_string()),
|
||||||
summary.exact_duplicates,
|
("{exact}", &summary.exact_duplicates.to_string()),
|
||||||
summary.multi_version,
|
("{multi}", &summary.multi_version.to_string()),
|
||||||
widgets::format_size(summary.total_potential_savings as i64),
|
("{savings}", &widgets::format_size(summary.total_potential_savings as i64)),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
let summary_label = gtk::Label::builder()
|
let summary_label = gtk::Label::builder()
|
||||||
.label(&summary_text)
|
.label(&summary_text)
|
||||||
@@ -101,13 +103,17 @@ pub fn show_duplicate_dialog(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let plural = if count == 1 { "" } else { "s" };
|
|
||||||
let confirm = adw::AlertDialog::builder()
|
let confirm = adw::AlertDialog::builder()
|
||||||
.heading("Confirm Removal")
|
.heading(&i18n("Confirm Removal"))
|
||||||
.body(&format!("Remove {} suggested duplicate{}?", count, plural))
|
.body(&ni18n_f(
|
||||||
|
"Remove {count} suggested duplicate?",
|
||||||
|
"Remove {count} suggested duplicates?",
|
||||||
|
count as u32,
|
||||||
|
&[("{count}", &count.to_string())],
|
||||||
|
))
|
||||||
.build();
|
.build();
|
||||||
confirm.add_response("cancel", "Cancel");
|
confirm.add_response("cancel", &i18n("Cancel"));
|
||||||
confirm.add_response("remove", "Remove");
|
confirm.add_response("remove", &i18n("Remove"));
|
||||||
confirm.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
|
confirm.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
|
||||||
confirm.set_default_response(Some("cancel"));
|
confirm.set_default_response(Some("cancel"));
|
||||||
confirm.set_close_response("cancel");
|
confirm.set_close_response("cancel");
|
||||||
@@ -136,9 +142,14 @@ pub fn show_duplicate_dialog(
|
|||||||
removed_count += 1;
|
removed_count += 1;
|
||||||
}
|
}
|
||||||
if removed_count > 0 {
|
if removed_count > 0 {
|
||||||
toast_confirm.add_toast(adw::Toast::new(&format!("Removed {} items", removed_count)));
|
toast_confirm.add_toast(adw::Toast::new(&ni18n_f(
|
||||||
|
"Removed {count} item",
|
||||||
|
"Removed {count} items",
|
||||||
|
removed_count as u32,
|
||||||
|
&[("{count}", &removed_count.to_string())],
|
||||||
|
)));
|
||||||
btn_ref.set_sensitive(false);
|
btn_ref.set_sensitive(false);
|
||||||
btn_ref.set_label("Done");
|
btn_ref.set_label(&i18n("Done"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -158,23 +169,27 @@ fn build_group_widget(
|
|||||||
toast_overlay: &adw::ToastOverlay,
|
toast_overlay: &adw::ToastOverlay,
|
||||||
) -> (adw::PreferencesGroup, Vec<(i64, String, String, bool)>) {
|
) -> (adw::PreferencesGroup, Vec<(i64, String, String, bool)>) {
|
||||||
let reason_text = match group.match_reason {
|
let reason_text = match group.match_reason {
|
||||||
MatchReason::ExactDuplicate => "Exact duplicate",
|
MatchReason::ExactDuplicate => i18n("Exact duplicate"),
|
||||||
MatchReason::MultiVersion => "Multiple versions",
|
MatchReason::MultiVersion => i18n("Multiple versions"),
|
||||||
MatchReason::SameVersionDifferentPath => "Same version, different path",
|
MatchReason::SameVersionDifferentPath => i18n("Same version, different path"),
|
||||||
};
|
};
|
||||||
|
|
||||||
let description = if group.potential_savings > 0 {
|
let description = if group.potential_savings > 0 {
|
||||||
format!(
|
i18n_f(
|
||||||
"{} - Total: {} - Potential savings: {}",
|
"{reason} - Total: {total} - Potential savings: {savings}",
|
||||||
reason_text,
|
&[
|
||||||
widgets::format_size(group.total_size as i64),
|
("{reason}", &reason_text),
|
||||||
widgets::format_size(group.potential_savings as i64),
|
("{total}", &widgets::format_size(group.total_size as i64)),
|
||||||
|
("{savings}", &widgets::format_size(group.potential_savings as i64)),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
format!(
|
i18n_f(
|
||||||
"{} - Total: {}",
|
"{reason} - Total: {total}",
|
||||||
reason_text,
|
&[
|
||||||
widgets::format_size(group.total_size as i64),
|
("{reason}", &reason_text),
|
||||||
|
("{total}", &widgets::format_size(group.total_size as i64)),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -187,11 +202,12 @@ fn build_group_widget(
|
|||||||
|
|
||||||
for member in &group.members {
|
for member in &group.members {
|
||||||
let record = &member.record;
|
let record = &member.record;
|
||||||
let version = record.app_version.as_deref().unwrap_or("unknown");
|
let unknown = i18n("unknown");
|
||||||
|
let version = record.app_version.as_deref().unwrap_or(&unknown);
|
||||||
let size = widgets::format_size(record.size_bytes);
|
let size = widgets::format_size(record.size_bytes);
|
||||||
|
|
||||||
let title = if member.is_recommended {
|
let title = if member.is_recommended {
|
||||||
format!("{} ({}) - Recommended", version, size)
|
i18n_f("{version} ({size}) - Recommended", &[("{version}", version), ("{size}", &size)])
|
||||||
} else {
|
} else {
|
||||||
format!("{} ({})", version, size)
|
format!("{} ({})", version, size)
|
||||||
};
|
};
|
||||||
@@ -225,7 +241,7 @@ fn build_group_widget(
|
|||||||
|
|
||||||
let delete_btn = gtk::Button::builder()
|
let delete_btn = gtk::Button::builder()
|
||||||
.icon_name("user-trash-symbolic")
|
.icon_name("user-trash-symbolic")
|
||||||
.tooltip_text("Delete this AppImage")
|
.tooltip_text(&i18n("Delete this AppImage"))
|
||||||
.css_classes(["flat", "circular"])
|
.css_classes(["flat", "circular"])
|
||||||
.valign(gtk::Align::Center)
|
.valign(gtk::Align::Center)
|
||||||
.build();
|
.build();
|
||||||
@@ -235,7 +251,7 @@ fn build_group_widget(
|
|||||||
let record_path = record.path.clone();
|
let record_path = record.path.clone();
|
||||||
let record_name = record.app_name.clone().unwrap_or_else(|| record.filename.clone());
|
let record_name = record.app_name.clone().unwrap_or_else(|| record.filename.clone());
|
||||||
delete_btn.update_property(&[
|
delete_btn.update_property(&[
|
||||||
gtk::accessible::Property::Label(&format!("Delete {}", record_name)),
|
gtk::accessible::Property::Label(&i18n_f("Delete {name}", &[("{name}", &record_name)])),
|
||||||
]);
|
]);
|
||||||
let db_ref = db.clone();
|
let db_ref = db.clone();
|
||||||
let toast_ref = toast_overlay.clone();
|
let toast_ref = toast_overlay.clone();
|
||||||
@@ -253,7 +269,7 @@ fn build_group_widget(
|
|||||||
db_ref.remove_appimage(record_id).ok();
|
db_ref.remove_appimage(record_id).ok();
|
||||||
// Update UI
|
// Update UI
|
||||||
btn.set_sensitive(false);
|
btn.set_sensitive(false);
|
||||||
toast_ref.add_toast(adw::Toast::new(&format!("Removed {}", record_name)));
|
toast_ref.add_toast(adw::Toast::new(&i18n_f("Removed {name}", &[("{name}", &record_name)])));
|
||||||
});
|
});
|
||||||
|
|
||||||
row.add_suffix(&delete_btn);
|
row.add_suffix(&delete_btn);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use crate::core::database::{AppImageRecord, Database};
|
|||||||
use crate::core::fuse::FuseStatus;
|
use crate::core::fuse::FuseStatus;
|
||||||
use crate::core::integrator;
|
use crate::core::integrator;
|
||||||
use crate::core::wayland::WaylandStatus;
|
use crate::core::wayland::WaylandStatus;
|
||||||
|
use crate::i18n::{i18n, i18n_f};
|
||||||
use super::widgets;
|
use super::widgets;
|
||||||
|
|
||||||
/// Show a confirmation dialog before integrating an AppImage into the desktop.
|
/// Show a confirmation dialog before integrating an AppImage into the desktop.
|
||||||
@@ -18,14 +19,14 @@ pub fn show_integration_dialog(
|
|||||||
let name = record.app_name.as_deref().unwrap_or(&record.filename);
|
let name = record.app_name.as_deref().unwrap_or(&record.filename);
|
||||||
|
|
||||||
let dialog = adw::AlertDialog::builder()
|
let dialog = adw::AlertDialog::builder()
|
||||||
.heading(&format!("Integrate {}?", name))
|
.heading(&i18n_f("Integrate {name}?", &[("{name}", name)]))
|
||||||
.body("This will add the application to your desktop menu.")
|
.body(&i18n("This will add the application to your desktop menu."))
|
||||||
.close_response("cancel")
|
.close_response("cancel")
|
||||||
.default_response("integrate")
|
.default_response("integrate")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
dialog.add_response("cancel", "Cancel");
|
dialog.add_response("cancel", &i18n("Cancel"));
|
||||||
dialog.add_response("integrate", "Integrate");
|
dialog.add_response("integrate", &i18n("Integrate"));
|
||||||
dialog.set_response_appearance("integrate", adw::ResponseAppearance::Suggested);
|
dialog.set_response_appearance("integrate", adw::ResponseAppearance::Suggested);
|
||||||
|
|
||||||
// Build extra content with details
|
// Build extra content with details
|
||||||
@@ -40,12 +41,12 @@ pub fn show_integration_dialog(
|
|||||||
identity_box.add_css_class("boxed-list");
|
identity_box.add_css_class("boxed-list");
|
||||||
identity_box.set_selection_mode(gtk::SelectionMode::None);
|
identity_box.set_selection_mode(gtk::SelectionMode::None);
|
||||||
identity_box.update_property(&[
|
identity_box.update_property(&[
|
||||||
gtk::accessible::Property::Label("Application details"),
|
gtk::accessible::Property::Label(&i18n("Application details")),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Name
|
// Name
|
||||||
let name_row = adw::ActionRow::builder()
|
let name_row = adw::ActionRow::builder()
|
||||||
.title("Application")
|
.title(&i18n("Application"))
|
||||||
.subtitle(name)
|
.subtitle(name)
|
||||||
.build();
|
.build();
|
||||||
if let Some(ref icon_path) = record.icon_path {
|
if let Some(ref icon_path) = record.icon_path {
|
||||||
@@ -65,7 +66,7 @@ pub fn show_integration_dialog(
|
|||||||
// Version
|
// Version
|
||||||
if let Some(ref version) = record.app_version {
|
if let Some(ref version) = record.app_version {
|
||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
.title("Version")
|
.title(&i18n("Version"))
|
||||||
.subtitle(version)
|
.subtitle(version)
|
||||||
.build();
|
.build();
|
||||||
identity_box.append(&row);
|
identity_box.append(&row);
|
||||||
@@ -78,12 +79,12 @@ pub fn show_integration_dialog(
|
|||||||
actions_box.add_css_class("boxed-list");
|
actions_box.add_css_class("boxed-list");
|
||||||
actions_box.set_selection_mode(gtk::SelectionMode::None);
|
actions_box.set_selection_mode(gtk::SelectionMode::None);
|
||||||
actions_box.update_property(&[
|
actions_box.update_property(&[
|
||||||
gtk::accessible::Property::Label("Integration actions"),
|
gtk::accessible::Property::Label(&i18n("Integration actions")),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let desktop_row = adw::ActionRow::builder()
|
let desktop_row = adw::ActionRow::builder()
|
||||||
.title("Desktop entry")
|
.title(&i18n("Desktop entry"))
|
||||||
.subtitle("A .desktop file will be created in ~/.local/share/applications")
|
.subtitle(&i18n("A .desktop file will be created in ~/.local/share/applications"))
|
||||||
.build();
|
.build();
|
||||||
let check1 = gtk::Image::from_icon_name("emblem-ok-symbolic");
|
let check1 = gtk::Image::from_icon_name("emblem-ok-symbolic");
|
||||||
check1.set_valign(gtk::Align::Center);
|
check1.set_valign(gtk::Align::Center);
|
||||||
@@ -91,8 +92,8 @@ pub fn show_integration_dialog(
|
|||||||
actions_box.append(&desktop_row);
|
actions_box.append(&desktop_row);
|
||||||
|
|
||||||
let icon_row = adw::ActionRow::builder()
|
let icon_row = adw::ActionRow::builder()
|
||||||
.title("Icon")
|
.title(&i18n("Icon"))
|
||||||
.subtitle("The app icon will be installed to ~/.local/share/icons")
|
.subtitle(&i18n("The app icon will be installed to ~/.local/share/icons"))
|
||||||
.build();
|
.build();
|
||||||
let check2 = gtk::Image::from_icon_name("emblem-ok-symbolic");
|
let check2 = gtk::Image::from_icon_name("emblem-ok-symbolic");
|
||||||
check2.set_valign(gtk::Align::Center);
|
check2.set_valign(gtk::Align::Center);
|
||||||
@@ -114,23 +115,31 @@ pub fn show_integration_dialog(
|
|||||||
.map(FuseStatus::from_str)
|
.map(FuseStatus::from_str)
|
||||||
.unwrap_or(FuseStatus::MissingLibfuse2);
|
.unwrap_or(FuseStatus::MissingLibfuse2);
|
||||||
|
|
||||||
let mut warnings: Vec<(&str, &str, &str)> = Vec::new();
|
let mut warnings: Vec<(String, String, String)> = Vec::new();
|
||||||
|
|
||||||
if wayland_status == WaylandStatus::X11Only {
|
if wayland_status == WaylandStatus::X11Only {
|
||||||
warnings.push(("X11 only", "This app does not support Wayland and will run through XWayland", "X11"));
|
warnings.push((
|
||||||
|
i18n("X11 only"),
|
||||||
|
i18n("This app does not support Wayland and will run through XWayland"),
|
||||||
|
"X11".to_string(),
|
||||||
|
));
|
||||||
} else if wayland_status == WaylandStatus::XWayland {
|
} else if wayland_status == WaylandStatus::XWayland {
|
||||||
warnings.push(("XWayland", "This app runs through the XWayland compatibility layer", "XWayland"));
|
warnings.push((
|
||||||
|
i18n("XWayland"),
|
||||||
|
i18n("This app runs through the XWayland compatibility layer"),
|
||||||
|
"XWayland".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if !fuse_status.is_functional() {
|
if !fuse_status.is_functional() {
|
||||||
let fuse_msg = match fuse_status {
|
let fuse_msg = match fuse_status {
|
||||||
FuseStatus::Fuse3Only => "Only FUSE3 is installed - libfuse2 may be needed",
|
FuseStatus::Fuse3Only => i18n("Only FUSE3 is installed - libfuse2 may be needed"),
|
||||||
FuseStatus::NoFusermount => "fusermount not found - AppImage mount may fail",
|
FuseStatus::NoFusermount => i18n("fusermount not found - AppImage mount may fail"),
|
||||||
FuseStatus::NoDevFuse => "/dev/fuse not available - AppImage mount will fail",
|
FuseStatus::NoDevFuse => i18n("/dev/fuse not available - AppImage mount will fail"),
|
||||||
FuseStatus::MissingLibfuse2 => "libfuse2 not installed - fallback extraction will be used",
|
FuseStatus::MissingLibfuse2 => i18n("libfuse2 not installed - fallback extraction will be used"),
|
||||||
_ => "FUSE issue detected",
|
_ => i18n("FUSE issue detected"),
|
||||||
};
|
};
|
||||||
warnings.push(("FUSE", fuse_msg, fuse_status.label()));
|
warnings.push(("FUSE".to_string(), fuse_msg, fuse_status.label().to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if !warnings.is_empty() {
|
if !warnings.is_empty() {
|
||||||
@@ -149,7 +158,7 @@ pub fn show_integration_dialog(
|
|||||||
warning_icon.set_pixel_size(16);
|
warning_icon.set_pixel_size(16);
|
||||||
warning_header.append(&warning_icon);
|
warning_header.append(&warning_icon);
|
||||||
let warning_title = gtk::Label::builder()
|
let warning_title = gtk::Label::builder()
|
||||||
.label("Compatibility Notes")
|
.label(&i18n("Compatibility Notes"))
|
||||||
.css_classes(["title-4"])
|
.css_classes(["title-4"])
|
||||||
.halign(gtk::Align::Start)
|
.halign(gtk::Align::Start)
|
||||||
.build();
|
.build();
|
||||||
@@ -162,8 +171,8 @@ pub fn show_integration_dialog(
|
|||||||
|
|
||||||
for (title, subtitle, badge_text) in &warnings {
|
for (title, subtitle, badge_text) in &warnings {
|
||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
.title(*title)
|
.title(title.as_str())
|
||||||
.subtitle(*subtitle)
|
.subtitle(subtitle.as_str())
|
||||||
.build();
|
.build();
|
||||||
let badge = widgets::status_badge(badge_text, "warning");
|
let badge = widgets::status_badge(badge_text, "warning");
|
||||||
badge.set_valign(gtk::Align::Center);
|
badge.set_valign(gtk::Align::Center);
|
||||||
|
|||||||
@@ -96,9 +96,28 @@ impl LibraryView {
|
|||||||
.title("Driftwood")
|
.title("Driftwood")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
// Add button (shows drop overlay)
|
||||||
|
let add_button_icon = gtk::Image::from_icon_name("list-add-symbolic");
|
||||||
|
let add_button_label = gtk::Label::new(Some(&i18n("Add app")));
|
||||||
|
let add_button_content = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Horizontal)
|
||||||
|
.spacing(6)
|
||||||
|
.build();
|
||||||
|
add_button_content.append(&add_button_icon);
|
||||||
|
add_button_content.append(&add_button_label);
|
||||||
|
|
||||||
|
let add_button = gtk::Button::builder()
|
||||||
|
.child(&add_button_content)
|
||||||
|
.tooltip_text(&i18n("Add AppImage"))
|
||||||
|
.build();
|
||||||
|
add_button.add_css_class("flat");
|
||||||
|
add_button.set_action_name(Some("win.show-drop-hint"));
|
||||||
|
add_button.update_property(&[AccessibleProperty::Label("Add AppImage")]);
|
||||||
|
|
||||||
let header_bar = adw::HeaderBar::builder()
|
let header_bar = adw::HeaderBar::builder()
|
||||||
.title_widget(&title_widget)
|
.title_widget(&title_widget)
|
||||||
.build();
|
.build();
|
||||||
|
header_bar.pack_start(&add_button);
|
||||||
header_bar.pack_end(&menu_button);
|
header_bar.pack_end(&menu_button);
|
||||||
header_bar.pack_end(&search_button);
|
header_bar.pack_end(&search_button);
|
||||||
header_bar.pack_end(&view_toggle_box);
|
header_bar.pack_end(&view_toggle_box);
|
||||||
@@ -175,8 +194,8 @@ impl LibraryView {
|
|||||||
.description(&i18n(
|
.description(&i18n(
|
||||||
"Driftwood manages your AppImage collection - scanning for apps, \
|
"Driftwood manages your AppImage collection - scanning for apps, \
|
||||||
integrating them into your desktop, and keeping them up to date.\n\n\
|
integrating them into your desktop, and keeping them up to date.\n\n\
|
||||||
Add AppImages to ~/Applications or ~/Downloads, or configure \
|
Drag AppImage files here, or add them to ~/Applications or ~/Downloads, \
|
||||||
custom scan locations in Preferences.",
|
then use Scan Now to find them.",
|
||||||
))
|
))
|
||||||
.child(&empty_button_box)
|
.child(&empty_button_box)
|
||||||
.build();
|
.build();
|
||||||
@@ -196,13 +215,13 @@ impl LibraryView {
|
|||||||
.selection_mode(gtk::SelectionMode::None)
|
.selection_mode(gtk::SelectionMode::None)
|
||||||
.homogeneous(true)
|
.homogeneous(true)
|
||||||
.min_children_per_line(2)
|
.min_children_per_line(2)
|
||||||
.max_children_per_line(4)
|
.max_children_per_line(5)
|
||||||
.row_spacing(14)
|
.row_spacing(12)
|
||||||
.column_spacing(14)
|
.column_spacing(12)
|
||||||
.margin_top(14)
|
.margin_top(12)
|
||||||
.margin_bottom(14)
|
.margin_bottom(12)
|
||||||
.margin_start(14)
|
.margin_start(12)
|
||||||
.margin_end(14)
|
.margin_end(12)
|
||||||
.build();
|
.build();
|
||||||
flow_box.update_property(&[AccessibleProperty::Label("AppImage library grid")]);
|
flow_box.update_property(&[AccessibleProperty::Label("AppImage library grid")]);
|
||||||
|
|
||||||
@@ -463,9 +482,14 @@ impl LibraryView {
|
|||||||
let name = record.app_name.as_deref().unwrap_or(&record.filename);
|
let name = record.app_name.as_deref().unwrap_or(&record.filename);
|
||||||
|
|
||||||
// Structured two-line subtitle:
|
// Structured two-line subtitle:
|
||||||
// Line 1: Description snippet or file path
|
// Line 1: Description snippet or file path (or "Analyzing..." if pending)
|
||||||
// Line 2: Version + size
|
// Line 2: Version + size
|
||||||
let line1 = if let Some(ref desc) = record.description {
|
let is_analyzing = record.app_name.is_none()
|
||||||
|
&& record.analysis_status.as_deref() != Some("complete");
|
||||||
|
|
||||||
|
let line1 = if is_analyzing {
|
||||||
|
i18n("Analyzing...")
|
||||||
|
} else if let Some(ref desc) = record.description {
|
||||||
if !desc.is_empty() {
|
if !desc.is_empty() {
|
||||||
let snippet: String = desc.chars().take(60).collect();
|
let snippet: String = desc.chars().take(60).collect();
|
||||||
if snippet.len() < desc.len() {
|
if snippet.len() < desc.len() {
|
||||||
@@ -496,7 +520,7 @@ impl LibraryView {
|
|||||||
.activatable(true)
|
.activatable(true)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// Icon prefix (48x48 with rounded clipping and letter fallback)
|
// Icon prefix with rounded clipping and letter fallback
|
||||||
let icon = widgets::app_icon(
|
let icon = widgets::app_icon(
|
||||||
record.icon_path.as_deref(),
|
record.icon_path.as_deref(),
|
||||||
name,
|
name,
|
||||||
@@ -583,19 +607,19 @@ fn build_context_menu(record: &AppImageRecord) -> gtk::gio::Menu {
|
|||||||
// Section 2: Actions
|
// Section 2: Actions
|
||||||
let section2 = gtk::gio::Menu::new();
|
let section2 = gtk::gio::Menu::new();
|
||||||
section2.append(Some("Check for Updates"), Some(&format!("win.check-update(int64 {})", record.id)));
|
section2.append(Some("Check for Updates"), Some(&format!("win.check-update(int64 {})", record.id)));
|
||||||
section2.append(Some("Scan for Vulnerabilities"), Some(&format!("win.scan-security(int64 {})", record.id)));
|
section2.append(Some("Security check"), Some(&format!("win.scan-security(int64 {})", record.id)));
|
||||||
menu.append_section(None, §ion2);
|
menu.append_section(None, §ion2);
|
||||||
|
|
||||||
// Section 3: Integration + folder
|
// Section 3: Integration + folder
|
||||||
let section3 = gtk::gio::Menu::new();
|
let section3 = gtk::gio::Menu::new();
|
||||||
let integrate_label = if record.integrated { "Remove Integration" } else { "Integrate" };
|
let integrate_label = if record.integrated { "Remove from app menu" } else { "Add to app menu" };
|
||||||
section3.append(Some(integrate_label), Some(&format!("win.toggle-integration(int64 {})", record.id)));
|
section3.append(Some(integrate_label), Some(&format!("win.toggle-integration(int64 {})", record.id)));
|
||||||
section3.append(Some("Open Containing Folder"), Some(&format!("win.open-folder(int64 {})", record.id)));
|
section3.append(Some("Show in file manager"), Some(&format!("win.open-folder(int64 {})", record.id)));
|
||||||
menu.append_section(None, §ion3);
|
menu.append_section(None, §ion3);
|
||||||
|
|
||||||
// Section 4: Clipboard
|
// Section 4: Clipboard
|
||||||
let section4 = gtk::gio::Menu::new();
|
let section4 = gtk::gio::Menu::new();
|
||||||
section4.append(Some("Copy Path"), Some(&format!("win.copy-path(int64 {})", record.id)));
|
section4.append(Some("Copy file location"), Some(&format!("win.copy-path(int64 {})", record.id)));
|
||||||
menu.append_section(None, §ion4);
|
menu.append_section(None, §ion4);
|
||||||
|
|
||||||
menu
|
menu
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
pub mod app_card;
|
pub mod app_card;
|
||||||
|
pub mod cleanup_wizard;
|
||||||
pub mod dashboard;
|
pub mod dashboard;
|
||||||
pub mod detail_view;
|
pub mod detail_view;
|
||||||
|
pub mod drop_dialog;
|
||||||
pub mod duplicate_dialog;
|
pub mod duplicate_dialog;
|
||||||
|
pub mod integration_dialog;
|
||||||
pub mod library_view;
|
pub mod library_view;
|
||||||
pub mod preferences;
|
pub mod preferences;
|
||||||
|
pub mod security_report;
|
||||||
pub mod update_dialog;
|
pub mod update_dialog;
|
||||||
pub mod widgets;
|
pub mod widgets;
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ use adw::prelude::*;
|
|||||||
use gtk::gio;
|
use gtk::gio;
|
||||||
|
|
||||||
use crate::config::APP_ID;
|
use crate::config::APP_ID;
|
||||||
|
use crate::i18n::i18n;
|
||||||
|
|
||||||
pub fn show_preferences_dialog(parent: &impl IsA<gtk::Widget>) {
|
pub fn show_preferences_dialog(parent: &impl IsA<gtk::Widget>) {
|
||||||
let dialog = adw::PreferencesDialog::new();
|
let dialog = adw::PreferencesDialog::new();
|
||||||
dialog.set_title("Preferences");
|
dialog.set_title(&i18n("Preferences"));
|
||||||
|
|
||||||
let settings = gio::Settings::new(APP_ID);
|
let settings = gio::Settings::new(APP_ID);
|
||||||
|
|
||||||
@@ -20,22 +21,22 @@ pub fn show_preferences_dialog(parent: &impl IsA<gtk::Widget>) {
|
|||||||
|
|
||||||
fn build_general_page(settings: &gio::Settings, dialog: &adw::PreferencesDialog) -> adw::PreferencesPage {
|
fn build_general_page(settings: &gio::Settings, dialog: &adw::PreferencesDialog) -> adw::PreferencesPage {
|
||||||
let page = adw::PreferencesPage::builder()
|
let page = adw::PreferencesPage::builder()
|
||||||
.title("General")
|
.title(&i18n("General"))
|
||||||
.icon_name("emblem-system-symbolic")
|
.icon_name("emblem-system-symbolic")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// Appearance group
|
// Appearance group
|
||||||
let appearance_group = adw::PreferencesGroup::builder()
|
let appearance_group = adw::PreferencesGroup::builder()
|
||||||
.title("Appearance")
|
.title(&i18n("Appearance"))
|
||||||
.description("Visual preferences for the application")
|
.description(&i18n("Visual preferences for the application"))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let theme_row = adw::ComboRow::builder()
|
let theme_row = adw::ComboRow::builder()
|
||||||
.title("Color Scheme")
|
.title(&i18n("Color Scheme"))
|
||||||
.subtitle("Choose light, dark, or follow system preference")
|
.subtitle(&i18n("Choose light, dark, or follow system preference"))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let model = gtk::StringList::new(&["Follow System", "Light", "Dark"]);
|
let model = gtk::StringList::new(&[&i18n("Follow System"), &i18n("Light"), &i18n("Dark")]);
|
||||||
theme_row.set_model(Some(&model));
|
theme_row.set_model(Some(&model));
|
||||||
|
|
||||||
let current = settings.string("color-scheme");
|
let current = settings.string("color-scheme");
|
||||||
@@ -58,10 +59,10 @@ fn build_general_page(settings: &gio::Settings, dialog: &adw::PreferencesDialog)
|
|||||||
appearance_group.add(&theme_row);
|
appearance_group.add(&theme_row);
|
||||||
|
|
||||||
let view_row = adw::ComboRow::builder()
|
let view_row = adw::ComboRow::builder()
|
||||||
.title("Default View")
|
.title(&i18n("Default View"))
|
||||||
.subtitle("Library display mode")
|
.subtitle(&i18n("Library display mode"))
|
||||||
.build();
|
.build();
|
||||||
let view_model = gtk::StringList::new(&["Grid", "List"]);
|
let view_model = gtk::StringList::new(&[&i18n("Grid"), &i18n("List")]);
|
||||||
view_row.set_model(Some(&view_model));
|
view_row.set_model(Some(&view_model));
|
||||||
let current_view = settings.string("view-mode");
|
let current_view = settings.string("view-mode");
|
||||||
view_row.set_selected(if current_view.as_str() == "list" { 1 } else { 0 });
|
view_row.set_selected(if current_view.as_str() == "list" { 1 } else { 0 });
|
||||||
@@ -73,12 +74,13 @@ fn build_general_page(settings: &gio::Settings, dialog: &adw::PreferencesDialog)
|
|||||||
});
|
});
|
||||||
|
|
||||||
appearance_group.add(&view_row);
|
appearance_group.add(&view_row);
|
||||||
|
|
||||||
page.add(&appearance_group);
|
page.add(&appearance_group);
|
||||||
|
|
||||||
// Scan Locations group
|
// Scan Locations group
|
||||||
let scan_group = adw::PreferencesGroup::builder()
|
let scan_group = adw::PreferencesGroup::builder()
|
||||||
.title("Scan Locations")
|
.title(&i18n("Scan Locations"))
|
||||||
.description("Directories to scan for AppImage files")
|
.description(&i18n("Directories to scan for AppImage files"))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let dirs = settings.strv("scan-directories");
|
let dirs = settings.strv("scan-directories");
|
||||||
@@ -86,7 +88,7 @@ fn build_general_page(settings: &gio::Settings, dialog: &adw::PreferencesDialog)
|
|||||||
dir_list_box.add_css_class("boxed-list");
|
dir_list_box.add_css_class("boxed-list");
|
||||||
dir_list_box.set_selection_mode(gtk::SelectionMode::None);
|
dir_list_box.set_selection_mode(gtk::SelectionMode::None);
|
||||||
dir_list_box.update_property(&[
|
dir_list_box.update_property(&[
|
||||||
gtk::accessible::Property::Label("Scan directories"),
|
gtk::accessible::Property::Label(&i18n("Scan directories")),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
for dir in &dirs {
|
for dir in &dirs {
|
||||||
@@ -97,11 +99,11 @@ fn build_general_page(settings: &gio::Settings, dialog: &adw::PreferencesDialog)
|
|||||||
|
|
||||||
// Add location button
|
// Add location button
|
||||||
let add_button = gtk::Button::builder()
|
let add_button = gtk::Button::builder()
|
||||||
.label("Add Location")
|
.label(&i18n("Add Location"))
|
||||||
.build();
|
.build();
|
||||||
add_button.add_css_class("flat");
|
add_button.add_css_class("flat");
|
||||||
add_button.update_property(&[
|
add_button.update_property(&[
|
||||||
gtk::accessible::Property::Label("Add scan directory"),
|
gtk::accessible::Property::Label(&i18n("Add scan directory")),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let settings_add = settings.clone();
|
let settings_add = settings.clone();
|
||||||
@@ -109,7 +111,7 @@ fn build_general_page(settings: &gio::Settings, dialog: &adw::PreferencesDialog)
|
|||||||
let dialog_weak = dialog.downgrade();
|
let dialog_weak = dialog.downgrade();
|
||||||
add_button.connect_clicked(move |_| {
|
add_button.connect_clicked(move |_| {
|
||||||
let file_dialog = gtk::FileDialog::builder()
|
let file_dialog = gtk::FileDialog::builder()
|
||||||
.title("Choose a directory")
|
.title(i18n("Choose a directory"))
|
||||||
.modal(true)
|
.modal(true)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -158,19 +160,19 @@ fn build_general_page(settings: &gio::Settings, dialog: &adw::PreferencesDialog)
|
|||||||
|
|
||||||
fn build_behavior_page(settings: &gio::Settings) -> adw::PreferencesPage {
|
fn build_behavior_page(settings: &gio::Settings) -> adw::PreferencesPage {
|
||||||
let page = adw::PreferencesPage::builder()
|
let page = adw::PreferencesPage::builder()
|
||||||
.title("Behavior")
|
.title(&i18n("Behavior"))
|
||||||
.icon_name("preferences-other-symbolic")
|
.icon_name("preferences-other-symbolic")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// Automation group
|
// Automation group
|
||||||
let automation_group = adw::PreferencesGroup::builder()
|
let automation_group = adw::PreferencesGroup::builder()
|
||||||
.title("Automation")
|
.title(&i18n("Automation"))
|
||||||
.description("What Driftwood does automatically")
|
.description(&i18n("What Driftwood does automatically"))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let auto_scan_row = adw::SwitchRow::builder()
|
let auto_scan_row = adw::SwitchRow::builder()
|
||||||
.title("Scan on startup")
|
.title(&i18n("Scan on startup"))
|
||||||
.subtitle("Automatically scan for new AppImages when the app starts")
|
.subtitle(&i18n("Automatically scan for new AppImages when the app starts"))
|
||||||
.active(settings.boolean("auto-scan-on-startup"))
|
.active(settings.boolean("auto-scan-on-startup"))
|
||||||
.build();
|
.build();
|
||||||
let settings_scan = settings.clone();
|
let settings_scan = settings.clone();
|
||||||
@@ -180,8 +182,8 @@ fn build_behavior_page(settings: &gio::Settings) -> adw::PreferencesPage {
|
|||||||
automation_group.add(&auto_scan_row);
|
automation_group.add(&auto_scan_row);
|
||||||
|
|
||||||
let auto_update_row = adw::SwitchRow::builder()
|
let auto_update_row = adw::SwitchRow::builder()
|
||||||
.title("Check for updates")
|
.title(&i18n("Check for updates"))
|
||||||
.subtitle("Periodically check if newer versions of your AppImages are available")
|
.subtitle(&i18n("Periodically check if newer versions of your AppImages are available"))
|
||||||
.active(settings.boolean("auto-check-updates"))
|
.active(settings.boolean("auto-check-updates"))
|
||||||
.build();
|
.build();
|
||||||
let settings_upd = settings.clone();
|
let settings_upd = settings.clone();
|
||||||
@@ -191,8 +193,8 @@ fn build_behavior_page(settings: &gio::Settings) -> adw::PreferencesPage {
|
|||||||
automation_group.add(&auto_update_row);
|
automation_group.add(&auto_update_row);
|
||||||
|
|
||||||
let auto_integrate_row = adw::SwitchRow::builder()
|
let auto_integrate_row = adw::SwitchRow::builder()
|
||||||
.title("Auto-integrate new AppImages")
|
.title(&i18n("Auto-integrate new AppImages"))
|
||||||
.subtitle("Automatically add newly discovered AppImages to the desktop menu")
|
.subtitle(&i18n("Automatically add newly discovered AppImages to the desktop menu"))
|
||||||
.active(settings.boolean("auto-integrate"))
|
.active(settings.boolean("auto-integrate"))
|
||||||
.build();
|
.build();
|
||||||
let settings_int = settings.clone();
|
let settings_int = settings.clone();
|
||||||
@@ -205,13 +207,13 @@ fn build_behavior_page(settings: &gio::Settings) -> adw::PreferencesPage {
|
|||||||
|
|
||||||
// Backup group
|
// Backup group
|
||||||
let backup_group = adw::PreferencesGroup::builder()
|
let backup_group = adw::PreferencesGroup::builder()
|
||||||
.title("Backups")
|
.title(&i18n("Backups"))
|
||||||
.description("Config and data backup settings for updates")
|
.description(&i18n("Config and data backup settings for updates"))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let auto_backup_row = adw::SwitchRow::builder()
|
let auto_backup_row = adw::SwitchRow::builder()
|
||||||
.title("Auto-backup before update")
|
.title(&i18n("Auto-backup before update"))
|
||||||
.subtitle("Back up config and data files before updating an AppImage")
|
.subtitle(&i18n("Back up config and data files before updating an AppImage"))
|
||||||
.active(settings.boolean("auto-backup-before-update"))
|
.active(settings.boolean("auto-backup-before-update"))
|
||||||
.build();
|
.build();
|
||||||
let settings_backup = settings.clone();
|
let settings_backup = settings.clone();
|
||||||
@@ -221,8 +223,8 @@ fn build_behavior_page(settings: &gio::Settings) -> adw::PreferencesPage {
|
|||||||
backup_group.add(&auto_backup_row);
|
backup_group.add(&auto_backup_row);
|
||||||
|
|
||||||
let retention_row = adw::SpinRow::builder()
|
let retention_row = adw::SpinRow::builder()
|
||||||
.title("Backup retention")
|
.title(&i18n("Backup retention"))
|
||||||
.subtitle("Days to keep config backups before auto-cleanup")
|
.subtitle(&i18n("Days to keep config backups before auto-cleanup"))
|
||||||
.build();
|
.build();
|
||||||
let adjustment = gtk::Adjustment::new(
|
let adjustment = gtk::Adjustment::new(
|
||||||
settings.int("backup-retention-days") as f64,
|
settings.int("backup-retention-days") as f64,
|
||||||
@@ -243,13 +245,13 @@ fn build_behavior_page(settings: &gio::Settings) -> adw::PreferencesPage {
|
|||||||
|
|
||||||
// Safety group
|
// Safety group
|
||||||
let safety_group = adw::PreferencesGroup::builder()
|
let safety_group = adw::PreferencesGroup::builder()
|
||||||
.title("Safety")
|
.title(&i18n("Safety"))
|
||||||
.description("Confirmation and cleanup behavior")
|
.description(&i18n("Confirmation and cleanup behavior"))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let confirm_row = adw::SwitchRow::builder()
|
let confirm_row = adw::SwitchRow::builder()
|
||||||
.title("Confirm before delete")
|
.title(&i18n("Confirm before delete"))
|
||||||
.subtitle("Show a confirmation dialog before deleting files or cleaning up")
|
.subtitle(&i18n("Show a confirmation dialog before deleting files or cleaning up"))
|
||||||
.active(settings.boolean("confirm-before-delete"))
|
.active(settings.boolean("confirm-before-delete"))
|
||||||
.build();
|
.build();
|
||||||
let settings_confirm = settings.clone();
|
let settings_confirm = settings.clone();
|
||||||
@@ -259,10 +261,10 @@ fn build_behavior_page(settings: &gio::Settings) -> adw::PreferencesPage {
|
|||||||
safety_group.add(&confirm_row);
|
safety_group.add(&confirm_row);
|
||||||
|
|
||||||
let cleanup_row = adw::ComboRow::builder()
|
let cleanup_row = adw::ComboRow::builder()
|
||||||
.title("After updating an AppImage")
|
.title(&i18n("After updating an AppImage"))
|
||||||
.subtitle("What to do with the old version after a successful update")
|
.subtitle(&i18n("What to do with the old version after a successful update"))
|
||||||
.build();
|
.build();
|
||||||
let cleanup_model = gtk::StringList::new(&["Ask each time", "Remove old version", "Keep backup"]);
|
let cleanup_model = gtk::StringList::new(&[&i18n("Ask each time"), &i18n("Remove old version"), &i18n("Keep backup")]);
|
||||||
cleanup_row.set_model(Some(&cleanup_model));
|
cleanup_row.set_model(Some(&cleanup_model));
|
||||||
|
|
||||||
let current_cleanup = settings.string("update-cleanup");
|
let current_cleanup = settings.string("update-cleanup");
|
||||||
@@ -292,18 +294,18 @@ fn build_behavior_page(settings: &gio::Settings) -> adw::PreferencesPage {
|
|||||||
|
|
||||||
fn build_security_page(settings: &gio::Settings) -> adw::PreferencesPage {
|
fn build_security_page(settings: &gio::Settings) -> adw::PreferencesPage {
|
||||||
let page = adw::PreferencesPage::builder()
|
let page = adw::PreferencesPage::builder()
|
||||||
.title("Security")
|
.title(&i18n("Security"))
|
||||||
.icon_name("security-medium-symbolic")
|
.icon_name("security-medium-symbolic")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let scan_group = adw::PreferencesGroup::builder()
|
let scan_group = adw::PreferencesGroup::builder()
|
||||||
.title("Vulnerability Scanning")
|
.title(&i18n("Vulnerability Scanning"))
|
||||||
.description("Check bundled libraries for known CVEs via OSV.dev")
|
.description(&i18n("Check bundled libraries for known CVEs via OSV.dev"))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let auto_security_row = adw::SwitchRow::builder()
|
let auto_security_row = adw::SwitchRow::builder()
|
||||||
.title("Auto-scan new AppImages")
|
.title(&i18n("Auto-scan new AppImages"))
|
||||||
.subtitle("Automatically run a security scan on newly discovered AppImages")
|
.subtitle(&i18n("Automatically run a security scan on newly discovered AppImages"))
|
||||||
.active(settings.boolean("auto-security-scan"))
|
.active(settings.boolean("auto-security-scan"))
|
||||||
.build();
|
.build();
|
||||||
let settings_sec = settings.clone();
|
let settings_sec = settings.clone();
|
||||||
@@ -313,8 +315,8 @@ fn build_security_page(settings: &gio::Settings) -> adw::PreferencesPage {
|
|||||||
scan_group.add(&auto_security_row);
|
scan_group.add(&auto_security_row);
|
||||||
|
|
||||||
let info_row = adw::ActionRow::builder()
|
let info_row = adw::ActionRow::builder()
|
||||||
.title("Data source")
|
.title(&i18n("Data source"))
|
||||||
.subtitle("OSV.dev - Open Source Vulnerability database")
|
.subtitle(&i18n("OSV.dev - Open Source Vulnerability database"))
|
||||||
.build();
|
.build();
|
||||||
scan_group.add(&info_row);
|
scan_group.add(&info_row);
|
||||||
|
|
||||||
@@ -322,13 +324,13 @@ fn build_security_page(settings: &gio::Settings) -> adw::PreferencesPage {
|
|||||||
|
|
||||||
// Notification settings
|
// Notification settings
|
||||||
let notify_group = adw::PreferencesGroup::builder()
|
let notify_group = adw::PreferencesGroup::builder()
|
||||||
.title("Notifications")
|
.title(&i18n("Notifications"))
|
||||||
.description("Desktop notification settings for security alerts")
|
.description(&i18n("Desktop notification settings for security alerts"))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let notify_row = adw::SwitchRow::builder()
|
let notify_row = adw::SwitchRow::builder()
|
||||||
.title("Security notifications")
|
.title(&i18n("Security notifications"))
|
||||||
.subtitle("Send desktop notifications when new CVEs are found")
|
.subtitle(&i18n("Send desktop notifications when new CVEs are found"))
|
||||||
.active(settings.boolean("security-notifications"))
|
.active(settings.boolean("security-notifications"))
|
||||||
.build();
|
.build();
|
||||||
let settings_notify = settings.clone();
|
let settings_notify = settings.clone();
|
||||||
@@ -338,10 +340,10 @@ fn build_security_page(settings: &gio::Settings) -> adw::PreferencesPage {
|
|||||||
notify_group.add(¬ify_row);
|
notify_group.add(¬ify_row);
|
||||||
|
|
||||||
let threshold_row = adw::ComboRow::builder()
|
let threshold_row = adw::ComboRow::builder()
|
||||||
.title("Notification threshold")
|
.title(&i18n("Notification threshold"))
|
||||||
.subtitle("Minimum severity to trigger a notification")
|
.subtitle(&i18n("Minimum severity to trigger a notification"))
|
||||||
.build();
|
.build();
|
||||||
let threshold_model = gtk::StringList::new(&["Critical", "High", "Medium", "Low"]);
|
let threshold_model = gtk::StringList::new(&[&i18n("Critical"), &i18n("High"), &i18n("Medium"), &i18n("Low")]);
|
||||||
threshold_row.set_model(Some(&threshold_model));
|
threshold_row.set_model(Some(&threshold_model));
|
||||||
|
|
||||||
let current_threshold = settings.string("security-notification-threshold");
|
let current_threshold = settings.string("security-notification-threshold");
|
||||||
@@ -369,19 +371,19 @@ fn build_security_page(settings: &gio::Settings) -> adw::PreferencesPage {
|
|||||||
|
|
||||||
// About security scanning
|
// About security scanning
|
||||||
let about_group = adw::PreferencesGroup::builder()
|
let about_group = adw::PreferencesGroup::builder()
|
||||||
.title("How It Works")
|
.title(&i18n("How It Works"))
|
||||||
.description("Understanding Driftwood's security scanning")
|
.description(&i18n("Understanding Driftwood's security scanning"))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let about_row = adw::ActionRow::builder()
|
let about_row = adw::ActionRow::builder()
|
||||||
.title("Bundled library detection")
|
.title(&i18n("Bundled library detection"))
|
||||||
.subtitle("Driftwood extracts the list of shared libraries (.so files) bundled inside each AppImage and checks them against the OSV vulnerability database.")
|
.subtitle(&i18n("Driftwood extracts the list of shared libraries (.so files) bundled inside each AppImage and checks them against the OSV vulnerability database."))
|
||||||
.build();
|
.build();
|
||||||
about_group.add(&about_row);
|
about_group.add(&about_row);
|
||||||
|
|
||||||
let limits_row = adw::ActionRow::builder()
|
let limits_row = adw::ActionRow::builder()
|
||||||
.title("Limitations")
|
.title(&i18n("Limitations"))
|
||||||
.subtitle("Not all bundled libraries can be identified. Version detection uses heuristics and may not always be accurate. Results should be treated as advisory.")
|
.subtitle(&i18n("Not all bundled libraries can be identified. Version detection uses heuristics and may not always be accurate. Results should be treated as advisory."))
|
||||||
.build();
|
.build();
|
||||||
about_group.add(&limits_row);
|
about_group.add(&limits_row);
|
||||||
|
|
||||||
@@ -398,11 +400,11 @@ fn add_directory_row(list_box: >k::ListBox, dir: &str, settings: &gio::Setting
|
|||||||
let remove_btn = gtk::Button::builder()
|
let remove_btn = gtk::Button::builder()
|
||||||
.icon_name("edit-delete-symbolic")
|
.icon_name("edit-delete-symbolic")
|
||||||
.valign(gtk::Align::Center)
|
.valign(gtk::Align::Center)
|
||||||
.tooltip_text("Remove")
|
.tooltip_text(&i18n("Remove"))
|
||||||
.build();
|
.build();
|
||||||
remove_btn.add_css_class("flat");
|
remove_btn.add_css_class("flat");
|
||||||
remove_btn.update_property(&[
|
remove_btn.update_property(&[
|
||||||
gtk::accessible::Property::Label(&format!("Remove directory {}", dir)),
|
gtk::accessible::Property::Label(&format!("{} {}", i18n("Remove directory"), dir)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let list_ref = list_box.clone();
|
let list_ref = list_box.clone();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use std::rc::Rc;
|
|||||||
use crate::config::APP_ID;
|
use crate::config::APP_ID;
|
||||||
use crate::core::database::{AppImageRecord, Database};
|
use crate::core::database::{AppImageRecord, Database};
|
||||||
use crate::core::updater;
|
use crate::core::updater;
|
||||||
|
use crate::i18n::{i18n, i18n_f};
|
||||||
|
|
||||||
/// Show an update check + apply dialog for a single AppImage.
|
/// Show an update check + apply dialog for a single AppImage.
|
||||||
pub fn show_update_dialog(
|
pub fn show_update_dialog(
|
||||||
@@ -13,13 +14,13 @@ pub fn show_update_dialog(
|
|||||||
db: &Rc<Database>,
|
db: &Rc<Database>,
|
||||||
) {
|
) {
|
||||||
let dialog = adw::AlertDialog::builder()
|
let dialog = adw::AlertDialog::builder()
|
||||||
.heading("Check for Updates")
|
.heading(&i18n("Check for Updates"))
|
||||||
.body(&format!(
|
.body(&i18n_f(
|
||||||
"Checking for updates for {}...",
|
"Checking for updates for {name}...",
|
||||||
record.app_name.as_deref().unwrap_or(&record.filename)
|
&[("{name}", record.app_name.as_deref().unwrap_or(&record.filename))],
|
||||||
))
|
))
|
||||||
.build();
|
.build();
|
||||||
dialog.add_response("close", "Close");
|
dialog.add_response("close", &i18n("Close"));
|
||||||
dialog.set_default_response(Some("close"));
|
dialog.set_default_response(Some("close"));
|
||||||
dialog.set_close_response("close");
|
dialog.set_close_response("close");
|
||||||
|
|
||||||
@@ -58,15 +59,17 @@ pub fn show_update_dialog(
|
|||||||
db_ref.set_update_available(record_id, Some(version), check_result.download_url.as_deref()).ok();
|
db_ref.set_update_available(record_id, Some(version), check_result.download_url.as_deref()).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut body = format!(
|
let mut body = i18n_f(
|
||||||
"{} -> {}",
|
"{current} -> {latest}",
|
||||||
record_clone.app_version.as_deref().unwrap_or("unknown"),
|
&[
|
||||||
check_result.latest_version.as_deref().unwrap_or("unknown"),
|
("{current}", record_clone.app_version.as_deref().unwrap_or("unknown")),
|
||||||
|
("{latest}", check_result.latest_version.as_deref().unwrap_or("unknown")),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
if let Some(size) = check_result.file_size {
|
if let Some(size) = check_result.file_size {
|
||||||
body.push_str(&format!(" ({})", humansize::format_size(size, humansize::BINARY)));
|
body.push_str(&format!(" ({})", humansize::format_size(size, humansize::BINARY)));
|
||||||
}
|
}
|
||||||
body.push_str("\n\nA new version is available.");
|
body.push_str(&format!("\n\n{}", i18n("A new version is available.")));
|
||||||
if let Some(ref notes) = check_result.release_notes {
|
if let Some(ref notes) = check_result.release_notes {
|
||||||
if !notes.is_empty() {
|
if !notes.is_empty() {
|
||||||
// Truncate long release notes for the dialog
|
// Truncate long release notes for the dialog
|
||||||
@@ -75,12 +78,12 @@ pub fn show_update_dialog(
|
|||||||
body.push_str(&format!("\n\n{}{}", truncated, suffix));
|
body.push_str(&format!("\n\n{}{}", truncated, suffix));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dialog_ref.set_heading(Some("Update Available"));
|
dialog_ref.set_heading(Some(&i18n("Update Available")));
|
||||||
dialog_ref.set_body(&body);
|
dialog_ref.set_body(&body);
|
||||||
|
|
||||||
// Add "Update Now" button if we have a download URL
|
// Add "Update Now" button if we have a download URL
|
||||||
if let Some(download_url) = check_result.download_url {
|
if let Some(download_url) = check_result.download_url {
|
||||||
dialog_ref.add_response("update", "Update Now");
|
dialog_ref.add_response("update", &i18n("Update Now"));
|
||||||
dialog_ref.set_response_appearance("update", adw::ResponseAppearance::Suggested);
|
dialog_ref.set_response_appearance("update", adw::ResponseAppearance::Suggested);
|
||||||
dialog_ref.set_default_response(Some("update"));
|
dialog_ref.set_default_response(Some("update"));
|
||||||
|
|
||||||
@@ -101,11 +104,13 @@ pub fn show_update_dialog(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
dialog_ref.set_heading(Some("Up to Date"));
|
dialog_ref.set_heading(Some(&i18n("Up to Date")));
|
||||||
dialog_ref.set_body(&format!(
|
dialog_ref.set_body(&i18n_f(
|
||||||
"{} is already at the latest version ({}).",
|
"{name} is already at the latest version ({version}).",
|
||||||
record_clone.app_name.as_deref().unwrap_or(&record_clone.filename),
|
&[
|
||||||
record_clone.app_version.as_deref().unwrap_or("unknown"),
|
("{name}", record_clone.app_name.as_deref().unwrap_or(&record_clone.filename)),
|
||||||
|
("{version}", record_clone.app_version.as_deref().unwrap_or("unknown")),
|
||||||
|
],
|
||||||
));
|
));
|
||||||
db_ref.clear_update_available(record_id).ok();
|
db_ref.clear_update_available(record_id).ok();
|
||||||
}
|
}
|
||||||
@@ -113,19 +118,18 @@ pub fn show_update_dialog(
|
|||||||
Ok((type_label, raw_info, None)) => {
|
Ok((type_label, raw_info, None)) => {
|
||||||
if raw_info.is_some() {
|
if raw_info.is_some() {
|
||||||
db_ref.update_update_info(record_id, raw_info.as_deref(), type_label.as_deref()).ok();
|
db_ref.update_update_info(record_id, raw_info.as_deref(), type_label.as_deref()).ok();
|
||||||
dialog_ref.set_heading(Some("Check Failed"));
|
dialog_ref.set_heading(Some(&i18n("Check Failed")));
|
||||||
dialog_ref.set_body("Could not reach the update server. Try again later.");
|
dialog_ref.set_body(&i18n("Could not reach the update server. Try again later."));
|
||||||
} else {
|
} else {
|
||||||
dialog_ref.set_heading(Some("No Update Info"));
|
dialog_ref.set_heading(Some(&i18n("No Update Info")));
|
||||||
dialog_ref.set_body(
|
dialog_ref.set_body(
|
||||||
"This app does not support automatic updates. \
|
&i18n("This app does not support automatic updates. Check the developer's website for newer versions."),
|
||||||
Check the developer's website for newer versions.",
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
dialog_ref.set_heading(Some("Error"));
|
dialog_ref.set_heading(Some(&i18n("Error")));
|
||||||
dialog_ref.set_body("An error occurred while checking for updates.");
|
dialog_ref.set_body(&i18n("An error occurred while checking for updates."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -142,8 +146,8 @@ fn start_update(
|
|||||||
new_version: Option<&str>,
|
new_version: Option<&str>,
|
||||||
db: &Rc<Database>,
|
db: &Rc<Database>,
|
||||||
) {
|
) {
|
||||||
dialog.set_heading(Some("Updating..."));
|
dialog.set_heading(Some(&i18n("Updating...")));
|
||||||
dialog.set_body("Downloading update. This may take a moment.");
|
dialog.set_body(&i18n("Downloading update. This may take a moment."));
|
||||||
dialog.set_response_enabled("update", false);
|
dialog.set_response_enabled("update", false);
|
||||||
|
|
||||||
let path = appimage_path.to_string();
|
let path = appimage_path.to_string();
|
||||||
@@ -171,12 +175,14 @@ fn start_update(
|
|||||||
}
|
}
|
||||||
db_ref.clear_update_available(record_id).ok();
|
db_ref.clear_update_available(record_id).ok();
|
||||||
|
|
||||||
let success_body = format!(
|
let success_body = i18n_f(
|
||||||
"Updated to {}\nPath: {}",
|
"Updated to {version}\nPath: {path}",
|
||||||
applied.new_version.as_deref().unwrap_or("latest"),
|
&[
|
||||||
applied.new_path.display(),
|
("{version}", applied.new_version.as_deref().unwrap_or("latest")),
|
||||||
|
("{path}", &applied.new_path.display().to_string()),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
dialog.set_heading(Some("Update Complete"));
|
dialog.set_heading(Some(&i18n("Update Complete")));
|
||||||
dialog.set_body(&success_body);
|
dialog.set_body(&success_body);
|
||||||
dialog.set_response_enabled("update", false);
|
dialog.set_response_enabled("update", false);
|
||||||
|
|
||||||
@@ -186,12 +192,15 @@ fn start_update(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
dialog.set_heading(Some("Update Failed"));
|
dialog.set_heading(Some(&i18n("Update Failed")));
|
||||||
dialog.set_body(&format!("The update could not be applied: {}", e));
|
dialog.set_body(&i18n_f(
|
||||||
|
"The update could not be applied: {error}",
|
||||||
|
&[("{error}", &e.to_string())],
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
dialog.set_heading(Some("Update Failed"));
|
dialog.set_heading(Some(&i18n("Update Failed")));
|
||||||
dialog.set_body("An unexpected error occurred during the update.");
|
dialog.set_body(&i18n("An unexpected error occurred during the update."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -215,18 +224,18 @@ fn handle_old_version_cleanup(dialog: &adw::AlertDialog, old_path: PathBuf) {
|
|||||||
}
|
}
|
||||||
"never" => {
|
"never" => {
|
||||||
// Keep the backup, just inform
|
// Keep the backup, just inform
|
||||||
dialog.set_body(&format!(
|
dialog.set_body(&i18n_f(
|
||||||
"Update complete. The old version is saved at:\n{}",
|
"Update complete. The old version is saved at:\n{path}",
|
||||||
old_path.display()
|
&[("{path}", &old_path.display().to_string())],
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// "ask" - prompt the user
|
// "ask" - prompt the user
|
||||||
dialog.set_body(&format!(
|
dialog.set_body(&i18n_f(
|
||||||
"Update complete.\n\nRemove the old version?\n{}",
|
"Update complete.\n\nRemove the old version?\n{path}",
|
||||||
old_path.display()
|
&[("{path}", &old_path.display().to_string())],
|
||||||
));
|
));
|
||||||
dialog.add_response("remove-old", "Remove Old Version");
|
dialog.add_response("remove-old", &i18n("Remove Old Version"));
|
||||||
dialog.set_response_appearance("remove-old", adw::ResponseAppearance::Destructive);
|
dialog.set_response_appearance("remove-old", adw::ResponseAppearance::Destructive);
|
||||||
|
|
||||||
let path = old_path.clone();
|
let path = old_path.clone();
|
||||||
|
|||||||
@@ -1,4 +1,59 @@
|
|||||||
use gtk::prelude::*;
|
use gtk::prelude::*;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
/// Ensures the shared letter-icon CSS provider is registered on the default
|
||||||
|
/// display exactly once. The provider defines `.letter-icon-a` through
|
||||||
|
/// `.letter-icon-z` (and `.letter-icon-other`) with distinct hue-based
|
||||||
|
/// background/foreground colors so that individual `build_letter_icon` calls
|
||||||
|
/// never need to create their own CssProvider.
|
||||||
|
fn ensure_letter_icon_css() {
|
||||||
|
static REGISTERED: OnceLock<bool> = OnceLock::new();
|
||||||
|
REGISTERED.get_or_init(|| {
|
||||||
|
let provider = gtk::CssProvider::new();
|
||||||
|
provider.load_from_string(&generate_letter_icon_css());
|
||||||
|
if let Some(display) = gtk::gdk::Display::default() {
|
||||||
|
gtk::style_context_add_provider_for_display(
|
||||||
|
&display,
|
||||||
|
&provider,
|
||||||
|
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate CSS rules for `.letter-icon-a` through `.letter-icon-z` and a
|
||||||
|
/// `.letter-icon-other` fallback. Each letter gets a unique hue evenly
|
||||||
|
/// distributed around the color wheel (saturation 55%, lightness 45% for
|
||||||
|
/// the background, lightness 97% for the foreground text) so that the 26
|
||||||
|
/// letter icons are visually distinct while remaining legible.
|
||||||
|
fn generate_letter_icon_css() -> String {
|
||||||
|
let mut css = String::with_capacity(4096);
|
||||||
|
for i in 0u32..26 {
|
||||||
|
let letter = (b'a' + i as u8) as char;
|
||||||
|
let hue = (i * 360) / 26;
|
||||||
|
// HSL background: moderate saturation, medium lightness
|
||||||
|
// HSL foreground: same hue, very light for contrast
|
||||||
|
css.push_str(&format!(
|
||||||
|
"label.letter-icon-{letter} {{ \
|
||||||
|
background: hsl({hue}, 55%, 45%); \
|
||||||
|
color: hsl({hue}, 100%, 97%); \
|
||||||
|
border-radius: 50%; \
|
||||||
|
font-weight: 700; \
|
||||||
|
}}\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Fallback for non-alphabetic first characters
|
||||||
|
css.push_str(
|
||||||
|
"label.letter-icon-other { \
|
||||||
|
background: hsl(0, 0%, 50%); \
|
||||||
|
color: hsl(0, 0%, 97%); \
|
||||||
|
border-radius: 50%; \
|
||||||
|
font-weight: 700; \
|
||||||
|
}\n"
|
||||||
|
);
|
||||||
|
css
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a status badge pill label with the given text and style class.
|
/// Create a status badge pill label with the given text and style class.
|
||||||
/// Style classes: "success", "warning", "error", "info", "neutral"
|
/// Style classes: "success", "warning", "error", "info", "neutral"
|
||||||
@@ -70,61 +125,47 @@ pub fn app_icon(icon_path: Option<&str>, app_name: &str, pixel_size: i32) -> gtk
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build a colored circle with the first letter of the name as a fallback icon.
|
/// Build a colored circle with the first letter of the name as a fallback icon.
|
||||||
|
///
|
||||||
|
/// The color CSS classes (`.letter-icon-a` .. `.letter-icon-z`) are registered
|
||||||
|
/// once via a shared CssProvider. This function only needs to pick the right
|
||||||
|
/// class and set per-widget sizing, avoiding a new provider per icon.
|
||||||
fn build_letter_icon(name: &str, size: i32) -> gtk::Widget {
|
fn build_letter_icon(name: &str, size: i32) -> gtk::Widget {
|
||||||
let letter = name
|
// Ensure the shared CSS for all 26 letter classes is loaded
|
||||||
|
ensure_letter_icon_css();
|
||||||
|
|
||||||
|
let first_char = name
|
||||||
.chars()
|
.chars()
|
||||||
.find(|c| c.is_alphanumeric())
|
.find(|c| c.is_alphanumeric())
|
||||||
.unwrap_or('?')
|
|
||||||
.to_uppercase()
|
|
||||||
.next()
|
|
||||||
.unwrap_or('?');
|
.unwrap_or('?');
|
||||||
|
let letter_upper = first_char.to_uppercase().next().unwrap_or('?');
|
||||||
|
|
||||||
// Pick a color based on the name hash for consistency
|
// Determine the CSS class: letter-icon-a .. letter-icon-z, or letter-icon-other
|
||||||
let color_index = name.bytes().fold(0u32, |acc, b| acc.wrapping_add(b as u32)) % 6;
|
let css_class = if first_char.is_ascii_alphabetic() {
|
||||||
let bg_color = match color_index {
|
format!("letter-icon-{}", first_char.to_ascii_lowercase())
|
||||||
0 => "@accent_bg_color",
|
} else {
|
||||||
1 => "@success_bg_color",
|
"letter-icon-other".to_string()
|
||||||
2 => "@warning_bg_color",
|
|
||||||
3 => "@error_bg_color",
|
|
||||||
4 => "@accent_bg_color",
|
|
||||||
_ => "@success_bg_color",
|
|
||||||
};
|
|
||||||
let fg_color = match color_index {
|
|
||||||
0 => "@accent_fg_color",
|
|
||||||
1 => "@success_fg_color",
|
|
||||||
2 => "@warning_fg_color",
|
|
||||||
3 => "@error_fg_color",
|
|
||||||
4 => "@accent_fg_color",
|
|
||||||
_ => "@success_fg_color",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use a label styled as a circle with the letter
|
// Font size scales with the icon (40% of the circle diameter).
|
||||||
|
let font_size_pt = size * 4 / 10;
|
||||||
|
|
||||||
let label = gtk::Label::builder()
|
let label = gtk::Label::builder()
|
||||||
.label(&letter.to_string())
|
.use_markup(true)
|
||||||
.halign(gtk::Align::Center)
|
.halign(gtk::Align::Center)
|
||||||
.valign(gtk::Align::Center)
|
.valign(gtk::Align::Center)
|
||||||
.width_request(size)
|
.width_request(size)
|
||||||
.height_request(size)
|
.height_request(size)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// Apply inline CSS via a provider on the display
|
// Use Pango markup to set the font size without a per-widget CssProvider.
|
||||||
let css_provider = gtk::CssProvider::new();
|
let markup = format!(
|
||||||
let unique_class = format!("letter-icon-{}", color_index);
|
"<span size='{}pt'>{}</span>",
|
||||||
let css = format!(
|
font_size_pt,
|
||||||
"label.{} {{ background: {}; color: {}; border-radius: 50%; min-width: {}px; min-height: {}px; font-size: {}px; font-weight: 700; }}",
|
glib::markup_escape_text(&letter_upper.to_string()),
|
||||||
unique_class, bg_color, fg_color, size, size, size * 4 / 10
|
|
||||||
);
|
);
|
||||||
css_provider.load_from_string(&css);
|
label.set_markup(&markup);
|
||||||
|
|
||||||
if let Some(display) = gtk::gdk::Display::default() {
|
label.add_css_class(&css_class);
|
||||||
gtk::style_context_add_provider_for_display(
|
|
||||||
&display,
|
|
||||||
&css_provider,
|
|
||||||
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION + 1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
label.add_css_class(&unique_class);
|
|
||||||
|
|
||||||
label.upcast()
|
label.upcast()
|
||||||
}
|
}
|
||||||
|
|||||||
423
src/window.rs
423
src/window.rs
@@ -6,20 +6,19 @@ use std::rc::Rc;
|
|||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use crate::config::APP_ID;
|
use crate::config::APP_ID;
|
||||||
|
use crate::core::analysis;
|
||||||
use crate::core::database::Database;
|
use crate::core::database::Database;
|
||||||
use crate::core::discovery;
|
use crate::core::discovery;
|
||||||
use crate::core::fuse;
|
|
||||||
use crate::core::inspector;
|
|
||||||
use crate::core::integrator;
|
use crate::core::integrator;
|
||||||
use crate::core::launcher;
|
use crate::core::launcher;
|
||||||
use crate::core::orphan;
|
use crate::core::orphan;
|
||||||
use crate::core::security;
|
use crate::core::security;
|
||||||
use crate::core::updater;
|
use crate::core::updater;
|
||||||
use crate::core::wayland;
|
|
||||||
use crate::i18n::{i18n, ni18n_f};
|
use crate::i18n::{i18n, ni18n_f};
|
||||||
use crate::ui::cleanup_wizard;
|
use crate::ui::cleanup_wizard;
|
||||||
use crate::ui::dashboard;
|
use crate::ui::dashboard;
|
||||||
use crate::ui::detail_view;
|
use crate::ui::detail_view;
|
||||||
|
use crate::ui::drop_dialog;
|
||||||
use crate::ui::duplicate_dialog;
|
use crate::ui::duplicate_dialog;
|
||||||
use crate::ui::library_view::{LibraryState, LibraryView};
|
use crate::ui::library_view::{LibraryState, LibraryView};
|
||||||
use crate::ui::preferences;
|
use crate::ui::preferences;
|
||||||
@@ -35,6 +34,8 @@ mod imp {
|
|||||||
pub navigation_view: OnceCell<adw::NavigationView>,
|
pub navigation_view: OnceCell<adw::NavigationView>,
|
||||||
pub library_view: OnceCell<LibraryView>,
|
pub library_view: OnceCell<LibraryView>,
|
||||||
pub database: OnceCell<Rc<Database>>,
|
pub database: OnceCell<Rc<Database>>,
|
||||||
|
pub drop_overlay: OnceCell<gtk::Box>,
|
||||||
|
pub drop_revealer: OnceCell<gtk::Revealer>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for DriftwoodWindow {
|
impl Default for DriftwoodWindow {
|
||||||
@@ -45,6 +46,8 @@ mod imp {
|
|||||||
navigation_view: OnceCell::new(),
|
navigation_view: OnceCell::new(),
|
||||||
library_view: OnceCell::new(),
|
library_view: OnceCell::new(),
|
||||||
database: OnceCell::new(),
|
database: OnceCell::new(),
|
||||||
|
drop_overlay: OnceCell::new(),
|
||||||
|
drop_revealer: OnceCell::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,9 +159,188 @@ impl DriftwoodWindow {
|
|||||||
let navigation_view = adw::NavigationView::new();
|
let navigation_view = adw::NavigationView::new();
|
||||||
navigation_view.push(&library_view.page);
|
navigation_view.push(&library_view.page);
|
||||||
|
|
||||||
// Toast overlay wraps everything
|
// Drop overlay - centered opaque card over a dimmed scrim
|
||||||
|
let drop_overlay_icon = gtk::Image::builder()
|
||||||
|
.icon_name("document-open-symbolic")
|
||||||
|
.pixel_size(64)
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.build();
|
||||||
|
drop_overlay_icon.add_css_class("drop-zone-icon");
|
||||||
|
|
||||||
|
let drop_overlay_title = gtk::Label::builder()
|
||||||
|
.label(&i18n("Add AppImage"))
|
||||||
|
.css_classes(["title-1"])
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let drop_overlay_subtitle = gtk::Label::builder()
|
||||||
|
.label(&i18n("Drop a file here or click to browse"))
|
||||||
|
.css_classes(["body", "dimmed"])
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// The card itself - acts as a clickable button to open file picker
|
||||||
|
let drop_zone_card = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.spacing(16)
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.valign(gtk::Align::Center)
|
||||||
|
.width_request(320)
|
||||||
|
.build();
|
||||||
|
drop_zone_card.add_css_class("drop-zone-card");
|
||||||
|
drop_zone_card.set_cursor_from_name(Some("pointer"));
|
||||||
|
drop_zone_card.append(&drop_overlay_icon);
|
||||||
|
drop_zone_card.append(&drop_overlay_title);
|
||||||
|
drop_zone_card.append(&drop_overlay_subtitle);
|
||||||
|
|
||||||
|
// Click on the card opens file picker (stop propagation so scrim doesn't dismiss)
|
||||||
|
{
|
||||||
|
let window_weak = self.downgrade();
|
||||||
|
let card_click = gtk::GestureClick::new();
|
||||||
|
card_click.connect_pressed(move |gesture, _, _, _| {
|
||||||
|
gesture.set_state(gtk::EventSequenceState::Claimed);
|
||||||
|
let Some(window) = window_weak.upgrade() else { return };
|
||||||
|
window.open_file_picker();
|
||||||
|
});
|
||||||
|
drop_zone_card.add_controller(card_click);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revealer for crossfade animation
|
||||||
|
let drop_revealer = gtk::Revealer::builder()
|
||||||
|
.transition_type(gtk::RevealerTransitionType::Crossfade)
|
||||||
|
.transition_duration(200)
|
||||||
|
.reveal_child(false)
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.valign(gtk::Align::Center)
|
||||||
|
.vexpand(true)
|
||||||
|
.hexpand(true)
|
||||||
|
.build();
|
||||||
|
drop_revealer.set_child(Some(&drop_zone_card));
|
||||||
|
|
||||||
|
// Scrim (dimmed background) that fills the whole window
|
||||||
|
let drop_overlay_content = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.halign(gtk::Align::Fill)
|
||||||
|
.valign(gtk::Align::Fill)
|
||||||
|
.hexpand(true)
|
||||||
|
.vexpand(true)
|
||||||
|
.build();
|
||||||
|
drop_overlay_content.add_css_class("drop-overlay-scrim");
|
||||||
|
drop_overlay_content.append(&drop_revealer);
|
||||||
|
drop_overlay_content.set_visible(false);
|
||||||
|
|
||||||
|
// Click on scrim (outside the card) to dismiss
|
||||||
|
{
|
||||||
|
let overlay_ref = drop_overlay_content.clone();
|
||||||
|
let revealer_ref = drop_revealer.clone();
|
||||||
|
let click = gtk::GestureClick::new();
|
||||||
|
click.connect_pressed(move |gesture, _, _, _| {
|
||||||
|
gesture.set_state(gtk::EventSequenceState::Claimed);
|
||||||
|
revealer_ref.set_reveal_child(false);
|
||||||
|
let overlay_hide = overlay_ref.clone();
|
||||||
|
glib::timeout_add_local_once(
|
||||||
|
std::time::Duration::from_millis(200),
|
||||||
|
move || { overlay_hide.set_visible(false); },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
drop_overlay_content.add_controller(click);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overlay wraps navigation view so the drop indicator sits on top
|
||||||
|
let overlay = gtk::Overlay::new();
|
||||||
|
overlay.set_child(Some(&navigation_view));
|
||||||
|
overlay.add_overlay(&drop_overlay_content);
|
||||||
|
|
||||||
|
// Toast overlay wraps the overlay
|
||||||
let toast_overlay = adw::ToastOverlay::new();
|
let toast_overlay = adw::ToastOverlay::new();
|
||||||
toast_overlay.set_child(Some(&navigation_view));
|
toast_overlay.set_child(Some(&overlay));
|
||||||
|
|
||||||
|
// --- Drag-and-drop support ---
|
||||||
|
let drop_target = gtk::DropTarget::new(gio::File::static_type(), gtk::gdk::DragAction::COPY);
|
||||||
|
|
||||||
|
// Show overlay on drag enter
|
||||||
|
{
|
||||||
|
let drop_indicator = drop_overlay_content.clone();
|
||||||
|
let revealer_ref = drop_revealer.clone();
|
||||||
|
drop_target.connect_enter(move |_target, _x, _y| {
|
||||||
|
drop_indicator.set_visible(true);
|
||||||
|
revealer_ref.set_reveal_child(true);
|
||||||
|
gtk::gdk::DragAction::COPY
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide overlay on drag leave
|
||||||
|
{
|
||||||
|
let drop_indicator = drop_overlay_content.clone();
|
||||||
|
let revealer_ref = drop_revealer.clone();
|
||||||
|
drop_target.connect_leave(move |_target| {
|
||||||
|
revealer_ref.set_reveal_child(false);
|
||||||
|
let overlay_hide = drop_indicator.clone();
|
||||||
|
glib::timeout_add_local_once(
|
||||||
|
std::time::Duration::from_millis(200),
|
||||||
|
move || { overlay_hide.set_visible(false); },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the drop
|
||||||
|
{
|
||||||
|
let drop_indicator = drop_overlay_content.clone();
|
||||||
|
let revealer_ref = drop_revealer.clone();
|
||||||
|
let toast_ref = toast_overlay.clone();
|
||||||
|
let window_weak = self.downgrade();
|
||||||
|
drop_target.connect_drop(move |_target, value, _x, _y| {
|
||||||
|
revealer_ref.set_reveal_child(false);
|
||||||
|
let overlay_hide = drop_indicator.clone();
|
||||||
|
glib::timeout_add_local_once(
|
||||||
|
std::time::Duration::from_millis(200),
|
||||||
|
move || { overlay_hide.set_visible(false); },
|
||||||
|
);
|
||||||
|
|
||||||
|
let file = match value.get::<gio::File>() {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let path = match file.path() {
|
||||||
|
Some(p) => p,
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate it's an AppImage via magic bytes
|
||||||
|
if discovery::detect_appimage(&path).is_none() {
|
||||||
|
toast_ref.add_toast(adw::Toast::new(&i18n("Not a valid AppImage file")));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(window) = window_weak.upgrade() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
let db = window.database().clone();
|
||||||
|
let toast_for_dialog = toast_ref.clone();
|
||||||
|
let window_weak2 = window.downgrade();
|
||||||
|
|
||||||
|
drop_dialog::show_drop_dialog(
|
||||||
|
&window,
|
||||||
|
vec![path],
|
||||||
|
&toast_for_dialog,
|
||||||
|
move || {
|
||||||
|
if let Some(win) = window_weak2.upgrade() {
|
||||||
|
let lib_view = win.imp().library_view.get().unwrap();
|
||||||
|
match db.get_all_appimages() {
|
||||||
|
Ok(records) => lib_view.populate(records),
|
||||||
|
Err(_) => lib_view.set_state(LibraryState::Empty),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toast_overlay.add_controller(drop_target);
|
||||||
|
|
||||||
self.set_content(Some(&toast_overlay));
|
self.set_content(Some(&toast_overlay));
|
||||||
|
|
||||||
@@ -221,6 +403,14 @@ impl DriftwoodWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Store references
|
// Store references
|
||||||
|
self.imp()
|
||||||
|
.drop_overlay
|
||||||
|
.set(drop_overlay_content)
|
||||||
|
.expect("DropOverlay already set");
|
||||||
|
self.imp()
|
||||||
|
.drop_revealer
|
||||||
|
.set(drop_revealer)
|
||||||
|
.expect("DropRevealer already set");
|
||||||
self.imp()
|
self.imp()
|
||||||
.toast_overlay
|
.toast_overlay
|
||||||
.set(toast_overlay)
|
.set(toast_overlay)
|
||||||
@@ -362,6 +552,18 @@ impl DriftwoodWindow {
|
|||||||
})
|
})
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
// Show drop overlay hint (triggered by "Add app" button)
|
||||||
|
let show_drop_hint_action = gio::ActionEntry::builder("show-drop-hint")
|
||||||
|
.activate(|window: &Self, _, _| {
|
||||||
|
if let Some(overlay) = window.imp().drop_overlay.get() {
|
||||||
|
overlay.set_visible(true);
|
||||||
|
if let Some(revealer) = window.imp().drop_revealer.get() {
|
||||||
|
revealer.set_reveal_child(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
|
||||||
self.add_action_entries([
|
self.add_action_entries([
|
||||||
dashboard_action,
|
dashboard_action,
|
||||||
preferences_action,
|
preferences_action,
|
||||||
@@ -373,6 +575,7 @@ impl DriftwoodWindow {
|
|||||||
security_report_action,
|
security_report_action,
|
||||||
cleanup_action,
|
cleanup_action,
|
||||||
shortcuts_action,
|
shortcuts_action,
|
||||||
|
show_drop_hint_action,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// --- Context menu actions (parameterized with record ID) ---
|
// --- Context menu actions (parameterized with record ID) ---
|
||||||
@@ -635,17 +838,16 @@ impl DriftwoodWindow {
|
|||||||
let toast_overlay = self.imp().toast_overlay.get().unwrap().clone();
|
let toast_overlay = self.imp().toast_overlay.get().unwrap().clone();
|
||||||
let window_weak = self.downgrade();
|
let window_weak = self.downgrade();
|
||||||
|
|
||||||
// Run scan in a background thread (opens its own DB connection),
|
// Two-phase scan:
|
||||||
// then update UI on main thread using the window's DB.
|
// Phase 1 (fast): discover files, upsert into DB, mark pending analysis
|
||||||
|
// Phase 2 (background): heavy analysis per file
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
|
// Phase 1: Fast registration
|
||||||
let result = gio::spawn_blocking(move || {
|
let result = gio::spawn_blocking(move || {
|
||||||
let bg_db = Database::open().expect("Failed to open database for scan");
|
let bg_db = Database::open().expect("Failed to open database for scan");
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let discovered = discovery::scan_directories(&dirs);
|
let discovered = discovery::scan_directories(&dirs);
|
||||||
|
|
||||||
// Detect system FUSE status once for all AppImages
|
|
||||||
let fuse_info = fuse::detect_system_fuse();
|
|
||||||
|
|
||||||
let mut new_count = 0i32;
|
let mut new_count = 0i32;
|
||||||
let total = discovered.len() as i32;
|
let total = discovered.len() as i32;
|
||||||
|
|
||||||
@@ -654,6 +856,7 @@ impl DriftwoodWindow {
|
|||||||
let removed_count = removed.len() as i32;
|
let removed_count = removed.len() as i32;
|
||||||
|
|
||||||
let mut skipped_count = 0i32;
|
let mut skipped_count = 0i32;
|
||||||
|
let mut needs_analysis: Vec<(i64, std::path::PathBuf, discovery::AppImageType)> = Vec::new();
|
||||||
|
|
||||||
for d in &discovered {
|
for d in &discovered {
|
||||||
let existing = bg_db
|
let existing = bg_db
|
||||||
@@ -668,15 +871,14 @@ impl DriftwoodWindow {
|
|||||||
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
|
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
|
||||||
});
|
});
|
||||||
|
|
||||||
// Skip re-processing unchanged files (same size + mtime, and all analysis done)
|
// Skip re-processing unchanged files that are fully analyzed.
|
||||||
|
// Trust analysis_status as the primary signal - some AppImages
|
||||||
|
// genuinely don't have app_name or other optional fields.
|
||||||
if let Some(ref ex) = existing {
|
if let Some(ref ex) = existing {
|
||||||
let size_unchanged = ex.size_bytes == d.size_bytes as i64;
|
let size_unchanged = ex.size_bytes == d.size_bytes as i64;
|
||||||
let mtime_unchanged = modified.as_deref() == ex.file_modified.as_deref();
|
let mtime_unchanged = modified.as_deref() == ex.file_modified.as_deref();
|
||||||
let fully_analyzed = ex.app_name.is_some()
|
let analysis_done = ex.analysis_status.as_deref() == Some("complete");
|
||||||
&& ex.fuse_status.is_some()
|
if size_unchanged && mtime_unchanged && analysis_done {
|
||||||
&& ex.wayland_status.is_some()
|
|
||||||
&& ex.sha256.is_some();
|
|
||||||
if size_unchanged && mtime_unchanged && fully_analyzed {
|
|
||||||
skipped_count += 1;
|
skipped_count += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -695,73 +897,13 @@ impl DriftwoodWindow {
|
|||||||
new_count += 1;
|
new_count += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
let needs_metadata = existing
|
// Mark for background analysis
|
||||||
.as_ref()
|
bg_db.update_analysis_status(id, "pending").ok();
|
||||||
.map(|r| r.app_name.is_none())
|
needs_analysis.push((id, d.path.clone(), d.appimage_type.clone()));
|
||||||
.unwrap_or(true);
|
|
||||||
|
|
||||||
if needs_metadata {
|
|
||||||
if let Ok(metadata) = inspector::inspect_appimage(&d.path, &d.appimage_type) {
|
|
||||||
let categories = if metadata.categories.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(metadata.categories.join(";"))
|
|
||||||
};
|
|
||||||
bg_db.update_metadata(
|
|
||||||
id,
|
|
||||||
metadata.app_name.as_deref(),
|
|
||||||
metadata.app_version.as_deref(),
|
|
||||||
metadata.description.as_deref(),
|
|
||||||
metadata.developer.as_deref(),
|
|
||||||
categories.as_deref(),
|
|
||||||
metadata.architecture.as_deref(),
|
|
||||||
metadata.cached_icon_path.as_ref().map(|p| p.to_string_lossy()).as_deref(),
|
|
||||||
Some(&metadata.desktop_entry_content),
|
|
||||||
).ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-AppImage FUSE status
|
|
||||||
let needs_fuse = existing
|
|
||||||
.as_ref()
|
|
||||||
.map(|r| r.fuse_status.is_none())
|
|
||||||
.unwrap_or(true);
|
|
||||||
if needs_fuse {
|
|
||||||
let app_fuse = fuse::determine_app_fuse_status(&fuse_info, &d.path);
|
|
||||||
bg_db.update_fuse_status(id, app_fuse.as_str()).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wayland compatibility analysis (slower - only for new/unanalyzed)
|
|
||||||
let needs_wayland = existing
|
|
||||||
.as_ref()
|
|
||||||
.map(|r| r.wayland_status.is_none())
|
|
||||||
.unwrap_or(true);
|
|
||||||
if needs_wayland {
|
|
||||||
let analysis = wayland::analyze_appimage(&d.path);
|
|
||||||
bg_db.update_wayland_status(id, analysis.status.as_str()).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
// SHA256 hash (for duplicate detection)
|
|
||||||
let needs_hash = existing
|
|
||||||
.as_ref()
|
|
||||||
.map(|r| r.sha256.is_none())
|
|
||||||
.unwrap_or(true);
|
|
||||||
if needs_hash {
|
|
||||||
if let Ok(hash) = crate::core::discovery::compute_sha256(&d.path) {
|
|
||||||
bg_db.update_sha256(id, &hash).ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Discover config/data paths (only for new AppImages)
|
|
||||||
if existing.is_none() {
|
|
||||||
if let Ok(Some(rec)) = bg_db.get_appimage_by_id(id) {
|
|
||||||
crate::core::footprint::discover_and_store(&bg_db, id, &rec);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"Scan complete: {} files, {} new, {} removed, {} skipped (unchanged), took {}ms",
|
"Scan phase 1: {} files, {} new, {} removed, {} skipped (unchanged), took {}ms",
|
||||||
total, new_count, removed_count, skipped_count, start.elapsed().as_millis()
|
total, new_count, removed_count, skipped_count, start.elapsed().as_millis()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -775,12 +917,13 @@ impl DriftwoodWindow {
|
|||||||
duration,
|
duration,
|
||||||
).ok();
|
).ok();
|
||||||
|
|
||||||
(total, new_count)
|
(total, new_count, needs_analysis)
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
if let Ok((total, new_count)) = result {
|
if let Ok((total, new_count, needs_analysis)) = result {
|
||||||
// Refresh the library view from the window's main-thread DB
|
// Refresh the library view immediately (apps appear with "Analyzing..." badge)
|
||||||
|
let window_weak2 = window_weak.clone();
|
||||||
if let Some(window) = window_weak.upgrade() {
|
if let Some(window) = window_weak.upgrade() {
|
||||||
let db = window.database();
|
let db = window.database();
|
||||||
let lib_view = window.imp().library_view.get().unwrap();
|
let lib_view = window.imp().library_view.get().unwrap();
|
||||||
@@ -797,6 +940,50 @@ impl DriftwoodWindow {
|
|||||||
n => format!("{} {} {}", i18n("Found"), n, i18n("new AppImages")),
|
n => format!("{} {} {}", i18n("Found"), n, i18n("new AppImages")),
|
||||||
};
|
};
|
||||||
toast_overlay.add_toast(adw::Toast::new(&msg));
|
toast_overlay.add_toast(adw::Toast::new(&msg));
|
||||||
|
|
||||||
|
// Phase 2: Background analysis per file with debounced UI refresh
|
||||||
|
if !needs_analysis.is_empty() {
|
||||||
|
let pending = Rc::new(std::cell::Cell::new(needs_analysis.len()));
|
||||||
|
let refresh_timer: Rc<std::cell::Cell<Option<glib::SourceId>>> =
|
||||||
|
Rc::new(std::cell::Cell::new(None));
|
||||||
|
|
||||||
|
for (id, path, appimage_type) in needs_analysis {
|
||||||
|
let window_weak3 = window_weak2.clone();
|
||||||
|
let pending = pending.clone();
|
||||||
|
let refresh_timer = refresh_timer.clone();
|
||||||
|
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let _ = gio::spawn_blocking(move || {
|
||||||
|
analysis::run_background_analysis(id, path, appimage_type, false);
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let remaining = pending.get().saturating_sub(1);
|
||||||
|
pending.set(remaining);
|
||||||
|
|
||||||
|
// Debounced refresh: wait 300ms before refreshing UI
|
||||||
|
if let Some(source_id) = refresh_timer.take() {
|
||||||
|
source_id.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
let window_weak4 = window_weak3.clone();
|
||||||
|
let timer_id = glib::timeout_add_local_once(
|
||||||
|
std::time::Duration::from_millis(300),
|
||||||
|
move || {
|
||||||
|
if let Some(window) = window_weak4.upgrade() {
|
||||||
|
let db = window.database();
|
||||||
|
let lib_view = window.imp().library_view.get().unwrap();
|
||||||
|
match db.get_all_appimages() {
|
||||||
|
Ok(records) => lib_view.populate(records),
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
refresh_timer.set(Some(timer_id));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -856,6 +1043,76 @@ impl DriftwoodWindow {
|
|||||||
dialog.present(Some(self));
|
dialog.present(Some(self));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn dismiss_drop_overlay(&self) {
|
||||||
|
if let Some(revealer) = self.imp().drop_revealer.get() {
|
||||||
|
revealer.set_reveal_child(false);
|
||||||
|
}
|
||||||
|
if let Some(overlay) = self.imp().drop_overlay.get() {
|
||||||
|
let overlay = overlay.clone();
|
||||||
|
glib::timeout_add_local_once(
|
||||||
|
std::time::Duration::from_millis(200),
|
||||||
|
move || { overlay.set_visible(false); },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_file_picker(&self) {
|
||||||
|
self.dismiss_drop_overlay();
|
||||||
|
|
||||||
|
let filter = gtk::FileFilter::new();
|
||||||
|
filter.set_name(Some("AppImage files"));
|
||||||
|
filter.add_pattern("*.AppImage");
|
||||||
|
filter.add_pattern("*.appimage");
|
||||||
|
// Also accept any file (AppImages don't always have the extension)
|
||||||
|
let all_filter = gtk::FileFilter::new();
|
||||||
|
all_filter.set_name(Some("All files"));
|
||||||
|
all_filter.add_pattern("*");
|
||||||
|
|
||||||
|
let filters = gio::ListStore::new::<gtk::FileFilter>();
|
||||||
|
filters.append(&filter);
|
||||||
|
filters.append(&all_filter);
|
||||||
|
|
||||||
|
let dialog = gtk::FileDialog::builder()
|
||||||
|
.title(i18n("Choose an AppImage"))
|
||||||
|
.filters(&filters)
|
||||||
|
.default_filter(&filter)
|
||||||
|
.modal(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let window_weak = self.downgrade();
|
||||||
|
dialog.open(Some(self), None::<&gio::Cancellable>, move |result| {
|
||||||
|
let Ok(file) = result else { return };
|
||||||
|
let Some(path) = file.path() else { return };
|
||||||
|
let Some(window) = window_weak.upgrade() else { return };
|
||||||
|
|
||||||
|
// Validate it's an AppImage via magic bytes
|
||||||
|
if discovery::detect_appimage(&path).is_none() {
|
||||||
|
let toast_overlay = window.imp().toast_overlay.get().unwrap();
|
||||||
|
toast_overlay.add_toast(adw::Toast::new(&i18n("Not a valid AppImage file")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let db = window.database().clone();
|
||||||
|
let toast_overlay = window.imp().toast_overlay.get().unwrap().clone();
|
||||||
|
let window_weak2 = window.downgrade();
|
||||||
|
|
||||||
|
drop_dialog::show_drop_dialog(
|
||||||
|
&window,
|
||||||
|
vec![path],
|
||||||
|
&toast_overlay,
|
||||||
|
move || {
|
||||||
|
if let Some(win) = window_weak2.upgrade() {
|
||||||
|
let lib_view = win.imp().library_view.get().unwrap();
|
||||||
|
match db.get_all_appimages() {
|
||||||
|
Ok(records) => lib_view.populate(records),
|
||||||
|
Err(_) => lib_view.set_state(LibraryState::Empty),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn save_window_state(&self) {
|
fn save_window_state(&self) {
|
||||||
let settings = self.settings();
|
let settings = self.settings();
|
||||||
let (width, height) = self.default_size();
|
let (width, height) = self.default_size();
|
||||||
|
|||||||
Reference in New Issue
Block a user