Fix performance, add screenshots, make banner scrollable

- Make detail view banner scroll with content instead of staying fixed,
  preventing tall banners from eating screen space
- Optimize squashfs offset scanning with buffered 256KB chunk reading
  instead of loading entire file into memory (critical for 1.5GB+ files)
- Add screenshot URL parsing from AppStream XML and async image display
  with carousel in the overview tab
- Fix infinite re-analysis bug: has_appstream check caused every app
  without AppStream data to be re-analyzed on every startup. Now handled
  via one-time migration reset in v10
- Database migration v10: add screenshot_urls column, reset analysis
  status for one-time re-scan with new parser
This commit is contained in:
lashman
2026-02-27 18:44:50 +02:00
parent 1bb7a3bdc0
commit 8362e066f7
8 changed files with 231 additions and 34 deletions

View File

@@ -60,6 +60,7 @@ pub struct AppImageMetadata {
pub releases: Vec<crate::core::appstream::ReleaseInfo>,
pub desktop_actions: Vec<String>,
pub has_signature: bool,
pub screenshot_urls: Vec<String>,
}
#[derive(Debug, Default)]
@@ -105,26 +106,73 @@ pub fn find_squashfs_offset_for(path: &Path) -> Option<u64> {
/// Find the squashfs offset by scanning for a valid superblock in the binary.
/// This avoids executing the AppImage, which can hang for apps with custom AppRun scripts.
/// Uses buffered chunk-based reading to avoid loading entire files into memory
/// (critical for large AppImages like Affinity at 1.5GB+).
fn find_squashfs_offset(path: &Path) -> Result<u64, InspectorError> {
let data = fs::read(path)?;
let magic = b"hsqs";
// Search for squashfs magic after the ELF header (skip first 4KB to avoid false matches)
let start = 4096.min(data.len());
for i in start..data.len().saturating_sub(96) {
if &data[i..i + 4] == magic {
// Validate: check squashfs superblock version at offset +28 (major) and +30 (minor)
let major = u16::from_le_bytes([data[i + 28], data[i + 29]]);
let minor = u16::from_le_bytes([data[i + 30], data[i + 31]]);
// Valid squashfs 4.0
if major == 4 && minor == 0 {
// Also check block_size at offset +12 is a power of 2 and reasonable (4KB-1MB)
let block_size = u32::from_le_bytes([data[i + 12], data[i + 13], data[i + 14], data[i + 15]]);
if block_size.is_power_of_two() && block_size >= 4096 && block_size <= 1_048_576 {
return Ok(i as u64);
use std::io::{BufReader, Seek, SeekFrom};
let file = fs::File::open(path)?;
let file_len = file.metadata()?.len();
let mut reader = BufReader::with_capacity(256 * 1024, file);
// Skip first 4KB to avoid false matches in ELF header
let start: u64 = 4096.min(file_len);
reader.seek(SeekFrom::Start(start))?;
// Read in 256KB chunks with 96-byte overlap to catch magic spanning boundaries
let chunk_size: usize = 256 * 1024;
let overlap: usize = 96;
let mut buf = vec![0u8; chunk_size];
let mut file_pos = start;
loop {
if file_pos >= file_len {
break;
}
let to_read = chunk_size.min((file_len - file_pos) as usize);
let mut total_read = 0;
while total_read < to_read {
let n = Read::read(&mut reader, &mut buf[total_read..to_read])?;
if n == 0 {
break;
}
total_read += n;
}
if total_read < 32 {
break;
}
// Scan this chunk for squashfs magic
let scan_end = total_read.saturating_sub(31);
for i in 0..scan_end {
if buf[i..i + 4] == *b"hsqs" {
let major = u16::from_le_bytes([buf[i + 28], buf[i + 29]]);
let minor = u16::from_le_bytes([buf[i + 30], buf[i + 31]]);
if major == 4 && minor == 0 {
let block_size = u32::from_le_bytes([
buf[i + 12], buf[i + 13], buf[i + 14], buf[i + 15],
]);
if block_size.is_power_of_two()
&& block_size >= 4096
&& block_size <= 1_048_576
{
return Ok(file_pos + i as u64);
}
}
}
}
// Advance, keeping overlap to catch magic spanning chunks
let advance = if total_read > overlap {
total_read - overlap
} else {
total_read
};
file_pos += advance as u64;
reader.seek(SeekFrom::Start(file_pos))?;
}
Err(InspectorError::NoOffset)
}
@@ -677,6 +725,10 @@ pub fn inspect_appimage(
.unwrap_or_default(),
desktop_actions: fields.actions,
has_signature: has_sig,
screenshot_urls: appstream
.as_ref()
.map(|a| a.screenshot_urls.clone())
.unwrap_or_default(),
})
}