From 6c10d359d2fefdd517ec873bf5e941750ab4bbbd Mon Sep 17 00:00:00 2001 From: Arne Moerman Date: Mon, 11 May 2026 13:59:22 +0200 Subject: [PATCH] Enhance SandwichReminder-AutoOrder feature with direct checkout navigation and improved logic - Added new configuration options for direct checkout navigation, including `useDirectCheckoutNavigation`, `checkoutPath`, and `checkoutOpenDelayMs`. - Updated the auto-order flow to navigate directly to the checkout page, skipping the mini-cart and date/time steps. - Improved keyboard automation logic for item remark and order confirmation processes. - Removed the old SandwichAutoOrder.ps1 file as its functionality has been integrated into SandwichReminder-AutoOrder.ps1. - Introduced a new runner-launcher.vbs to start the runner.ps1 without a visible console window, ensuring WinForms dialogs can appear. - Implemented a mutex mechanism in runner.ps1 to prevent concurrent execution of the same feature. --- README.md | 15 +- configure.ps1 | 54 ++- .../features/SandwichReminder-AutoOrder.ps1 | 418 ++++++++++++++++++ internal/features/SandwichReminder.ps1 | 11 + internal/lib/SandwichAutoOrder.ps1 | 184 -------- internal/runner-launcher.vbs | 25 ++ internal/runner.ps1 | 23 +- 7 files changed, 525 insertions(+), 205 deletions(-) delete mode 100644 internal/lib/SandwichAutoOrder.ps1 create mode 100644 internal/runner-launcher.vbs diff --git a/README.md b/README.md index 7d4c71d..d9d8bff 100644 --- a/README.md +++ b/README.md @@ -38,12 +38,11 @@ ArnePowershellAutomation/ │ ├── NetworkUtils.ps1 # DNS suffix detection │ ├── ToastHelper.ps1 # Windows 11 toast notifications │ ├── PromptHelper.ps1 # Multi-choice dialog boxes (up to 4 buttons) - │ └── SandwichAutoOrder.ps1 # Browser keyboard automation for sandwich auto-order ├── features/ # Automation feature modules │ ├── DynamicLock.ps1 # Toggle Dynamic Lock on network presence │ ├── DefaultBrowser.ps1 # Monitor default browser setting │ ├── SandwichReminder.ps1 # Time-based reminder with snooze options - │ └── SandwichReminder-AutoOrder.ps1 # Personal browser automation for sandwich ordering + │ └── SandwichReminder-AutoOrder.ps1 # Personal browser automation for sandwich ordering (self-contained flow) └── data/ ├── config.json # Feature toggles & settings (auto-seeded) ├── state/ @@ -140,12 +139,12 @@ Shows a reminder dialog at a specific time on a specific network with snooze opt ### SandwichReminder-AutoOrder Personal browser automation feature that drives the sandwich ordering website via keyboard navigation. - **Settings**: Base URL, item ID, browser window title hint, tab counts for each navigation step, delays, calibration mode -- **Logic**: Opens the item modal URL, brings the browser window to the foreground (with retry), then sends keyboard inputs to: toggle option checkboxes → click add-to-cart → refresh page → tab to mini-cart → open cart → tab to confirm order button → open delivery popup +- **Logic**: Opens the item modal URL, brings the browser window to the foreground (with retry), then sends keyboard inputs to: toggle option checkboxes → click add-to-cart → navigate directly to `/checkout` → fill remarks → confirm order → confirm default payment option - **Key settings**: - - `tabsToOption1` / `tabsBetweenOptions` / `tabsToAddButton`: navigation within item modal - - `refreshBeforeCart`: send F5 after add-to-cart to reset focus to a known page position - - `tabsToCartButton` / `tabsToConfirmButton`: navigation after refresh - - `calibrationOnly` + `calibrationTabs`: safe mode to manually count tab stops without clicking + - `tabsToOption1` / `tabsBetweenOptions` / `tabsToAddButton`: navigation within item modal + - `useDirectCheckoutNavigation` + `checkoutPath`: skip mini-cart/date-time by opening checkout directly + - `tabsToItemRemarkButton` / `tabsToOrderRemarkInput` / `tabsToFinalConfirmButton` / `tabsToPaymentOption`: navigation on checkout and payment screens + - `calibrationOnly` + `calibrationTabs`: safe mode to manually count tab stops without clicking - **Note**: Uses WScript.Shell SendKeys (best-effort); relies on stable tab order in the target website ## Usage Guide @@ -190,7 +189,7 @@ When you select a feature for configuration, you'll be prompted for each setting - **Windows 11 Only for Toasts**: Toast notifications require WinRT (Windows 10+), but full Windows 11 support is assumed. Win10 may work but is untested. - **Scheduled Task Security**: The runner task executes as the current user with Limited RunLevel (no elevation). Features cannot perform system-wide administrative tasks; they're limited to user-level operations. - **DNS Suffix Matching**: Network detection relies on `Get-DnsClient` adapter DNS suffix. Make sure your network adapter is configured with the correct suffix for accurate detection. -- **Manual Execution & Repetition Timer**: Executing the runner manually via menu option 5 ("Execute runner now") runs the task immediately but does **not** start the 2-minute repetition timer. The repetition timer only activates on logon/restart. For continuous testing, you may need to restart or wait for next logon. +- **Manual Execution & Repetition Timer**: Executing the runner manually via menu option 5 ("Execute runner now") launches the runner directly via a hidden launcher and does **not** start the 2-minute repetition timer. The repetition timer only activates on logon/restart. ## Extending with New Features diff --git a/configure.ps1 b/configure.ps1 index a71a46b..eb32478 100644 --- a/configure.ps1 +++ b/configure.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = 'Continue' $script:InternalRoot = Join-Path $PSScriptRoot 'internal' $script:TaskName = 'PSAutomation-Runner' -$script:TaskPath = '\' +$script:TaskPath = '\PowerShell Automation\' $script:RunnerIntervalMinutes = 2 $script:ConfigureScriptPath = if ($PSCommandPath) { $PSCommandPath } else { $MyInvocation.MyCommand.Path } $script:RegisterResultPath = Join-Path $script:InternalRoot 'data\logs\last-register-result.json' @@ -394,6 +394,7 @@ function Show-RegistrationState { } $runnerPath = Join-Path $script:InternalRoot 'runner.ps1' + $runnerLauncherPath = Join-Path $script:InternalRoot 'runner-launcher.vbs' Write-Host '' Write-Host " Runner: $runnerPath" -ForegroundColor DarkGray Write-Log -Level Info -Message ("Task found at path '{0}' with state '{1}'." -f $task.TaskPath, $task.State) -Feature 'Configure' @@ -415,6 +416,7 @@ function Register-Runner { Write-Log -Level Info -Message 'Register-Runner invoked.' -Feature 'Configure' $runnerPath = Join-Path $script:InternalRoot 'runner.ps1' + $runnerLauncherPath = Join-Path $script:InternalRoot 'runner-launcher.vbs' # Request elevation only for this operation, when needed. if (-not (Test-Administrator)) { @@ -474,6 +476,14 @@ function Register-Runner { return } + if (-not (Test-Path $runnerLauncherPath)) { + Write-Host " ERROR: runner-launcher.vbs not found at: $runnerLauncherPath" -ForegroundColor Red + Write-Log -Level Error -Message "runner-launcher.vbs missing at '$runnerLauncherPath'." -Feature 'Configure' + Write-OperationResult -Path $script:RegisterResultPath -Success $false -Message "runner-launcher.vbs missing at '$runnerLauncherPath'." + if (-not $NoPause) { Pause-ForKey } + return + } + $existing = Get-RunnerTask if ($existing) { Write-Host " Task '$($script:TaskName)' already exists (state: $($existing.State))." -ForegroundColor Yellow @@ -504,8 +514,8 @@ function Register-Runner { try { $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name $action = New-ScheduledTaskAction ` - -Execute 'powershell.exe' ` - -Argument "-WindowStyle Hidden -NonInteractive -ExecutionPolicy Bypass -File `"$runnerPath`"" + -Execute 'wscript.exe' ` + -Argument "//B //Nologo `"$runnerLauncherPath`" `"$runnerPath`"" # Logon trigger — copy Repetition from a -Once trigger (reliable workaround) $logonTrigger = New-ScheduledTaskTrigger -AtLogOn @@ -766,9 +776,21 @@ function Initialize-FeatureCache { # ═════════════════════════════════════════════════════════════════════════════ function Invoke-RunnerNow { + $runnerPath = Join-Path $script:InternalRoot 'runner.ps1' + $runnerLauncherPath = Join-Path $script:InternalRoot 'runner-launcher.vbs' $task = Get-RunnerTask - if (-not $task) { - Write-Host " ERROR: Task is not registered. Use option 2 to register first." -ForegroundColor Red + + if (-not (Test-Path $runnerPath)) { + Write-Host " ERROR: runner.ps1 not found at: $runnerPath" -ForegroundColor Red + Write-Log -Level Error -Message "runner.ps1 missing at '$runnerPath'." -Feature 'Configure' + Write-Host '' + Pause-ForKey + return + } + + if (-not (Test-Path $runnerLauncherPath)) { + Write-Host " ERROR: runner-launcher.vbs not found at: $runnerLauncherPath" -ForegroundColor Red + Write-Log -Level Error -Message "runner-launcher.vbs missing at '$runnerLauncherPath'." -Feature 'Configure' Write-Host '' Pause-ForKey return @@ -776,15 +798,23 @@ function Invoke-RunnerNow { try { Write-Host '' - Write-Host " Executing runner task now..." -ForegroundColor Cyan - Start-ScheduledTask -TaskName $script:TaskName -TaskPath $script:TaskPath -ErrorAction Stop - Write-Host " Task execution started (runs async). Check logs in a moment." -ForegroundColor Green - Write-Hint " Note: The 2-minute repetition timer only starts on next logon/restart." - Write-Log -Level Info -Message "Runner task invoked manually from configure menu." -Feature 'Configure' + if ($task) { + Write-Host " Executing registered runner task now..." -ForegroundColor Cyan + Start-ScheduledTask -TaskName $task.TaskName -TaskPath $task.TaskPath -ErrorAction Stop + Write-Host " Task execution started (runs async). Check logs in a moment." -ForegroundColor Green + Write-Hint " Note: The 2-minute repetition timer only starts on next logon/restart." + Write-Log -Level Info -Message "Runner task invoked manually from configure menu." -Feature 'Configure' + } + else { + Write-Host " Task not registered; executing runner directly (hidden)..." -ForegroundColor Yellow + Start-Process -FilePath 'wscript.exe' -ArgumentList "//B //Nologo `"$runnerLauncherPath`" `"$runnerPath`"" -WindowStyle Hidden + Write-Host " Runner launch started (runs async). Check logs in a moment." -ForegroundColor Green + Write-Log -Level Info -Message "Runner invoked manually from configure menu via hidden launcher (task absent)." -Feature 'Configure' + } } catch { - Write-Host " ERROR: Failed to start task: $_" -ForegroundColor Red - Write-Log -Level Error -Message "Failed to start runner task manually: $_" -Feature 'Configure' + Write-Host " ERROR: Failed to start runner: $_" -ForegroundColor Red + Write-Log -Level Error -Message "Failed to start runner manually: $_" -Feature 'Configure' } Write-Host '' diff --git a/internal/features/SandwichReminder-AutoOrder.ps1 b/internal/features/SandwichReminder-AutoOrder.ps1 index 6001ef0..c3c6c4c 100644 --- a/internal/features/SandwichReminder-AutoOrder.ps1 +++ b/internal/features/SandwichReminder-AutoOrder.ps1 @@ -100,6 +100,27 @@ $FeatureMeta = @{ Default = 6 Description = 'Tab count from second checkbox to add-to-cart button' }, + @{ + Key = 'useDirectCheckoutNavigation' + Label = 'Use Direct Checkout Navigation' + Type = 'bool' + Default = $true + Description = 'After add-to-cart, navigate directly to /checkout to skip mini-cart and date/time steps' + }, + @{ + Key = 'checkoutPath' + Label = 'Checkout Path' + Type = 'string' + Default = '/checkout' + Description = 'Relative path used for direct checkout navigation' + }, + @{ + Key = 'checkoutOpenDelayMs' + Label = 'Checkout Open Delay (ms)' + Type = 'int' + Default = 1800 + Description = 'Wait time after opening checkout page before continuing keyboard flow' + }, @{ Key = 'openCartPopupAfterAdd' Label = 'Open Cart Popup After Add' @@ -134,10 +155,407 @@ $FeatureMeta = @{ Type = 'int' Default = 3 Description = 'Tab count from mini-cart button to confirm order button (opens delivery popup)' + }, + @{ + Key = 'tabsToDateTimeConfirm' + Label = 'Tabs To Date Time Confirm' + Type = 'int' + Default = 4 + Description = 'Tab count on delivery popup to confirm prefilled day and time' + }, + @{ + Key = 'tabsToItemRemarkButton' + Label = 'Tabs To Item Remark Button' + Type = 'int' + Default = 8 + Description = 'Tab count on order overview to open item remark popup' + }, + @{ + Key = 'tabsToCloseItemRemarkPopup' + Label = 'Tabs To Close Item Remark Popup' + Type = 'int' + Default = 1 + Description = 'Tab count after filling item remark to reach confirm button in popup' + }, + @{ + Key = 'tabsBeforeItemRemarkInput' + Label = 'Tabs Before Item Remark Input' + Type = 'int' + Default = -5 + Description = 'Tab count inside item remark popup before typing (negative means SHIFT+TAB)' + }, + @{ + Key = 'tabsToOrderRemarkInput' + Label = 'Tabs To Order Remark Input' + Type = 'int' + Default = 6 + Description = 'Tab count from item remark popup close to order remark input' + }, + @{ + Key = 'tabsToFinalConfirmButton' + Label = 'Tabs To Final Confirm Button' + Type = 'int' + Default = 3 + Description = 'Tab count from order remark input to final confirm button' + }, + @{ + Key = 'tabsToPaymentOption' + Label = 'Tabs To Payment Option' + Type = 'int' + Default = -9 + Description = 'Tab count on payment option screen to default option (negative means SHIFT+TAB)' + }, + @{ + Key = 'transitionDelayMs' + Label = 'Transition Delay (ms)' + Type = 'int' + Default = 400 + Description = 'Wait time after major Enter actions while screens or popups render' + }, + @{ + Key = 'deliveryPopupRenderDelayMs' + Label = 'Delivery Popup Render Delay (ms)' + Type = 'int' + Default = 1200 + Description = 'Extra wait right before the 4-tab date/time confirm step' + }, + @{ + Key = 'checkoutOverviewRenderDelayMs' + Label = 'Checkout Overview Render Delay (ms)' + Type = 'int' + Default = 1200 + Description = 'Extra wait after confirming date/time before running 3-tab item remark navigation' + }, + @{ + Key = 'itemRemarkPopupRenderDelayMs' + Label = 'Item Remark Popup Render Delay (ms)' + Type = 'int' + Default = 1200 + Description = 'Extra wait after opening item remark popup before tabbing and typing' } ) } +function Invoke-AppActivateBestEffort { + param( + [Parameter(Mandatory)]$Shell, + [Parameter(Mandatory)][string[]]$TitleCandidates, + [int]$TimeoutMs = 10000, + [int]$RetryDelayMs = 300 + ) + + $deadline = (Get-Date).AddMilliseconds($TimeoutMs) + $candidates = @($TitleCandidates | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + + do { + foreach ($candidate in $candidates) { + if ($Shell.AppActivate($candidate)) { + return $true + } + } + + Start-Sleep -Milliseconds $RetryDelayMs + } while ((Get-Date) -lt $deadline) + + return $false +} + +function ConvertTo-SendKeysSafeText { + param( + [string]$Text + ) + + if ($null -eq $Text) { return '' } + + # Escape SendKeys reserved characters so user remarks are typed literally. + $escaped = $Text + $escaped = $escaped -replace '(\+|\^|%|~|\(|\)|\[|\]|\{|\})', '{$1}' + return $escaped +} + +function Send-TabSteps { + param( + [Parameter(Mandatory)]$Shell, + [int]$Count, + [int]$StepDelayMs + ) + + $key = if ($Count -lt 0) { '+{TAB}' } else { '{TAB}' } + $iterations = [Math]::Abs($Count) + + for ($i = 0; $i -lt $iterations; $i++) { + $Shell.SendKeys($key) + Start-Sleep -Milliseconds $StepDelayMs + } +} + +function Invoke-SandwichAutoOrderFlow { + [CmdletBinding()] + param( + [Parameter(Mandatory)][hashtable]$AutoOrderConfig, + [Parameter(Mandatory)][string]$FallbackUrl, + [string]$FeatureName = 'SandwichReminder-AutoOrder' + ) + + $baseUrl = [string]$AutoOrderConfig['baseUrl'] + if ([string]::IsNullOrWhiteSpace($baseUrl)) { $baseUrl = 'https://ylos-kitchen.unipage.eu' } + + $itemId = [string]$AutoOrderConfig['itemId'] + if ([string]::IsNullOrWhiteSpace($itemId)) { $itemId = 'dmwmo3' } + + $windowTitleHint = [string]$AutoOrderConfig['windowTitleHint'] + if ([string]::IsNullOrWhiteSpace($windowTitleHint)) { $windowTitleHint = "YLO'S Kitchen" } + + $initialDelayMs = [int]$AutoOrderConfig['initialDelayMs'] + if ($initialDelayMs -le 0) { $initialDelayMs = 2500 } + + $stepDelayMs = [int]$AutoOrderConfig['stepDelayMs'] + if ($stepDelayMs -le 0) { $stepDelayMs = 120 } + + $toggleKeySetting = [string]$AutoOrderConfig['optionToggleKey'] + if ([string]::IsNullOrWhiteSpace($toggleKeySetting)) { $toggleKeySetting = 'SPACE' } + $toggleKey = if ($toggleKeySetting.ToUpperInvariant() -eq 'ENTER') { '{ENTER}' } else { ' ' } + + $calibrationOnly = [bool]$AutoOrderConfig['calibrationOnly'] + $calibrationTabs = [int]$AutoOrderConfig['calibrationTabs'] + if ($calibrationTabs -lt 0) { $calibrationTabs = 0 } + + $tabsToOption1 = [int]$AutoOrderConfig['tabsToOption1'] + if ($tabsToOption1 -lt 0) { $tabsToOption1 = 3 } + + $tabsBetweenOptions = [int]$AutoOrderConfig['tabsBetweenOptions'] + if ($tabsBetweenOptions -lt 0) { $tabsBetweenOptions = 2 } + + $tabsToAddButton = [int]$AutoOrderConfig['tabsToAddButton'] + if ($tabsToAddButton -lt 0) { $tabsToAddButton = 4 } + + $useDirectCheckoutNavigation = [bool]$AutoOrderConfig['useDirectCheckoutNavigation'] + $checkoutPath = [string]$AutoOrderConfig['checkoutPath'] + if ([string]::IsNullOrWhiteSpace($checkoutPath)) { $checkoutPath = '/checkout' } + if (-not $checkoutPath.StartsWith('/')) { $checkoutPath = '/' + $checkoutPath } + $checkoutOpenDelayMs = [int]$AutoOrderConfig['checkoutOpenDelayMs'] + if ($checkoutOpenDelayMs -le 0) { $checkoutOpenDelayMs = 1800 } + + $openCartPopup = [bool]$AutoOrderConfig['openCartPopupAfterAdd'] + $refreshBeforeCart = [bool]$AutoOrderConfig['refreshBeforeCart'] + $postRefreshDelayMs = [int]$AutoOrderConfig['postRefreshDelayMs'] + if ($postRefreshDelayMs -le 0) { $postRefreshDelayMs = 2500 } + $tabsToCartButton = [int]$AutoOrderConfig['tabsToCartButton'] + if ($tabsToCartButton -le 0) { $tabsToCartButton = 5 } + $tabsToConfirmButton = [int]$AutoOrderConfig['tabsToConfirmButton'] + if ($tabsToConfirmButton -le 0) { $tabsToConfirmButton = 3 } + $tabsToDateTimeConfirm = [int]$AutoOrderConfig['tabsToDateTimeConfirm'] + if ($tabsToDateTimeConfirm -le 0) { $tabsToDateTimeConfirm = 4 } + $tabsToItemRemarkButton = [int]$AutoOrderConfig['tabsToItemRemarkButton'] + if ($tabsToItemRemarkButton -le 0) { $tabsToItemRemarkButton = 8 } + $tabsToCloseItemRemarkPopup = [int]$AutoOrderConfig['tabsToCloseItemRemarkPopup'] + if ($tabsToCloseItemRemarkPopup -le 0) { $tabsToCloseItemRemarkPopup = 1 } + $tabsBeforeItemRemarkInput = [int]$AutoOrderConfig['tabsBeforeItemRemarkInput'] + if ($tabsBeforeItemRemarkInput -eq 0) { $tabsBeforeItemRemarkInput = -5 } + $tabsToOrderRemarkInput = [int]$AutoOrderConfig['tabsToOrderRemarkInput'] + if ($tabsToOrderRemarkInput -le 0) { $tabsToOrderRemarkInput = 6 } + $tabsToFinalConfirmButton = [int]$AutoOrderConfig['tabsToFinalConfirmButton'] + if ($tabsToFinalConfirmButton -le 0) { $tabsToFinalConfirmButton = 3 } + $tabsToPaymentOption = [int]$AutoOrderConfig['tabsToPaymentOption'] + if ($tabsToPaymentOption -eq 0) { $tabsToPaymentOption = -9 } + $transitionDelayMs = [int]$AutoOrderConfig['transitionDelayMs'] + if ($transitionDelayMs -le 0) { $transitionDelayMs = 400 } + $deliveryPopupRenderDelayMs = [int]$AutoOrderConfig['deliveryPopupRenderDelayMs'] + if ($deliveryPopupRenderDelayMs -le 0) { $deliveryPopupRenderDelayMs = 1200 } + $checkoutOverviewRenderDelayMs = [int]$AutoOrderConfig['checkoutOverviewRenderDelayMs'] + if ($checkoutOverviewRenderDelayMs -le 0) { $checkoutOverviewRenderDelayMs = 1200 } + $itemRemarkPopupRenderDelayMs = [int]$AutoOrderConfig['itemRemarkPopupRenderDelayMs'] + if ($itemRemarkPopupRenderDelayMs -le 0) { $itemRemarkPopupRenderDelayMs = 1200 } + + $activationTimeoutMs = [int]$AutoOrderConfig['activationTimeoutMs'] + if ($activationTimeoutMs -le 0) { $activationTimeoutMs = 10000 } + + $itemUrl = ($baseUrl.TrimEnd('/') + '/item/' + $itemId) + $checkoutUrl = ($baseUrl.TrimEnd('/') + $checkoutPath) + + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue | Out-Null + + Start-Process $itemUrl + Write-Log -Level Info -Message "Opened item modal URL for auto-order: $itemUrl" -Feature $FeatureName + Start-Sleep -Milliseconds $initialDelayMs + + $shell = New-Object -ComObject WScript.Shell + $hostHint = $null + try { $hostHint = ([uri]$baseUrl).Host } catch {} + + $titleCandidates = @( + 'Bestel online bij YLO''S Kitchen Pittem', + 'Bestel online bij YLO', + 'Bestel online bij', + $windowTitleHint, + ($windowTitleHint -replace "'", ''), + $hostHint, + 'ylos-kitchen', + 'unipage' + ) + + $activated = Invoke-AppActivateBestEffort -Shell $shell -TitleCandidates $titleCandidates -TimeoutMs $activationTimeoutMs -RetryDelayMs 300 + + if (-not $activated) { + Write-Log -Level Warn -Message "Could not foreground browser window (title hint '$windowTitleHint'). Skipping key automation to avoid acting on the wrong window." -Feature $FeatureName + return $false + } + + if ($calibrationOnly) { + for ($i = 0; $i -lt $calibrationTabs; $i++) { + $shell.SendKeys('{TAB}') + Start-Sleep -Milliseconds $stepDelayMs + } + + Write-Log -Level Info -Message ("Calibration mode completed: sent {0} TAB keys and stopped." -f $calibrationTabs) -Feature $FeatureName + return $true + } + + # Modal flow keyboard navigation (best-effort; tab indexes are configurable). + for ($i = 0; $i -lt $tabsToOption1; $i++) { + $shell.SendKeys('{TAB}') + Start-Sleep -Milliseconds $stepDelayMs + } + $shell.SendKeys($toggleKey) + Start-Sleep -Milliseconds $stepDelayMs + + for ($i = 0; $i -lt $tabsBetweenOptions; $i++) { + $shell.SendKeys('{TAB}') + Start-Sleep -Milliseconds $stepDelayMs + } + $shell.SendKeys($toggleKey) + Start-Sleep -Milliseconds $stepDelayMs + + for ($i = 0; $i -lt $tabsToAddButton; $i++) { + $shell.SendKeys('{TAB}') + Start-Sleep -Milliseconds $stepDelayMs + } + $shell.SendKeys('{ENTER}') + Start-Sleep -Milliseconds 350 + + if ($useDirectCheckoutNavigation) { + Start-Process $checkoutUrl + Write-Log -Level Info -Message "Opened checkout URL directly: $checkoutUrl" -Feature $FeatureName + Start-Sleep -Milliseconds $checkoutOpenDelayMs + + $activated = Invoke-AppActivateBestEffort -Shell $shell -TitleCandidates $titleCandidates -TimeoutMs $activationTimeoutMs -RetryDelayMs 300 + if (-not $activated) { + Write-Log -Level Warn -Message 'Could not foreground browser after opening checkout URL. Skipping remaining checkout automation.' -Feature $FeatureName + return $false + } + + # Order overview: open item remark popup. + Start-Sleep -Milliseconds $checkoutOverviewRenderDelayMs + [void](Invoke-AppActivateBestEffort -Shell $shell -TitleCandidates $titleCandidates -TimeoutMs 4000 -RetryDelayMs 200) + Send-TabSteps -Shell $shell -Count $tabsToItemRemarkButton -StepDelayMs $stepDelayMs + $shell.SendKeys('{ENTER}') + + # Item remark popup: type remark and confirm. + Start-Sleep -Milliseconds $itemRemarkPopupRenderDelayMs + [void](Invoke-AppActivateBestEffort -Shell $shell -TitleCandidates $titleCandidates -TimeoutMs 4000 -RetryDelayMs 200) + Send-TabSteps -Shell $shell -Count $tabsBeforeItemRemarkInput -StepDelayMs $stepDelayMs + $itemRemark = ConvertTo-SendKeysSafeText -Text ([string]$AutoOrderConfig['defaultItemRemark']) + if ([string]::IsNullOrWhiteSpace($itemRemark)) { $itemRemark = 'zonder tomaat aub' } + $shell.SendKeys($itemRemark) + Start-Sleep -Milliseconds $stepDelayMs + Send-TabSteps -Shell $shell -Count $tabsToCloseItemRemarkPopup -StepDelayMs $stepDelayMs + $shell.SendKeys('{ENTER}') + + # Back on order overview: move to order remark input, type text, confirm order. + Start-Sleep -Milliseconds $transitionDelayMs + Send-TabSteps -Shell $shell -Count $tabsToOrderRemarkInput -StepDelayMs $stepDelayMs + $orderRemark = ConvertTo-SendKeysSafeText -Text ([string]$AutoOrderConfig['defaultOrderRemark']) + if ([string]::IsNullOrWhiteSpace($orderRemark)) { $orderRemark = 'levering in Sioen Bistro' } + $shell.SendKeys($orderRemark) + Start-Sleep -Milliseconds $stepDelayMs + Send-TabSteps -Shell $shell -Count $tabsToFinalConfirmButton -StepDelayMs $stepDelayMs + $shell.SendKeys('{ENTER}') + + # Payment option screen: move to default option and confirm, then handoff to user. + Start-Sleep -Milliseconds $transitionDelayMs + Send-TabSteps -Shell $shell -Count $tabsToPaymentOption -StepDelayMs $stepDelayMs + $shell.SendKeys('{ENTER}') + + Write-Log -Level Info -Message 'Auto-order flow executed through direct checkout navigation and payment option confirmation; handoff to user at payment screen (best-effort).' -Feature $FeatureName + return $true + } + + if ($openCartPopup) { + if ($refreshBeforeCart) { + $shell.SendKeys('{F5}') + Write-Log -Level Info -Message 'Refreshing page to reset focus before tabbing to mini-cart.' -Feature $FeatureName + Start-Sleep -Milliseconds $postRefreshDelayMs + + $activated = Invoke-AppActivateBestEffort -Shell $shell -TitleCandidates $titleCandidates -TimeoutMs $activationTimeoutMs -RetryDelayMs 300 + + if (-not $activated) { + Write-Log -Level Warn -Message 'Could not re-foreground browser after refresh. Skipping cart popup.' -Feature $FeatureName + return $false + } + } + + for ($i = 0; $i -lt $tabsToCartButton; $i++) { + $shell.SendKeys('{TAB}') + Start-Sleep -Milliseconds $stepDelayMs + } + $shell.SendKeys('{ENTER}') + Start-Sleep -Milliseconds $stepDelayMs + + for ($i = 0; $i -lt $tabsToConfirmButton; $i++) { + $shell.SendKeys('{TAB}') + Start-Sleep -Milliseconds $stepDelayMs + } + $shell.SendKeys('{ENTER}') + + # Delivery popup: re-activate and wait longer before 4-tab date/time confirm. + Start-Sleep -Milliseconds $deliveryPopupRenderDelayMs + [void](Invoke-AppActivateBestEffort -Shell $shell -TitleCandidates $titleCandidates -TimeoutMs 4000 -RetryDelayMs 200) + Send-TabSteps -Shell $shell -Count $tabsToDateTimeConfirm -StepDelayMs $stepDelayMs + $shell.SendKeys('{ENTER}') + + # Order overview: open item remark popup. + Start-Sleep -Milliseconds $checkoutOverviewRenderDelayMs + [void](Invoke-AppActivateBestEffort -Shell $shell -TitleCandidates $titleCandidates -TimeoutMs 4000 -RetryDelayMs 200) + Send-TabSteps -Shell $shell -Count $tabsToItemRemarkButton -StepDelayMs $stepDelayMs + $shell.SendKeys('{ENTER}') + + # Item remark popup: type remark and confirm. + Start-Sleep -Milliseconds $itemRemarkPopupRenderDelayMs + [void](Invoke-AppActivateBestEffort -Shell $shell -TitleCandidates $titleCandidates -TimeoutMs 4000 -RetryDelayMs 200) + Send-TabSteps -Shell $shell -Count $tabsBeforeItemRemarkInput -StepDelayMs $stepDelayMs + $itemRemark = ConvertTo-SendKeysSafeText -Text ([string]$AutoOrderConfig['defaultItemRemark']) + if ([string]::IsNullOrWhiteSpace($itemRemark)) { $itemRemark = 'zonder tomaat aub' } + $shell.SendKeys($itemRemark) + Start-Sleep -Milliseconds $stepDelayMs + Send-TabSteps -Shell $shell -Count $tabsToCloseItemRemarkPopup -StepDelayMs $stepDelayMs + $shell.SendKeys('{ENTER}') + + # Back on order overview: move to order remark input, type text, confirm order. + Start-Sleep -Milliseconds $transitionDelayMs + Send-TabSteps -Shell $shell -Count $tabsToOrderRemarkInput -StepDelayMs $stepDelayMs + $orderRemark = ConvertTo-SendKeysSafeText -Text ([string]$AutoOrderConfig['defaultOrderRemark']) + if ([string]::IsNullOrWhiteSpace($orderRemark)) { $orderRemark = 'levering in Sioen Bistro' } + $shell.SendKeys($orderRemark) + Start-Sleep -Milliseconds $stepDelayMs + Send-TabSteps -Shell $shell -Count $tabsToFinalConfirmButton -StepDelayMs $stepDelayMs + $shell.SendKeys('{ENTER}') + + # Payment option screen: move to default option and confirm, then handoff to user. + Start-Sleep -Milliseconds $transitionDelayMs + Send-TabSteps -Shell $shell -Count $tabsToPaymentOption -StepDelayMs $stepDelayMs + $shell.SendKeys('{ENTER}') + } + + Write-Log -Level Info -Message 'Auto-order flow executed through payment option confirmation; handoff to user at payment screen (best-effort).' -Feature $FeatureName + return $true + } + catch { + Write-Log -Level Error -Message "Auto-order flow failed: $_" -Feature $FeatureName + return $false + } +} + function Invoke-Feature { param( [hashtable]$Config, diff --git a/internal/features/SandwichReminder.ps1 b/internal/features/SandwichReminder.ps1 index 55e323d..64248be 100644 --- a/internal/features/SandwichReminder.ps1 +++ b/internal/features/SandwichReminder.ps1 @@ -127,6 +127,17 @@ function Invoke-Feature { if ($autoOrderConfig -and $autoOrderConfig['enabled']) { Write-Log -Level Info -Message 'SandwichReminder-AutoOrder is enabled; running auto-order flow.' -Feature 'SandwichReminder' + + if (-not (Get-Command -Name Invoke-SandwichAutoOrderFlow -ErrorAction SilentlyContinue)) { + $autoOrderFeaturePath = Join-Path $PSScriptRoot 'SandwichReminder-AutoOrder.ps1' + if (Test-Path $autoOrderFeaturePath) { + . $autoOrderFeaturePath + } + else { + throw "Auto-order feature module not found at '$autoOrderFeaturePath'." + } + } + [void](Invoke-SandwichAutoOrderFlow -AutoOrderConfig $autoOrderConfig -FallbackUrl $Config['url']) } else { diff --git a/internal/lib/SandwichAutoOrder.ps1 b/internal/lib/SandwichAutoOrder.ps1 deleted file mode 100644 index e269233..0000000 --- a/internal/lib/SandwichAutoOrder.ps1 +++ /dev/null @@ -1,184 +0,0 @@ -# SandwichAutoOrder.ps1 — best-effort browser keyboard automation for personal sandwich flow. - -function Invoke-AppActivateBestEffort { - param( - [Parameter(Mandatory)]$Shell, - [Parameter(Mandatory)][string[]]$TitleCandidates, - [int]$TimeoutMs = 10000, - [int]$RetryDelayMs = 300 - ) - - $deadline = (Get-Date).AddMilliseconds($TimeoutMs) - $candidates = @($TitleCandidates | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) - - do { - foreach ($candidate in $candidates) { - if ($Shell.AppActivate($candidate)) { - return $true - } - } - - Start-Sleep -Milliseconds $RetryDelayMs - } while ((Get-Date) -lt $deadline) - - return $false -} - -function Invoke-SandwichAutoOrderFlow { - [CmdletBinding()] - param( - [Parameter(Mandatory)][hashtable]$AutoOrderConfig, - [Parameter(Mandatory)][string]$FallbackUrl, - [string]$FeatureName = 'SandwichReminder-AutoOrder' - ) - - $baseUrl = [string]$AutoOrderConfig['baseUrl'] - if ([string]::IsNullOrWhiteSpace($baseUrl)) { $baseUrl = 'https://ylos-kitchen.unipage.eu' } - - $itemId = [string]$AutoOrderConfig['itemId'] - if ([string]::IsNullOrWhiteSpace($itemId)) { $itemId = 'dmwmo3' } - - $windowTitleHint = [string]$AutoOrderConfig['windowTitleHint'] - if ([string]::IsNullOrWhiteSpace($windowTitleHint)) { $windowTitleHint = "YLO'S Kitchen" } - - $initialDelayMs = [int]$AutoOrderConfig['initialDelayMs'] - if ($initialDelayMs -le 0) { $initialDelayMs = 2500 } - - $stepDelayMs = [int]$AutoOrderConfig['stepDelayMs'] - if ($stepDelayMs -le 0) { $stepDelayMs = 120 } - - $toggleKeySetting = [string]$AutoOrderConfig['optionToggleKey'] - if ([string]::IsNullOrWhiteSpace($toggleKeySetting)) { $toggleKeySetting = 'SPACE' } - $toggleKey = if ($toggleKeySetting.ToUpperInvariant() -eq 'ENTER') { '{ENTER}' } else { ' ' } - - $calibrationOnly = [bool]$AutoOrderConfig['calibrationOnly'] - $calibrationTabs = [int]$AutoOrderConfig['calibrationTabs'] - if ($calibrationTabs -lt 0) { $calibrationTabs = 0 } - - $tabsToOption1 = [int]$AutoOrderConfig['tabsToOption1'] - if ($tabsToOption1 -lt 0) { $tabsToOption1 = 3 } - - $tabsBetweenOptions = [int]$AutoOrderConfig['tabsBetweenOptions'] - if ($tabsBetweenOptions -lt 0) { $tabsBetweenOptions = 2 } - - $tabsToAddButton = [int]$AutoOrderConfig['tabsToAddButton'] - if ($tabsToAddButton -lt 0) { $tabsToAddButton = 4 } - - $openCartPopup = [bool]$AutoOrderConfig['openCartPopupAfterAdd'] - $refreshBeforeCart = [bool]$AutoOrderConfig['refreshBeforeCart'] - $postRefreshDelayMs = [int]$AutoOrderConfig['postRefreshDelayMs'] - if ($postRefreshDelayMs -le 0) { $postRefreshDelayMs = 2500 } - $tabsToCartButton = [int]$AutoOrderConfig['tabsToCartButton'] - if ($tabsToCartButton -le 0) { $tabsToCartButton = 5 } - $tabsToConfirmButton = [int]$AutoOrderConfig['tabsToConfirmButton'] - if ($tabsToConfirmButton -le 0) { $tabsToConfirmButton = 3 } - - $activationTimeoutMs = [int]$AutoOrderConfig['activationTimeoutMs'] - if ($activationTimeoutMs -le 0) { $activationTimeoutMs = 10000 } - - $itemUrl = ($baseUrl.TrimEnd('/') + '/item/' + $itemId) - - try { - Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue | Out-Null - - Start-Process $itemUrl - Write-Log -Level Info -Message "Opened item modal URL for auto-order: $itemUrl" -Feature $FeatureName - Start-Sleep -Milliseconds $initialDelayMs - - $shell = New-Object -ComObject WScript.Shell - $hostHint = $null - try { $hostHint = ([uri]$baseUrl).Host } catch {} - - $activated = Invoke-AppActivateBestEffort -Shell $shell -TitleCandidates @( - 'Bestel online bij YLO''S Kitchen Pittem', - 'Bestel online bij YLO', - 'Bestel online bij', - $windowTitleHint, - ($windowTitleHint -replace "'", ''), - $hostHint, - 'ylos-kitchen', - 'unipage' - ) -TimeoutMs $activationTimeoutMs -RetryDelayMs 300 - - if (-not $activated) { - Write-Log -Level Warn -Message "Could not foreground browser window (title hint '$windowTitleHint'). Skipping key automation to avoid acting on the wrong window." -Feature $FeatureName - return $false - } - - if ($calibrationOnly) { - for ($i = 0; $i -lt $calibrationTabs; $i++) { - $shell.SendKeys('{TAB}') - Start-Sleep -Milliseconds $stepDelayMs - } - - Write-Log -Level Info -Message ("Calibration mode completed: sent {0} TAB keys and stopped." -f $calibrationTabs) -Feature $FeatureName - return $true - } - - # Modal flow keyboard navigation (best-effort; tab indexes are configurable). - for ($i = 0; $i -lt $tabsToOption1; $i++) { - $shell.SendKeys('{TAB}') - Start-Sleep -Milliseconds $stepDelayMs - } - $shell.SendKeys($toggleKey) - Start-Sleep -Milliseconds $stepDelayMs - - for ($i = 0; $i -lt $tabsBetweenOptions; $i++) { - $shell.SendKeys('{TAB}') - Start-Sleep -Milliseconds $stepDelayMs - } - $shell.SendKeys($toggleKey) - Start-Sleep -Milliseconds $stepDelayMs - - for ($i = 0; $i -lt $tabsToAddButton; $i++) { - $shell.SendKeys('{TAB}') - Start-Sleep -Milliseconds $stepDelayMs - } - $shell.SendKeys('{ENTER}') - Start-Sleep -Milliseconds 350 - - if ($openCartPopup) { - if ($refreshBeforeCart) { - $shell.SendKeys('{F5}') - Write-Log -Level Info -Message 'Refreshing page to reset focus before tabbing to mini-cart.' -Feature $FeatureName - Start-Sleep -Milliseconds $postRefreshDelayMs - - $activated = Invoke-AppActivateBestEffort -Shell $shell -TitleCandidates @( - 'Bestel online bij YLO''S Kitchen Pittem', - 'Bestel online bij YLO', - 'Bestel online bij', - $windowTitleHint, - ($windowTitleHint -replace "'", ''), - $hostHint, - 'ylos-kitchen', - 'unipage' - ) -TimeoutMs $activationTimeoutMs -RetryDelayMs 300 - - if (-not $activated) { - Write-Log -Level Warn -Message 'Could not re-foreground browser after refresh. Skipping cart popup.' -Feature $FeatureName - return $false - } - } - - for ($i = 0; $i -lt $tabsToCartButton; $i++) { - $shell.SendKeys('{TAB}') - Start-Sleep -Milliseconds $stepDelayMs - } - $shell.SendKeys('{ENTER}') - Start-Sleep -Milliseconds $stepDelayMs - - for ($i = 0; $i -lt $tabsToConfirmButton; $i++) { - $shell.SendKeys('{TAB}') - Start-Sleep -Milliseconds $stepDelayMs - } - $shell.SendKeys('{ENTER}') - } - - Write-Log -Level Info -Message 'Auto-order flow executed up to add-to-cart and cart popup open (best-effort).' -Feature $FeatureName - return $true - } - catch { - Write-Log -Level Error -Message "Auto-order flow failed: $_" -Feature $FeatureName - return $false - } -} diff --git a/internal/runner-launcher.vbs b/internal/runner-launcher.vbs new file mode 100644 index 0000000..65d3f2b --- /dev/null +++ b/internal/runner-launcher.vbs @@ -0,0 +1,25 @@ +' runner-launcher.vbs +' Starts runner.ps1 without a visible console window. +' Uses a shown/minimized style (not 0) so the process joins the interactive window station, +' which is required for WinForms dialogs to appear. PowerShell's own +' -WindowStyle Hidden suppresses the console window. + +Option Explicit + +If WScript.Arguments.Count < 1 Then + WScript.Quit 1 +End If + +Dim runnerPath +runnerPath = WScript.Arguments(0) + +Dim shell, cmd +Set shell = CreateObject("WScript.Shell") +cmd = "powershell.exe -NoLogo -NoProfile -Sta -WindowStyle Hidden -ExecutionPolicy Bypass -File """ & runnerPath & """" + +' Window style 1 = SW_SHOWNORMAL — do NOT use 0 here. +' Style 0 (CREATE_NO_WINDOW) detaches the process from the interactive +' window station, which prevents WinForms dialogs from rendering. +' PowerShell's own -WindowStyle Hidden flag suppresses the console window. +shell.Run cmd, 1, False +WScript.Quit 0 diff --git a/internal/runner.ps1 b/internal/runner.ps1 index 366bcba..d835691 100644 --- a/internal/runner.ps1 +++ b/internal/runner.ps1 @@ -11,7 +11,6 @@ $script:InternalRoot = $PSScriptRoot # runner.ps1 lives in internal\ . (Join-Path $InternalRoot 'lib\NetworkUtils.ps1') . (Join-Path $InternalRoot 'lib\ToastHelper.ps1') . (Join-Path $InternalRoot 'lib\PromptHelper.ps1') -. (Join-Path $InternalRoot 'lib\SandwichAutoOrder.ps1') . (Join-Path $InternalRoot 'lib\Config.ps1') Write-Log -Level Info -Message '────── Runner started ──────' -Feature 'Runner' @@ -38,7 +37,23 @@ foreach ($featureKey in $config.features.Keys) { continue } + # Per-feature mutex: prevents two concurrent runner instances from executing + # the same feature simultaneously (e.g. if the previous run is still in progress). + $mutexName = "Global\PSAutomation-Feature-$featureKey" + $mutex = $null + $mutexAcquired = $false + try { + $mutex = New-Object System.Threading.Mutex($false, $mutexName) + $mutexAcquired = $mutex.WaitOne(0) # non-blocking: skip if already locked + + if (-not $mutexAcquired) { + Write-Log -Level Warn ` + -Message "Feature '$featureKey' skipped: another instance is still running." ` + -Feature 'Runner' + continue + } + # Dot-source the feature — defines $FeatureMeta and Invoke-Feature in local scope. # Each iteration overwrites Invoke-Feature, which is fine because we call it immediately. $ErrorActionPreference = 'Stop' @@ -61,6 +76,12 @@ foreach ($featureKey in $config.features.Keys) { } finally { $ErrorActionPreference = 'Stop' + if ($mutexAcquired -and $null -ne $mutex) { + try { $mutex.ReleaseMutex() } catch {} + } + if ($null -ne $mutex) { + try { $mutex.Dispose() } catch {} + } } }