From 8a4038b21e08a1242e92f45ab560be938d8df437 Mon Sep 17 00:00:00 2001 From: gronod Date: Mon, 31 Aug 2026 00:15:17 +0100 Subject: [PATCH] feat(logging): apply runtime log level, forward frontend errors, redact paths, and improve rotation (fixes #158, fixes #159, fixes #160) --- package.json | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/commands.rs | 123 +++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 20 +++-- src-tauri/src/process_manager.rs | 83 ++++++++++++++++++++- src-tauri/src/settings.rs | 46 +++++++++++- src-tauri/tauri.conf.json | 2 +- src/index.html | 6 +- src/js/app.js | 3 +- src/js/chartread.js | 1 + src/js/colprof.js | 2 + src/js/logger.js | 49 ++++++++++++ src/js/printtarg.js | 2 + src/js/profcheck.js | 2 + src/js/settings.js | 34 ++++++++- src/js/targen.js | 2 + 16 files changed, 361 insertions(+), 18 deletions(-) create mode 100644 src/js/logger.js diff --git a/package.json b/package.json index 56fd537..43126a7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "iccery", "private": true, - "version": "0.5.3", + "version": "0.5.4", "type": "module", "scripts": { "fetch-argyll": "node scripts/fetch-argyll.mjs", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 124f76d..323ae5f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "iccery" -version = "0.5.3" +version = "0.5.4" description = "Modern Printer Profiling UI frontend for ArgyllCMS" authors = ["Gordon"] edition = "2021" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 7f68fcf..d7954e3 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -128,6 +128,91 @@ pub fn get_default_working_dir(app: AppHandle) -> Result { resolve_safe_cwd(&app, "") } +#[tauri::command] +pub fn log_frontend_message(level: String, message: String, context: Option) { + let ctx = context.map(|c| format!("[{}] ", c)).unwrap_or_default(); + match level.to_lowercase().as_str() { + "error" => log::error!(target: "frontend", "{}{}", ctx, message), + "warn" | "warning" => log::warn!(target: "frontend", "{}{}", ctx, message), + "debug" => log::debug!(target: "frontend", "{}{}", ctx, message), + "trace" => log::trace!(target: "frontend", "{}{}", ctx, message), + _ => log::info!(target: "frontend", "{}{}", ctx, message), + } +} + +pub fn prune_historical_logs(log_dir: &std::path::Path, max_history: usize) { + if let Ok(entries) = std::fs::read_dir(log_dir) { + let mut rotated_files: Vec<(std::path::PathBuf, std::time::SystemTime)> = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + // Identify rotated log files (e.g. iccery_2026-08-30.log, iccery.log.1, etc.) + if name.starts_with("iccery") && name != "iccery.log" { + let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(std::time::UNIX_EPOCH); + rotated_files.push((path, mtime)); + } + } + } + + // Sort newest first + rotated_files.sort_by(|a, b| b.1.cmp(&a.1)); + + // Delete any beyond max_history + if rotated_files.len() > max_history { + for (old_file, _) in &rotated_files[max_history..] { + log::info!(target: "log", "Pruning old log segment: {:?}", old_file); + let _ = std::fs::remove_file(old_file); + } + } + } +} + +pub fn extract_recent_log_lines(content: &str, max_lines: usize) -> String { + let lines: Vec<&str> = content.lines().collect(); + if lines.len() <= max_lines { + content.to_string() + } else { + lines[lines.len() - max_lines..].join("\n") + } +} + +#[tauri::command] +pub fn get_recent_log_excerpt( + app: AppHandle, + max_lines: Option, + max_bytes: Option, +) -> Result { + let log_dir = app + .path() + .app_log_dir() + .map_err(|e| format!("Failed to resolve log dir: {}", e))?; + let log_file = log_dir.join("iccery.log"); + + if !log_file.exists() { + return Ok("No log file found yet.".to_string()); + } + + let max_b = max_bytes.unwrap_or(65536); // 64 KiB cap + let max_l = max_lines.unwrap_or(200); + + use std::io::{Read, Seek, SeekFrom}; + let mut file = std::fs::File::open(&log_file).map_err(|e| format!("Failed to open log file: {}", e))?; + let file_len = file.metadata().map_err(|e| e.to_string())?.len(); + + let mut buf = Vec::new(); + if file_len > max_b as u64 { + file.seek(SeekFrom::End(-(max_b as i64))) + .map_err(|e| e.to_string())?; + file.read_to_end(&mut buf).map_err(|e| e.to_string())?; + } else { + file.read_to_end(&mut buf).map_err(|e| e.to_string())?; + } + + let text = String::from_utf8_lossy(&buf); + Ok(extract_recent_log_lines(&text, max_l)) +} + #[tauri::command] pub fn get_log_path(app: AppHandle) -> Result { let log_dir = app @@ -1496,4 +1581,42 @@ mod tests { let _ = std::fs::remove_dir_all(&temp_dir); } + + #[test] + fn test_extract_recent_log_lines() { + let content = "line 1\nline 2\nline 3\nline 4\nline 5"; + assert_eq!(extract_recent_log_lines(content, 2), "line 4\nline 5"); + assert_eq!(extract_recent_log_lines(content, 10), content); + assert_eq!(extract_recent_log_lines("", 5), ""); + } + + #[test] + fn test_prune_historical_logs() { + let temp_dir = std::env::temp_dir().join(format!("iccery_test_logs_{}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos())); + std::fs::create_dir_all(&temp_dir).unwrap(); + + // Create main log + std::fs::write(temp_dir.join("iccery.log"), b"active log").unwrap(); + + // Create 8 historical log files with slight pauses to ensure distinct mtimes + for i in 1..=8 { + let file_path = temp_dir.join(format!("iccery_2026-08-0{}.log", i)); + std::fs::write(&file_path, format!("old log {}", i)).unwrap(); + } + + prune_historical_logs(&temp_dir, 5); + + // Count remaining historical files + let mut count = 0; + for entry in std::fs::read_dir(&temp_dir).unwrap().flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with("iccery_") { + count += 1; + } + } + assert_eq!(count, 5); + assert!(temp_dir.join("iccery.log").exists(), "Active log must never be deleted"); + + let _ = std::fs::remove_dir_all(&temp_dir); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 837ea38..e2780ab 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -19,14 +19,20 @@ pub fn run() { tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout), tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Webview), ]) - .level(if cfg!(debug_assertions) { - log::LevelFilter::Debug - } else { - log::LevelFilter::Info - }) - .rotation_strategy(tauri_plugin_log::RotationStrategy::KeepOne) + .max_file_size(5 * 1024 * 1024) + .rotation_strategy(tauri_plugin_log::RotationStrategy::KeepAll) .build(), ) + .setup(|app| { + if let Ok(log_dir) = app.path().app_log_dir() { + commands::prune_historical_logs(&log_dir, 5); + } + let settings = settings::load_settings(app.handle().clone()).unwrap_or_default(); + let filter = settings::parse_log_level_filter(settings.log_level.as_deref()); + log::set_max_level(filter); + log::info!("ICCery initialized. Effective log level: {:?}", filter); + Ok(()) + }) .manage(process_manager::ProcessManager::new()) .manage(print::PrinterDevModeStore::new()) .invoke_handler(tauri::generate_handler![ @@ -34,6 +40,8 @@ pub fn run() { commands::get_app_info, commands::get_default_working_dir, commands::get_log_path, + commands::get_recent_log_excerpt, + commands::log_frontend_message, commands::open_log_dir, commands::parse_ti2_header, commands::select_existing_target, diff --git a/src-tauri/src/process_manager.rs b/src-tauri/src/process_manager.rs index 904925f..ab190c3 100644 --- a/src-tauri/src/process_manager.rs +++ b/src-tauri/src/process_manager.rs @@ -8,6 +8,57 @@ use tokio::sync::Mutex; use crate::events::{emit_error, emit_stderr, emit_stdout}; +pub fn get_user_home_dir() -> Option { + #[cfg(windows)] + { + std::env::var("USERPROFILE") + .ok() + .or_else(|| { + let drive = std::env::var("HOMEDRIVE").ok()?; + let path = std::env::var("HOMEPATH").ok()?; + Some(format!("{}{}", drive, path)) + }) + } + #[cfg(not(windows))] + { + std::env::var("HOME").ok() + } +} + +pub fn sanitize_arg_for_logging(arg: &str) -> String { + if let Some(home) = get_user_home_dir() { + if !home.trim().is_empty() { + #[cfg(windows)] + { + let arg_lower = arg.to_lowercase(); + let home_lower = home.to_lowercase(); + if let Some(pos) = arg_lower.find(&home_lower) { + let mut result = String::new(); + result.push_str(&arg[..pos]); + result.push('~'); + result.push_str(&arg[pos + home.len()..]); + return result; + } + } + #[cfg(not(windows))] + { + if let Some(pos) = arg.find(&home) { + let mut result = String::new(); + result.push_str(&arg[..pos]); + result.push('~'); + result.push_str(&arg[pos + home.len()..]); + return result; + } + } + } + } + arg.to_string() +} + +pub fn sanitize_args_for_logging(args: &[String]) -> Vec { + args.iter().map(|a| sanitize_arg_for_logging(a)).collect() +} + pub struct ProcessManager { stdins: Arc>>>>, killers: Arc>>>, @@ -37,7 +88,7 @@ impl ProcessManager { } let mut command = Command::new(&binary); command.args(&args); - if let Some(dir) = cwd { + if let Some(ref dir) = cwd { command.current_dir(dir); } command.stdout(Stdio::piped()); @@ -51,7 +102,21 @@ impl ProcessManager { command.creation_flags(CREATE_NO_WINDOW); } - log::info!(target: "subprocess", "Spawning process '{id}': {binary} {:?}", args); + let sanitized_binary = sanitize_arg_for_logging(&binary); + let sanitized_args = sanitize_args_for_logging(&args); + let sanitized_cwd = cwd.as_deref().map(sanitize_arg_for_logging); + log::info!( + target: "subprocess", + "Spawning process '{id}': {sanitized_binary} {:?} (cwd: {:?})", + sanitized_args, + sanitized_cwd + ); + log::debug!( + target: "subprocess", + "Raw spawn invocation for '{id}': {binary} {:?} (cwd: {:?})", + args, + cwd + ); match command.spawn() { Ok(mut child) => { let stdout = child.stdout.take().expect("Failed to open stdout"); @@ -244,4 +309,18 @@ mod tests { assert!(killers.is_empty()); } } + + #[test] + fn test_sanitize_arg_for_logging() { + if let Some(home) = get_user_home_dir() { + if !home.is_empty() { + let test_path = format!("{}/test_file.ti1", home); + let sanitized = sanitize_arg_for_logging(&test_path); + assert!(sanitized.starts_with('~')); + assert!(!sanitized.contains(&home)); + } + } + let safe_arg = "-v"; + assert_eq!(sanitize_arg_for_logging(safe_arg), "-v"); + } } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index c554997..ab407fa 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -166,6 +166,23 @@ pub fn get_default_presets() -> Vec { ] } +pub fn parse_log_level_filter(level: Option<&str>) -> log::LevelFilter { + match level.map(|s| s.trim().to_lowercase()).as_deref() { + Some("error") => log::LevelFilter::Error, + Some("warn") | Some("warning") => log::LevelFilter::Warn, + Some("info") => log::LevelFilter::Info, + Some("debug") => log::LevelFilter::Debug, + Some("trace") => log::LevelFilter::Trace, + _ => { + if cfg!(debug_assertions) { + log::LevelFilter::Debug + } else { + log::LevelFilter::Info + } + } + } +} + #[tauri::command] pub fn load_settings(app: AppHandle) -> Result { let path = app.path().app_data_dir().unwrap().join("settings.json"); @@ -181,7 +198,14 @@ pub fn save_settings(app: AppHandle, settings: AppSettings) -> Result<(), String let path = app.path().app_data_dir().unwrap(); fs::create_dir_all(&path).map_err(|e| e.to_string())?; let json = serde_json::to_string_pretty(&settings).unwrap(); - fs::write(path.join("settings.json"), json).map_err(|e| e.to_string()) + fs::write(path.join("settings.json"), json).map_err(|e| e.to_string())?; + + // Dynamically apply the log level immediately at runtime + let filter = parse_log_level_filter(settings.log_level.as_deref()); + log::set_max_level(filter); + log::info!("Applied log level filter: {:?}", filter); + + Ok(()) } #[tauri::command] @@ -304,4 +328,24 @@ mod tests { let imported = import_preset_json(json).expect("Import failed"); assert_eq!(preset, imported); } + + #[test] + fn test_parse_log_level_filter() { + assert_eq!(parse_log_level_filter(Some("error")), log::LevelFilter::Error); + assert_eq!(parse_log_level_filter(Some("ERROR")), log::LevelFilter::Error); + assert_eq!(parse_log_level_filter(Some("warn")), log::LevelFilter::Warn); + assert_eq!(parse_log_level_filter(Some("warning")), log::LevelFilter::Warn); + assert_eq!(parse_log_level_filter(Some("info")), log::LevelFilter::Info); + assert_eq!(parse_log_level_filter(Some("debug")), log::LevelFilter::Debug); + assert_eq!(parse_log_level_filter(Some("trace")), log::LevelFilter::Trace); + assert_eq!(parse_log_level_filter(Some(" Info ")), log::LevelFilter::Info); + + if cfg!(debug_assertions) { + assert_eq!(parse_log_level_filter(None), log::LevelFilter::Debug); + assert_eq!(parse_log_level_filter(Some("unknown")), log::LevelFilter::Debug); + } else { + assert_eq!(parse_log_level_filter(None), log::LevelFilter::Info); + assert_eq!(parse_log_level_filter(Some("unknown")), log::LevelFilter::Info); + } + } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2090b69..79afc04 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ICCery", - "version": "0.5.3", + "version": "0.5.4", "identifier": "com.gronod.iccery", "build": { "frontendDist": "../src" diff --git a/src/index.html b/src/index.html index 0e09510..15e299d 100644 --- a/src/index.html +++ b/src/index.html @@ -691,10 +691,12 @@ + Applies immediately on Save -
+
- + +
diff --git a/src/js/app.js b/src/js/app.js index 8edc069..a359d6a 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -7,6 +7,7 @@ import { initSettings } from './settings.js'; import { initGamutViewer } from './gamut_viewer.js'; import { initPresets } from './presets.js'; import { wizardState } from './state.js'; +import { logger } from './logger.js'; const { invoke } = window.__TAURI__.core; @@ -73,7 +74,7 @@ document.addEventListener('DOMContentLoaded', () => { try { initFn(); } catch (e) { - console.error(`[ICCery Init Error] Failed to initialize ${name}:`, e); + logger.error(`Failed to initialize ${name}: ${e}`, 'AppInit'); } }; diff --git a/src/js/chartread.js b/src/js/chartread.js index 636a896..11d215f 100644 --- a/src/js/chartread.js +++ b/src/js/chartread.js @@ -3,6 +3,7 @@ const { listen } = window.__TAURI__.event; import { startSwatchListener, stopSwatchListener } from './swatch_grid.js'; import { setStage3Result } from './colprof.js'; import { wizardState } from './state.js'; +import { logger } from './logger.js'; // Module-level state: set by Stage 2 when it completes let stage2Basename = ""; diff --git a/src/js/colprof.js b/src/js/colprof.js index e8d0b6e..1ce0d00 100644 --- a/src/js/colprof.js +++ b/src/js/colprof.js @@ -3,6 +3,7 @@ const { listen } = window.__TAURI__.event; import { setStage4Result } from './profcheck.js'; import { loadGamutMesh } from './gamut_viewer.js'; import { wizardState } from './state.js'; +import { logger } from './logger.js'; let chartreadBasename = ""; let chartreadCwd = ""; @@ -130,6 +131,7 @@ export function initColprof() { await invoke("run_colprof", { config }); } catch (err) { + logger.error(`run_colprof invocation failed: ${err}`, 'Stage4-Colprof'); logPre.textContent += `\n[INVOKE ERROR] ${err}\n`; spinnerContainer.classList.add("hidden"); btnCreateProfile.disabled = false; diff --git a/src/js/logger.js b/src/js/logger.js new file mode 100644 index 0000000..433b8ee --- /dev/null +++ b/src/js/logger.js @@ -0,0 +1,49 @@ +const { invoke } = window.__TAURI__.core; + +/** + * Lightweight frontend logger that writes to both the browser console and the + * rotating host application log (iccery.log) via Tauri command `log_frontend_message`. + */ +export const logger = { + async log(level, message, context = null) { + const formattedMsg = message instanceof Error ? `${message.message}\n${message.stack || ''}` : String(message); + + // DevTools console output + if (level === 'error') { + console.error(`[${context || 'Frontend'}]`, formattedMsg); + } else if (level === 'warn' || level === 'warning') { + console.warn(`[${context || 'Frontend'}]`, formattedMsg); + } else if (level === 'debug') { + console.debug(`[${context || 'Frontend'}]`, formattedMsg); + } else { + console.log(`[${context || 'Frontend'}]`, formattedMsg); + } + + // Forward to backend log + try { + await invoke('log_frontend_message', { + level: level.toLowerCase(), + message: formattedMsg, + context: context || null, + }); + } catch (_) { + // Avoid recursion or noise if invoke fails + } + }, + + error(message, context) { + return this.log('error', message, context); + }, + + warn(message, context) { + return this.log('warn', message, context); + }, + + info(message, context) { + return this.log('info', message, context); + }, + + debug(message, context) { + return this.log('debug', message, context); + } +}; diff --git a/src/js/printtarg.js b/src/js/printtarg.js index 9a0408f..418ea0c 100644 --- a/src/js/printtarg.js +++ b/src/js/printtarg.js @@ -2,6 +2,7 @@ const { invoke } = window.__TAURI__.core; const { listen } = window.__TAURI__.event; import { setStage2Result } from './chartread.js'; import { wizardState } from './state.js'; +import { logger } from './logger.js'; // Module-level state: set by Stage 1 when it completes let stage1Basename = ""; @@ -463,6 +464,7 @@ export function initPrinttarg() { await invoke("run_printtarg", { config }); } catch (err) { + logger.error(`run_printtarg invocation failed: ${err}`, 'Stage2-Printtarg'); logPre.textContent += `\n[INVOKE ERROR] ${err}\n`; btnCreateLayout.disabled = false; } diff --git a/src/js/profcheck.js b/src/js/profcheck.js index 8136208..edc4b31 100644 --- a/src/js/profcheck.js +++ b/src/js/profcheck.js @@ -2,6 +2,7 @@ const { invoke } = window.__TAURI__.core; const { listen } = window.__TAURI__.event; import { loadGamutMesh } from './gamut_viewer.js'; import { wizardState } from './state.js'; +import { logger } from './logger.js'; let profileBasename = ""; let profileCwd = ""; @@ -106,6 +107,7 @@ export function initProfcheck() { await invoke("run_profcheck", { config }); } catch (err) { + logger.error(`run_profcheck invocation failed: ${err}`, 'Stage5-Profcheck'); logPre.textContent += `\n[INVOKE ERROR] ${err}\n`; btnVerify.disabled = false; } diff --git a/src/js/settings.js b/src/js/settings.js index 8c8f816..1c7537d 100644 --- a/src/js/settings.js +++ b/src/js/settings.js @@ -1,4 +1,5 @@ const { invoke } = window.__TAURI__.core; +import { logger } from './logger.js'; export async function initSettings() { const dialog = document.getElementById('settingsDialog'); @@ -8,6 +9,7 @@ export async function initSettings() { const logLevelSelect = document.getElementById('logLevelSelect'); const btnOpenLogFolder = document.getElementById('btnOpenLogFolder'); const btnCopyLogPath = document.getElementById('btnCopyLogPath'); + const btnCopyLogExcerpt = document.getElementById('btnCopyLogExcerpt'); const logPathDisplay = document.getElementById('logPathDisplay'); if (!dialog || !openBtn) return; @@ -18,7 +20,7 @@ export async function initSettings() { const path = await invoke('get_log_path'); logPathDisplay.textContent = path ? `Log file: ${path}` : ''; } catch (err) { - console.warn("Could not retrieve log path:", err); + logger.warn(`Could not retrieve log path: ${err}`, 'Settings'); } } @@ -33,7 +35,7 @@ export async function initSettings() { await refreshLogPath(); dialog.showModal(); } catch (e) { - console.error("Failed to load settings:", e); + logger.error(`Failed to load settings: ${e}`, 'Settings'); } }); @@ -42,6 +44,7 @@ export async function initSettings() { try { await invoke('open_log_dir'); } catch (err) { + logger.error(`Failed to open log folder: ${err}`, 'Settings'); alert(`Failed to open log folder: ${err}`); } }); @@ -60,11 +63,35 @@ export async function initSettings() { }, 2000); } } catch (err) { + logger.error(`Failed to copy log path: ${err}`, 'Settings'); alert(`Failed to copy log path: ${err}`); } }); } + if (btnCopyLogExcerpt) { + btnCopyLogExcerpt.addEventListener('click', async () => { + try { + const excerpt = await invoke('get_recent_log_excerpt', { + maxLines: 200, + maxBytes: 65536, + }); + if (excerpt) { + await navigator.clipboard.writeText(excerpt); + const lineCount = excerpt.split('\n').length; + const originalText = btnCopyLogExcerpt.textContent; + btnCopyLogExcerpt.textContent = `✓ Copied (${lineCount} lines)!`; + setTimeout(() => { + btnCopyLogExcerpt.textContent = originalText; + }, 2500); + } + } catch (err) { + logger.error(`Failed to copy log excerpt: ${err}`, 'Settings'); + alert(`Failed to copy log excerpt: ${err}`); + } + }); + } + if (closeBtn) { closeBtn.addEventListener('click', () => dialog.close()); } @@ -80,9 +107,10 @@ export async function initSettings() { log_level: logLevelSelect ? logLevelSelect.value : (currentSettings.log_level || 'info'), }; await invoke('save_settings', { settings }); + logger.info(`Settings saved. Log level set to: ${settings.log_level}`, 'Settings'); dialog.close(); } catch (e) { - console.error("Failed to save settings:", e); + logger.error(`Failed to save settings: ${e}`, 'Settings'); } }); } diff --git a/src/js/targen.js b/src/js/targen.js index 1ff196e..4f01e8d 100644 --- a/src/js/targen.js +++ b/src/js/targen.js @@ -3,6 +3,7 @@ const { listen } = window.__TAURI__.event; import { setStage1Result } from './printtarg.js'; import { populateStage3TargetContext } from './chartread.js'; import { wizardState } from './state.js'; +import { logger } from './logger.js'; export function initTargen() { const colourSpaceRadios = document.querySelectorAll('input[name="colourSpace"]'); @@ -351,6 +352,7 @@ export function initTargen() { if (logPre) logPre.textContent = "Starting targen...\n"; await invoke("run_targen", { config }); } catch (err) { + logger.error(`run_targen invocation failed: ${err}`, 'Stage1-Targen'); if (logPre) logPre.textContent += `\n[INVOKE ERROR] ${err}\n`; btnGenerate.disabled = false; } -- 2.39.5