feat: add settings panel with theme, language, and developer options

Implement comprehensive settings modal with:
- Theme switching (light/dark/auto) with system preference detection
- Language selection for 17 languages with native name sorting
- Developer mode with detailed logging and log viewer
- About section with app info and external links
- Update notification improvements with reduced log spam

Technical improvements:
- Added ThemeContext with persistent state management
- Implemented memory-safe log file reading (5MB limit)
- Fixed all ESLint, TypeScript, and Clippy warnings
- Added JSDoc documentation for public APIs
- Updated README.md and DEVELOPMENT.md with new features
This commit is contained in:
SuperKali
2025-12-30 01:33:50 +01:00
parent 41c0e90f54
commit 8e99d25c8d
60 changed files with 3712 additions and 121 deletions

View File

@@ -150,6 +150,13 @@ armbian-imager/
│ │ │ ├── BoardModal.tsx
│ │ │ ├── ImageModal.tsx
│ │ │ └── DeviceModal.tsx
│ │ ├── settings/ # Settings modal components
│ │ │ ├── SettingsModal.tsx # Main settings modal
│ │ │ ├── GeneralSection.tsx# General settings (MOTD, updates)
│ │ │ ├── ThemeSection.tsx # Theme selection (light/dark/auto)
│ │ │ ├── LanguageSection.tsx# Language selection (17 languages)
│ │ │ ├── AdvancedSection.tsx# Developer mode & logs
│ │ │ └── AboutSection.tsx # App info & links
│ │ └── shared/ # Reusable components
│ │ ├── AppVersion.tsx # Version display
│ │ ├── ErrorDisplay.tsx # Error presentation
@@ -158,12 +165,16 @@ armbian-imager/
│ ├── hooks/ # Custom React Hooks
│ │ ├── useTauri.ts # Tauri IPC wrappers
│ │ ├── useVendorLogos.ts # Logo validation
│ │ ── useAsyncData.ts # Async data fetching pattern
│ │ ── useAsyncData.ts # Async data fetching pattern
│ │ └── useSettings.ts # Settings persistence hook
│ ├── contexts/ # React Context providers
│ │ └── ThemeContext.tsx # Theme state management (light/dark/auto)
│ ├── config/ # Static configuration
│ │ ├── constants.ts # App constants
│ │ ├── deviceColors.ts # Device color mapping
│ │ ── os-info.ts # OS information
├── locales/ # i18n translations (15 languages)
│ │ ── os-info.ts # OS information
│ └── i18n.ts # i18n config & language metadata
│ ├── locales/ # i18n translations (17 languages)
│ ├── styles/ # Modular CSS
│ │ ├── theme.css # Theme variables (light/dark)
│ │ ├── components.css # Component styles
@@ -181,6 +192,7 @@ armbian-imager/
│ │ │ ├── operations.rs # Download & flash operations
│ │ │ ├── custom_image.rs # Custom image handling
│ │ │ ├── progress.rs # Progress event emission
│ │ │ ├── settings.rs # Settings commands (get/set dev mode, logs)
│ │ │ ├── system.rs # System utilities
│ │ │ └── state.rs # Shared application state
│ │ ├── devices/ # Platform-specific device detection
@@ -245,7 +257,8 @@ armbian-imager/
| React 19 | UI Framework |
| TypeScript | Type Safety |
| Vite | Build Tool & Dev Server |
| i18next | i18n (15 languages) |
| React Context API | State Management (Theme) |
| i18next | i18n (17 languages) |
| Lucide | Icons |
### Backend
@@ -254,6 +267,7 @@ armbian-imager/
|------------|---------|
| Rust | Systems Programming |
| Tauri 2 | Desktop Framework |
| Tauri Store Plugin | Persistent Settings |
| Tokio | Async Runtime |
| Serde | Serialization |
| Reqwest | HTTP Client |

View File

@@ -51,6 +51,12 @@ On first launch, macOS may block the application because it is not signed. If th
3. **Select Image** — Choose desktop or server, kernel variant, and stable or nightly builds
4. **Flash** — Download, decompress, write, and verify automatically
## Customization
- **Theme Selection**: Light, dark, or automatic based on system preferences
- **Developer Mode**: Enable detailed logging and view application logs
- **Language Selection**: 17 languages with automatic system detection
## Platform Support
| Platform | Architecture | Notes |

107
package-lock.json generated
View File

