diff --git a/examples/software-installer/README.md b/examples/software-installer/README.md new file mode 100644 index 00000000..2c464670 --- /dev/null +++ b/examples/software-installer/README.md @@ -0,0 +1,80 @@ +# Universal Software Installer + +A single, simple workflow that installs any software by name. Just pass the software name and it handles everything. + +## Usage + +```bash +# Install Chrome +terminator mcp run universal-installer.yml --url http://localhost:3000 --input software="chrome" + +# Install VS Code +terminator mcp run universal-installer.yml --url http://localhost:3000 --input software="vscode" + +# Install Discord +terminator mcp run universal-installer.yml --url http://localhost:3000 --input software="discord" + +# Install OneDrive +terminator mcp run universal-installer.yml --url http://localhost:3000 --input software="onedrive" +``` + +## How It Works + +1. **Maps software name** to package IDs and download URLs +2. **Tries CLI installation** via winget → chocolatey → scoop +3. **Downloads installer** if CLI fails +4. **Runs silent installation** with appropriate flags +5. **Falls back to browser** for manual download if needed + +## Supported Software + +The workflow has built-in support for: + +- **Browsers**: chrome, firefox, brave +- **Development**: vscode, git, nodejs, python, docker, postman +- **Communication**: discord, slack, zoom, teams +- **Media**: spotify, vlc, obs +- **Utilities**: 7zip, notepad++, steam +- **Microsoft**: onedrive, teams + +For any other software, it will: +1. Try common package manager naming conventions +2. Fall back to browser search for manual download + +## Features + +- ✅ Single workflow for all software +- ✅ Automatic package manager detection +- ✅ Silent installation support +- ✅ Fallback to UI when needed +- ✅ Cross-platform package manager support +- ✅ No complex configuration needed + +## Examples + +```bash +# Install multiple software sequentially +terminator mcp run universal-installer.yml --url http://localhost:3000 --input software="git" +terminator mcp run universal-installer.yml --url http://localhost:3000 --input software="nodejs" +terminator mcp run universal-installer.yml --url http://localhost:3000 --input software="vscode" + +# Install communication tools +terminator mcp run universal-installer.yml --url http://localhost:3000 --input software="discord" +terminator mcp run universal-installer.yml --url http://localhost:3000 --input software="slack" +``` + +## Adding New Software + +To add support for new software, edit the `softwareMap` in the workflow: + +```javascript +'newsoftware': { + name: 'Software Display Name', + winget: 'Publisher.SoftwareName', + choco: 'package-name', + download: 'https://download.url/installer.exe', + silent: '/S' +} +``` + +That's it! One workflow, any software. \ No newline at end of file diff --git a/examples/software-installer/universal-installer.yml b/examples/software-installer/universal-installer.yml new file mode 100644 index 00000000..15fd47e3 --- /dev/null +++ b/examples/software-installer/universal-installer.yml @@ -0,0 +1,502 @@ +--- +# Universal Software Installer +# Input: software name (e.g., "chrome", "vscode", "onedrive", "discord", etc.) +# Automatically tries CLI first, then falls back to UI installation +tool_name: execute_sequence +arguments: + variables: + software: + type: string + label: "Software to install (e.g., chrome, vscode, git, nodejs, python, discord, slack, etc.)" + required: true + default: "onedrive" + + inputs: + software: "onedrive" + + steps: + # Step 1: Map software name to package IDs and download URLs + - tool_name: run_command + arguments: + engine: javascript + run: | + const softwareName = '{{software}}'.toLowerCase().trim(); + + // Software database with package IDs and download URLs + const softwareMap = { + 'chrome': { + name: 'Google Chrome', + winget: 'Google.Chrome', + choco: 'googlechrome', + scoop: 'googlechrome', + brew: 'google-chrome', + download: 'https://dl.google.com/chrome/install/latest/chrome_installer.exe', + silent: '/silent /install' + }, + 'firefox': { + name: 'Mozilla Firefox', + winget: 'Mozilla.Firefox', + choco: 'firefox', + scoop: 'firefox', + brew: 'firefox', + download: 'https://download.mozilla.org/?product=firefox-latest&os=win64&lang=en-US', + silent: '/S' + }, + 'vscode': { + name: 'Visual Studio Code', + winget: 'Microsoft.VisualStudioCode', + choco: 'vscode', + scoop: 'vscode', + brew: 'visual-studio-code', + download: 'https://code.visualstudio.com/sha/download?build=stable&os=win32-x64-user', + silent: '/verysilent /mergetasks=!runcode' + }, + 'git': { + name: 'Git', + winget: 'Git.Git', + choco: 'git', + scoop: 'git', + brew: 'git', + apt: 'git', + download: 'https://github.com/git-for-windows/git/releases/latest/download/Git-2.43.0-64-bit.exe', + silent: '/VERYSILENT' + }, + 'nodejs': { + name: 'Node.js', + winget: 'OpenJS.NodeJS', + choco: 'nodejs', + scoop: 'nodejs', + brew: 'node', + apt: 'nodejs', + download: 'https://nodejs.org/dist/latest-lts/node-latest-lts-x64.msi', + silent: '/quiet' + }, + 'python': { + name: 'Python', + winget: 'Python.Python.3.11', + choco: 'python', + scoop: 'python', + brew: 'python@3.11', + apt: 'python3', + download: 'https://www.python.org/ftp/python/3.11.0/python-3.11.0-amd64.exe', + silent: '/quiet InstallAllUsers=1 PrependPath=1' + }, + 'discord': { + name: 'Discord', + winget: 'Discord.Discord', + choco: 'discord', + scoop: 'discord', + brew: 'discord', + download: 'https://discord.com/api/downloads/distributions/app/installers/latest?channel=stable&platform=win&arch=x86', + silent: '-s' + }, + 'slack': { + name: 'Slack', + winget: 'SlackTechnologies.Slack', + choco: 'slack', + scoop: 'slack', + brew: 'slack', + download: 'https://slack.com/downloads/windows', + silent: '/S' + }, + 'zoom': { + name: 'Zoom', + winget: 'Zoom.Zoom', + choco: 'zoom', + scoop: 'zoom', + brew: 'zoom', + download: 'https://zoom.us/client/latest/ZoomInstaller.exe', + silent: '/quiet' + }, + 'spotify': { + name: 'Spotify', + winget: 'Spotify.Spotify', + choco: 'spotify', + scoop: 'spotify', + brew: 'spotify', + download: 'https://download.scdn.co/SpotifySetup.exe', + silent: '/silent' + }, + '7zip': { + name: '7-Zip', + winget: '7zip.7zip', + choco: '7zip', + scoop: '7zip', + brew: 'p7zip', + apt: 'p7zip-full', + download: 'https://www.7-zip.org/a/7z2301-x64.exe', + silent: '/S' + }, + 'vlc': { + name: 'VLC Media Player', + winget: 'VideoLAN.VLC', + choco: 'vlc', + scoop: 'vlc', + brew: 'vlc', + apt: 'vlc', + download: 'https://get.videolan.org/vlc/latest/win64/vlc-latest-win64.exe', + silent: '/S' + }, + 'notepad++': { + name: 'Notepad++', + winget: 'Notepad++.Notepad++', + choco: 'notepadplusplus', + scoop: 'notepadplusplus', + download: 'https://github.com/notepad-plus-plus/notepad-plus-plus/releases/latest/download/npp.Installer.x64.exe', + silent: '/S' + }, + 'docker': { + name: 'Docker Desktop', + winget: 'Docker.DockerDesktop', + choco: 'docker-desktop', + brew: 'docker', + download: 'https://desktop.docker.com/win/main/amd64/Docker%20Desktop%20Installer.exe', + silent: 'install --quiet' + }, + 'postman': { + name: 'Postman', + winget: 'Postman.Postman', + choco: 'postman', + scoop: 'postman', + brew: 'postman', + download: 'https://dl.pstmn.io/download/latest/win64', + silent: '-s' + }, + 'onedrive': { + name: 'Microsoft OneDrive', + winget: 'Microsoft.OneDrive', + choco: 'onedrive', + download: 'https://go.microsoft.com/fwlink/?linkid=844652', + silent: '/silent' + }, + 'teams': { + name: 'Microsoft Teams', + winget: 'Microsoft.Teams', + choco: 'microsoft-teams', + brew: 'microsoft-teams', + download: 'https://go.microsoft.com/fwlink/?linkid=2187327&clcid=0x409&culture=en-us&country=us', + silent: '/s' + }, + 'steam': { + name: 'Steam', + winget: 'Valve.Steam', + choco: 'steam', + brew: 'steam', + download: 'https://cdn.akamai.steamstatic.com/client/installer/SteamSetup.exe', + silent: '/S' + }, + 'obs': { + name: 'OBS Studio', + winget: 'OBSProject.OBSStudio', + choco: 'obs-studio', + scoop: 'obs-studio', + brew: 'obs', + download: 'https://cdn-fastly.obsproject.com/downloads/OBS-Studio-Windows-Installer.exe', + silent: '/S' + }, + 'brave': { + name: 'Brave Browser', + winget: 'Brave.Brave', + choco: 'brave', + scoop: 'brave', + brew: 'brave-browser', + download: 'https://laptop-updates.brave.com/latest/winx64', + silent: '/silent /install' + } + }; + + const info = softwareMap[softwareName] || { + name: softwareName.charAt(0).toUpperCase() + softwareName.slice(1), + winget: softwareName, + choco: softwareName, + download: null, + silent: '/quiet' + }; + + console.log(`Software: ${info.name}`); + console.log(`Winget ID: ${info.winget || 'N/A'}`); + console.log(`Chocolatey ID: ${info.choco || 'N/A'}`); + console.log(`Download URL: ${info.download || 'N/A'}`); + + return info; + id: "map_software" + delay_ms: 500 + + # Step 2: Try CLI installation with available package managers + - tool_name: run_command + arguments: + run: | + $ErrorActionPreference = 'Continue' + $software = '${{steps.map_software.name}}' + $wingetId = '${{steps.map_software.winget}}' + $chocoId = '${{steps.map_software.choco}}' + + Write-Host "Attempting to install $software via package manager..." + + # Try winget first + if (Get-Command winget -ErrorAction SilentlyContinue) { + Write-Host "Trying winget..." + try { + winget install --id $wingetId --accept-package-agreements --accept-source-agreements -h + if ($LASTEXITCODE -eq 0) { + Write-Host "Successfully installed via winget" + exit 0 + } + } catch { + Write-Host "Winget failed: $_" + } + } + + # Try chocolatey + if (Get-Command choco -ErrorAction SilentlyContinue) { + Write-Host "Trying chocolatey..." + try { + choco install $chocoId -y --no-progress + if ($LASTEXITCODE -eq 0) { + Write-Host "Successfully installed via chocolatey" + exit 0 + } + } catch { + Write-Host "Chocolatey failed: $_" + } + } + + # Try scoop + if (Get-Command scoop -ErrorAction SilentlyContinue) { + Write-Host "Trying scoop..." + $scoopId = '${{steps.map_software.scoop}}' + if ($scoopId) { + try { + scoop install $scoopId + if ($LASTEXITCODE -eq 0) { + Write-Host "Successfully installed via scoop" + exit 0 + } + } catch { + Write-Host "Scoop failed: $_" + } + } + } + + Write-Host "All package managers failed or not available" + exit 1 + shell: powershell + id: "cli_install" + continue_on_error: true + timeout_ms: 180000 + + # Step 3: Download and run installer if CLI failed + - tool_name: run_command + arguments: + engine: javascript + run: | + const fs = require('fs'); + const path = require('path'); + const os = require('os'); + + // Check if CLI succeeded + const cliStep = context.steps.find(s => s.id === 'cli_install'); + if (cliStep && cliStep.status === 'success') { + console.log('CLI installation succeeded, skipping download'); + return { skip: true }; + } + + const downloadUrl = context.steps.find(s => s.id === 'map_software')?.result?.download; + if (!downloadUrl) { + console.log('No download URL available, will try UI search'); + return { skip: true }; + } + + console.log(`Downloading from: ${downloadUrl}`); + + try { + const response = await fetch(downloadUrl); + if (!response.ok) throw new Error(`Download failed: ${response.status}`); + + const ext = downloadUrl.includes('.msi') ? '.msi' : '.exe'; + const installerPath = path.join(os.tmpdir(), `installer_${Date.now()}${ext}`); + + const buffer = Buffer.from(await response.arrayBuffer()); + fs.writeFileSync(installerPath, buffer); + + console.log(`Downloaded to: ${installerPath}`); + return { installer_path: installerPath, ext: ext }; + } catch (error) { + console.log(`Download failed: ${error.message}`); + return { skip: true }; + } + id: "download" + continue_on_error: true + delay_ms: 2000 + + # Step 4: Try silent installation + - tool_name: run_command + arguments: + run: | + $installerPath = '${{steps.download.installer_path}}' + $silent = '${{steps.map_software.silent}}' + + if (-not $installerPath -or -not (Test-Path $installerPath)) { + Write-Host "No installer to run" + exit 1 + } + + Write-Host "Running silent install: $installerPath $silent" + + try { + if ($installerPath -match '\.msi$') { + Start-Process msiexec -ArgumentList "/i `"$installerPath`" $silent" -Wait -NoNewWindow + } else { + Start-Process $installerPath -ArgumentList $silent -Wait -NoNewWindow + } + Write-Host "Installation completed" + exit 0 + } catch { + Write-Host "Silent install failed: $_" + exit 1 + } + shell: powershell + id: "silent_install" + continue_on_error: true + condition: "${{steps.download.installer_path}}" + timeout_ms: 300000 + + # Step 4b: If silent install fails, try running installer with UI + - tool_name: open_application + arguments: + app_name: "${{steps.download.installer_path}}" + id: "run_installer_ui" + condition: "${{steps.download.installer_path && steps.silent_install.status == 'error'}}" + continue_on_error: true + delay_ms: 3000 + + # Step 4c: Try to click through common installer UI patterns + - tool_name: click_element + arguments: + selector: "role:Button|name:Next" + id: "click_next" + condition: "${{steps.run_installer_ui.status == 'success'}}" + continue_on_error: true + timeout_ms: 10000 + delay_ms: 2000 + + - tool_name: click_element + arguments: + selector: "role:CheckBox|name:I agree" + id: "click_agree" + condition: "${{steps.run_installer_ui.status == 'success'}}" + continue_on_error: true + timeout_ms: 5000 + delay_ms: 1000 + + - tool_name: click_element + arguments: + selector: "role:Button|name:Install" + id: "click_install" + condition: "${{steps.run_installer_ui.status == 'success'}}" + continue_on_error: true + timeout_ms: 10000 + delay_ms: 2000 + + - tool_name: wait_for_element + arguments: + selector: "role:Button|name:Finish" + timeout_ms: 180000 + id: "wait_finish" + condition: "${{steps.run_installer_ui.status == 'success'}}" + continue_on_error: true + delay_ms: 2000 + + - tool_name: click_element + arguments: + selector: "role:Button|name:Finish" + id: "click_finish" + condition: "${{steps.run_installer_ui.status == 'success'}}" + continue_on_error: true + delay_ms: 1000 + + # Step 5: Fallback to UI - open browser to download if all else fails + - tool_name: run_command + arguments: + engine: javascript + run: | + // Check if any installation succeeded + const steps = context.steps || []; + const success = steps.find(s => + (s.id === 'cli_install' || s.id === 'silent_install') && + s.status === 'success' + ); + + if (success) { + console.log('Installation completed successfully'); + return { success: true }; + } + + const userSoftware = '{{software}}'; + console.log(`Automated installation failed for ${userSoftware}`); + console.log('Opening browser for manual download...'); + + // Build search URL + const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(userSoftware + ' download windows')}`; + + return { + fallback_to_ui: true, + search_url: searchUrl, + software: userSoftware + }; + id: "check_status" + delay_ms: 1000 + + # Step 6: Open browser for manual download + - tool_name: navigate_browser + arguments: + url: "${{steps.check_status.search_url}}" + browser: "chrome" + id: "open_browser" + condition: "${{steps.check_status.fallback_to_ui}}" + continue_on_error: true + delay_ms: 2000 + + # Step 7: Try to click first download result + - tool_name: click_element + arguments: + selector: "role:Link|name:Download" + id: "click_download" + condition: "${{steps.check_status.fallback_to_ui}}" + continue_on_error: true + timeout_ms: 10000 + + output_parser: + javascript_code: | + const steps = context.steps || []; + const cliSuccess = steps.find(s => s.id === 'cli_install' && s.status === 'success'); + const silentSuccess = steps.find(s => s.id === 'silent_install' && s.status === 'success'); + const uiSuccess = steps.find(s => s.id === 'click_finish' && s.status === 'success'); + const software = steps.find(s => s.id === 'map_software')?.result?.name || context.inputs.software; + + let method = 'failed'; + let success = false; + + if (cliSuccess) { + method = 'package_manager'; + success = true; + } else if (silentSuccess) { + method = 'silent_install'; + success = true; + } else if (uiSuccess) { + method = 'ui_install'; + success = true; + } else if (steps.find(s => s.id === 'open_browser' && s.status === 'success')) { + method = 'manual_browser'; + } + + return { + success: success, + data: { + software: software, + method: method, + message: success + ? `${software} installed successfully via ${method}` + : `Could not install ${software} automatically. Manual installation may be required.` + } + }; \ No newline at end of file diff --git a/terminator/src/platforms/windows/highlighting.rs b/terminator/src/platforms/windows/highlighting.rs index 44cf0128..28cf240b 100644 --- a/terminator/src/platforms/windows/highlighting.rs +++ b/terminator/src/platforms/windows/highlighting.rs @@ -195,9 +195,8 @@ pub fn highlight( thread::sleep(Duration::from_millis(50)); } - // Let the overlay window be destroyed by the OS when the process exits - // or when a subsequent highlight replaces it. Avoid explicit DestroyWindow - // here to reduce flakiness if the caller drops the handle early. + // Clean up the overlay window when highlight expires or is manually closed + cleanup_overlay_window(); // info!( // "OVERLAY_THREAD_DONE elapsed_ms={}", @@ -235,6 +234,9 @@ fn create_and_show_overlay( let instance = GetModuleHandleW(None) .map_err(|e| AutomationError::PlatformError(format!("GetModuleHandleW failed: {e}")))?; + // Clean up any previous overlay window before creating a new one + cleanup_previous_overlay(); + // Register window class (ignore already registered) let wc = WNDCLASSEXW { cbSize: std::mem::size_of::() as u32, @@ -414,3 +416,21 @@ unsafe extern "system" fn overlay_window_proc( _ => DefWindowProcW(hwnd, msg, wparam, lparam), } } + +/// Cleans up the previous overlay window stored in thread-local storage +fn cleanup_previous_overlay() { + LAST_CREATED_OVERLAY.with(|cell| { + if let Some(hwnd) = cell.borrow_mut().take() { + unsafe { + use windows::Win32::UI::WindowsAndMessaging::DestroyWindow; + let _ = DestroyWindow(hwnd); + debug!("Destroyed previous overlay window"); + } + } + }); +} + +/// Cleans up the current overlay window and clears thread-local storage +fn cleanup_overlay_window() { + cleanup_previous_overlay(); +} diff --git a/test-onedrive-direct.json b/test-onedrive-direct.json new file mode 100644 index 00000000..e151c13c --- /dev/null +++ b/test-onedrive-direct.json @@ -0,0 +1,3 @@ +{ + "software": "onedrive" +} \ No newline at end of file diff --git a/test-onedrive-install.yml b/test-onedrive-install.yml new file mode 100644 index 00000000..6c294039 --- /dev/null +++ b/test-onedrive-install.yml @@ -0,0 +1,18 @@ +--- +tool_name: execute_sequence +arguments: + steps: + - tool_name: execute_sequence + arguments: + workflow_file: "./examples/software-installer/universal-installer.yml" + inputs: + software: "onedrive" + id: "install_onedrive" + + output_parser: + javascript_code: | + const step = context.steps.find(s => s.id === 'install_onedrive'); + return { + success: step?.status === 'success', + data: step?.result || {} + }; diff --git a/test-simple.json b/test-simple.json new file mode 100644 index 00000000..64fa2340 --- /dev/null +++ b/test-simple.json @@ -0,0 +1 @@ +{"tool_name":"execute_sequence","arguments":{"variables":{"software":{"type":"string","label":"Software to install","required":true}},"steps":[{"tool_name":"run_command","arguments":{"engine":"javascript","run":"console.log(\"Testing software: \" + context.inputs.software); return {software: context.inputs.software};"},"id":"test"}],"inputs":{"software":"onedrive"}}} diff --git a/test_overlay_cleanup.js b/test_overlay_cleanup.js new file mode 100644 index 00000000..ed38399c --- /dev/null +++ b/test_overlay_cleanup.js @@ -0,0 +1,87 @@ +#!/usr/bin/env node +/** + * Test overlay window cleanup fix + * This test rapidly creates and destroys highlights to verify no ghost windows remain + */ + +const terminator = require('./bindings/nodejs'); +const { Desktop } = terminator; + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function main() { + console.log('🧪 Testing Overlay Window Cleanup Fix'); + console.log('='.repeat(60)); + + const desktop = new Desktop(); + + // Open Calculator + console.log('\n1. Opening Calculator...'); + await desktop.runCommand('calc.exe', 'calc.exe'); + await sleep(2000); + + // Find Calculator + const apps = desktop.applications(); + let calculator = null; + for (const app of apps) { + if (app.name().toLowerCase().includes('calculator')) { + calculator = app; + break; + } + } + + if (!calculator) { + console.log('❌ Calculator not found'); + return; + } + + console.log(`✅ Found Calculator: ${calculator.name()}`); + + // Find a button to highlight + try { + const locator = desktop.locator('name:1'); + const button = await locator.first(); + + console.log('\n2. Testing rapid highlights (ghost window test)...'); + console.log(' Creating 10 highlights rapidly - old windows should be cleaned up'); + + // Rapidly create highlights - each should clean up the previous one + for (let i = 0; i < 10; i++) { + console.log(` → Highlight ${i + 1}/10`); + const fontStyle = { + size: 14, + bold: true, + color: 0x000000 + }; + + const handle = button.highlight( + 0x00FF00, // Green + 500, // 500ms duration + `Test ${i}`, + 'Top', // TextPosition.Top + fontStyle + ); + await sleep(200); // Overlap highlights intentionally + } + + console.log('\n3. Waiting for all highlights to expire...'); + await sleep(2000); + + console.log('\n✅ Test complete!'); + console.log('\n📝 Expected behavior:'); + console.log(' - Only ONE overlay window should exist at a time'); + console.log(' - All overlays should be cleaned up after expiration'); + console.log(' - NO ghost/steamy mirror effects should remain'); + console.log('\n🔍 Check your screen - is it clean? No ghost artifacts?'); + + } catch (error) { + console.error('❌ Error:', error); + } +} + +main().catch(err => { + console.error('Fatal error:', err); + process.exit(1); +});