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:
lashman
2026-02-27 17:16:41 +02:00
parent a7ed3742fb
commit 423323d5a9
51 changed files with 10583 additions and 481 deletions

137
CONTRIBUTING.md Normal file
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -42,5 +42,11 @@ env_logger = "0.11"
# Temp directories (for AppImage extraction)
tempfile = "3"
# Desktop notifications
notify-rust = "4"
# File system watching (inotify)
notify = "7"
[build-dependencies]
glib-build-tools = "0.22"

100
README.md Normal file
View 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

View 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": ".."
}
]
}
]
}

View File

@@ -33,4 +33,12 @@ fn main() {
"cargo::rustc-env=GSETTINGS_SCHEMA_DIR={}",
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()
);
}

View File

@@ -31,5 +31,60 @@
<summary>Color scheme</summary>
<description>Application color scheme: default (follow system), force-light, or force-dark.</description>
</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>
</schemalist>

View 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

File diff suppressed because it is too large Load Diff

246
docs/USER-GUIDE.md Normal file
View 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

View 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

File diff suppressed because it is too large Load Diff

68
meson.build Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
i18n.gettext('app.driftwood.Driftwood', preset: 'glib')

View File

@@ -59,6 +59,17 @@ pub enum Commands {
/// Path to the AppImage
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 {
@@ -81,6 +92,8 @@ pub fn run_command(command: Commands) -> ExitCode {
Commands::CheckUpdates => cmd_check_updates(&db),
Commands::Duplicates => cmd_duplicates(&db),
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
}

View File

@@ -1,3 +1,4 @@
pub const APP_ID: &str = "app.driftwood.Driftwood";
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
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
View 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
View 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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
// --- 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 &amp; world");
assert_eq!(xml_escape("<tag>"), "&lt;tag&gt;");
assert_eq!(xml_escape("it's \"quoted\""), "it&apos;s &quot;quoted&quot;");
}
#[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
View 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
View 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());
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -48,7 +48,7 @@ pub fn expand_tilde(path: &str) -> PathBuf {
/// ELF magic at offset 0: 0x7F 'E' 'L' 'F'
/// AppImage Type 2 at offset 8: 'A' 'I' 0x02
/// 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 header = [0u8; 16];
file.read_exact(&mut header).ok()?;
@@ -153,6 +153,15 @@ pub fn scan_directories(dirs: &[String]) -> Vec<DiscoveredAppImage> {
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)]
mod tests {
use super::*;

View File

@@ -405,6 +405,14 @@ mod tests {
update_checked: None,
update_url: 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!(

479
src/core/footprint.rs Normal file
View 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);
}
}

View File

@@ -261,6 +261,14 @@ mod tests {
update_checked: None,
update_url: 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,

View File

@@ -4,6 +4,36 @@ use std::process::{Child, Command, Stdio};
use super::database::Database;
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.
#[derive(Debug, Clone, PartialEq)]
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.
pub fn has_firejail() -> bool {
Command::new("firejail")

View File

@@ -1,10 +1,17 @@
pub mod analysis;
pub mod backup;
pub mod database;
pub mod discovery;
pub mod duplicates;
pub mod footprint;
pub mod fuse;
pub mod inspector;
pub mod integrator;
pub mod launcher;
pub mod notification;
pub mod orphan;
pub mod report;
pub mod security;
pub mod updater;
pub mod watcher;
pub mod wayland;

203
src/core/notification.rs Normal file
View 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(&notif).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
View 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
View 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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
/// 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>&"), "&lt;script&gt;&amp;");
}
}

405
src/core/sandbox.rs Normal file
View 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
View 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
View 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,
}
}

View File

@@ -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.
pub fn has_xwayland() -> bool {
// Check if Xwayland process is running

34
src/i18n.rs Normal file
View 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
}

View File

@@ -2,6 +2,7 @@ mod application;
mod cli;
mod config;
mod core;
mod i18n;
mod ui;
mod window;

View File