@@ -12,14 +12,17 @@
"@tauri-apps/plugin-fs": "^2.4.4",
"@tauri-apps/plugin-process": "^2",
"@tauri-apps/plugin-shell": "^2.3.3",
"@tauri-apps/plugin-store": "^2.4.1",
"@tauri-apps/plugin-updater": "^2",
"@types/qrcode": "^1.5.6",
"ansi-to-html": "^0.7.0",
"i18next": "^25.7.2",
"lucide-react": "^0.560.0",
"qrcode": "^1.5.4",
"react": "^19.2.2",
"react-dom": "^19.2.2",
"react-i18next": "^16.5.0"
"react-i18next": "^16.5.0",
"twemoji": "^14.0.2"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
@@ -1602,6 +1605,15 @@
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-store": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-store/-/plugin-store-2.4.1.tgz",
"integrity": "sha512-ckGSEzZ5Ii4Hf2D5x25Oqnm2Zf9MfDWAzR+volY0z/OOBz6aucPKEY0F649JvQ0Vupku6UJo7ugpGRDOFOunkA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-updater": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.9.0.tgz",
@@ -2065,6 +2077,21 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/ansi-to-html": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/ansi-to-html/-/ansi-to-html-0.7.2.tgz",
"integrity": "sha512-v6MqmEpNlxF+POuyhKkidusCHWWkaLcGRURzivcU3I9tv7k4JVhFcnukrM5Rlk2rUywdZuzYAZ+kbZqWCnfN3g==",
"license": "MIT",
"dependencies": {
"entities": "^2.2.0"
},
"bin": {
"ansi-to-html": "bin/ansi-to-html"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -2310,6 +2337,15 @@
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/entities": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
"integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
"license": "BSD-2-Clause",
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -2650,6 +2686,29 @@
"dev": true,
"license": "ISC"
},
"node_modules/fs-extra": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^4.0.0",
"universalify": "^0.1.0"
},
"engines": {
"node": ">=6 <7 || >=8"
}
},
"node_modules/fs-extra/node_modules/jsonfile": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
"license": "MIT",
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -2710,6 +2769,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -2921,6 +2986,18 @@
"node": ">=6"
}
},
"node_modules/jsonfile": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-5.0.0.tgz",
"integrity": "sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w==",
"license": "MIT",
"dependencies": {
"universalify": "^0.1.2"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -3242,6 +3319,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.2.tgz",
"integrity": "sha512-fhyD2BLrew6qYf4NNtHff1rLXvzR25rq49p+FeqByOazc6TcSi2n8EYulo5C1PbH+1uBW++5S1SG7FcUU6mlDg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -3490,6 +3568,24 @@
"typescript": ">=4.8.4"
}
},
"node_modules/twemoji": {
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/twemoji/-/twemoji-14.0.2.tgz",
"integrity": "sha512-BzOoXIe1QVdmsUmZ54xbEH+8AgtOKUiG53zO5vVP2iUu6h5u9lN15NcuS6te4OY96qx0H7JK9vjjl9WQbkTRuA==",
"license": "MIT",
"dependencies": {
"fs-extra": "^8.0.1",
"jsonfile": "^5.0.0",
"twemoji-parser": "14.0.0",
"universalify": "^0.1.2"
}
},
"node_modules/twemoji-parser": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/twemoji-parser/-/twemoji-parser-14.0.0.tgz",
"integrity": "sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA==",
"license": "MIT"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -3548,6 +3644,15 @@
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"license": "MIT"
},
"node_modules/universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/update-browserslist-db": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz",

View File

@@ -31,16 +31,19 @@
"dependencies": {
"@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-fs": "^2.4.4",
"@tauri-apps/plugin-shell": "^2.3.3",
"@tauri-apps/plugin-updater": "^2",
"@tauri-apps/plugin-process": "^2",
"@tauri-apps/plugin-shell": "^2.3.3",
"@tauri-apps/plugin-store": "^2.4.1",
"@tauri-apps/plugin-updater": "^2",
"@types/qrcode": "^1.5.6",
"ansi-to-html": "^0.7.0",
"i18next": "^25.7.2",
"lucide-react": "^0.560.0",
"qrcode": "^1.5.4",
"react": "^19.2.2",
"react-dom": "^19.2.2",
"react-i18next": "^16.5.0"
"react-i18next": "^16.5.0",
"twemoji": "^14.0.2"
},
"devDependencies": {
"@eslint/js": "^9.39.1",

View File

@@ -15,6 +15,7 @@ tauri-plugin-shell = "2"
tauri-plugin-dialog = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
tauri-plugin-store = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }

View File

