diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..4d85ffb
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,23 @@
+# 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.
+- 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/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..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(title)}
+
${escapeHtml(message || '')}
+
+ OK
+
+ `);
+ document.getElementById('info-ok').addEventListener('click', () => { hideModal(); resolve(true); });
+ });
+}
+
// ── Profile editor ──
export function showProfileEditor(profile, isNew) {
return new Promise((resolve) => {
@@ -128,7 +142,7 @@ export function showProfileEditor(profile, isNew) {
export function showStepEditor(step, isNew) {
return new Promise((resolve) => {
const typeOptions = ['app', 'terminal', 'folder', 'url']
- .map(t => `
${t === 'terminal' ? 'Terminal (CMD)' : t.charAt(0).toUpperCase() + t.slice(1)} `)
+ .map(t => `
${t === 'terminal' ? 'Terminal' : t.charAt(0).toUpperCase() + t.slice(1)} `)
.join('');
showModal(`
@@ -201,6 +215,10 @@ function renderStepFields(step) {
Skip if already running
+
Launch with Windows
@@ -354,9 +406,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();
@@ -365,6 +419,70 @@ export function showSettings(settings) {
});
}
+// -- Kill & Wipe dialog --
+export function showKillAndWipe(settings) {
+ return new Promise((resolve) => {
+ const s = settings || {};
+
+ showModal(`
+
Kill & Wipe
+
+ This is destructive. It can close apps, delete cache, wipe browser data, and log you out.
+
+
+
+ Kill running processes (excluding critical system ones)
+
+
+
+ Clear %TEMP% and Windows Temp
+
+
+
+ Wipe browser cache, cookies, and history
+
+
+
+ Flush DNS cache
+
+
+
+ Log out after completion
+
+
+
+ Show this dialog before running
+
+
+
+ Create desktop shortcut for Kill & Wipe
+
+
+ Cancel
+ Run
+
+ `);
+
+ 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 01676b8..fd0b767 100644
--- a/frontend/src/main.js
+++ b/frontend/src/main.js
@@ -2,9 +2,10 @@ 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';
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) {
@@ -22,6 +24,8 @@ async function init() {
}
wireEvents();
+ await maybeShowPostLogoutMessage();
+ await handleStartupFlags();
await listenTrayEvents();
} catch (err) {
console.error('Init error:', err);
@@ -63,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();
}
@@ -83,6 +93,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 +114,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 +148,107 @@ async function handleSettings() {
config.settings = result;
await saveConfig(config);
+ 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() {
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..6e6b403 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;
@@ -136,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;
@@ -530,7 +654,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;
@@ -659,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/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..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;
+use std::sync::{Arc, Mutex};
use tauri::{Emitter, Manager, State};
+use serde::Serialize;
pub struct LaunchState {
pub cancel_flag: Arc
,
@@ -21,6 +23,75 @@ 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()
+ }
+}
+
+#[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())
@@ -224,6 +295,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")]
@@ -322,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 6457b02..e293bc7 100644
--- a/src-tauri/src/config.rs
+++ b/src-tauri/src/config.rs
@@ -35,7 +35,29 @@ 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,
+ #[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)]
@@ -80,9 +102,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,
}
@@ -99,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 {
@@ -108,7 +144,10 @@ impl Default for AppConfig {
start_minimized: false,
close_on_switch: true,
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/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/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/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..bd200a2 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -1,7 +1,9 @@
mod commands;
mod config;
mod discovery;
+mod kill_wipe;
mod launcher;
+mod lifecycle;
mod process;
mod scheduler;
mod tray;
@@ -15,6 +17,8 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init())
.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,
@@ -26,6 +30,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,
@@ -34,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
@@ -74,13 +82,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