@@ -2,7 +2,6 @@ use gtk::prelude::*;
use gtk::accessible::Property as AccessibleProperty;
use crate::core::database::AppImageRecord;
use crate::core::fuse::FuseStatus;
use crate::core::wayland::WaylandStatus;
use super::widgets;
@@ -11,25 +10,20 @@ pub fn build_app_card(record: &AppImageRecord) -> gtk::FlowBoxChild {
let card = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(6)
.margin_top(14)
.margin_bottom(14)
.margin_start(14)
.margin_end(14)
.halign(gtk::Align::Center)
.halign(gtk::Align::Fill)
.build();
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);
// Icon (64x64) with integration emblem overlay
let icon_widget = widgets::app_icon(
record.icon_path.as_deref(),
name,
72,
64,
);
icon_widget.add_css_class("icon-dropshadow");
// If integrated, overlay a small checkmark emblem
if record.integrated {
let overlay = gtk::Overlay::new();
overlay.set_child(Some(&icon_widget));
@@ -47,13 +41,14 @@ pub fn build_app_card(record: &AppImageRecord) -> gtk::FlowBoxChild {
card.append(&icon_widget);
}
// App name - .title-3 for more visual weight
// App name
let name_label = gtk::Label::builder()
.label(name)
.css_classes(["title-3"])
.css_classes(["title-4"])
.ellipsize(gtk::pango::EllipsizeMode::End)
.max_width_chars(20)
.build();
card.append(&name_label);
// Version + size combined on one line
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() {
size_text
} else {
format!("{} - {}", version_text, size_text)
format!("{} - {}", version_text, size_text)
};
let meta_label = gtk::Label::builder()
.label(&meta_text)
.css_classes(["caption", "dimmed", "numeric"])
.ellipsize(gtk::pango::EllipsizeMode::End)
.build();
card.append(&name_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)
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()
.orientation(gtk::Orientation::Horizontal)
.halign(gtk::Align::Center)
.margin_top(4)
.margin_top(2)
.build();
badge_box.append(&badge);
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()
.child(&card)
.build();
@@ -95,9 +118,14 @@ pub fn build_app_card(record: &AppImageRecord) -> gtk::FlowBoxChild {
child
}
/// Return the single most important badge for a card.
/// Priority: Update available > FUSE issue > Wayland issue.
/// Return the single most important badge for a record.
/// Priority: Analyzing > Update available > FUSE issue > Wayland issue.
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)
if let (Some(ref latest), Some(ref current)) = (&record.latest_version, &record.app_version) {
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
// 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 {
let status = FuseStatus::from_str(fs);
if !status.is_functional() {
return Some(widgets::status_badge(status.label(), status.badge_class()));
let is_ok = matches!(
fs.as_str(),
"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 {
let status = WaylandStatus::from_str(ws);
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"));
}
}

View File

@@ -7,6 +7,7 @@ use crate::core::database::Database;
use crate::core::duplicates;
use crate::core::footprint;
use crate::core::orphan;
use crate::i18n::{i18n, i18n_f};
use super::widgets;
/// A reclaimable item discovered during analysis.
@@ -28,11 +29,11 @@ enum ReclaimCategory {
}
impl ReclaimCategory {
fn label(&self) -> &'static str {
fn label(&self) -> String {
match self {
ReclaimCategory::OrphanedDesktopEntry => "Orphaned desktop entries",
ReclaimCategory::CacheData => "Cache data",
ReclaimCategory::DuplicateAppImage => "Duplicate AppImages",
ReclaimCategory::OrphanedDesktopEntry => i18n("Orphaned desktop entries"),
ReclaimCategory::CacheData => i18n("Cache data"),
ReclaimCategory::DuplicateAppImage => i18n("Duplicate AppImages"),
}
}
@@ -48,7 +49,7 @@ impl ReclaimCategory {
/// Show the disk space reclamation wizard as an AdwDialog.
pub fn show_cleanup_wizard(parent: &impl IsA<gtk::Widget>, _db: &Rc<Database>) {
let dialog = adw::Dialog::builder()
.title("Disk Space Cleanup")
.title(&i18n("Disk Space Cleanup"))
.content_width(500)
.content_height(550)
.build();
@@ -118,8 +119,8 @@ pub fn show_cleanup_wizard(parent: &impl IsA<gtk::Widget>, _db: &Rc<Database>) {
Err(_) => {
let error_page = adw::StatusPage::builder()
.icon_name("dialog-error-symbolic")
.title("Analysis Failed")
.description("Could not analyze disk usage.")
.title(&i18n("Analysis Failed"))
.description(&i18n("Could not analyze disk usage."))
.build();
if let Some(child) = stack_ref.child_by_name("review") {
stack_ref.remove(&child);
@@ -148,13 +149,13 @@ fn build_analysis_step() -> gtk::Box {
page.append(&spinner);
let label = gtk::Label::builder()
.label("Analyzing disk usage...")
.label(&i18n("Analyzing disk usage..."))
.css_classes(["title-3"])
.build();
page.append(&label);
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"])
.build();
page.append(&subtitle);
@@ -178,8 +179,8 @@ fn build_review_step(
if items_ref.is_empty() {
let empty = adw::StatusPage::builder()
.icon_name("emblem-ok-symbolic")
.title("All Clean")
.description("No reclaimable disk space found.")
.title(&i18n("All Clean"))
.description(&i18n("No reclaimable disk space found."))
.vexpand(true)
.build();
page.append(&empty);
@@ -189,7 +190,7 @@ fn build_review_step(
// Summary header
let total_size: u64 = items_ref.iter().map(|i| i.size_bytes).sum();
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"])
.margin_top(12)
.margin_start(18)
@@ -199,7 +200,7 @@ fn build_review_step(
page.append(&summary_label);
let desc_label = gtk::Label::builder()
.label("Select items to remove")
.label(&i18n("Select items to remove"))
.css_classes(["dimmed"])
.margin_start(18)
.margin_end(18)
@@ -251,8 +252,9 @@ fn build_review_step(
let cat_icon = gtk::Image::from_icon_name(cat.icon_name());
cat_icon.set_pixel_size(16);
cat_header.append(&cat_icon);
let cat_label_text = cat.label();
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"])
.halign(gtk::Align::Start)
.build();
@@ -263,7 +265,7 @@ fn build_review_step(
list_box.add_css_class("boxed-list");
list_box.set_selection_mode(gtk::SelectionMode::None);
list_box.update_property(&[
gtk::accessible::Property::Label(cat.label()),
gtk::accessible::Property::Label(&cat_label_text),
]);
for (idx, item) in &cat_items {
@@ -305,11 +307,11 @@ fn build_review_step(
.build();
let clean_button = gtk::Button::builder()
.label("Clean Selected")
.label(&i18n("Clean Selected"))
.build();
clean_button.add_css_class("destructive-action");
clean_button.update_property(&[
gtk::accessible::Property::Label("Clean selected items"),
gtk::accessible::Property::Label(&i18n("Clean selected items")),
]);
let items_clone = items.clone();
@@ -337,17 +339,16 @@ fn build_review_step(
}
let confirm = adw::AlertDialog::builder()
.heading("Confirm Cleanup")
.body(&format!(
"Remove {} item{}?",
count,
if count == 1 { "" } else { "s" }
.heading(&i18n("Confirm Cleanup"))
.body(&i18n_f(
"Remove {} items?",
&[("{}", &count.to_string())],
))
.close_response("cancel")
.default_response("clean")
.build();
confirm.add_response("cancel", "Cancel");
confirm.add_response("clean", "Clean");
confirm.add_response("cancel", &i18n("Cancel"));
confirm.add_response("clean", &i18n("Clean"));
confirm.set_response_appearance("clean", adw::ResponseAppearance::Destructive);
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 {
let status = adw::StatusPage::builder()
.icon_name("emblem-ok-symbolic")
.title("Nothing Selected")
.description("No items were selected for cleanup.")
.title(&i18n("Nothing Selected"))
.description(&i18n("No items were selected for cleanup."))
.build();
page.append(&status);
} else {
let status = adw::StatusPage::builder()
.icon_name("user-trash-symbolic")
.title("Cleanup Complete")
.description(&format!(
"Removed {} item{}, freeing {}",
count,
if count == 1 { "" } else { "s" },
widgets::format_size(size as i64),
.title(&i18n("Cleanup Complete"))
.description(&i18n_f(
"Removed {count} items, freeing {size}",
&[
("{count}", &count.to_string()),
("{size}", &widgets::format_size(size as i64)),
],
))
.build();
page.append(&status);
}
let close_button = gtk::Button::builder()
.label("Close")
.label(&i18n("Close"))
.halign(gtk::Align::Center)
.build();
close_button.add_css_class("pill");
close_button.update_property(&[
gtk::accessible::Property::Label("Close cleanup dialog"),
gtk::accessible::Property::Label(&i18n("Close cleanup dialog")),
]);
let dialog_ref = dialog.clone();

View File

@@ -22,32 +22,15 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::Nav
// Toast overlay for copy actions
let toast_overlay = adw::ToastOverlay::new();
// Main content container
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
// ViewStack for tabbed content
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
let overview_page = build_overview_tab(record, db);
view_stack.add_titled(&overview_page, Some("overview"), "Overview");
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.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.page(&storage_page).set_icon_name(Some("drive-harddisk-symbolic"));
// Scrollable area for tab content
// Scrollable view stack
let scrolled = gtk::ScrolledWindow::builder()
.child(&view_stack)
.vexpand(true)
.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);
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 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()
.label("Launch")
.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();
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 path_clone = path.clone();
glib::spawn_future_local(async move {
// Wait 3 seconds for the process to initialize
glib::timeout_future(std::time::Duration::from_secs(3)).await;
let analysis_result = gio::spawn_blocking(move || {
@@ -165,7 +159,10 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::Nav
.build()
}
/// Rich banner at top: large icon + app name + version + badges
// ---------------------------------------------------------------------------
// Banner
// ---------------------------------------------------------------------------
fn build_banner(record: &AppImageRecord) -> gtk::Box {
let banner = gtk::Box::builder()
.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);
// Large icon (96x96) with drop shadow
// Large icon with drop shadow
let icon = widgets::app_icon(record.icon_path.as_deref(), name, 96);
icon.set_valign(gtk::Align::Start);
icon.add_css_class("icon-dropshadow");
@@ -259,7 +256,10 @@ fn build_banner(record: &AppImageRecord) -> gtk::Box {
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 {
let tab = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
@@ -283,6 +283,7 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
// Updates section
let updates_group = adw::PreferencesGroup::builder()
.title("Updates")
.description("Keep this app up to date by checking for new versions.")
.build();
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");
let row = adw::ActionRow::builder()
.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();
updates_group.add(&row);
} else {
let row = adw::ActionRow::builder()
.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();
let badge = widgets::status_badge("None", "neutral");
let badge = widgets::status_badge("Manual only", "neutral");
badge.set_valign(gtk::Align::Center);
row.add_suffix(&badge);
updates_group.add(&row);
@@ -314,9 +331,9 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
if is_newer {
let subtitle = format!(
"{} -> {}",
"A newer version is available: {} (you have {})",
latest,
record.app_version.as_deref().unwrap_or("unknown"),
latest
);
let row = adw::ActionRow::builder()
.title("Update available")
@@ -328,8 +345,8 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
updates_group.add(&row);
} else {
let row = adw::ActionRow::builder()
.title("Status")
.subtitle("Up to date")
.title("Version status")
.subtitle("You are running the latest version.")
.build();
let badge = widgets::status_badge("Latest", "success");
badge.set_valign(gtk::Align::Center);
@@ -375,20 +392,29 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
.build();
let type_str = match record.appimage_type {
Some(1) => "Type 1",
Some(2) => "Type 2",
_ => "Unknown",
Some(1) => "Type 1 (ISO 9660) - older format, still widely supported",
Some(2) => "Type 2 (SquashFS) - modern format, most common today",
_ => "Unknown type",
};
let type_row = adw::ActionRow::builder()
.title("AppImage type")
.title("AppImage format")
.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();
info_group.add(&type_row);
let exec_row = adw::ActionRow::builder()
.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();
info_group.add(&exec_row);
@@ -420,8 +446,11 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
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()
.orientation(gtk::Orientation::Vertical)
.spacing(24)
@@ -444,13 +473,21 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
// Desktop Integration group
let integration_group = adw::PreferencesGroup::builder()
.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();
let switch_row = adw::SwitchRow::builder()
.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)
.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();
let record_id = record.id;
@@ -501,8 +538,11 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
// Runtime Compatibility group
let compat_group = adw::PreferencesGroup::builder()
.title("Runtime Compatibility")
.description("Wayland support and FUSE status")
.title("Compatibility")
.description(
"How well this app works with your display server and filesystem. \
Most issues here can be resolved with a small package install."
)
.build();
let wayland_status = record
@@ -512,20 +552,31 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
.unwrap_or(WaylandStatus::Unknown);
let wayland_row = adw::ActionRow::builder()
.title("Wayland")
.subtitle(wayland_description(&wayland_status))
.tooltip_text("Display protocol for Linux desktops")
.title("Wayland display")
.subtitle(wayland_user_explanation(&wayland_status))
.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();
let wayland_badge = widgets::status_badge(wayland_status.label(), wayland_status.badge_class());
wayland_badge.set_valign(gtk::Align::Center);
wayland_row.add_suffix(&wayland_badge);
compat_group.add(&wayland_row);
// Wayland analyze button
// Analyze toolkit button
let analyze_row = adw::ActionRow::builder()
.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)
.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();
let analyze_icon = gtk::Image::from_icon_name("system-search-symbolic");
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 lib_count = analysis.libraries_found.len();
row_clone.set_subtitle(&format!(
"Toolkit: {} ({} libraries scanned)",
"Detected: {} ({} libraries scanned)",
toolkit_label, lib_count,
));
}
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)
if let Some(ref runtime_status) = record.runtime_wayland_status {
let runtime_row = adw::ActionRow::builder()
.title("Runtime display protocol")
.subtitle(runtime_status)
.title("Last observed protocol")
.subtitle(&format!(
"When this app was last launched, it used: {}",
runtime_status
))
.build();
if let Some(ref checked) = record.runtime_wayland_checked {
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);
}
// FUSE status
let fuse_system = fuse::detect_system_fuse();
let fuse_status = record
.fuse_status
@@ -589,9 +644,14 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
.unwrap_or(fuse_system.status.clone());
let fuse_row = adw::ActionRow::builder()
.title("FUSE")
.subtitle(fuse_description(&fuse_status))
.tooltip_text("Filesystem in Userspace - required for mounting AppImages")
.title("FUSE (filesystem)")
.subtitle(fuse_user_explanation(&fuse_status))
.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();
let fuse_badge = widgets::status_badge_with_icon(
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_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(&copy_btn);
}
compat_group.add(&fuse_row);
// Per-app FUSE launch method
// Per-app launch method
let appimage_path = std::path::Path::new(&record.path);
let app_fuse_status = fuse::determine_app_fuse_status(&fuse_system, appimage_path);
let launch_method_row = adw::ActionRow::builder()
.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();
let launch_badge = widgets::status_badge(
fuse_system.status.as_str(),
@@ -621,7 +696,10 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
// Sandboxing group
let sandbox_group = adw::PreferencesGroup::builder()
.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();
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 sandbox_subtitle = if firejail_available {
format!("Current mode: {}", current_mode.label())
format!(
"Isolate this app using Firejail. Current mode: {}",
current_mode.display_label()
)
} 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()
.title("Firejail sandbox")
.subtitle(&sandbox_subtitle)
.tooltip_text("Linux application sandboxing tool")
.active(current_mode == SandboxMode::Firejail)
.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();
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);
if !firejail_available {
let firejail_cmd = "sudo apt install firejail";
let info_row = adw::ActionRow::builder()
.title("Install Firejail")
.subtitle("sudo apt install firejail")
.subtitle(firejail_cmd)
.build();
let badge = widgets::status_badge("Missing", "warning");
badge.set_valign(gtk::Align::Center);
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(&copy_btn);
sandbox_group.add(&info_row);
}
inner.append(&sandbox_group);
@@ -677,7 +768,10 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
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 {
let tab = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
@@ -700,7 +794,10 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
let group = adw::PreferencesGroup::builder()
.title("Vulnerability Scanning")
.description("Check bundled libraries for known CVEs")
.description(
"Scan the libraries bundled inside this AppImage for known \
security vulnerabilities (CVEs)."
)
.build();
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() {
let row = adw::ActionRow::builder()
.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();
let badge = widgets::status_badge("Not scanned", "neutral");
badge.set_valign(gtk::Align::Center);
@@ -718,14 +818,17 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
} else {
let lib_row = adw::ActionRow::builder()
.title("Bundled libraries")
.subtitle(&libs.len().to_string())
.subtitle(&format!(
"{} libraries detected inside this AppImage",
libs.len()
))
.build();
group.add(&lib_row);
if summary.total() == 0 {
let row = adw::ActionRow::builder()
.title("Vulnerabilities")
.subtitle("No known vulnerabilities")
.subtitle("No known security issues found in the bundled libraries.")
.build();
let badge = widgets::status_badge("Clean", "success");
badge.set_valign(gtk::Align::Center);
@@ -734,7 +837,11 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
} else {
let row = adw::ActionRow::builder()
.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();
let badge = widgets::status_badge(summary.max_severity(), summary.badge_class());
badge.set_valign(gtk::Align::Center);
@@ -745,9 +852,16 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
// Scan button
let scan_row = adw::ActionRow::builder()
.title("Scan this AppImage")
.subtitle("Check bundled libraries for known CVEs")
.title("Run security scan")
.subtitle("Check bundled libraries against known CVE databases")
.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();
let scan_icon = gtk::Image::from_icon_name("security-medium-symbolic");
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| {
row.set_sensitive(false);
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 path = record_path.clone();
glib::spawn_future_local(async move {
@@ -775,15 +889,17 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
Ok(scan_result) => {
let total = scan_result.total_cves();
if total == 0 {
row_clone.set_subtitle("No vulnerabilities found");
row_clone.set_subtitle("No vulnerabilities found - looking good!");
} else {
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(_) => {
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() {
let integrity_group = adw::PreferencesGroup::builder()
.title("Integrity")
.description("Verify that the file has not been modified or corrupted.")
.build();
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")
.subtitle(hash)
.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();
hash_row.add_css_class("property");
integrity_group.add(&hash_row);
@@ -815,7 +937,10 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
tab
}
/// Tab 4: Storage - disk usage and data discovery
// ---------------------------------------------------------------------------
// Tab 4: Storage - disk usage, data paths, file location
// ---------------------------------------------------------------------------
fn build_storage_tab(
record: &AppImageRecord,
db: &Rc<Database>,
@@ -843,12 +968,16 @@ fn build_storage_tab(
// Disk usage group
let size_group = adw::PreferencesGroup::builder()
.title("Disk Usage")
.description(
"Disk space used by this app, including any configuration, \
cache, or data files it may have created."
)
.build();
let fp = footprint::get_footprint(db, record.id, record.size_bytes as u64);
let appimage_row = adw::ActionRow::builder()
.title("AppImage file size")
.title("AppImage file")
.subtitle(&widgets::format_size(record.size_bytes))
.build();
size_group.add(&appimage_row);
@@ -857,9 +986,9 @@ fn build_storage_tab(
let data_total = fp.data_total();
if data_total > 0 {
let total_row = adw::ActionRow::builder()
.title("Total disk footprint")
.title("Total disk usage")
.subtitle(&format!(
"{} (AppImage) + {} (data) = {}",
"{} (AppImage) + {} (app data) = {}",
widgets::format_size(record.size_bytes),
widgets::format_size(data_total as i64),
widgets::format_size(fp.total_size() as i64),
@@ -872,14 +1001,14 @@ fn build_storage_tab(
// Data paths group
let paths_group = adw::PreferencesGroup::builder()
.title("Data Paths")
.description("Config, data, and cache directories for this app")
.title("App Data")
.description("Config, cache, and data directories this app may have created.")
.build();
// Discover button
let discover_row = adw::ActionRow::builder()
.title("Discover data paths")
.subtitle("Search for config, data, and cache directories")
.title("Find app data")
.subtitle("Search for config, cache, and data directories")
.activatable(true)
.build();
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;
discover_row.connect_activated(move |row| {
row.set_sensitive(false);
row.set_subtitle("Discovering...");
row.set_subtitle("Searching...");
let row_clone = row.clone();
let rec = record_clone.clone();
glib::spawn_future_local(async move {
@@ -906,10 +1035,10 @@ fn build_storage_tab(
Ok(fp) => {
let count = fp.paths.len();
if count == 0 {
row_clone.set_subtitle("No associated paths found");
row_clone.set_subtitle("No associated data directories found");
} else {
row_clone.set_subtitle(&format!(
"Found {} path{} ({})",
"Found {} path{} using {}",
count,
if count == 1 { "" } else { "s" },
widgets::format_size(fp.data_total() as i64),
@@ -917,14 +1046,14 @@ fn build_storage_tab(
}
}
Err(_) => {
row_clone.set_subtitle("Discovery failed");
row_clone.set_subtitle("Search failed");
}
}
});
});
paths_group.add(&discover_row);
// Individual discovered paths with type icons and confidence badges
// Individual discovered paths
for dp in &fp.paths {
if dp.exists {
let row = adw::ActionRow::builder()
@@ -998,22 +1127,51 @@ fn build_storage_tab(
tab
}
fn wayland_description(status: &WaylandStatus) -> &'static str {
// ---------------------------------------------------------------------------
// User-friendly explanations
// ---------------------------------------------------------------------------
fn wayland_user_explanation(status: &WaylandStatus) -> &'static str {
match status {
WaylandStatus::Native => "Runs natively on Wayland",
WaylandStatus::XWayland => "Runs via XWayland compatibility layer",
WaylandStatus::Possible => "May run on Wayland with additional flags",
WaylandStatus::X11Only => "X11 only - no Wayland support",
WaylandStatus::Unknown => "Could not determine Wayland compatibility",
WaylandStatus::Native =>
"Runs natively on Wayland - the best experience on modern Linux desktops.",
WaylandStatus::XWayland =>
"Uses XWayland for display. Works fine, but may appear slightly \
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 {
FuseStatus::FullyFunctional => "FUSE mount available - native AppImage launch",
FuseStatus::Fuse3Only => "Only FUSE3 installed - may need libfuse2",
FuseStatus::NoFusermount => "fusermount binary not found",
FuseStatus::NoDevFuse => "/dev/fuse device not available",
FuseStatus::MissingLibfuse2 => "libfuse2 not installed",
FuseStatus::FullyFunctional =>
"FUSE is working - AppImages mount directly for fast startup.",
FuseStatus::Fuse3Only =>
"Only FUSE 3 found. Some AppImages need FUSE 2. \
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
View 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)
}

View File

@@ -5,6 +5,7 @@ use std::rc::Rc;
use crate::core::database::Database;
use crate::core::duplicates::{self, DuplicateGroup, MatchReason, MemberRecommendation};
use crate::core::integrator;
use crate::i18n::{i18n, i18n_f, ni18n_f};
use super::widgets;
/// Show a dialog listing duplicate/multi-version AppImages with resolution options.
@@ -17,10 +18,10 @@ pub fn show_duplicate_dialog(
if groups.is_empty() {
let dialog = adw::AlertDialog::builder()
.heading("No Duplicates Found")
.body("No duplicate or multi-version AppImages were detected.")
.heading(&i18n("No Duplicates Found"))
.body(&i18n("No duplicate or multi-version AppImages were detected."))
.build();
dialog.add_response("ok", "OK");
dialog.add_response("ok", &i18n("OK"));
dialog.set_default_response(Some("ok"));
dialog.present(Some(parent));
return;
@@ -29,7 +30,7 @@ pub fn show_duplicate_dialog(
let summary = duplicates::summarize_duplicates(&groups);
let dialog = adw::Dialog::builder()
.title("Duplicates & Old Versions")
.title(&i18n("Duplicates & Old Versions"))
.content_width(600)
.content_height(500)
.build();
@@ -39,12 +40,12 @@ pub fn show_duplicate_dialog(
// "Remove All Suggested" bulk action button
let bulk_btn = gtk::Button::builder()
.label("Remove All Suggested")
.tooltip_text("Delete all items recommended for removal")
.label(&i18n("Remove All Suggested"))
.tooltip_text(&i18n("Delete all items recommended for removal"))
.build();
bulk_btn.add_css_class("destructive-action");
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);
@@ -64,13 +65,14 @@ pub fn show_duplicate_dialog(
.build();
// Summary banner
let summary_text = format!(
"{} groups found ({} exact duplicates, {} with multiple versions). \
Potential savings: {}",
summary.total_groups,
summary.exact_duplicates,
summary.multi_version,
widgets::format_size(summary.total_potential_savings as i64),
let summary_text = i18n_f(
"{groups} groups found ({exact} exact duplicates, {multi} with multiple versions). Potential savings: {savings}",
&[
("{groups}", &summary.total_groups.to_string()),
("{exact}", &summary.exact_duplicates.to_string()),
("{multi}", &summary.multi_version.to_string()),
("{savings}", &widgets::format_size(summary.total_potential_savings as i64)),
],
);
let summary_label = gtk::Label::builder()
.label(&summary_text)
@@ -101,13 +103,17 @@ pub fn show_duplicate_dialog(
return;
}
let plural = if count == 1 { "" } else { "s" };
let confirm = adw::AlertDialog::builder()
.heading("Confirm Removal")
.body(&format!("Remove {} suggested duplicate{}?", count, plural))
.heading(&i18n("Confirm Removal"))
.body(&ni18n_f(
"Remove {count} suggested duplicate?",
"Remove {count} suggested duplicates?",
count as u32,
&[("{count}", &count.to_string())],
))
.build();
confirm.add_response("cancel", "Cancel");
confirm.add_response("remove", "Remove");
confirm.add_response("cancel", &i18n("Cancel"));
confirm.add_response("remove", &i18n("Remove"));
confirm.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
confirm.set_default_response(Some("cancel"));
confirm.set_close_response("cancel");
@@ -136,9 +142,14 @@ pub fn show_duplicate_dialog(
removed_count += 1;
}
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_label("Done");
btn_ref.set_label(&i18n("Done"));
}
});
@@ -158,23 +169,27 @@ fn build_group_widget(
toast_overlay: &adw::ToastOverlay,
) -> (adw::PreferencesGroup, Vec<(i64, String, String, bool)>) {
let reason_text = match group.match_reason {
MatchReason::ExactDuplicate => "Exact duplicate",
MatchReason::MultiVersion => "Multiple versions",
MatchReason::SameVersionDifferentPath => "Same version, different path",
MatchReason::ExactDuplicate => i18n("Exact duplicate"),
MatchReason::MultiVersion => i18n("Multiple versions"),
MatchReason::SameVersionDifferentPath => i18n("Same version, different path"),
};
let description = if group.potential_savings > 0 {
format!(
"{} - Total: {} - Potential savings: {}",
reason_text,
widgets::format_size(group.total_size as i64),
widgets::format_size(group.potential_savings as i64),
i18n_f(
"{reason} - Total: {total} - Potential savings: {savings}",
&[
("{reason}", &reason_text),
("{total}", &widgets::format_size(group.total_size as i64)),
("{savings}", &widgets::format_size(group.potential_savings as i64)),
],
)
} else {
format!(
"{} - Total: {}",
reason_text,
widgets::format_size(group.total_size as i64),
i18n_f(
"{reason} - Total: {total}",
&[
("{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 {
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 title = if member.is_recommended {
format!("{} ({}) - Recommended", version, size)
i18n_f("{version} ({size}) - Recommended", &[("{version}", version), ("{size}", &size)])
} else {
format!("{} ({})", version, size)
};
@@ -225,7 +241,7 @@ fn build_group_widget(
let delete_btn = gtk::Button::builder()
.icon_name("user-trash-symbolic")
.tooltip_text("Delete this AppImage")
.tooltip_text(&i18n("Delete this AppImage"))
.css_classes(["flat", "circular"])
.valign(gtk::Align::Center)
.build();
@@ -235,7 +251,7 @@ fn build_group_widget(
let record_path = record.path.clone();
let record_name = record.app_name.clone().unwrap_or_else(|| record.filename.clone());
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 toast_ref = toast_overlay.clone();
@@ -253,7 +269,7 @@ fn build_group_widget(
db_ref.remove_appimage(record_id).ok();
// Update UI
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);

View File

@@ -5,6 +5,7 @@ use crate::core::database::{AppImageRecord, Database};
use crate::core::fuse::FuseStatus;
use crate::core::integrator;
use crate::core::wayland::WaylandStatus;
use crate::i18n::{i18n, i18n_f};
use super::widgets;
/// 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 dialog = adw::AlertDialog::builder()
.heading(&format!("Integrate {}?", name))
.body("This will add the application to your desktop menu.")
.heading(&i18n_f("Integrate {name}?", &[("{name}", name)]))
.body(&i18n("This will add the application to your desktop menu."))
.close_response("cancel")
.default_response("integrate")
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("integrate", "Integrate");
dialog.add_response("cancel", &i18n("Cancel"));
dialog.add_response("integrate", &i18n("Integrate"));
dialog.set_response_appearance("integrate", adw::ResponseAppearance::Suggested);
// Build extra content with details
@@ -40,12 +41,12 @@ pub fn show_integration_dialog(
identity_box.add_css_class("boxed-list");
identity_box.set_selection_mode(gtk::SelectionMode::None);
identity_box.update_property(&[
gtk::accessible::Property::Label("Application details"),
gtk::accessible::Property::Label(&i18n("Application details")),
]);
// Name
let name_row = adw::ActionRow::builder()
.title("Application")
.title(&i18n("Application"))
.subtitle(name)
.build();
if let Some(ref icon_path) = record.icon_path {
@@ -65,7 +66,7 @@ pub fn show_integration_dialog(
// Version
if let Some(ref version) = record.app_version {
let row = adw::ActionRow::builder()
.title("Version")
.title(&i18n("Version"))
.subtitle(version)
.build();
identity_box.append(&row);
@@ -78,12 +79,12 @@ pub fn show_integration_dialog(
actions_box.add_css_class("boxed-list");
actions_box.set_selection_mode(gtk::SelectionMode::None);
actions_box.update_property(&[
gtk::accessible::Property::Label("Integration actions"),
gtk::accessible::Property::Label(&i18n("Integration actions")),
]);
let desktop_row = adw::ActionRow::builder()
.title("Desktop entry")
.subtitle("A .desktop file will be created in ~/.local/share/applications")
.title(&i18n("Desktop entry"))
.subtitle(&i18n("A .desktop file will be created in ~/.local/share/applications"))
.build();
let check1 = gtk::Image::from_icon_name("emblem-ok-symbolic");
check1.set_valign(gtk::Align::Center);
@@ -91,8 +92,8 @@ pub fn show_integration_dialog(
actions_box.append(&desktop_row);
let icon_row = adw::ActionRow::builder()
.title("Icon")
.subtitle("The app icon will be installed to ~/.local/share/icons")
.title(&i18n("Icon"))
.subtitle(&i18n("The app icon will be installed to ~/.local/share/icons"))
.build();
let check2 = gtk::Image::from_icon_name("emblem-ok-symbolic");
check2.set_valign(gtk::Align::Center);
@@ -114,23 +115,31 @@ pub fn show_integration_dialog(
.map(FuseStatus::from_str)
.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 {
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 {
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() {
let fuse_msg = match fuse_status {
FuseStatus::Fuse3Only => "Only FUSE3 is installed - libfuse2 may be needed",
FuseStatus::NoFusermount => "fusermount not found - AppImage mount may fail",
FuseStatus::NoDevFuse => "/dev/fuse not available - AppImage mount will fail",
FuseStatus::MissingLibfuse2 => "libfuse2 not installed - fallback extraction will be used",
_ => "FUSE issue detected",
FuseStatus::Fuse3Only => i18n("Only FUSE3 is installed - libfuse2 may be needed"),
FuseStatus::NoFusermount => i18n("fusermount not found - AppImage mount may fail"),
FuseStatus::NoDevFuse => i18n("/dev/fuse not available - AppImage mount will fail"),
FuseStatus::MissingLibfuse2 => i18n("libfuse2 not installed - fallback extraction will be used"),
_ => 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() {
@@ -149,7 +158,7 @@ pub fn show_integration_dialog(
warning_icon.set_pixel_size(16);
warning_header.append(&warning_icon);
let warning_title = gtk::Label::builder()
.label("Compatibility Notes")
.label(&i18n("Compatibility Notes"))
.css_classes(["title-4"])
.halign(gtk::Align::Start)
.build();
@@ -162,8 +171,8 @@ pub fn show_integration_dialog(
for (title, subtitle, badge_text) in &warnings {
let row = adw::ActionRow::builder()
.title(*title)
.subtitle(*subtitle)
.title(title.as_str())
.subtitle(subtitle.as_str())
.build();
let badge = widgets::status_badge(badge_text, "warning");
badge.set_valign(gtk::Align::Center);

View File

@@ -96,9 +96,28 @@ impl LibraryView {
.title("Driftwood")
.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()
.title_widget(&title_widget)
.build();
header_bar.pack_start(&add_button);
header_bar.pack_end(&menu_button);
header_bar.pack_end(&search_button);
header_bar.pack_end(&view_toggle_box);
@@ -175,8 +194,8 @@ impl LibraryView {
.description(&i18n(
"Driftwood manages your AppImage collection - scanning for apps, \
integrating them into your desktop, and keeping them up to date.\n\n\
Add AppImages to ~/Applications or ~/Downloads, or configure \
custom scan locations in Preferences.",
Drag AppImage files here, or add them to ~/Applications or ~/Downloads, \
then use Scan Now to find them.",
))
.child(&empty_button_box)
.build();
@@ -196,13 +215,13 @@ impl LibraryView {
.selection_mode(gtk::SelectionMode::None)
.homogeneous(true)
.min_children_per_line(2)
.max_children_per_line(4)
.row_spacing(14)
.column_spacing(14)
.margin_top(14)
.margin_bottom(14)
.margin_start(14)
.margin_end(14)
.max_children_per_line(5)
.row_spacing(12)
.column_spacing(12)
.margin_top(12)
.margin_bottom(12)
.margin_start(12)
.margin_end(12)
.build();
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);
// 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
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() {
let snippet: String = desc.chars().take(60).collect();
if snippet.len() < desc.len() {
@@ -496,7 +520,7 @@ impl LibraryView {
.activatable(true)
.build();
// Icon prefix (48x48 with rounded clipping and letter fallback)
// Icon prefix with rounded clipping and letter fallback
let icon = widgets::app_icon(
record.icon_path.as_deref(),
name,
@@ -583,19 +607,19 @@ fn build_context_menu(record: &AppImageRecord) -> gtk::gio::Menu {
// Section 2: Actions
let section2 = gtk::gio::Menu::new();
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, &section2);
// Section 3: Integration + folder
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("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, &section3);
// Section 4: Clipboard
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, &section4);
menu

View File

@@ -1,8 +1,12 @@
pub mod app_card;
pub mod cleanup_wizard;
pub mod dashboard;
pub mod detail_view;
pub mod drop_dialog;
pub mod duplicate_dialog;
pub mod integration_dialog;
pub mod library_view;
pub mod preferences;
pub mod security_report;
pub mod update_dialog;
pub mod widgets;

View File

@@ -2,10 +2,11 @@ use adw::prelude::*;
use gtk::gio;
use crate::config::APP_ID;
use crate::i18n::i18n;
pub fn show_preferences_dialog(parent: &impl IsA<gtk::Widget>) {
let dialog = adw::PreferencesDialog::new();
dialog.set_title("Preferences");
dialog.set_title(&i18n("Preferences"));
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 {
let page = adw::PreferencesPage::builder()
.title("General")
.title(&i18n("General"))
.icon_name("emblem-system-symbolic")
.build();
// Appearance group
let appearance_group = adw::PreferencesGroup::builder()
.title("Appearance")
.description("Visual preferences for the application")
.title(&i18n("Appearance"))
.description(&i18n("Visual preferences for the application"))
.build();
let theme_row = adw::ComboRow::builder()
.title("Color Scheme")
.subtitle("Choose light, dark, or follow system preference")
.title(&i18n("Color Scheme"))
.subtitle(&i18n("Choose light, dark, or follow system preference"))
.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));
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);
let view_row = adw::ComboRow::builder()
.title("Default View")
.subtitle("Library display mode")
.title(&i18n("Default View"))
.subtitle(&i18n("Library display mode"))
.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));
let current_view = settings.string("view-mode");
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);
page.add(&appearance_group);
// Scan Locations group
let scan_group = adw::PreferencesGroup::builder()
.title("Scan Locations")
.description("Directories to scan for AppImage files")
.title(&i18n("Scan Locations"))
.description(&i18n("Directories to scan for AppImage files"))
.build();
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.set_selection_mode(gtk::SelectionMode::None);
dir_list_box.update_property(&[
gtk::accessible::Property::Label("Scan directories"),
gtk::accessible::Property::Label(&i18n("Scan directories")),
]);
for dir in &dirs {
@@ -97,11 +99,11 @@ fn build_general_page(settings: &gio::Settings, dialog: &adw::PreferencesDialog)
// Add location button
let add_button = gtk::Button::builder()
.label("Add Location")
.label(&i18n("Add Location"))
.build();
add_button.add_css_class("flat");
add_button.update_property(&[
gtk::accessible::Property::Label("Add scan directory"),
gtk::accessible::Property::Label(&i18n("Add scan directory")),
]);
let settings_add = settings.clone();
@@ -109,7 +111,7 @@ fn build_general_page(settings: &gio::Settings, dialog: &adw::PreferencesDialog)
let dialog_weak = dialog.downgrade();
add_button.connect_clicked(move |_| {
let file_dialog = gtk::FileDialog::builder()
.title("Choose a directory")
.title(i18n("Choose a directory"))
.modal(true)
.build();
@@ -158,19 +160,19 @@ fn build_general_page(settings: &gio::Settings, dialog: &adw::PreferencesDialog)
fn build_behavior_page(settings: &gio::Settings) -> adw::PreferencesPage {
let page = adw::PreferencesPage::builder()
.title("Behavior")
.title(&i18n("Behavior"))
.icon_name("preferences-other-symbolic")
.build();
// Automation group
let automation_group = adw::PreferencesGroup::builder()
.title("Automation")
.description("What Driftwood does automatically")
.title(&i18n("Automation"))
.description(&i18n("What Driftwood does automatically"))
.build();
let auto_scan_row = adw::SwitchRow::builder()
.title("Scan on startup")
.subtitle("Automatically scan for new AppImages when the app starts")
.title(&i18n("Scan on startup"))
.subtitle(&i18n("Automatically scan for new AppImages when the app starts"))
.active(settings.boolean("auto-scan-on-startup"))
.build();
let settings_scan = settings.clone();
@@ -180,8 +182,8 @@ fn build_behavior_page(settings: &gio::Settings) -> adw::PreferencesPage {
automation_group.add(&auto_scan_row);
let auto_update_row = adw::SwitchRow::builder()
.title("Check for updates")
.subtitle("Periodically check if newer versions of your AppImages are available")
.title(&i18n("Check for updates"))
.subtitle(&i18n("Periodically check if newer versions of your AppImages are available"))
.active(settings.boolean("auto-check-updates"))
.build();
let settings_upd = settings.clone();
@@ -191,8 +193,8 @@ fn build_behavior_page(settings: &gio::Settings) -> adw::PreferencesPage {
automation_group.add(&auto_update_row);
let auto_integrate_row = adw::SwitchRow::builder()
.title("Auto-integrate new AppImages")
.subtitle("Automatically add newly discovered AppImages to the desktop menu")
.title(&i18n("Auto-integrate new AppImages"))
.subtitle(&i18n("Automatically add newly discovered AppImages to the desktop menu"))
.active(settings.boolean("auto-integrate"))
.build();
let settings_int = settings.clone();
@@ -205,13 +207,13 @@ fn build_behavior_page(settings: &gio::Settings) -> adw::PreferencesPage {
// Backup group
let backup_group = adw::PreferencesGroup::builder()
.title("Backups")
.description("Config and data backup settings for updates")
.title(&i18n("Backups"))
.description(&i18n("Config and data backup settings for updates"))
.build();
let auto_backup_row = adw::SwitchRow::builder()
.title("Auto-backup before update")
.subtitle("Back up config and data files before updating an AppImage")
.title(&i18n("Auto-backup before update"))
.subtitle(&i18n("Back up config and data files before updating an AppImage"))
.active(settings.boolean("auto-backup-before-update"))
.build();
let settings_backup = settings.clone();
@@ -221,8 +223,8 @@ fn build_behavior_page(settings: &gio::Settings) -> adw::PreferencesPage {
backup_group.add(&auto_backup_row);
let retention_row = adw::SpinRow::builder()
.title("Backup retention")
.subtitle("Days to keep config backups before auto-cleanup")
.title(&i18n("Backup retention"))
.subtitle(&i18n("Days to keep config backups before auto-cleanup"))
.build();
let adjustment = gtk::Adjustment::new(
settings.int("backup-retention-days") as f64,
@@ -243,13 +245,13 @@ fn build_behavior_page(settings: &gio::Settings) -> adw::PreferencesPage {
// Safety group
let safety_group = adw::PreferencesGroup::builder()
.title("Safety")
.description("Confirmation and cleanup behavior")
.title(&i18n("Safety"))
.description(&i18n("Confirmation and cleanup behavior"))
.build();
let confirm_row = adw::SwitchRow::builder()
.title("Confirm before delete")
.subtitle("Show a confirmation dialog before deleting files or cleaning up")
.title(&i18n("Confirm before delete"))
.subtitle(&i18n("Show a confirmation dialog before deleting files or cleaning up"))
.active(settings.boolean("confirm-before-delete"))
.build();
let settings_confirm = settings.clone();
@@ -259,10 +261,10 @@ fn build_behavior_page(settings: &gio::Settings) -> adw::PreferencesPage {
safety_group.add(&confirm_row);
let cleanup_row = adw::ComboRow::builder()
.title("After updating an AppImage")
.subtitle("What to do with the old version after a successful update")
.title(&i18n("After updating an AppImage"))
.subtitle(&i18n("What to do with the old version after a successful update"))
.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));
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 {
let page = adw::PreferencesPage::builder()
.title("Security")
.title(&i18n("Security"))
.icon_name("security-medium-symbolic")
.build();
let scan_group = adw::PreferencesGroup::builder()
.title("Vulnerability Scanning")
.description("Check bundled libraries for known CVEs via OSV.dev")
.title(&i18n("Vulnerability Scanning"))
.description(&i18n("Check bundled libraries for known CVEs via OSV.dev"))
.build();
let auto_security_row = adw::SwitchRow::builder()
.title("Auto-scan new AppImages")
.subtitle("Automatically run a security scan on newly discovered AppImages")
.title(&i18n("Auto-scan new AppImages"))
.subtitle(&i18n("Automatically run a security scan on newly discovered AppImages"))
.active(settings.boolean("auto-security-scan"))
.build();
let settings_sec = settings.clone();
@@ -313,8 +315,8 @@ fn build_security_page(settings: &gio::Settings) -> adw::PreferencesPage {
scan_group.add(&auto_security_row);
let info_row = adw::ActionRow::builder()
.title("Data source")
.subtitle("OSV.dev - Open Source Vulnerability database")
.title(&i18n("Data source"))
.subtitle(&i18n("OSV.dev - Open Source Vulnerability database"))
.build();
scan_group.add(&info_row);
@@ -322,13 +324,13 @@ fn build_security_page(settings: &gio::Settings) -> adw::PreferencesPage {
// Notification settings
let notify_group = adw::PreferencesGroup::builder()
.title("Notifications")
.description("Desktop notification settings for security alerts")
.title(&i18n("Notifications"))
.description(&i18n("Desktop notification settings for security alerts"))
.build();
let notify_row = adw::SwitchRow::builder()
.title("Security notifications")
.subtitle("Send desktop notifications when new CVEs are found")
.title(&i18n("Security notifications"))
.subtitle(&i18n("Send desktop notifications when new CVEs are found"))
.active(settings.boolean("security-notifications"))
.build();
let settings_notify = settings.clone();
@@ -338,10 +340,10 @@ fn build_security_page(settings: &gio::Settings) -> adw::PreferencesPage {
notify_group.add(&notify_row);
let threshold_row = adw::ComboRow::builder()
.title("Notification threshold")
.subtitle("Minimum severity to trigger a notification")
.title(&i18n("Notification threshold"))
.subtitle(&i18n("Minimum severity to trigger a notification"))
.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));
let current_threshold = settings.string("security-notification-threshold");
@@ -369,19 +371,19 @@ fn build_security_page(settings: &gio::Settings) -> adw::PreferencesPage {
// About security scanning
let about_group = adw::PreferencesGroup::builder()
.title("How It Works")
.description("Understanding Driftwood's security scanning")
.title(&i18n("How It Works"))
.description(&i18n("Understanding Driftwood's security scanning"))
.build();
let about_row = adw::ActionRow::builder()
.title("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.")
.title(&i18n("Bundled library detection"))
.subtitle(&i18n("Driftwood extracts the list of shared libraries (.so files) bundled inside each AppImage and checks them against the OSV vulnerability database."))
.build();
about_group.add(&about_row);
let limits_row = adw::ActionRow::builder()
.title("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.")
.title(&i18n("Limitations"))
.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();
about_group.add(&limits_row);
@@ -398,11 +400,11 @@ fn add_directory_row(list_box: &gtk::ListBox, dir: &str, settings: &gio::Setting
let remove_btn = gtk::Button::builder()
.icon_name("edit-delete-symbolic")
.valign(gtk::Align::Center)
.tooltip_text("Remove")
.tooltip_text(&i18n("Remove"))
.build();
remove_btn.add_css_class("flat");
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();

View File

@@ -5,6 +5,7 @@ use std::rc::Rc;
use crate::config::APP_ID;
use crate::core::database::{AppImageRecord, Database};
use crate::core::updater;
use crate::i18n::{i18n, i18n_f};
/// Show an update check + apply dialog for a single AppImage.
pub fn show_update_dialog(
@@ -13,13 +14,13 @@ pub fn show_update_dialog(
db: &Rc<Database>,
) {
let dialog = adw::AlertDialog::builder()
.heading("Check for Updates")
.body(&format!(
"Checking for updates for {}...",
record.app_name.as_deref().unwrap_or(&record.filename)
.heading(&i18n("Check for Updates"))
.body(&i18n_f(
"Checking for updates for {name}...",
&[("{name}", record.app_name.as_deref().unwrap_or(&record.filename))],
))
.build();
dialog.add_response("close", "Close");
dialog.add_response("close", &i18n("Close"));
dialog.set_default_response(Some("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();
}
let mut body = format!(
"{} -> {}",
record_clone.app_version.as_deref().unwrap_or("unknown"),
check_result.latest_version.as_deref().unwrap_or("unknown"),
let mut body = i18n_f(
"{current} -> {latest}",
&[
("{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 {
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 !notes.is_empty() {
// Truncate long release notes for the dialog
@@ -75,12 +78,12 @@ pub fn show_update_dialog(
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);
// Add "Update Now" button if we have a 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_default_response(Some("update"));
@@ -101,11 +104,13 @@ pub fn show_update_dialog(
});
}
} else {
dialog_ref.set_heading(Some("Up to Date"));
dialog_ref.set_body(&format!(
"{} is already at the latest version ({}).",
record_clone.app_name.as_deref().unwrap_or(&record_clone.filename),
record_clone.app_version.as_deref().unwrap_or("unknown"),
dialog_ref.set_heading(Some(&i18n("Up to Date")));
dialog_ref.set_body(&i18n_f(
"{name} is already at the latest version ({version}).",
&[
("{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();
}
@@ -113,19 +118,18 @@ pub fn show_update_dialog(
Ok((type_label, raw_info, None)) => {
if raw_info.is_some() {
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_body("Could not reach the update server. Try again later.");
dialog_ref.set_heading(Some(&i18n("Check Failed")));
dialog_ref.set_body(&i18n("Could not reach the update server. Try again later."));
} else {
dialog_ref.set_heading(Some("No Update Info"));
dialog_ref.set_heading(Some(&i18n("No Update Info")));
dialog_ref.set_body(
"This app does not support automatic updates. \
Check the developer's website for newer versions.",
&i18n("This app does not support automatic updates. Check the developer's website for newer versions."),
);
}
}
Err(_) => {
dialog_ref.set_heading(Some("Error"));
dialog_ref.set_body("An error occurred while checking for updates.");
dialog_ref.set_heading(Some(&i18n("Error")));
dialog_ref.set_body(&i18n("An error occurred while checking for updates."));
}
}
});
@@ -142,8 +146,8 @@ fn start_update(
new_version: Option<&str>,
db: &Rc<Database>,
) {
dialog.set_heading(Some("Updating..."));
dialog.set_body("Downloading update. This may take a moment.");
dialog.set_heading(Some(&i18n("Updating...")));
dialog.set_body(&i18n("Downloading update. This may take a moment."));
dialog.set_response_enabled("update", false);
let path = appimage_path.to_string();
@@ -171,12 +175,14 @@ fn start_update(
}
db_ref.clear_update_available(record_id).ok();
let success_body = format!(
"Updated to {}\nPath: {}",
applied.new_version.as_deref().unwrap_or("latest"),
applied.new_path.display(),
let success_body = i18n_f(
"Updated to {version}\nPath: {path}",
&[
("{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_response_enabled("update", false);
@@ -186,12 +192,15 @@ fn start_update(
}
}
Ok(Err(e)) => {
dialog.set_heading(Some("Update Failed"));
dialog.set_body(&format!("The update could not be applied: {}", e));
dialog.set_heading(Some(&i18n("Update Failed")));
dialog.set_body(&i18n_f(
"The update could not be applied: {error}",
&[("{error}", &e.to_string())],
));
}
Err(_) => {
dialog.set_heading(Some("Update Failed"));
dialog.set_body("An unexpected error occurred during the update.");
dialog.set_heading(Some(&i18n("Update Failed")));
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" => {
// Keep the backup, just inform
dialog.set_body(&format!(
"Update complete. The old version is saved at:\n{}",
old_path.display()
dialog.set_body(&i18n_f(
"Update complete. The old version is saved at:\n{path}",
&[("{path}", &old_path.display().to_string())],
));
}
_ => {
// "ask" - prompt the user
dialog.set_body(&format!(
"Update complete.\n\nRemove the old version?\n{}",
old_path.display()
dialog.set_body(&i18n_f(
"Update complete.\n\nRemove the old version?\n{path}",
&[("{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);
let path = old_path.clone();

View File

@@ -1,4 +1,59 @@
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.
/// 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.
///
/// 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 {
let letter = name
// Ensure the shared CSS for all 26 letter classes is loaded
ensure_letter_icon_css();
let first_char = name
.chars()
.find(|c| c.is_alphanumeric())
.unwrap_or('?')
.to_uppercase()
.next()
.unwrap_or('?');
let letter_upper = first_char.to_uppercase().next().unwrap_or('?');
// Pick a color based on the name hash for consistency
let color_index = name.bytes().fold(0u32, |acc, b| acc.wrapping_add(b as u32)) % 6;
let bg_color = match color_index {
0 => "@accent_bg_color",
1 => "@success_bg_color",
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",
// Determine the CSS class: letter-icon-a .. letter-icon-z, or letter-icon-other
let css_class = if first_char.is_ascii_alphabetic() {
format!("letter-icon-{}", first_char.to_ascii_lowercase())
} else {
"letter-icon-other".to_string()
};
// 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()
.label(&letter.to_string())
.use_markup(true)
.halign(gtk::Align::Center)
.valign(gtk::Align::Center)
.width_request(size)
.height_request(size)
.build();
// Apply inline CSS via a provider on the display
let css_provider = gtk::CssProvider::new();
let unique_class = format!("letter-icon-{}", color_index);
let css = format!(
"label.{} {{ background: {}; color: {}; border-radius: 50%; min-width: {}px; min-height: {}px; font-size: {}px; font-weight: 700; }}",
unique_class, bg_color, fg_color, size, size, size * 4 / 10
// Use Pango markup to set the font size without a per-widget CssProvider.
let markup = format!(
"<span size='{}pt'>{}</span>",
font_size_pt,
glib::markup_escape_text(&letter_upper.to_string()),
);
css_provider.load_from_string(&css);
label.set_markup(&markup);
if let Some(display) = gtk::gdk::Display::default() {
gtk::style_context_add_provider_for_display(
&display,
&css_provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION + 1,
);
}
label.add_css_class(&unique_class);
label.add_css_class(&css_class);
label.upcast()
}

View File

@@ -6,20 +6,19 @@ use std::rc::Rc;
use std::time::Instant;
use crate::config::APP_ID;
use crate::core::analysis;
use crate::core::database::Database;
use crate::core::discovery;
use crate::core::fuse;
use crate::core::inspector;
use crate::core::integrator;
use crate::core::launcher;
use crate::core::orphan;
use crate::core::security;
use crate::core::updater;
use crate::core::wayland;
use crate::i18n::{i18n, ni18n_f};
use crate::ui::cleanup_wizard;
use crate::ui::dashboard;
use crate::ui::detail_view;
use crate::ui::drop_dialog;
use crate::ui::duplicate_dialog;
use crate::ui::library_view::{LibraryState, LibraryView};
use crate::ui::preferences;
@@ -35,6 +34,8 @@ mod imp {
pub navigation_view: OnceCell<adw::NavigationView>,
pub library_view: OnceCell<LibraryView>,
pub database: OnceCell<Rc<Database>>,
pub drop_overlay: OnceCell<gtk::Box>,
pub drop_revealer: OnceCell<gtk::Revealer>,
}
impl Default for DriftwoodWindow {
@@ -45,6 +46,8 @@ mod imp {
navigation_view: OnceCell::new(),
library_view: 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();
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();
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));
@@ -221,6 +403,14 @@ impl DriftwoodWindow {
}
// 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()
.toast_overlay
.set(toast_overlay)
@@ -362,6 +552,18 @@ impl DriftwoodWindow {
})
.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([
dashboard_action,
preferences_action,
@@ -373,6 +575,7 @@ impl DriftwoodWindow {
security_report_action,
cleanup_action,
shortcuts_action,
show_drop_hint_action,
]);
// --- Context menu actions (parameterized with record ID) ---
@@ -635,17 +838,16 @@ impl DriftwoodWindow {
let toast_overlay = self.imp().toast_overlay.get().unwrap().clone();
let window_weak = self.downgrade();
// Run scan in a background thread (opens its own DB connection),
// then update UI on main thread using the window's DB.
// Two-phase scan:
// Phase 1 (fast): discover files, upsert into DB, mark pending analysis
// Phase 2 (background): heavy analysis per file
glib::spawn_future_local(async move {
// Phase 1: Fast registration
let result = gio::spawn_blocking(move || {
let bg_db = Database::open().expect("Failed to open database for scan");
let start = Instant::now();
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 total = discovered.len() as i32;
@@ -654,6 +856,7 @@ impl DriftwoodWindow {
let removed_count = removed.len() as i32;
let mut skipped_count = 0i32;
let mut needs_analysis: Vec<(i64, std::path::PathBuf, discovery::AppImageType)> = Vec::new();
for d in &discovered {
let existing = bg_db
@@ -668,15 +871,14 @@ impl DriftwoodWindow {
.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 {
let size_unchanged = ex.size_bytes == d.size_bytes as i64;
let mtime_unchanged = modified.as_deref() == ex.file_modified.as_deref();
let fully_analyzed = ex.app_name.is_some()
&& ex.fuse_status.is_some()
&& ex.wayland_status.is_some()
&& ex.sha256.is_some();
if size_unchanged && mtime_unchanged && fully_analyzed {
let analysis_done = ex.analysis_status.as_deref() == Some("complete");
if size_unchanged && mtime_unchanged && analysis_done {
skipped_count += 1;
continue;
}
@@ -695,73 +897,13 @@ impl DriftwoodWindow {
new_count += 1;
}
let needs_metadata = existing
.as_ref()
.map(|r| r.app_name.is_none())
.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);
}
}
// Mark for background analysis
bg_db.update_analysis_status(id, "pending").ok();
needs_analysis.push((id, d.path.clone(), d.appimage_type.clone()));
}
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()
);
@@ -775,12 +917,13 @@ impl DriftwoodWindow {
duration,
).ok();
(total, new_count)
(total, new_count, needs_analysis)
})
.await;
if let Ok((total, new_count)) = result {
// Refresh the library view from the window's main-thread DB
if let Ok((total, new_count, needs_analysis)) = result {
// Refresh the library view immediately (apps appear with "Analyzing..." badge)
let window_weak2 = window_weak.clone();
if let Some(window) = window_weak.upgrade() {
let db = window.database();
let lib_view = window.imp().library_view.get().unwrap();
@@ -797,6 +940,50 @@ impl DriftwoodWindow {
n => format!("{} {} {}", i18n("Found"), n, i18n("new AppImages")),
};
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));
}
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) {
let settings = self.settings();
let (width, height) = self.default_size();