@@ -1,4 +1,7 @@
fn main() {
// Extract Tauri version from Cargo.toml and expose it as a compile-time env var
println!("cargo:rustc-env=TAURI_VERSION={}", tauri_version());
// On Windows, embed the manifest to request admin privileges at startup
#[cfg(windows)]
{
@@ -11,3 +14,26 @@ fn main() {
#[cfg(not(windows))]
tauri_build::build();
}
/// Extract Tauri version from Cargo.toml dependencies
fn tauri_version() -> String {
let cargo_toml = std::path::PathBuf::from("Cargo.toml");
if let Ok(content) = std::fs::read_to_string(&cargo_toml) {
// Look for tauri dependency version
for line in content.lines() {
if line.contains("tauri =") || line.contains("tauri =") {
// Extract version from line like: tauri = { version = "2.x", ... }
if let Some(start) = line.find("version = \"") {
let after_version = &line[start + 11..];
if let Some(end) = after_version.find('"') {
return after_version[..end].to_string();
}
}
}
}
}
// Fallback to unknown if parsing fails
"unknown".to_string()
}

View File

@@ -8,6 +8,7 @@
"shell:allow-open",
"dialog:default",
"updater:default",
"process:allow-restart"
"process:allow-restart",
"store:default"
]
}

View File

