From 2482b8fe9165d6e21614de2b8792c90e9b1c75b0 Mon Sep 17 00:00:00 2001 From: briviamoon Date: Sun, 15 Feb 2026 14:58:35 +0100 Subject: [PATCH 1/2] feat: add theming, terminal options, and lifecycle cleanup - add theme system (dark/light/auto/frosted) with UI selector - support terminal app choice and keep-open behavior per step - track last-launched processes and close them on exit if enabled - implement Windows app discovery (Steam/Epic/installed apps) --- CHANGELOG.md | 19 ++ frontend/src/config.js | 10 +- frontend/src/dialogs.js | 50 +++- frontend/src/main.js | 13 +- frontend/src/startup.js | 8 +- frontend/src/steps.js | 11 +- frontend/src/styles.css | 119 +++++++++- frontend/src/theme.js | 7 + src-tauri/src/commands.rs | 38 +++- src-tauri/src/config.rs | 6 + src-tauri/src/discovery.rs | 451 +++++++++++++++++++++++++++++++++++++ src-tauri/src/launcher.rs | 50 ++-- src-tauri/src/lib.rs | 17 +- src-tauri/src/lifecycle.rs | 21 ++ src-tauri/src/tray.rs | 2 + 15 files changed, 785 insertions(+), 37 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 frontend/src/theme.js create mode 100644 src-tauri/src/discovery.rs create mode 100644 src-tauri/src/lifecycle.rs diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4b4edd0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## Unreleased + +## 1.0.1 - 2026-02-15 + +### Added +- Theme system with `dark`, `light`, `auto`, and `frosted` modes, plus a new Theme setting. +- Per-step “Leave process running” option and terminal app selection (Windows Terminal or Command Prompt). +- Close-on-exit option to terminate last-launched processes. +- Windows app discovery for Steam, Epic Games, and installed apps. +- `LastLaunch` state and command to track last-launched processes. + +### Changed +- Terminal steps now display `WT`/`CMD` badges and show default labels when no command is provided. +- Close-on-switch ignores steps marked to keep running. +- Titlebar and overlay now use theme-aware colors. diff --git a/frontend/src/config.js b/frontend/src/config.js index 07dc2c5..87ca345 100644 --- a/frontend/src/config.js +++ b/frontend/src/config.js @@ -48,14 +48,14 @@ export function newStep(type = 'app') { switch (type) { case 'app': - return { ...base, target: '', check_running: true }; + return { ...base, target: '', check_running: true, keep_open: false }; case 'terminal': - return { ...base, command: '', working_dir: '', keep_open: true }; + return { ...base, terminal_app: 'windows-terminal', command: '', working_dir: '', keep_open: true }; case 'folder': - return { ...base, target: '' }; + return { ...base, target: '', keep_open: false }; case 'url': - return { ...base, target: '' }; + return { ...base, target: '', keep_open: false }; default: - return { ...base, target: '' }; + return { ...base, target: '', keep_open: false }; } } diff --git a/frontend/src/dialogs.js b/frontend/src/dialogs.js index 8f73915..2426fce 100644 --- a/frontend/src/dialogs.js +++ b/frontend/src/dialogs.js @@ -128,7 +128,7 @@ export function showProfileEditor(profile, isNew) { export function showStepEditor(step, isNew) { return new Promise((resolve) => { const typeOptions = ['app', 'terminal', 'folder', 'url'] - .map(t => ``) + .map(t => ``) .join(''); showModal(` @@ -201,6 +201,10 @@ function renderStepFields(step) { +
+ + +
`; document.getElementById('se-browse-file').addEventListener('click', async () => { try { @@ -221,8 +225,15 @@ function renderStepFields(step) { case 'terminal': container.innerHTML = `
- - + + +
+
+ +
@@ -233,7 +244,7 @@ function renderStepFields(step) {
- +
`; document.getElementById('se-browse-dir').addEventListener('click', async () => { @@ -253,6 +264,10 @@ function renderStepFields(step) { +
+ + +
`; document.getElementById('se-browse-folder').addEventListener('click', async () => { try { @@ -268,6 +283,10 @@ function renderStepFields(step) { +
+ + +
`; break; } @@ -279,22 +298,26 @@ function readStepFields(step) { // Clean up fields from other types delete step.target; delete step.check_running; + delete step.terminal_app; delete step.command; delete step.working_dir; - delete step.keep_open; switch (type) { case 'app': { const target = document.getElementById('se-target'); const checkRunning = document.getElementById('se-check-running'); + const keepOpen = document.getElementById('se-keep-open'); step.target = target ? target.value.trim() : ''; step.check_running = checkRunning ? checkRunning.checked : true; + step.keep_open = keepOpen ? keepOpen.checked : false; break; } case 'terminal': { + const terminalApp = document.getElementById('se-terminal-app'); const command = document.getElementById('se-command'); const workdir = document.getElementById('se-workdir'); const keepOpen = document.getElementById('se-keep-open'); + step.terminal_app = terminalApp ? terminalApp.value : 'windows-terminal'; step.command = command ? command.value.trim() : ''; step.working_dir = workdir ? workdir.value.trim() : ''; step.keep_open = keepOpen ? keepOpen.checked : true; @@ -303,7 +326,9 @@ function readStepFields(step) { case 'folder': case 'url': { const target = document.getElementById('se-target'); + const keepOpen = document.getElementById('se-keep-open'); step.target = target ? target.value.trim() : ''; + step.keep_open = keepOpen ? keepOpen.checked : false; break; } } @@ -319,6 +344,15 @@ export function showSettings(settings) { +
+ + +
@@ -331,6 +365,10 @@ export function showSettings(settings) {
+
+ + +
@@ -354,9 +392,11 @@ export function showSettings(settings) { const result = { ...settings, launch_delay_ms: parseInt(document.getElementById('set-delay').value) || 500, + theme: document.getElementById('set-theme').value, start_minimized: document.getElementById('set-minimized').checked, minimize_to_tray: document.getElementById('set-tray').checked, close_on_switch: document.getElementById('set-close-switch').checked, + close_on_exit: document.getElementById('set-close-exit').checked, auto_start_with_windows: autoStart }; hideModal(); diff --git a/frontend/src/main.js b/frontend/src/main.js index 01676b8..785be24 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -5,6 +5,7 @@ import { startLaunch, cancelLaunch, isLaunching } from './launcher.js'; import { showSettings, showCloseOnSwitch, showLaunchHistory } from './dialogs.js'; import { showStartupPanel } from './startup.js'; import { toggleProcessPanel } from './processes.js'; +import { applyTheme } from './theme.js'; const { invoke } = window.__TAURI__.core; const { listen } = window.__TAURI__.event; @@ -15,6 +16,7 @@ let _lastLaunchedProfileId = null; async function init() { try { const config = await loadConfig(); + applyTheme(config.settings?.theme); renderProfiles(); if (config.profiles.length > 0) { @@ -83,6 +85,14 @@ async function handleLaunch() { } _lastLaunchedProfileId = profile.id; + const processNames = profile.steps + .filter(s => s.enabled && s.process_name && s.keep_open !== true) + .map(s => s.process_name); + try { + await invoke('set_last_launch_processes', { processNames }); + } catch (e) { + console.error('Failed to set last launch processes:', e); + } const enabledSteps = profile.steps.filter(s => s.enabled); await startLaunch(profile.steps, config.settings.launch_delay_ms || 500); // Record in history (count enabled steps as launched; errors handled by launcher events) @@ -96,7 +106,7 @@ async function handleLaunch() { async function handleCloseOnSwitch(previousProfile) { // Get process names from previous profile that have process_name set const processNames = previousProfile.steps - .filter(s => s.process_name && s.enabled) + .filter(s => s.process_name && s.enabled && s.keep_open !== true) .map(s => s.process_name); if (processNames.length === 0) return; @@ -130,6 +140,7 @@ async function handleSettings() { config.settings = result; await saveConfig(config); + applyTheme(config.settings?.theme); } async function listenTrayEvents() { diff --git a/frontend/src/startup.js b/frontend/src/startup.js index 51dc933..6a9cac0 100644 --- a/frontend/src/startup.js +++ b/frontend/src/startup.js @@ -25,8 +25,12 @@ function buildStartupHTML() { stepsHtml = '
No startup apps
'; } else { stepsHtml = apps.map((step, index) => { - const badgeLabel = step.type === 'terminal' ? 'CMD' : step.type.toUpperCase(); - const detail = step.target || step.command || ''; + const badgeLabel = step.type === 'terminal' + ? (step.terminal_app === 'cmd' ? 'CMD' : 'WT') + : step.type.toUpperCase(); + const detail = step.type === 'terminal' + ? (step.command || (step.terminal_app === 'cmd' ? 'Open Command Prompt' : 'Open Windows Terminal')) + : (step.target || step.command || ''); return `
diff --git a/frontend/src/steps.js b/frontend/src/steps.js index efa5487..b2c2ab1 100644 --- a/frontend/src/steps.js +++ b/frontend/src/steps.js @@ -23,7 +23,9 @@ export function renderSteps() { card.className = 'step-card' + (step.enabled ? '' : ' disabled'); card.dataset.stepId = step.id; - const badgeLabel = step.type === 'terminal' ? 'CMD' : step.type.toUpperCase(); + const badgeLabel = step.type === 'terminal' + ? (step.terminal_app === 'cmd' ? 'CMD' : 'WT') + : step.type.toUpperCase(); const detail = getStepDetail(step); card.innerHTML = ` @@ -101,7 +103,12 @@ export function renderSteps() { function getStepDetail(step) { switch (step.type) { case 'app': return step.target || ''; - case 'terminal': return step.command || ''; + case 'terminal': { + const defaultLabel = step.terminal_app === 'cmd' + ? 'Open Command Prompt' + : 'Open Windows Terminal'; + return step.command || defaultLabel; + } case 'folder': return step.target || ''; case 'url': return step.target || ''; default: return ''; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index a02b183..3bc7269 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -12,6 +12,7 @@ --bg-card: #1e2a4a; --bg-card-hover: #253352; --bg-input: #0d1b36; + --bg-titlebar: #0e0e1a; --text-primary: #e0e0e0; --text-secondary: #a0a0b0; --text-muted: #707080; @@ -23,6 +24,9 @@ --success: #16a34a; --border: #2a2a4a; --border-light: #3a3a5a; + --overlay: rgba(0, 0, 0, 0.6); + --glass-blur: 0px; + --glass-saturate: 1; --badge-app: #2563eb; --badge-cmd: #16a34a; --badge-dir: #ea580c; @@ -31,6 +35,117 @@ --radius-lg: 10px; } +:root[data-theme="light"] { + --bg-primary: #f5f7fb; + --bg-secondary: #eef2f7; + --bg-tertiary: #e2e8f0; + --bg-card: #ffffff; + --bg-card-hover: #f1f5f9; + --bg-input: #ffffff; + --bg-titlebar: #e9edf4; + --text-primary: #0f172a; + --text-secondary: #475569; + --text-muted: #64748b; + --accent: #2563eb; + --accent-hover: #1d4ed8; + --accent-dim: #3b82f6; + --danger: #dc2626; + --danger-hover: #ef4444; + --success: #16a34a; + --border: #dbe2ea; + --border-light: #cbd5e1; + --overlay: rgba(15, 23, 42, 0.45); +} + +:root[data-theme="auto"] { + --bg-primary: #f5f7fb; + --bg-secondary: #eef2f7; + --bg-tertiary: #e2e8f0; + --bg-card: #ffffff; + --bg-card-hover: #f1f5f9; + --bg-input: #ffffff; + --bg-titlebar: #e9edf4; + --text-primary: #0f172a; + --text-secondary: #475569; + --text-muted: #64748b; + --accent: #2563eb; + --accent-hover: #1d4ed8; + --accent-dim: #3b82f6; + --danger: #dc2626; + --danger-hover: #ef4444; + --success: #16a34a; + --border: #dbe2ea; + --border-light: #cbd5e1; + --overlay: rgba(15, 23, 42, 0.45); +} + +@media (prefers-color-scheme: dark) { + :root[data-theme="auto"] { + --bg-primary: #1a1a2e; + --bg-secondary: #16213e; + --bg-tertiary: #0f3460; + --bg-card: #1e2a4a; + --bg-card-hover: #253352; + --bg-input: #0d1b36; + --bg-titlebar: #0e0e1a; + --text-primary: #e0e0e0; + --text-secondary: #a0a0b0; + --text-muted: #707080; + --accent: #2563eb; + --accent-hover: #3b82f6; + --accent-dim: #1e40af; + --danger: #dc2626; + --danger-hover: #ef4444; + --success: #16a34a; + --border: #2a2a4a; + --border-light: #3a3a5a; + --overlay: rgba(0, 0, 0, 0.6); + } +} + +:root[data-theme="frosted"] { + --bg-primary: #0b1120; + --bg-secondary: rgba(15, 23, 42, 0.6); + --bg-tertiary: rgba(30, 41, 59, 0.6); + --bg-card: rgba(15, 23, 42, 0.55); + --bg-card-hover: rgba(30, 41, 59, 0.7); + --bg-input: rgba(15, 23, 42, 0.6); + --bg-titlebar: rgba(15, 23, 42, 0.55); + --text-primary: #e2e8f0; + --text-secondary: #94a3b8; + --text-muted: #7c8aa5; + --accent: #38bdf8; + --accent-hover: #7dd3fc; + --accent-dim: #0ea5e9; + --danger: #f87171; + --danger-hover: #ef4444; + --success: #34d399; + --border: rgba(148, 163, 184, 0.25); + --border-light: rgba(148, 163, 184, 0.35); + --overlay: rgba(2, 6, 23, 0.6); + --glass-blur: 14px; + --glass-saturate: 1.15; +} + +:root[data-theme="frosted"] body { + background: + radial-gradient(900px 700px at 10% 10%, rgba(56, 189, 248, 0.12), transparent 60%), + radial-gradient(800px 600px at 90% 20%, rgba(99, 102, 241, 0.12), transparent 55%), + linear-gradient(160deg, #0b1120 0%, #0a0f1b 45%, #0b1120 100%); +} + +@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { + :root[data-theme="frosted"] #titlebar, + :root[data-theme="frosted"] #toolbar, + :root[data-theme="frosted"] #sidebar, + :root[data-theme="frosted"] #statusbar, + :root[data-theme="frosted"] #process-panel, + :root[data-theme="frosted"] #modal-content { + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); + } +} + body { font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; background: var(--bg-primary); @@ -48,7 +163,7 @@ body { display: flex; align-items: center; justify-content: space-between; - background: #0e0e1a; + background: var(--bg-titlebar); height: 32px; flex-shrink: 0; -webkit-app-region: drag; @@ -530,7 +645,7 @@ body { #modal-overlay { position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.6); + background: var(--overlay); display: flex; align-items: center; justify-content: center; diff --git a/frontend/src/theme.js b/frontend/src/theme.js new file mode 100644 index 0000000..17fd260 --- /dev/null +++ b/frontend/src/theme.js @@ -0,0 +1,7 @@ +const THEMES = new Set(['dark', 'light', 'auto', 'frosted']); + +export function applyTheme(theme) { + const root = document.documentElement; + const value = (theme || 'dark').toLowerCase(); + root.dataset.theme = THEMES.has(value) ? value : 'dark'; +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 9b0ed12..b2f0f3c 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -4,7 +4,7 @@ use crate::launcher; use crate::process; use crate::tray; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use tauri::{Emitter, Manager, State}; pub struct LaunchState { @@ -21,6 +21,37 @@ impl Default for LaunchState { } } +pub struct LastLaunch { + process_names: Mutex>, +} + +impl Default for LastLaunch { + fn default() -> Self { + LastLaunch { + process_names: Mutex::new(Vec::new()), + } + } +} + +impl LastLaunch { + pub fn set_processes(&self, mut names: Vec) { + names.retain(|n| !n.trim().is_empty()); + let mut names: Vec = names + .into_iter() + .map(|n| n.trim().to_lowercase()) + .collect(); + names.sort(); + names.dedup(); + let mut guard = self.process_names.lock().unwrap_or_else(|e| e.into_inner()); + *guard = names; + } + + pub fn get_processes(&self) -> Vec { + let guard = self.process_names.lock().unwrap_or_else(|e| e.into_inner()); + guard.clone() + } +} + #[tauri::command] pub fn get_config() -> Result { Ok(config::load_config()) @@ -224,6 +255,11 @@ pub async fn scan_apps() -> Vec { .unwrap_or_default() } +#[tauri::command] +pub fn set_last_launch_processes(process_names: Vec, state: State<'_, LastLaunch>) { + state.set_processes(process_names); +} + #[tauri::command] pub fn set_auto_start(enabled: bool) -> Result<(), String> { #[cfg(target_os = "windows")] diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 6457b02..1c44ac5 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -35,6 +35,8 @@ pub struct Settings { #[serde(default = "default_true")] pub minimize_to_tray: bool, #[serde(default)] + pub close_on_exit: bool, + #[serde(default)] pub auto_start_with_windows: bool, } @@ -80,9 +82,12 @@ pub struct Step { pub check_running: Option, // Terminal fields #[serde(skip_serializing_if = "Option::is_none")] + pub terminal_app: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub command: Option, #[serde(skip_serializing_if = "Option::is_none")] pub working_dir: Option, + // Step behavior fields #[serde(skip_serializing_if = "Option::is_none")] pub keep_open: Option, } @@ -108,6 +113,7 @@ impl Default for AppConfig { start_minimized: false, close_on_switch: true, minimize_to_tray: true, + close_on_exit: false, auto_start_with_windows: false, }, profiles: vec![], diff --git a/src-tauri/src/discovery.rs b/src-tauri/src/discovery.rs new file mode 100644 index 0000000..4464f80 --- /dev/null +++ b/src-tauri/src/discovery.rs @@ -0,0 +1,451 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize)] +pub struct DiscoveredApp { + pub name: String, + pub target: String, + pub process_name: String, + pub source: String, +} + +pub fn scan_all() -> Vec { + #[cfg(target_os = "windows")] + { + return scan_all_windows(); + } + + #[cfg(not(target_os = "windows"))] + { + Vec::new() + } +} + +#[cfg(target_os = "windows")] +fn scan_all_windows() -> Vec { + use std::collections::HashSet; + + let mut apps = Vec::new(); + let mut seen = HashSet::new(); + + for app in scan_steam() { + add_unique(&mut apps, &mut seen, app); + } + for app in scan_epic() { + add_unique(&mut apps, &mut seen, app); + } + for app in scan_windows() { + add_unique(&mut apps, &mut seen, app); + } + + apps +} + +#[cfg(target_os = "windows")] +fn add_unique(apps: &mut Vec, seen: &mut std::collections::HashSet, app: DiscoveredApp) { + let key = if !app.target.is_empty() { + format!("t:{}", app.target.to_lowercase()) + } else { + format!("n:{}:{}", app.source, app.name.to_lowercase()) + }; + if seen.insert(key) { + apps.push(app); + } +} + +#[cfg(target_os = "windows")] +fn scan_steam() -> Vec { + use std::fs; + + let mut results = Vec::new(); + for lib in steam_library_paths() { + let steamapps = lib.join("steamapps"); + if !steamapps.is_dir() { + continue; + } + let entries = match fs::read_dir(&steamapps) { + Ok(entries) => entries, + Err(_) => continue, + }; + + for entry in entries.flatten() { + let path = entry.path(); + let file_name = match path.file_name().and_then(|s| s.to_str()) { + Some(name) => name, + None => continue, + }; + if !file_name.starts_with("appmanifest_") || !file_name.ends_with(".acf") { + continue; + } + let contents = match fs::read_to_string(&path) { + Ok(contents) => contents, + Err(_) => continue, + }; + let (appid, name) = match parse_steam_manifest(&contents) { + Some(values) => values, + None => continue, + }; + + let target = format!("steam://rungameid/{}", appid); + results.push(DiscoveredApp { + name, + target, + process_name: String::new(), + source: "steam".to_string(), + }); + } + } + + results +} + +#[cfg(target_os = "windows")] +fn steam_library_paths() -> Vec { + use std::collections::HashSet; + use std::fs; + use std::path::PathBuf; + use winreg::enums::HKEY_CURRENT_USER; + use winreg::RegKey; + + let mut paths = Vec::new(); + let mut seen = HashSet::new(); + + if let Ok(hkcu) = RegKey::predef(HKEY_CURRENT_USER).open_subkey("Software\\Valve\\Steam") { + if let Ok(raw_path) = hkcu.get_value::("SteamPath") { + let normalized = raw_path.replace('/', "\\"); + let path = PathBuf::from(normalized); + if path.is_dir() && seen.insert(path.clone()) { + paths.push(path); + } + } + } + + let fallback_paths = [ + r"C:\Program Files (x86)\Steam", + r"C:\Program Files\Steam", + ]; + for fallback in fallback_paths { + let path = PathBuf::from(fallback); + if path.is_dir() && seen.insert(path.clone()) { + paths.push(path); + } + } + + let steam_path = paths.get(0).cloned(); + if let Some(steam_root) = steam_path { + let vdf_path = steam_root.join("steamapps").join("libraryfolders.vdf"); + if let Ok(contents) = fs::read_to_string(&vdf_path) { + for line in contents.lines() { + if let Some((key, value)) = parse_vdf_kv(line) { + if key == "path" { + let mut normalized = value.replace("\\\\", "\\"); + normalized = normalized.replace('/', "\\"); + let path = PathBuf::from(normalized); + if path.is_dir() && seen.insert(path.clone()) { + paths.push(path); + } + } + } + } + } + } + + paths +} + +#[cfg(target_os = "windows")] +fn parse_steam_manifest(contents: &str) -> Option<(String, String)> { + let mut appid: Option = None; + let mut name: Option = None; + + for line in contents.lines() { + if let Some((key, value)) = parse_vdf_kv(line) { + match key.as_str() { + "appid" => appid = Some(value), + "name" => name = Some(value), + _ => {} + } + } + if appid.is_some() && name.is_some() { + break; + } + } + + match (appid, name) { + (Some(id), Some(n)) if !id.is_empty() && !n.is_empty() => Some((id, n)), + _ => None, + } +} + +#[cfg(target_os = "windows")] +fn parse_vdf_kv(line: &str) -> Option<(String, String)> { + let line = line.trim(); + if !line.starts_with('"') { + return None; + } + + let mut parts = Vec::new(); + let mut current = String::new(); + let mut in_quotes = false; + + for ch in line.chars() { + if ch == '"' { + if in_quotes { + parts.push(current.clone()); + current.clear(); + } + in_quotes = !in_quotes; + } else if in_quotes { + current.push(ch); + } + } + + if parts.len() >= 2 { + Some((parts[0].clone(), parts[1].clone())) + } else { + None + } +} + +#[cfg(target_os = "windows")] +fn scan_epic() -> Vec { + use std::fs; + use std::path::PathBuf; + + #[derive(Debug, Deserialize)] + struct EpicManifest { + #[serde(rename = "DisplayName")] + display_name: Option, + #[serde(rename = "AppName")] + app_name: Option, + #[serde(rename = "InstallLocation")] + install_location: Option, + #[serde(rename = "LaunchExecutable")] + launch_executable: Option, + #[serde(rename = "LaunchCommand")] + launch_command: Option, + } + + let program_data = std::env::var("PROGRAMDATA").unwrap_or_else(|_| "C:\\ProgramData".to_string()); + let manifest_dir = PathBuf::from(program_data) + .join("Epic") + .join("EpicGamesLauncher") + .join("Data") + .join("Manifests"); + + let mut results = Vec::new(); + + let entries = match fs::read_dir(&manifest_dir) { + Ok(entries) => entries, + Err(_) => return results, + }; + + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()).unwrap_or("") != "item" { + continue; + } + + let contents = match fs::read_to_string(&path) { + Ok(contents) => contents, + Err(_) => continue, + }; + + let manifest: EpicManifest = match serde_json::from_str(&contents) { + Ok(manifest) => manifest, + Err(_) => continue, + }; + + let name = manifest + .display_name + .clone() + .or(manifest.app_name.clone()) + .unwrap_or_default(); + if name.is_empty() { + continue; + } + + let mut target: Option = None; + let mut process_name = String::new(); + + if let (Some(install), Some(exec)) = (manifest.install_location.clone(), manifest.launch_executable.clone()) { + let exe_path = if PathBuf::from(&exec).is_absolute() { + PathBuf::from(exec) + } else { + let mut path = PathBuf::from(install); + path.push(exec); + path + }; + if exe_path.exists() { + target = Some(exe_path.to_string_lossy().to_string()); + process_name = process_name_from_path(target.as_deref().unwrap_or("")); + } + } + + if target.is_none() { + if let Some(cmd) = manifest.launch_command.clone() { + if cmd.contains("com.epicgames.launcher://") { + target = Some(cmd); + } + } + } + + if target.is_none() { + if let Some(app_name) = manifest.app_name.clone() { + target = Some(format!( + "com.epicgames.launcher://apps/{}?action=launch&silent=true", + app_name + )); + } + } + + if let Some(target) = target { + results.push(DiscoveredApp { + name, + target, + process_name, + source: "epic".to_string(), + }); + } + } + + results +} + +#[cfg(target_os = "windows")] +fn scan_windows() -> Vec { + use std::path::Path; + use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE}; + use winreg::RegKey; + + let mut results = Vec::new(); + let uninstall_paths = [ + (HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"), + ( + HKEY_LOCAL_MACHINE, + r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall", + ), + (HKEY_CURRENT_USER, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"), + ]; + + for (root, path) in uninstall_paths { + let root = RegKey::predef(root); + let key = match root.open_subkey(path) { + Ok(key) => key, + Err(_) => continue, + }; + + for subkey_name in key.enum_keys().flatten() { + let subkey = match key.open_subkey(&subkey_name) { + Ok(subkey) => subkey, + Err(_) => continue, + }; + + if let Ok(component) = subkey.get_value::("SystemComponent") { + if component == 1 { + continue; + } + } + + let display_name: String = match subkey.get_value("DisplayName") { + Ok(name) => name, + Err(_) => continue, + }; + if display_name.trim().is_empty() { + continue; + } + + let display_icon: Option = subkey.get_value("DisplayIcon").ok(); + let target = display_icon + .and_then(|icon| extract_exe_path(&icon)) + .and_then(|path| { + let p = Path::new(&path); + if p.exists() { Some(path) } else { None } + }); + + let target = match target { + Some(target) => target, + None => continue, + }; + + let process_name = process_name_from_path(&target); + + results.push(DiscoveredApp { + name: display_name, + target, + process_name, + source: "windows".to_string(), + }); + } + } + + results +} + +#[cfg(target_os = "windows")] +fn process_name_from_path(path: &str) -> String { + std::path::Path::new(path) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_lowercase() +} + +#[cfg(target_os = "windows")] +fn extract_exe_path(raw: &str) -> Option { + let expanded = expand_env_vars(raw); + let mut text = expanded.trim().to_string(); + if text.is_empty() { + return None; + } + + if text.starts_with('"') { + if let Some(end) = text[1..].find('"') { + let end_idx = 1 + end; + text = text[1..end_idx].to_string(); + } + } + + let lower = text.to_lowercase(); + if let Some(idx) = lower.find(".exe") { + let path = text[..idx + 4].trim().to_string(); + if path.is_empty() { + None + } else { + Some(path) + } + } else { + None + } +} + +#[cfg(target_os = "windows")] +fn expand_env_vars(input: &str) -> String { + let mut result = String::new(); + let mut chars = input.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch == '%' { + let mut var = String::new(); + while let Some(&next) = chars.peek() { + chars.next(); + if next == '%' { + break; + } + var.push(next); + } + if var.is_empty() { + result.push('%'); + } else if let Ok(value) = std::env::var(&var) { + result.push_str(&value); + } else { + result.push('%'); + result.push_str(&var); + result.push('%'); + } + } else { + result.push(ch); + } + } + + result +} diff --git a/src-tauri/src/launcher.rs b/src-tauri/src/launcher.rs index bb4d571..7c802d6 100644 --- a/src-tauri/src/launcher.rs +++ b/src-tauri/src/launcher.rs @@ -86,10 +86,7 @@ fn launch_via_start(target: &str) -> Result<(), String> { } fn launch_terminal(step: &Step) -> Result<(), String> { - let command = step.command.as_deref().unwrap_or(""); - if command.is_empty() { - return Err("No command specified".to_string()); - } + let command = step.command.as_deref().unwrap_or("").trim(); let working_dir = step .working_dir @@ -98,20 +95,47 @@ fn launch_terminal(step: &Step) -> Result<(), String> { .unwrap_or_default(); let keep_open = step.keep_open.unwrap_or(true); + let terminal_app = step + .terminal_app + .as_deref() + .unwrap_or("windows-terminal") + .to_lowercase(); #[cfg(target_os = "windows")] { - let flag = if keep_open { "/K" } else { "/C" }; - let mut cmd = Command::new("cmd"); - cmd.args(["/C", "start", "cmd", flag, command]); + if terminal_app == "cmd" || terminal_app == "command-prompt" { + let mut cmd = Command::new("cmd"); - if !working_dir.is_empty() { - cmd.current_dir(&working_dir); - } + if command.is_empty() { + cmd.args(["/C", "start", "cmd"]); + } else { + let flag = if keep_open { "/K" } else { "/C" }; + cmd.args(["/C", "start", "cmd", flag, command]); + } - cmd.creation_flags(CREATE_NO_WINDOW) - .spawn() - .map_err(|e| format!("Failed to launch terminal: {}", e))?; + if !working_dir.is_empty() { + cmd.current_dir(&working_dir); + } + + cmd.creation_flags(CREATE_NO_WINDOW) + .spawn() + .map_err(|e| format!("Failed to launch Command Prompt: {}", e))?; + } else { + let mut cmd = Command::new("wt"); + + if !working_dir.is_empty() { + cmd.args(["-d", &working_dir]); + } + + if !command.is_empty() { + let flag = if keep_open { "/K" } else { "/C" }; + cmd.args(["cmd", flag, command]); + } + + cmd.creation_flags(CREATE_NO_WINDOW) + .spawn() + .map_err(|e| format!("Failed to launch Windows Terminal: {}", e))?; + } } Ok(()) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c4af876..d1a7bf4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod commands; mod config; mod discovery; mod launcher; +mod lifecycle; mod process; mod scheduler; mod tray; @@ -15,6 +16,7 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_shell::init()) .manage(LaunchState::default()) + .manage(commands::LastLaunch::default()) .invoke_handler(tauri::generate_handler![ commands::get_config, commands::save_config, @@ -26,6 +28,7 @@ pub fn run() { commands::browse_file, commands::browse_folder, commands::scan_apps, + commands::set_last_launch_processes, commands::show_window, commands::set_auto_start, commands::browse_save_profile, @@ -74,13 +77,15 @@ pub fn run() { .on_window_event(|window, event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { // Check minimize_to_tray setting - let cfg = config::load_config(); - if cfg.settings.minimize_to_tray { - api.prevent_close(); - let _ = window.hide(); - } + let cfg = config::load_config(); + if cfg.settings.minimize_to_tray { + api.prevent_close(); + let _ = window.hide(); + return; } - }) + lifecycle::close_apps_on_exit(&window.app_handle()); + } + }) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src-tauri/src/lifecycle.rs b/src-tauri/src/lifecycle.rs new file mode 100644 index 0000000..47f9e1f --- /dev/null +++ b/src-tauri/src/lifecycle.rs @@ -0,0 +1,21 @@ +use crate::commands::LastLaunch; +use crate::config; +use crate::process; +use tauri::Manager; + +pub fn close_apps_on_exit(app: &tauri::AppHandle) { + let cfg = config::load_config(); + if !cfg.settings.close_on_exit { + return; + } + + let state = app.state::(); + let process_names = state.get_processes(); + if process_names.is_empty() { + return; + } + + for name in process_names { + let _ = process::kill_process(&name); + } +} diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index d532033..0ff0c17 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -1,4 +1,5 @@ use crate::config::AppConfig; +use crate::lifecycle; use tauri::{ image::Image, menu::{MenuBuilder, MenuItemBuilder}, @@ -33,6 +34,7 @@ pub fn create_tray(app: &tauri::AppHandle) -> Result<(), Box Date: Sun, 15 Feb 2026 16:55:06 +0100 Subject: [PATCH 2/2] feat: add Kill & Wipe workflow, shortcuts, and post-logout message --- CHANGELOG.md | 4 + frontend/index.html | 1 + frontend/src/dialogs.js | 78 ++++++ frontend/src/main.js | 110 ++++++++- frontend/src/styles.css | 19 ++ src-tauri/src/commands.rs | 64 +++++ src-tauri/src/config.rs | 33 +++ src-tauri/src/kill_wipe.rs | 473 +++++++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 5 + 9 files changed, 786 insertions(+), 1 deletion(-) create mode 100644 src-tauri/src/kill_wipe.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b4edd0..4d85ffb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,12 @@ All notable changes to this project will be documented in this file. - Close-on-exit option to terminate last-launched processes. - Windows app discovery for Steam, Epic Games, and installed apps. - `LastLaunch` state and command to track last-launched processes. +- Kill & Wipe workflow with a toolbar action, selectable options, and optional immediate mode. +- Desktop shortcut creation for Kill & Wipe with startup flags (`--kill-and-wipe`, `--kill-and-wipe-immediate`). +- Post-logout greeting message after Kill & Wipe completes. ### Changed - Terminal steps now display `WT`/`CMD` badges and show default labels when no command is provided. - Close-on-switch ignores steps marked to keep running. - Titlebar and overlay now use theme-aware colors. +- Kill & Wipe runs Windows-first process cleanup, temp clearing, browser data wiping, DNS flush, and optional logout. diff --git a/frontend/index.html b/frontend/index.html index 1f919ba..0051d81 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -25,6 +25,7 @@ +
diff --git a/frontend/src/dialogs.js b/frontend/src/dialogs.js index 2426fce..e6f77ac 100644 --- a/frontend/src/dialogs.js +++ b/frontend/src/dialogs.js @@ -29,6 +29,20 @@ export function showConfirm(title, message) { }); } +// -- Info dialog -- +export function showInfo(title, message) { + return new Promise((resolve) => { + showModal(` + +

${escapeHtml(message || '')}

+ + `); + document.getElementById('info-ok').addEventListener('click', () => { hideModal(); resolve(true); }); + }); +} + // ── Profile editor ── export function showProfileEditor(profile, isNew) { return new Promise((resolve) => { @@ -405,6 +419,70 @@ export function showSettings(settings) { }); } +// -- Kill & Wipe dialog -- +export function showKillAndWipe(settings) { + return new Promise((resolve) => { + const s = settings || {}; + + showModal(` + +
+ This is destructive. It can close apps, delete cache, wipe browser data, and log you out. +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + `); + + document.getElementById('kw-cancel').addEventListener('click', () => { hideModal(); resolve(null); }); + document.getElementById('kw-run').addEventListener('click', () => { + const result = { + settings: { + ...s, + kill_processes: document.getElementById('kw-kill').checked, + clear_temp: document.getElementById('kw-temp').checked, + clear_browsers: document.getElementById('kw-browsers').checked, + flush_dns: document.getElementById('kw-dns').checked, + logout: document.getElementById('kw-logout').checked, + confirm_before: document.getElementById('kw-confirm').checked + }, + create_shortcut: document.getElementById('kw-shortcut').checked + }; + hideModal(); + resolve(result); + }); + }); +} + // ── Close-on-switch dialog ── export function showCloseOnSwitch(processes) { return new Promise((resolve) => { diff --git a/frontend/src/main.js b/frontend/src/main.js index 785be24..fd0b767 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -2,7 +2,7 @@ import { loadConfig, getConfig, saveConfig } from './config.js'; import { renderProfiles, selectProfile, addProfile, getSelectedProfile, getSelectedProfileId, importProfile } from './profiles.js'; import { renderSteps, addStep } from './steps.js'; import { startLaunch, cancelLaunch, isLaunching } from './launcher.js'; -import { showSettings, showCloseOnSwitch, showLaunchHistory } from './dialogs.js'; +import { showSettings, showCloseOnSwitch, showLaunchHistory, showKillAndWipe, showInfo } from './dialogs.js'; import { showStartupPanel } from './startup.js'; import { toggleProcessPanel } from './processes.js'; import { applyTheme } from './theme.js'; @@ -24,6 +24,8 @@ async function init() { } wireEvents(); + await maybeShowPostLogoutMessage(); + await handleStartupFlags(); await listenTrayEvents(); } catch (err) { console.error('Init error:', err); @@ -65,6 +67,12 @@ function wireEvents() { // Import profile document.getElementById('btn-import-profile').addEventListener('click', importProfile); + // Kill & Wipe + const killBtn = document.getElementById('btn-kill-wipe'); + if (killBtn) { + killBtn.addEventListener('click', () => handleKillAndWipe(false)); + } + // Global hotkeys registerHotkeys(); } @@ -143,6 +151,106 @@ async function handleSettings() { applyTheme(config.settings?.theme); } +async function handleKillAndWipe(immediateOverride) { + const config = getConfig(); + if (!config) return; + + const killWipe = normalizeKillWipeSettings(config.settings?.kill_wipe); + const runImmediate = immediateOverride === true || killWipe.confirm_before === false; + + if (!runImmediate) { + const result = await showKillAndWipe(killWipe); + if (!result) return; + config.settings.kill_wipe = result.settings; + await saveConfig(config); + + if (result.create_shortcut) { + try { + await invoke('create_kill_and_wipe_shortcut', { immediate: result.settings.confirm_before === false }); + } catch (e) { + console.error('Shortcut creation failed:', e); + } + } + + await runKillAndWipe(result.settings); + return; + } + + await runKillAndWipe(killWipe); +} + +async function runKillAndWipe(settings) { + const status = document.getElementById('status-text'); + if (status) status.textContent = 'Kill & Wipe in progress...'; + + const options = { + kill_processes: settings.kill_processes !== false, + clear_temp: settings.clear_temp !== false, + clear_browsers: settings.clear_browsers !== false, + flush_dns: settings.flush_dns !== false, + logout: settings.logout !== false + }; + + try { + const report = await invoke('kill_and_wipe', { options }); + if (options.logout) { + if (status) status.textContent = 'Logging out...'; + return; + } + + const summary = [ + `Killed processes: ${report.killed_count}`, + `Browser clears: ${report.browser_cleared.length > 0 ? report.browser_cleared.join(', ') : 'None'}`, + `DNS flushed: ${report.dns_flushed ? 'Yes' : 'No'}` + ].join(' | '); + + const warningCount = (report.kill_failures?.length || 0) + + (report.temp_failures?.length || 0) + + (report.browser_failures?.length || 0) + + (options.flush_dns && !report.dns_flushed ? 1 : 0); + + if (status) { + status.textContent = warningCount > 0 ? `${summary} | Warnings: ${warningCount}` : summary; + } + } catch (e) { + console.error('Kill & Wipe failed:', e); + if (status) status.textContent = 'Kill & Wipe failed. Check logs.'; + } +} + +function normalizeKillWipeSettings(settings) { + const base = { + confirm_before: true, + kill_processes: true, + clear_temp: true, + clear_browsers: true, + flush_dns: true, + logout: true + }; + return { ...base, ...(settings || {}) }; +} + +async function maybeShowPostLogoutMessage() { + const config = getConfig(); + if (!config?.settings?.post_logout_message_pending) return; + + await showInfo('Cleanup Complete', 'We cleaned up everything while you pannicked!'); + config.settings.post_logout_message_pending = false; + await saveConfig(config); +} + +async function handleStartupFlags() { + try { + const flags = await invoke('get_startup_flags'); + if (!flags?.kill_and_wipe) return; + setTimeout(() => { + handleKillAndWipe(flags.kill_and_wipe_immediate); + }, 150); + } catch (e) { + console.error('Startup flags error:', e); + } +} + async function listenTrayEvents() { await listen('tray-launch-profile', async (event) => { const profileId = event.payload; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 3bc7269..6e6b403 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -251,6 +251,15 @@ body { color: var(--text-primary); } +.toolbar-btn.danger { + color: var(--danger); +} + +.toolbar-btn.danger:hover { + background: rgba(220, 38, 38, 0.15); + color: var(--danger-hover); +} + /* === Main Layout === */ #main { display: flex; @@ -774,6 +783,16 @@ body { margin-top: 20px; } +.warning-box { + background: rgba(220, 38, 38, 0.12); + border: 1px solid rgba(220, 38, 38, 0.4); + color: var(--text-primary); + padding: 10px 12px; + border-radius: var(--radius); + font-size: 12px; + margin-bottom: 12px; +} + .btn-primary { background: var(--accent); border: none; diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index b2f0f3c..cff306b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2,10 +2,12 @@ use crate::config::{self, AppConfig, Profile, Step}; use crate::discovery; use crate::launcher; use crate::process; +use crate::kill_wipe; use crate::tray; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use tauri::{Emitter, Manager, State}; +use serde::Serialize; pub struct LaunchState { pub cancel_flag: Arc, @@ -52,6 +54,44 @@ impl LastLaunch { } } +#[derive(Clone)] +pub struct StartupFlags { + kill_and_wipe: bool, + kill_and_wipe_immediate: bool, +} + +impl StartupFlags { + pub fn from_args() -> Self { + let mut flags = StartupFlags { + kill_and_wipe: false, + kill_and_wipe_immediate: false, + }; + for arg in std::env::args() { + if arg == "--kill-and-wipe" { + flags.kill_and_wipe = true; + } else if arg == "--kill-and-wipe-immediate" { + flags.kill_and_wipe = true; + flags.kill_and_wipe_immediate = true; + } + } + flags + } +} + +#[derive(Serialize)] +pub struct StartupFlagsResponse { + pub kill_and_wipe: bool, + pub kill_and_wipe_immediate: bool, +} + +#[tauri::command] +pub fn get_startup_flags(state: State<'_, StartupFlags>) -> StartupFlagsResponse { + StartupFlagsResponse { + kill_and_wipe: state.kill_and_wipe, + kill_and_wipe_immediate: state.kill_and_wipe_immediate, + } +} + #[tauri::command] pub fn get_config() -> Result { Ok(config::load_config()) @@ -358,3 +398,27 @@ pub fn show_window(app: tauri::AppHandle) -> Result<(), String> { } Ok(()) } + +#[tauri::command] +pub async fn kill_and_wipe( + options: kill_wipe::KillWipeOptions, +) -> Result { + let logout = options.logout; + let report = tokio::task::spawn_blocking(move || kill_wipe::run(&options)) + .await + .map_err(|e| format!("Kill & Wipe task failed: {}", e))?; + + if logout { + let mut cfg = config::load_config(); + cfg.settings.post_logout_message_pending = true; + let _ = config::save_config(&cfg); + kill_wipe::request_logout(); + } + + Ok(report) +} + +#[tauri::command] +pub fn create_kill_and_wipe_shortcut(immediate: bool) -> Result<(), String> { + kill_wipe::create_desktop_shortcut(immediate) +} diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 1c44ac5..e293bc7 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -38,6 +38,26 @@ pub struct Settings { pub close_on_exit: bool, #[serde(default)] pub auto_start_with_windows: bool, + #[serde(default = "default_kill_wipe")] + pub kill_wipe: KillWipeSettings, + #[serde(default)] + pub post_logout_message_pending: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KillWipeSettings { + #[serde(default = "default_true")] + pub confirm_before: bool, + #[serde(default = "default_true")] + pub kill_processes: bool, + #[serde(default = "default_true")] + pub clear_temp: bool, + #[serde(default = "default_true")] + pub clear_browsers: bool, + #[serde(default = "default_true")] + pub flush_dns: bool, + #[serde(default = "default_true")] + pub logout: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -104,6 +124,17 @@ fn default_true() -> bool { true } +fn default_kill_wipe() -> KillWipeSettings { + KillWipeSettings { + confirm_before: true, + kill_processes: true, + clear_temp: true, + clear_browsers: true, + flush_dns: true, + logout: true, + } +} + impl Default for AppConfig { fn default() -> Self { AppConfig { @@ -115,6 +146,8 @@ impl Default for AppConfig { minimize_to_tray: true, close_on_exit: false, auto_start_with_windows: false, + kill_wipe: default_kill_wipe(), + post_logout_message_pending: false, }, profiles: vec![], startup_apps: vec![], diff --git a/src-tauri/src/kill_wipe.rs b/src-tauri/src/kill_wipe.rs new file mode 100644 index 0000000..bb8e2fe --- /dev/null +++ b/src-tauri/src/kill_wipe.rs @@ -0,0 +1,473 @@ +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[cfg(target_os = "windows")] +use std::os::windows::process::CommandExt; + +#[cfg(target_os = "windows")] +const CREATE_NO_WINDOW: u32 = 0x08000000; + +#[derive(Debug, Clone, Deserialize)] +pub struct KillWipeOptions { + pub kill_processes: bool, + pub clear_temp: bool, + pub clear_browsers: bool, + pub flush_dns: bool, + pub logout: bool, +} + +#[derive(Debug, Serialize)] +pub struct KillWipeReport { + pub killed_count: usize, + pub kill_failures: Vec, + pub temp_failures: Vec, + pub browser_cleared: Vec, + pub browser_failures: Vec, + pub dns_flushed: bool, +} + +pub fn run(options: &KillWipeOptions) -> KillWipeReport { + let mut report = KillWipeReport { + killed_count: 0, + kill_failures: Vec::new(), + temp_failures: Vec::new(), + browser_cleared: Vec::new(), + browser_failures: Vec::new(), + dns_flushed: false, + }; + + if options.kill_processes { + let (killed, failures) = kill_user_processes(); + report.killed_count = killed; + report.kill_failures = failures; + } + + if options.clear_temp { + report + .temp_failures + .extend(clear_temp_folders().into_iter()); + } + + if options.clear_browsers { + let (cleared, failures) = clear_browser_data(); + report.browser_cleared = cleared; + report.browser_failures = failures; + } + + if options.flush_dns { + report.dns_flushed = flush_dns_cache(); + } + + report +} + +pub fn request_logout() { + #[cfg(target_os = "windows")] + { + let _ = Command::new("shutdown") + .args(["/l"]) + .creation_flags(CREATE_NO_WINDOW) + .spawn(); + } +} + +pub fn create_desktop_shortcut(immediate: bool) -> Result<(), String> { + #[cfg(target_os = "windows")] + { + let exe_path = std::env::current_exe() + .map_err(|e| format!("Failed to get exe path: {}", e))?; + let exe_str = exe_path.to_string_lossy().to_string(); + let desktop = desktop_dir().ok_or_else(|| "Unable to resolve Desktop path".to_string())?; + let shortcut_path = desktop.join("WorkSwitch Kill & Wipe.lnk"); + let args = if immediate { + "--kill-and-wipe-immediate" + } else { + "--kill-and-wipe" + }; + + let script = format!( + "$WshShell = New-Object -ComObject WScript.Shell; \ + $Shortcut = $WshShell.CreateShortcut('{}'); \ + $Shortcut.TargetPath = '{}'; \ + $Shortcut.Arguments = '{}'; \ + $Shortcut.WorkingDirectory = '{}'; \ + $Shortcut.IconLocation = '{}'; \ + $Shortcut.Save();", + escape_ps_string(&shortcut_path.to_string_lossy()), + escape_ps_string(&exe_str), + args, + escape_ps_string( + &exe_path + .parent() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "".to_string()), + ), + escape_ps_string(&exe_str) + ); + + let status = Command::new("powershell") + .args(["-NoProfile", "-Command", &script]) + .creation_flags(CREATE_NO_WINDOW) + .status() + .map_err(|e| format!("Failed to create shortcut: {}", e))?; + + if !status.success() { + return Err("PowerShell failed to create shortcut".to_string()); + } + } + + Ok(()) +} + +fn desktop_dir() -> Option { + let user_profile = std::env::var("USERPROFILE").ok()?; + Some(PathBuf::from(user_profile).join("Desktop")) +} + +fn escape_ps_string(input: &str) -> String { + input.replace('\'', "''") +} + +fn kill_user_processes() -> (usize, Vec) { + #[cfg(target_os = "windows")] + { + let current_exe = std::env::current_exe() + .ok() + .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string())) + .unwrap_or_default() + .to_lowercase(); + let current_user = std::env::var("USERNAME").unwrap_or_default().to_lowercase(); + + let critical = critical_processes(); + + let output = Command::new("tasklist") + .args(["/V", "/FO", "CSV", "/NH"]) + .creation_flags(CREATE_NO_WINDOW) + .output(); + + let mut killed = 0usize; + let mut failures = Vec::new(); + + if let Ok(output) = output { + let stdout = String::from_utf8_lossy(&output.stdout); + let mut targets = HashSet::new(); + + for line in stdout.lines() { + let fields = parse_csv_line(line); + if fields.len() < 7 { + continue; + } + let image = fields[0].trim().to_lowercase(); + let user = fields[6].trim().to_lowercase(); + + if image.is_empty() { + continue; + } + if image == current_exe { + continue; + } + if critical.contains(&image) { + continue; + } + if is_system_user(&user) { + continue; + } + if !is_current_user(&user, ¤t_user) { + continue; + } + targets.insert(image); + } + + for name in targets { + let output = Command::new("taskkill") + .args(["/F", "/IM", &name]) + .creation_flags(CREATE_NO_WINDOW) + .output(); + + match output { + Ok(out) => { + if out.status.success() { + killed += 1; + } else { + let stderr = String::from_utf8_lossy(&out.stderr); + failures.push(format!("{}: {}", name, stderr.trim())); + } + } + Err(e) => failures.push(format!("{}: {}", name, e)), + } + } + } + + return (killed, failures); + } + #[cfg(not(target_os = "windows"))] + { + (0, vec![]) + } +} + +fn clear_temp_folders() -> Vec { + let mut failures = Vec::new(); + + let mut paths = Vec::new(); + if let Ok(temp) = std::env::var("TEMP") { + paths.push(PathBuf::from(temp)); + } + if let Ok(tmp) = std::env::var("TMP") { + paths.push(PathBuf::from(tmp)); + } + paths.push(PathBuf::from(r"C:\Windows\Temp")); + + for path in paths { + if !path.exists() { + continue; + } + if let Err(e) = clear_directory_contents(&path) { + failures.push(format!("{}: {}", path.to_string_lossy(), e)); + } + } + + failures +} + +fn clear_browser_data() -> (Vec, Vec) { + let mut cleared = Vec::new(); + let mut failures = Vec::new(); + + let local_app = std::env::var("LOCALAPPDATA").ok(); + let roam_app = std::env::var("APPDATA").ok(); + + if let Some(local) = local_app { + let local = PathBuf::from(local); + let chromium = vec![ + ("Chrome", local.join(r"Google\Chrome\User Data")), + ("Edge", local.join(r"Microsoft\Edge\User Data")), + ("Brave", local.join(r"BraveSoftware\Brave-Browser\User Data")), + ]; + + for (name, base) in chromium { + if base.exists() { + let (ok, err) = clear_chromium_profiles(&base); + if ok { + cleared.push(name.to_string()); + } + failures.extend(err.into_iter().map(|e| format!("{}: {}", name, e))); + } + } + } + + if let Some(roam) = roam_app { + let ff_base = PathBuf::from(roam).join(r"Mozilla\Firefox\Profiles"); + if ff_base.exists() { + let (ok, err) = clear_firefox_profiles(&ff_base); + if ok { + cleared.push("Firefox".to_string()); + } + failures.extend(err.into_iter().map(|e| format!("Firefox: {}", e))); + } + } + + (cleared, failures) +} + +fn clear_chromium_profiles(base: &Path) -> (bool, Vec) { + let mut errors = Vec::new(); + let profiles = list_profile_dirs(base); + + for profile in &profiles { + let paths = vec![ + profile.join("Cache"), + profile.join("Code Cache"), + profile.join("GPUCache"), + profile.join("History"), + profile.join("History-wal"), + profile.join("History-journal"), + profile.join("Cookies"), + profile.join("Cookies-wal"), + profile.join("Cookies-journal"), + profile.join("Network").join("Cookies"), + profile.join("Network").join("Cookies-wal"), + profile.join("Network").join("Cookies-journal"), + profile.join("Service Worker").join("CacheStorage"), + ]; + + for p in paths { + if let Err(e) = remove_path(&p) { + errors.push(format!("{}: {}", p.to_string_lossy(), e)); + } + } + } + + (!profiles.is_empty(), errors) +} + +fn clear_firefox_profiles(base: &Path) -> (bool, Vec) { + let mut errors = Vec::new(); + let profiles = list_all_dirs(base); + + for profile in &profiles { + let paths = vec![ + profile.join("cache2"), + profile.join("cookies.sqlite"), + profile.join("cookies.sqlite-wal"), + profile.join("cookies.sqlite-shm"), + profile.join("places.sqlite"), + profile.join("places.sqlite-wal"), + profile.join("places.sqlite-shm"), + ]; + + for p in paths { + if let Err(e) = remove_path(&p) { + errors.push(format!("{}: {}", p.to_string_lossy(), e)); + } + } + } + + (!profiles.is_empty(), errors) +} + +fn list_profile_dirs(base: &Path) -> Vec { + let mut profiles = Vec::new(); + if let Ok(read_dir) = std::fs::read_dir(base) { + for entry in read_dir.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let name = entry.file_name().to_string_lossy().to_string(); + if name == "Default" || name.starts_with("Profile ") { + profiles.push(path); + } + } + } + profiles +} + +fn list_all_dirs(base: &Path) -> Vec { + let mut dirs = Vec::new(); + if let Ok(read_dir) = std::fs::read_dir(base) { + for entry in read_dir.flatten() { + let path = entry.path(); + if path.is_dir() { + dirs.push(path); + } + } + } + dirs +} + +fn remove_path(path: &Path) -> Result<(), String> { + if !path.exists() { + return Ok(()); + } + if path.is_dir() { + std::fs::remove_dir_all(path).map_err(|e| e.to_string())?; + } else { + std::fs::remove_file(path).map_err(|e| e.to_string())?; + } + Ok(()) +} + +fn clear_directory_contents(path: &Path) -> Result<(), String> { + let entries = std::fs::read_dir(path).map_err(|e| e.to_string())?; + let mut errors: HashMap = HashMap::new(); + + for entry in entries.flatten() { + let p = entry.path(); + let result = if p.is_dir() { + std::fs::remove_dir_all(&p).map_err(|e| e.to_string()) + } else { + std::fs::remove_file(&p).map_err(|e| e.to_string()) + }; + if let Err(e) = result { + errors.insert(p.to_string_lossy().to_string(), e); + } + } + + if errors.is_empty() { + Ok(()) + } else { + Err(format!("Failed to delete {} items", errors.len())) + } +} + +fn flush_dns_cache() -> bool { + #[cfg(target_os = "windows")] + { + if let Ok(output) = Command::new("ipconfig") + .args(["/flushdns"]) + .creation_flags(CREATE_NO_WINDOW) + .output() + { + return output.status.success(); + } + } + false +} + +fn parse_csv_line(line: &str) -> Vec { + let mut fields = Vec::new(); + let mut current = String::new(); + let mut in_quotes = false; + + for ch in line.chars() { + if ch == '"' { + in_quotes = !in_quotes; + continue; + } + if ch == ',' && !in_quotes { + fields.push(current.clone()); + current.clear(); + } else { + current.push(ch); + } + } + fields.push(current); + fields +} + +fn is_current_user(user: &str, current: &str) -> bool { + if current.is_empty() || user.is_empty() { + return false; + } + if user == current { + return true; + } + if let Some(pos) = user.rfind('\\') { + return user[pos + 1..].eq_ignore_ascii_case(current); + } + false +} + +fn is_system_user(user: &str) -> bool { + let u = user.to_lowercase(); + u == "system" || u == "local service" || u == "network service" +} + +fn critical_processes() -> HashSet { + let mut set = HashSet::new(); + for name in [ + "system", + "system idle process", + "smss.exe", + "csrss.exe", + "wininit.exe", + "winlogon.exe", + "services.exe", + "lsass.exe", + "lsm.exe", + "svchost.exe", + "fontdrvhost.exe", + "dwm.exe", + "registry", + "memcompression", + "securityhealthservice.exe", + "sihost.exe", + "ctfmon.exe", + ] { + set.insert(name.to_string()); + } + set +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d1a7bf4..bd200a2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod commands; mod config; mod discovery; +mod kill_wipe; mod launcher; mod lifecycle; mod process; @@ -17,6 +18,7 @@ pub fn run() { .plugin(tauri_plugin_shell::init()) .manage(LaunchState::default()) .manage(commands::LastLaunch::default()) + .manage(commands::StartupFlags::from_args()) .invoke_handler(tauri::generate_handler![ commands::get_config, commands::save_config, @@ -37,6 +39,9 @@ pub fn run() { commands::import_profile, commands::save_profile_file, commands::load_profile_file, + commands::get_startup_flags, + commands::kill_and_wipe, + commands::create_kill_and_wipe_shortcut, ]) .setup(|app| { // Create tray icon