@@ -13,7 +13,7 @@ use crate::images::{
extract_images, fetch_all_images, filter_images_for_board, get_unique_boards, BoardInfo,
ImageInfo,
};
use crate::{log_error, log_info};
use crate::{log_debug, log_error, log_info};
use super::state::AppState;
@@ -59,6 +59,13 @@ pub async fn get_images_for_board(
board_slug,
stable_only
);
log_debug!(
"board_queries",
"Filters - preapp: {:?}, kernel: {:?}, variant: {:?}",
preapp_filter,
kernel_filter,
variant_filter
);
let json_guard = state.images_json.lock().await;
let json = json_guard.as_ref().ok_or_else(|| {
@@ -71,6 +78,7 @@ pub async fn get_images_for_board(
})?;
let images = extract_images(json);
log_debug!("board_queries", "Total images available: {}", images.len());
let filtered = filter_images_for_board(
&images,
&board_slug,
@@ -79,6 +87,12 @@ pub async fn get_images_for_board(
variant_filter.as_deref(),
stable_only,
);
log_debug!(
"board_queries",
"Filtered down to {} images for board {}",
filtered.len(),
board_slug
);
log_info!(
"board_queries",
"Found {} images for board {}",

View File

@@ -7,6 +7,7 @@ pub mod custom_image;
pub mod operations;
pub mod progress;
pub mod scraping;
pub mod settings;
mod state;
pub mod system;
pub mod update;

View File

@@ -3,13 +3,13 @@
//! Handles download and flash operations.
use std::path::PathBuf;
use tauri::State;
use tauri::{AppHandle, State};
use crate::config;
use crate::download::download_image as do_download;
use crate::flash::{flash_image as do_flash, request_authorization};
use crate::utils::get_cache_dir;
use crate::{log_error, log_info};
use crate::{log_debug, log_error, log_info};
use super::state::AppState;
@@ -57,10 +57,16 @@ pub async fn download_image(
state: State<'_, AppState>,
) -> Result<String, String> {
log_info!("operations", "Starting download: {}", file_url);
log_debug!(
"operations",
"Download directory: {:?}",
get_cache_dir(config::app::NAME).join("images")
);
if let Some(ref sha) = file_url_sha {
log_info!("operations", "SHA URL: {}", sha);
} else {
log_info!("operations", "No SHA URL provided");
log_debug!("operations", "SHA verification will be skipped");
}
let download_dir = get_cache_dir(config::app::NAME).join("images");
@@ -92,6 +98,7 @@ pub async fn flash_image(
device_path: String,
verify: bool,
state: State<'_, AppState>,
_app: AppHandle,
) -> Result<(), String> {
log_info!(
"operations",
@@ -100,15 +107,32 @@ pub async fn flash_image(
device_path,
verify
);
log_debug!(
"operations",
"Image path exists: {}",
std::path::Path::new(&image_path).exists()
);
log_debug!(
"operations",
"Device path exists: {}",
std::path::Path::new(&device_path).exists()
);
log_debug!("operations", "Verification enabled: {}", verify);
let path = PathBuf::from(&image_path);
let flash_state = state.flash_state.clone();
let result = do_flash(&path, &device_path, flash_state, verify).await;
if let Err(ref e) = result {
log_error!("operations", "Flash failed: {}", e);
} else {
log_info!("operations", "Flash completed successfully");
match &result {
Ok(_) => {
log_info!("operations", "Flash completed successfully");
}
Err(e) => {
log_error!("operations", "Flash failed: {}", e);
}
}
result
}

View File

@@ -0,0 +1,302 @@
//! Settings persistence commands using Tauri Store plugin
//!
//! Manages user preferences like theme and language using the Tauri Store plugin.
use crate::log_info;
use tauri_plugin_store::StoreExt;
const MODULE: &str = "commands::settings";
const SETTINGS_STORE: &str = "settings.json";
const MAX_LOG_SIZE: u64 = 5 * 1024 * 1024; // 5MB
const MAX_LOG_LINES: usize = 10_000;
/// Default values for settings
fn default_theme() -> String {
"auto".to_string()
}
fn default_language() -> String {
"auto".to_string()
}
fn default_show_motd() -> bool {
true
}
fn default_show_updater_modal() -> bool {
true
}
fn default_developer_mode() -> bool {
false
}
/// Get the current theme preference
#[tauri::command]
pub fn get_theme(app: tauri::AppHandle) -> String {
match app.store(SETTINGS_STORE) {
Ok(store) => match store.get("theme") {
Some(value) => value.as_str().unwrap_or("auto").to_string(),
None => {
log_info!(MODULE, "Theme not found in store, using default");
default_theme()
}
},
Err(e) => {
log_info!(MODULE, "Error loading store, using default theme: {}", e);
default_theme()
}
}
}
/// Set the theme preference
#[tauri::command]
pub fn set_theme(theme: String, app: tauri::AppHandle) -> Result<(), String> {
log_info!(MODULE, "Setting theme to: {}", theme);
match app.store(SETTINGS_STORE) {
Ok(store) => {
store.set("theme", theme);
Ok(())
}
Err(e) => Err(format!("Failed to access store: {}", e)),
}
}
/// Get the current language preference
#[tauri::command]
pub fn get_language(app: tauri::AppHandle) -> String {
match app.store(SETTINGS_STORE) {
Ok(store) => match store.get("language") {
Some(value) => value.as_str().unwrap_or("auto").to_string(),
None => {
log_info!(MODULE, "Language not found in store, using default");
default_language()
}
},
Err(e) => {
log_info!(MODULE, "Error loading store, using default language: {}", e);
default_language()
}
}
}
/// Set the language preference
#[tauri::command]
pub fn set_language(language: String, app: tauri::AppHandle) -> Result<(), String> {
log_info!(MODULE, "Setting language to: {}", language);
match app.store(SETTINGS_STORE) {
Ok(store) => {
store.set("language", language);
Ok(())
}
Err(e) => Err(format!("Failed to access store: {}", e)),
}
}
/// Get the MOTD visibility preference
#[tauri::command]
pub fn get_show_motd(app: tauri::AppHandle) -> bool {
match app.store(SETTINGS_STORE) {
Ok(store) => match store.get("show_motd") {
Some(value) => value.as_bool().unwrap_or(true),
None => {
log_info!(MODULE, "show_motd not found in store, using default");
default_show_motd()
}
},
Err(e) => {
log_info!(
MODULE,
"Error loading store, using default show_motd: {}",
e
);
default_show_motd()
}
}
}
/// Set the MOTD visibility preference
#[tauri::command]
pub fn set_show_motd(show: bool, app: tauri::AppHandle) -> Result<(), String> {
log_info!(MODULE, "Setting show_motd to: {}", show);
match app.store(SETTINGS_STORE) {
Ok(store) => {
store.set("show_motd", show);
Ok(())
}
Err(e) => Err(format!("Failed to access store: {}", e)),
}
}
/// System information structure
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct SystemInfo {
pub platform: String,
pub arch: String,
}
/// Get the real system platform and architecture
#[tauri::command]
pub fn get_system_info() -> SystemInfo {
let platform = std::env::consts::OS.to_string();
let arch = match std::env::consts::ARCH {
"x86_64" => "x64",
"aarch64" => "ARM64",
"x86" => "x86",
"arm" => "ARM",
_ => std::env::consts::ARCH,
}
.to_string();
SystemInfo { platform, arch }
}
/// Get the Tauri version
///
/// Returns the Tauri framework version as a compile-time constant.
/// The version is extracted from Cargo.toml during build time via build.rs.
#[tauri::command]
pub fn get_tauri_version() -> String {
env!("TAURI_VERSION").to_string()
}
/// Get the updater modal visibility preference
#[tauri::command]
pub fn get_show_updater_modal(app: tauri::AppHandle) -> bool {
match app.store(SETTINGS_STORE) {
Ok(store) => match store.get("show_updater_modal") {
Some(value) => value.as_bool().unwrap_or(true),
None => {
log_info!(
MODULE,
"show_updater_modal not found in store, using default"
);
default_show_updater_modal()
}
},
Err(e) => {
log_info!(
MODULE,
"Error loading store, using default show_updater_modal: {}",
e
);
default_show_updater_modal()
}
}
}
/// Set the updater modal visibility preference
#[tauri::command]
pub fn set_show_updater_modal(show: bool, app: tauri::AppHandle) -> Result<(), String> {
log_info!(MODULE, "Setting show_updater_modal to: {}", show);
match app.store(SETTINGS_STORE) {
Ok(store) => {
store.set("show_updater_modal", show);
Ok(())
}
Err(e) => Err(format!("Failed to access store: {}", e)),
}
}
/// Get the developer mode preference
#[tauri::command]
pub fn get_developer_mode(app: tauri::AppHandle) -> bool {
match app.store(SETTINGS_STORE) {
Ok(store) => match store.get("developer_mode") {
Some(value) => value.as_bool().unwrap_or_else(default_developer_mode),
None => {
log_info!(MODULE, "developer_mode not found in store, using default");
default_developer_mode()
}
},
Err(e) => {
log_info!(
MODULE,
"Error loading store, using default developer_mode: {}",
e
);
default_developer_mode()
}
}
}
/// Set the developer mode preference
#[tauri::command]
pub fn set_developer_mode(enabled: bool, app: tauri::AppHandle) -> Result<(), String> {
log_info!(MODULE, "Setting developer_mode to: {}", enabled);
// Update the log level based on developer mode
crate::logging::set_log_level(enabled);
match app.store(SETTINGS_STORE) {
Ok(store) => {
store.set("developer_mode", enabled);
Ok(())
}
Err(e) => Err(format!("Failed to access store: {}", e)),
}
}
/// Read only the last N lines from a file to avoid loading large files into memory
///
/// This function is optimized for large log files by reading line-by-line
/// and only keeping the last N lines in memory.
fn read_last_lines(path: &std::path::PathBuf, lines: usize) -> Result<String, String> {
use std::io::{BufRead, BufReader};
let file = std::fs::File::open(path).map_err(|e| format!("Failed to open log file: {}", e))?;
let reader = BufReader::new(file);
let all_lines: Vec<String> = reader.lines().map_while(Result::ok).collect();
let start = if all_lines.len() > lines {
all_lines.len() - lines
} else {
0
};
Ok(all_lines[start..].join("\n"))
}
/// Get the latest log file contents
///
/// For large log files (>5MB), only the last 10,000 lines are returned
/// to avoid memory issues. This prevents the application from consuming
/// excessive memory when viewing logs.
#[tauri::command]
pub fn get_logs() -> Result<String, String> {
use crate::logging;
use std::fs::Metadata;
match logging::get_current_log_path() {
Some(log_path) => {
if !log_path.exists() {
return Ok("No log file found".to_string());
}
// Get file metadata to check size
let metadata: Metadata = std::fs::metadata(&log_path)
.map_err(|e| format!("Failed to read log file metadata: {}", e))?;
// For large files, use optimized line reader
if metadata.len() > MAX_LOG_SIZE {
log_info!(
MODULE,
"Log file is large ({} bytes), reading last {} lines",
metadata.len(),
MAX_LOG_LINES
);
return read_last_lines(&log_path, MAX_LOG_LINES);
}
// For small files, read entire contents
std::fs::read_to_string(&log_path)
.map_err(|e| format!("Failed to read log file: {}", e))
}
None => Ok("No log file available".to_string()),
}
}

View File

@@ -2,17 +2,23 @@
//!
//! Platform-specific system operations like opening URLs and locale detection.
use crate::log_info;
use crate::{log_debug, log_info};
use sys_locale::get_locale;
const MODULE: &str = "commands::system";
/// Log a message from the frontend
/// Log a message from the frontend (INFO level)
#[tauri::command]
pub fn log_from_frontend(module: String, message: String) {
log_info!(&format!("frontend::{}", module), "{}", message);
}
/// Log a debug message from the frontend (DEBUG level - only shown in developer mode)
#[tauri::command]
pub fn log_debug_from_frontend(module: String, message: String) {
log_debug!(&format!("frontend::{}", module), "{}", message);
}
/// Get the system locale (e.g., "en-US", "it-IT", "de-DE")
/// Returns the language code for i18n initialization
#[tauri::command]

View File

@@ -14,7 +14,7 @@ use tokio::sync::Mutex;
use crate::config;
use crate::decompress::decompress_with_rust_xz;
use crate::{log_error, log_info, log_warn};
use crate::{log_debug, log_error, log_info, log_warn};
const MODULE: &str = "download";
@@ -59,12 +59,15 @@ impl Default for DownloadState {
/// Extract filename from URL
fn extract_filename(url: &str) -> Result<&str, String> {
log_debug!(MODULE, "Extracting filename from URL: {}", url);
let url_path = url.split('?').next().unwrap_or(url);
url_path
let filename = url_path
.split('/')
.next_back()
.filter(|s| !s.is_empty())
.ok_or_else(|| "Invalid URL: no filename".to_string())
.ok_or_else(|| "Invalid URL: no filename".to_string())?;
log_debug!(MODULE, "Extracted filename: {}", filename);
Ok(filename)
}
/// Fetch expected SHA256 from URL
@@ -108,10 +111,16 @@ async fn fetch_expected_sha(client: &Client, sha_url: &str) -> Result<String, St
/// Calculate SHA256 of a file
fn calculate_file_sha256(path: &Path, state: &Arc<DownloadState>) -> Result<String, String> {
log_info!(MODULE, "Calculating SHA256 of: {}", path.display());
log_debug!(
MODULE,
"File size: {:?} bytes",
path.metadata().ok().map(|m| m.len())
);
let mut file = File::open(path).map_err(|e| format!("Failed to open file for SHA: {}", e))?;
let mut hasher = Sha256::new();
let mut buffer = [0u8; 8192];
let mut bytes_processed = 0u64;
loop {
// Check for cancellation
@@ -127,6 +136,16 @@ fn calculate_file_sha256(path: &Path, state: &Arc<DownloadState>) -> Result<Stri
break;
}
hasher.update(&buffer[..bytes_read]);
bytes_processed += bytes_read as u64;
// Log progress every 10MB in debug mode
if bytes_processed % (10 * 1024 * 1024) == 0 {
log_debug!(
MODULE,
"SHA256 calculation progress: {} MB",
bytes_processed / (1024 * 1024)
);
}
}
let result = hasher.finalize();

View File

@@ -78,6 +78,11 @@ impl Logger {
}
}
/// Update the minimum log level at runtime
fn set_min_level(&mut self, level: LogLevel) {
self.config.min_level = level;
}
fn create_log_file() -> (Option<File>, Option<PathBuf>) {
let log_dir = get_log_dir();
@@ -217,6 +222,30 @@ pub fn error(module: &str, message: &str) {
}
}
/// Set the minimum log level at runtime
///
/// This function allows dynamically changing the log level, for example
/// when developer mode is toggled. When enabled, debug messages are shown.
/// When disabled, only info and above are shown.
pub fn set_log_level(debug_enabled: bool) {
if let Ok(mut logger) = LOGGER.lock() {
let new_level = if debug_enabled {
LogLevel::Debug
} else {
LogLevel::Info
};
logger.set_min_level(new_level);
// Log the change after setting it (using the new level)
let level_str = if debug_enabled { "DEBUG" } else { "INFO" };
logger.log(
LogLevel::Info,
"logging",
&format!("Log level changed to {}", level_str),
);
}
}
/// Log a message with format arguments (debug level)
#[macro_export]
macro_rules! log_debug {

View File

@@ -20,6 +20,7 @@ mod utils;
use commands::AppState;
#[allow(unused_imports)] // Used by get_webview_window in debug builds
use tauri::Manager;
use tauri_plugin_store::StoreExt;
use crate::utils::get_cache_dir;
@@ -97,7 +98,8 @@ fn main() {
let mut builder = tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_process::init());
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_store::Builder::new().build());
// Enable updater only for AppImage on Linux (other formats like .deb don't support it)
#[cfg(target_os = "linux")]
@@ -135,8 +137,22 @@ fn main() {
commands::system::open_url,
commands::system::get_system_locale,
commands::system::log_from_frontend,
commands::system::log_debug_from_frontend,
commands::update::get_github_release,
paste::upload::upload_logs,
commands::settings::get_theme,
commands::settings::set_theme,
commands::settings::get_language,
commands::settings::set_language,
commands::settings::get_show_motd,
commands::settings::set_show_motd,
commands::settings::get_show_updater_modal,
commands::settings::set_show_updater_modal,
commands::settings::get_developer_mode,
commands::settings::set_developer_mode,
commands::settings::get_logs,
commands::settings::get_system_info,
commands::settings::get_tauri_version,
])
.setup(|app| {
#[cfg(debug_assertions)]
@@ -145,6 +161,31 @@ fn main() {
window.open_devtools();
}
}
// Initialize log level based on developer mode setting
match app.store("settings.json") {
Ok(store) => {
let developer_mode = store
.get("developer_mode")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if developer_mode {
log_info!("main", "Developer mode enabled, setting log level to DEBUG");
logging::set_log_level(true);
} else {
log_info!("main", "Developer mode disabled, using default log level");
}
}
Err(e) => {
log_warn!(
"main",
"Failed to access settings store: {}. Using default log level (INFO).",
e
);
}
}
let _ = app; // Suppress unused warning in release
Ok(())
})

View File

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Header, HomePage } from './components/layout';
import { ManufacturerModal, BoardModal, ImageModal, DeviceModal } from './components/modals';
import { FlashProgress } from './components/flash';
import { AppVersion } from './components/shared';
import { SettingsButton } from './components/settings';
import { selectCustomImage, detectBoardFromFilename, logInfo } from './hooks/useTauri';
import { useDeviceMonitor } from './hooks/useDeviceMonitor';
import type { BoardInfo, ImageInfo, BlockDevice, ModalType, SelectionStep, Manufacturer } from './types';
@@ -208,7 +208,7 @@ function App() {
onSelect={handleDeviceSelect}
/>
<AppVersion />
{!isFlashing && <SettingsButton />}
</div>
);
}

View File

@@ -5,6 +5,9 @@
// Layout
export * from './layout';
// Settings
export * from './settings';
// Modals
export * from './modals';

View File

@@ -1,5 +1,5 @@
import { type ReactNode, useEffect, useCallback, useState, useRef } from 'react';
import { X } from 'lucide-react';
import { X, ChevronLeft } from 'lucide-react';
interface ModalProps {
isOpen: boolean;
@@ -7,9 +7,11 @@ interface ModalProps {
title: string;
children: ReactNode;
searchBar?: ReactNode;
showBack?: boolean;
onBack?: () => void;
}
export function Modal({ isOpen, onClose, title, children, searchBar }: ModalProps) {
export function Modal({ isOpen, onClose, title, children, searchBar, showBack, onBack }: ModalProps) {
const [isExiting, setIsExiting] = useState(false);
const isExitingRef = useRef(false);
@@ -26,9 +28,13 @@ export function Modal({ isOpen, onClose, title, children, searchBar }: ModalProp
const handleEscape = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') {
handleClose();
if (showBack && onBack) {
onBack();
} else {
handleClose();
}
}
}, [handleClose]);
}, [handleClose, showBack, onBack]);
useEffect(() => {
if (isOpen) {
@@ -52,7 +58,14 @@ export function Modal({ isOpen, onClose, title, children, searchBar }: ModalProp
<div className={`modal-overlay ${animationClass}`} onClick={handleClose}>
<div className={`modal ${animationClass}`} onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">{title}</h2>
<div className="modal-header-left">
{showBack && onBack && (
<button className="modal-back" onClick={onBack}>
<ChevronLeft size={20} />
</button>
)}
<h2 className="modal-title">{title}</h2>
</div>
<button className="modal-close" onClick={handleClose}>
<X size={20} />
</button>

View File

@@ -0,0 +1,164 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { getVersion } from '@tauri-apps/api/app';
import { open } from '@tauri-apps/plugin-shell';
import {
Cpu,
Monitor,
Tag,
Box,
Github,
BookOpen,
AlertCircle,
MessageSquare,
type LucideIcon,
} from 'lucide-react';
import { getTauriVersion, getSystemInfo } from '../../hooks/useTauri';
import { LINKS } from '../../config/constants';
import armbianLogo from '../../../src-tauri/icons/icon.png';
/**
* About section
*
* Displays app information including:
* - Hero with logo and description
* - Technical details (version, platform, arch, Tauri version)
* - External links (GitHub, Docs, Issues, Forum)
*/
/**
* Format platform name for display
* Maps platform identifiers to user-friendly names
*/
function formatPlatformName(platform: string): string {
const platformNames: Record<string, string> = {
macos: 'macOS',
windows: 'Windows',
linux: 'Linux'
};
return platformNames[platform] || platform;
}
/**
* Info card component
*
* Displays a piece of information with an icon, label, and value.
* Used in the About section to show app details.
*/
interface InfoCardProps {
icon: LucideIcon;
label: string;
value: string;
}
function InfoCard({ icon: Icon, label, value }: InfoCardProps) {
return (
<div className="info-card">
<Icon size={20} className="info-card-icon" />
<div className="info-card-content">
<div className="info-card-label">{label}</div>
<div className="info-card-value">{value}</div>
</div>
</div>
);
}
/**
* Link button component
*/
interface LinkButtonProps {
icon: React.ComponentType<{ className?: string; size?: number }>;
text: string;
onClick: () => void;
}
function LinkButton({ icon: Icon, text, onClick }: LinkButtonProps) {
return (
<button className="link-button" onClick={onClick}>
<Icon className="link-button-icon" size={20} />
<span className="link-button-text">{text}</span>
<svg className="link-button-arrow" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
);
}
export function AboutSection() {
const { t } = useTranslation();
const [appVersion, setAppVersion] = useState<string>('');
const [platform, setPlatform] = useState<string>('');
const [arch, setArch] = useState<string>('');
const [tauriVersion, setTauriVersion] = useState<string>('');
useEffect(() => {
const loadAppInfo = async () => {
try {
const [version, tauriVer, systemInfo] = await Promise.all([
getVersion(),
getTauriVersion(),
getSystemInfo()
]);
setAppVersion(version);
setTauriVersion(tauriVer);
// Format platform name for display
setPlatform(formatPlatformName(systemInfo.platform));
setArch(systemInfo.arch);
} catch (error) {
console.error('Failed to load app info:', error);
}
};
loadAppInfo();
}, []);
const openLink = (url: string) => {
open(url);
};
return (
<div className="about-section">
{/* Hero Section */}
<div className="about-hero">
<img src={armbianLogo} alt="Armbian" className="about-logo" />
<h2 className="about-title">Armbian Imager</h2>
<p className="about-description">{t('settings.appDescription')}</p>
</div>
{/* Technical Info Cards */}
<div className="about-info-cards">
<InfoCard icon={Tag} label={t('settings.version')} value={`v${appVersion}`} />
<InfoCard icon={Monitor} label={t('settings.platform')} value={platform} />
<InfoCard icon={Cpu} label={t('settings.arch')} value={arch} />
<InfoCard icon={Box} label={t('settings.tauriVersion')} value={`v${tauriVersion}`} />
</div>
{/* Links Section */}
<div className="about-links">
<h4>{t('settings.links')}</h4>
<div className="about-links-grid">
<LinkButton
icon={Github}
text={t('settings.githubRepo')}
onClick={() => openLink(LINKS.GITHUB_REPO)}
/>
<LinkButton
icon={BookOpen}
text={t('settings.documentation')}
onClick={() => openLink(LINKS.DOCS)}
/>
<LinkButton
icon={AlertCircle}
text={t('settings.reportIssue')}
onClick={() => openLink(`${LINKS.GITHUB_REPO}/issues`)}
/>
<LinkButton
icon={MessageSquare}
text={t('settings.community')}
onClick={() => openLink(LINKS.FORUM)}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,106 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Code, FileText } from 'lucide-react';
import { getDeveloperMode, setDeveloperMode } from '../../hooks/useSettings';
import { LogsModal } from './LogsModal';
/**
* Advanced settings section for power users
*
* Contains developer mode toggle and view logs button.
*/
export function AdvancedSection() {
const { t } = useTranslation();
const [developerMode, setDeveloperModeState] = useState<boolean>(false);
const [logsModalOpen, setLogsModalOpen] = useState<boolean>(false);
const [isToggling, setIsToggling] = useState<boolean>(false);
// Load developer mode preference on mount
useEffect(() => {
const loadDeveloperModePreference = async () => {
try {
const value = await getDeveloperMode();
setDeveloperModeState(value);
} catch (error) {
console.error('Failed to load developer mode preference:', error);
}
};
loadDeveloperModePreference();
}, []);
const handleToggleDeveloperMode = async () => {
// Prevent concurrent toggles
if (isToggling) {
return;
}
const previousValue = developerMode;
const newValue = !developerMode;
// Optimistic update
setDeveloperModeState(newValue);
setIsToggling(true);
try {
await setDeveloperMode(newValue);
// Notify other components that settings changed
window.dispatchEvent(new Event('armbian-settings-changed'));
} catch (error) {
// Rollback on error
console.error('Failed to set developer mode preference:', error);
setDeveloperModeState(previousValue);
} finally {
setIsToggling(false);
}
};
return (
<div className="settings-section">
<h3 className="settings-section-title">{t('settings.advancedCategory')}</h3>
<div className="settings-list">
{/* Developer Mode Toggle */}
<div className="settings-item">
<div className="settings-item-left">
<div className="settings-item-icon">
<Code />
</div>
<div className="settings-item-content">
<div className="settings-item-label">{t('settings.developerMode')}</div>
<div className="settings-item-description">{t('settings.developerModeDescription')}</div>
</div>
</div>
<label className="toggle-switch">
<input
type="checkbox"
checked={developerMode}
onChange={handleToggleDeveloperMode}
disabled={isToggling}
/>
<span className="toggle-slider"></span>
</label>
</div>
{/* View Logs Button */}
<div className="settings-item settings-item-clickable" onClick={() => setLogsModalOpen(true)}>
<div className="settings-item-left">
<div className="settings-item-icon">
<FileText />
</div>
<div className="settings-item-content">
<div className="settings-item-label">{t('settings.viewLogs')}</div>
<div className="settings-item-description">{t('settings.viewLogsDescription')}</div>
</div>
</div>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</div>
</div>
{/* Logs Modal */}
<LogsModal isOpen={logsModalOpen} onClose={() => setLogsModalOpen(false)} />
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More