diff --git a/scripts/send_text.ps1 b/scripts/send_text.ps1 index 358fa66..0847068 100644 --- a/scripts/send_text.ps1 +++ b/scripts/send_text.ps1 @@ -1,6 +1,16 @@ param( [Parameter(Mandatory = $true)] - [string]$EncodedText + [string]$EncodedText, + + # How the text reaches the focused app. 'auto' inspects the foreground window + # and picks the shortcut that app actually understands. + [ValidateSet('auto', 'ctrl-v', 'shift-insert', 'ctrl-shift-v', 'type')] + [string]$Method = 'auto', + + # How long to leave the transcription on the clipboard before restoring the + # user's previous content. Slow apps (Electron, remote sessions) read the + # clipboard asynchronously and need more than a few dozen milliseconds. + [int]$RestoreDelayMs = 400 ) try { @@ -20,40 +30,317 @@ Add-Type -AssemblyName System.Drawing # SendKeys across applications (especially Chromium/Electron targets and apps that # debounce the higher-level WM_* messages SendKeys relies on). if (-not ([System.Management.Automation.PSTypeName]'OpenFlow.Native.Keyboard').Type) { - Add-Type -Namespace 'OpenFlow.Native' -Name 'Keyboard' -MemberDefinition @" + Add-Type -Namespace 'OpenFlow.Native' -Name 'Keyboard' -UsingNamespace 'System.Text' -MemberDefinition @" [System.Runtime.InteropServices.DllImport("user32.dll")] public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, System.UIntPtr dwExtraInfo); [System.Runtime.InteropServices.DllImport("user32.dll")] public static extern short GetAsyncKeyState(int vKey); + + [System.Runtime.InteropServices.DllImport("user32.dll")] + public static extern uint MapVirtualKey(uint uCode, uint uMapType); + + [System.Runtime.InteropServices.DllImport("user32.dll")] + public static extern System.IntPtr GetForegroundWindow(); + + [System.Runtime.InteropServices.DllImport("user32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode)] + public static extern int GetClassName(System.IntPtr hWnd, StringBuilder lpClassName, int nMaxCount); + + [System.Runtime.InteropServices.DllImport("user32.dll")] + public static extern uint GetWindowThreadProcessId(System.IntPtr hWnd, out uint lpdwProcessId); "@ } -$VK_CONTROL = [byte]0x11 -$VK_MENU = [byte]0x12 -$VK_SHIFT = [byte]0x10 +$VK_LSHIFT = [byte]0xA0 +$VK_RSHIFT = [byte]0xA1 +$VK_LCONTROL = [byte]0xA2 +$VK_RCONTROL = [byte]0xA3 +$VK_LMENU = [byte]0xA4 +$VK_RMENU = [byte]0xA5 $VK_LWIN = [byte]0x5B $VK_RWIN = [byte]0x5C $VK_V = [byte]0x56 +$VK_INSERT = [byte]0x2D + +$KEYEVENTF_EXTENDEDKEY = [uint32]0x0001 $KEYEVENTF_KEYUP = [uint32]0x0002 -function Release-Modifiers { - # Lift any modifier the user may still be holding (e.g. the Ctrl+Alt+V hotkey) so - # that the synthetic Ctrl+V is not contaminated into Ctrl+Alt+V or similar. - foreach ($vk in @($VK_CONTROL, $VK_MENU, $VK_SHIFT, $VK_LWIN, $VK_RWIN)) { - [OpenFlow.Native.Keyboard]::keybd_event($vk, 0, $KEYEVENTF_KEYUP, [System.UIntPtr]::Zero) +# Modifiers that can contaminate the synthetic paste if the user is still holding +# them (the dictation hotkey is Ctrl+Win, the paste-last hotkey is Ctrl+Alt+V). +$ModifierKeys = @($VK_LSHIFT, $VK_RSHIFT, $VK_LCONTROL, $VK_RCONTROL, $VK_LMENU, $VK_RMENU, $VK_LWIN, $VK_RWIN) + +# Keys that live on the extended part of the keyboard. Injecting them without the +# extended flag makes apps that read the scan code (consoles in particular) see a +# different key than the one we mean. +$ExtendedKeys = @($VK_RCONTROL, $VK_RMENU, $VK_LWIN, $VK_RWIN, $VK_INSERT) + +function Test-KeyDown { + param([byte]$Vk) + return ([OpenFlow.Native.Keyboard]::GetAsyncKeyState([int]$Vk) -band 0x8000) -ne 0 +} + +function Send-Key { + param( + [Parameter(Mandatory = $true)][byte]$Vk, + [switch]$KeyUp + ) + + # Always ship a real scan code. Apps that inspect the scan code in the WM_KEYDOWN + # lParam (terminals, games, remote-desktop and VM clients) ignore VK-only events, + # which is the most common reason a paste silently does nothing. + $scan = [byte]([OpenFlow.Native.Keyboard]::MapVirtualKey([uint32]$Vk, 0) -band 0xFF) + + $flags = [uint32]0 + if ($ExtendedKeys -contains $Vk) { + $flags = $flags -bor $KEYEVENTF_EXTENDEDKEY + } + if ($KeyUp) { + $flags = $flags -bor $KEYEVENTF_KEYUP + } + + [OpenFlow.Native.Keyboard]::keybd_event($Vk, $scan, $flags, [System.UIntPtr]::Zero) +} + +function Wait-ForModifiersReleased { + param([int]$TimeoutMs = 700) + + # Prefer waiting for the user to physically let go of the hotkey over forcing a + # synthetic key-up. Forcing it desynchronises the modifier state that console + # hosts track, which is what makes a later Shift+Enter arrive as a bare Enter. + $watch = [System.Diagnostics.Stopwatch]::StartNew() + while ($watch.ElapsedMilliseconds -lt $TimeoutMs) { + $stillDown = $false + foreach ($vk in $ModifierKeys) { + if (Test-KeyDown -Vk $vk) { + $stillDown = $true + break + } + } + + if (-not $stillDown) { + return $true + } + + Start-Sleep -Milliseconds 20 + } + + return $false +} + +function Clear-StuckModifiers { + # Last resort for a modifier the user really is still holding (hands-free mode + # keeps Ctrl+Win down). Only release the exact left/right key that is down: a + # blanket key-up on the generic VK_SHIFT/VK_CONTROL codes clears state for keys + # that were never pressed and leaves consoles believing Shift is up forever. + foreach ($vk in $ModifierKeys) { + if (Test-KeyDown -Vk $vk) { + Send-Key -Vk $vk -KeyUp + } + } +} + +function Get-ForegroundTarget { + $info = [ordered]@{ + Handle = [System.IntPtr]::Zero + Class = '' + Process = '' + ProcessId = 0 + Inaccessible = $false + } + + try { + $hwnd = [OpenFlow.Native.Keyboard]::GetForegroundWindow() + if ($hwnd -eq [System.IntPtr]::Zero) { + return $info + } + + $info.Handle = $hwnd + + $builder = New-Object System.Text.StringBuilder 256 + if ([OpenFlow.Native.Keyboard]::GetClassName($hwnd, $builder, $builder.Capacity) -gt 0) { + $info.Class = $builder.ToString() + } + + $targetProcessId = [uint32]0 + [void][OpenFlow.Native.Keyboard]::GetWindowThreadProcessId($hwnd, [ref]$targetProcessId) + $info.ProcessId = [int]$targetProcessId + + if ($targetProcessId -ne 0) { + $process = Get-Process -Id ([int]$targetProcessId) -ErrorAction Stop + $info.Process = $process.ProcessName + try { + # Reading the image path of a process running at a higher integrity level + # fails with access denied. Synthetic input into such a window is dropped + # by Windows (UIPI), so this doubles as an elevation probe. + $null = $process.MainModule.FileName + } catch { + $info.Inaccessible = $true + } + } + } catch { + # Best effort: an unknown target just means we fall back to Ctrl+V. + } + + return $info +} + +function Test-IsElevated { + try { + $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object System.Security.Principal.WindowsPrincipal($identity) + return $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) + } catch { + return $false + } +} + +function Resolve-PasteMethod { + param($Target) + + if ($Method -ne 'auto') { + return $Method + } + + $class = [string]$Target.Class + $process = [string]$Target.Process + + # mintty (Git Bash, MSYS2, Cygwin) and the PuTTY family do not bind Ctrl+V at + # all by default; Shift+Insert is their paste shortcut. These are exactly the + # terminals where the transcription appears to vanish. + if ($class -eq 'mintty' -or $process -eq 'mintty') { + return 'shift-insert' + } + if ($class -like 'PuTTY*' -or $class -eq 'ZOC' -or $process -in @('putty', 'kitty', 'plink', 'superputty')) { + return 'shift-insert' + } + + return 'ctrl-v' +} + +function Send-PasteShortcut { + param([string]$PasteMethod) + + switch ($PasteMethod) { + 'shift-insert' { + Send-Key -Vk $VK_LSHIFT + Start-Sleep -Milliseconds 8 + Send-Key -Vk $VK_INSERT + Start-Sleep -Milliseconds 30 + Send-Key -Vk $VK_INSERT -KeyUp + Send-Key -Vk $VK_LSHIFT -KeyUp + } + 'ctrl-shift-v' { + Send-Key -Vk $VK_LCONTROL + Start-Sleep -Milliseconds 8 + Send-Key -Vk $VK_LSHIFT + Start-Sleep -Milliseconds 8 + Send-Key -Vk $VK_V + Start-Sleep -Milliseconds 30 + Send-Key -Vk $VK_V -KeyUp + Send-Key -Vk $VK_LSHIFT -KeyUp + Send-Key -Vk $VK_LCONTROL -KeyUp + } + default { + Send-Key -Vk $VK_LCONTROL + Start-Sleep -Milliseconds 8 + Send-Key -Vk $VK_V + Start-Sleep -Milliseconds 30 + Send-Key -Vk $VK_V -KeyUp + Send-Key -Vk $VK_LCONTROL -KeyUp + } + } +} + +function Send-TextAsKeystrokes { + param([Parameter(Mandatory = $true)][string]$Value) + + # Clipboard-free fallback: synthesise the characters themselves with + # KEYEVENTF_UNICODE. This works in apps that refuse clipboard paste entirely + # and needs SendInput, so the interop type is only compiled when it is used. + if (-not ([System.Management.Automation.PSTypeName]'OpenFlow.Native.Typist').Type) { + Add-Type -TypeDefinition @" +using System; +using System.Runtime.InteropServices; + +namespace OpenFlow.Native { + [StructLayout(LayoutKind.Sequential)] + public struct MOUSEINPUT { + public int dx; + public int dy; + public uint mouseData; + public uint dwFlags; + public uint time; + public IntPtr dwExtraInfo; + } + + [StructLayout(LayoutKind.Sequential)] + public struct KEYBDINPUT { + public ushort wVk; + public ushort wScan; + public uint dwFlags; + public uint time; + public IntPtr dwExtraInfo; + } + + [StructLayout(LayoutKind.Sequential)] + public struct HARDWAREINPUT { + public uint uMsg; + public ushort wParamL; + public ushort wParamH; + } + + [StructLayout(LayoutKind.Explicit)] + public struct INPUTUNION { + [FieldOffset(0)] public MOUSEINPUT mi; + [FieldOffset(0)] public KEYBDINPUT ki; + [FieldOffset(0)] public HARDWAREINPUT hi; + } + + [StructLayout(LayoutKind.Sequential)] + public struct INPUT { + public uint type; + public INPUTUNION u; + } + + public static class Typist { + const uint INPUT_KEYBOARD = 1; + const uint KEYEVENTF_KEYUP = 0x0002; + const uint KEYEVENTF_UNICODE = 0x0004; + + [DllImport("user32.dll", SetLastError = true)] + static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize); + + public static uint Type(string text) { + INPUT[] inputs = new INPUT[text.Length * 2]; + for (int i = 0; i < text.Length; i++) { + inputs[i * 2].type = INPUT_KEYBOARD; + inputs[i * 2].u.ki.wScan = text[i]; + inputs[i * 2].u.ki.dwFlags = KEYEVENTF_UNICODE; + + inputs[i * 2 + 1].type = INPUT_KEYBOARD; + inputs[i * 2 + 1].u.ki.wScan = text[i]; + inputs[i * 2 + 1].u.ki.dwFlags = KEYEVENTF_UNICODE | KEYEVENTF_KEYUP; + } + + return SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(INPUT))); + } } } +"@ + } -function Send-Paste { - Release-Modifiers - Start-Sleep -Milliseconds 15 - [OpenFlow.Native.Keyboard]::keybd_event($VK_CONTROL, 0, 0, [System.UIntPtr]::Zero) - Start-Sleep -Milliseconds 5 - [OpenFlow.Native.Keyboard]::keybd_event($VK_V, 0, 0, [System.UIntPtr]::Zero) - Start-Sleep -Milliseconds 25 - [OpenFlow.Native.Keyboard]::keybd_event($VK_V, 0, $KEYEVENTF_KEYUP, [System.UIntPtr]::Zero) - [OpenFlow.Native.Keyboard]::keybd_event($VK_CONTROL, 0, $KEYEVENTF_KEYUP, [System.UIntPtr]::Zero) + # Chunked so a long transcription does not overflow the target's input queue. + $chunkSize = 200 + for ($offset = 0; $offset -lt $Value.Length; $offset += $chunkSize) { + $length = [Math]::Min($chunkSize, $Value.Length - $offset) + $expectedInputCount = [uint32]($length * 2) + $sentInputCount = [OpenFlow.Native.Typist]::Type($Value.Substring($offset, $length)) + if ($sentInputCount -ne $expectedInputCount) { + $nativeError = [System.Runtime.InteropServices.Marshal]::GetLastWin32Error() + throw "Windows accepted $sentInputCount of $expectedInputCount keyboard events (error $nativeError)." + } + Start-Sleep -Milliseconds 5 + } } function Invoke-ClipboardAction { @@ -147,6 +434,37 @@ function Set-ClipboardTextForPaste { [System.Windows.Forms.Clipboard]::SetDataObject($dataObject, $true) } +$target = Get-ForegroundTarget +$targetLabel = if ($target.Process) { "$($target.Process) [$($target.Class)]" } else { 'the active app' } + +# Windows silently discards injected input aimed at a window owned by a process +# running at a higher integrity level. Detect that up front and say so, instead of +# looking like the paste worked while nothing appears. +if ($target.Inaccessible -and -not (Test-IsElevated)) { + try { + Invoke-ClipboardAction -Operation 'set-text' -Action { + Set-ClipboardTextForPaste -Value $Text + } | Out-Null + } catch { + # The clipboard hand-off is a convenience; the error below is the real result. + } + + throw "$targetLabel runs with higher privileges, so Windows blocks OpenFlow from pasting into it. Run OpenFlow as administrator, or press Ctrl+V yourself (the text is on the clipboard)." +} + +if ($Method -eq 'type') { + # Explicit clipboard-free mode: never touch the clipboard at all. + Wait-ForModifiersReleased | Out-Null + Clear-StuckModifiers + Start-Sleep -Milliseconds 20 + Send-TextAsKeystrokes -Value $Text + [Console]::Out.WriteLine('__OPENFLOW_PASTE_OK__') + [Console]::Out.Flush() + exit 0 +} + +$pasteMethod = Resolve-PasteMethod -Target $target + # Remember what the user had on the clipboard so it can be restored after the paste. $previousClipboard = Get-ClipboardSnapshot @@ -179,16 +497,34 @@ for ($attempt = 0; $attempt -lt 6; $attempt++) { } if (-not $clipboardReady) { - throw "Clipboard was not ready for pasting." + # Another app is holding the clipboard open. Typing the text is slower but does + # not need the clipboard at all, so the transcription still lands. + try { + Wait-ForModifiersReleased | Out-Null + Clear-StuckModifiers + Start-Sleep -Milliseconds 20 + Send-TextAsKeystrokes -Value $Text + [Console]::Out.WriteLine('__OPENFLOW_PASTE_OK__') + [Console]::Out.Flush() + exit 0 + } catch { + throw "Clipboard was not ready for pasting and typing the text failed: $($_.Exception.Message)" + } } try { + # Let go of the hotkey modifiers before injecting, otherwise the target sees + # Ctrl+Win+V (or Ctrl+Alt+V) instead of a plain paste and ignores it. + Wait-ForModifiersReleased | Out-Null + Clear-StuckModifiers Start-Sleep -Milliseconds 30 - Send-Paste + + Send-PasteShortcut -PasteMethod $pasteMethod [Console]::Out.WriteLine('__OPENFLOW_PASTE_OK__') [Console]::Out.Flush() - # Give the target app time to read the clipboard before removing our temporary text. - Start-Sleep -Milliseconds 180 + # Give the target app time to read the clipboard before removing our temporary + # text. Chromium/Electron targets and remote sessions read it asynchronously. + Start-Sleep -Milliseconds ([Math]::Max(120, $RestoreDelayMs)) } finally { # Put the user's previous clipboard contents back, but only while the clipboard # still holds our temporary text — if the user copied something newer in the diff --git a/src/main/main.js b/src/main/main.js index 0f3f62c..f164f4d 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -45,7 +45,12 @@ const PERSISTENCE_VERSION = 6; const SERVICE_SHUTDOWN_TIMEOUT_MS = 2500; const HANDS_FREE_SOUND_DELAY_MS = 250; const WINDOWS_PASTE_READY_SIGNAL = '__OPENFLOW_PASTE_OK__'; -const WINDOWS_PASTE_TIMEOUT_MS = 4000; +// Generous enough to cover PowerShell start-up, waiting for the user to let go of +// the hotkey modifiers, clipboard retries and the post-paste clipboard dwell. +const WINDOWS_PASTE_TIMEOUT_MS = 8000; +// Overrides the paste keystroke the script picks from the foreground window. +// One of: auto | ctrl-v | shift-insert | ctrl-shift-v | type. +const WINDOWS_PASTE_METHODS = new Set(['auto', 'ctrl-v', 'shift-insert', 'ctrl-shift-v', 'type']); // Burst early after capture-end, then keep retrying long enough that media apps // (Chrome etc.) which recycle sessions after a long silence still get unmuted. // Final delay is 5 minutes so late session rebirth is not abandoned after ~10s. @@ -68,6 +73,8 @@ const OPENROUTER_STT_MODELS_URL = `${OPENROUTER_BASE_URL}/models?output_modaliti const OPENROUTER_STT_URL = `${OPENROUTER_BASE_URL}/audio/transcriptions`; const OPENROUTER_DEFAULT_MODEL = DEFAULT_CLOUD_TRANSCRIPTION_MODEL; const CLOUD_RETRY_LIMIT = 20; +const CLOUD_RETRY_TTL_MS = 60 * 60 * 1000; +const CLOUD_RETRY_PRUNE_INTERVAL_MS = 60 * 1000; const CLOUD_TRANSCRIPTION_TIMEOUT_MS = 120000; const BACKGROUND_TRANSCRIPTION_SESSION_TTL_MS = 30 * 60 * 1000; const MODEL_OPTIONS = [ @@ -1620,7 +1627,15 @@ function readCloudRetryRecord(id) { return unprotectCloudRetryRecord(protectedPayload); } -function getCloudRetryRecords() { +function isCloudRetryExpired(record, now = Date.now()) { + const createdAtMs = Date.parse(record?.createdAt || ''); + if (!Number.isFinite(createdAtMs)) { + return true; + } + return now - createdAtMs >= CLOUD_RETRY_TTL_MS; +} + +function listCloudRetryRecords() { try { return fs .readdirSync(getCloudRetriesDirectory(), { withFileTypes: true }) @@ -1633,6 +1648,11 @@ function getCloudRetryRecords() { } } +function getCloudRetryRecords() { + pruneCloudRetries(); + return listCloudRetryRecords().filter((record) => !isCloudRetryExpired(record)); +} + function getCloudRetrySnapshot() { return getCloudRetryRecords().map((record) => ({ id: record.id, @@ -1645,8 +1665,23 @@ function getCloudRetrySnapshot() { } function pruneCloudRetries() { - const records = getCloudRetryRecords(); - for (const record of records.slice(CLOUD_RETRY_LIMIT)) { + const records = listCloudRetryRecords(); + const now = Date.now(); + const active = []; + + for (const record of records) { + if (isCloudRetryExpired(record, now)) { + try { + fs.unlinkSync(getCloudRetryPath(record.id)); + } catch (_error) { + // Best effort. + } + continue; + } + active.push(record); + } + + for (const record of active.slice(CLOUD_RETRY_LIMIT)) { try { fs.unlinkSync(getCloudRetryPath(record.id)); } catch (_error) { @@ -1655,6 +1690,37 @@ function pruneCloudRetries() { } } +function cloudRetrySnapshotSignature(retries) { + return (retries || []) + .map((retry) => `${retry.id}:${retry.createdAt}:${retry.error || ''}`) + .join('|'); +} + +function refreshCloudRetriesState() { + const next = getCloudRetrySnapshot(); + if (cloudRetrySnapshotSignature(state.cloudRetries) === cloudRetrySnapshotSignature(next)) { + return false; + } + setState({ + cloudRetries: next, + }); + return true; +} + +let cloudRetryPruneTimer = null; + +function startCloudRetryPruneWatch() { + if (cloudRetryPruneTimer) { + return; + } + cloudRetryPruneTimer = setInterval(() => { + refreshCloudRetriesState(); + }, CLOUD_RETRY_PRUNE_INTERVAL_MS); + if (typeof cloudRetryPruneTimer.unref === 'function') { + cloudRetryPruneTimer.unref(); + } +} + function saveCloudRetry(payload, error, options = {}) { const record = { id: createCloudRetryId(), @@ -2662,6 +2728,17 @@ function restorePreviousClipboard(injectedText, snapshot) { } } +// Escape hatch for apps whose paste shortcut the script cannot infer from the +// foreground window. `type` bypasses the clipboard entirely and synthesises the +// characters, which works even where clipboard paste is blocked. +function getWindowsPasteMethod() { + const requested = String(process.env.FLOW_PASTE_METHOD || '') + .trim() + .toLowerCase(); + + return WINDOWS_PASTE_METHODS.has(requested) ? requested : 'auto'; +} + function runTextInsertion(text) { return new Promise((resolve, reject) => { if (process.platform === 'darwin') { @@ -2714,7 +2791,18 @@ function runTextInsertion(text) { const encodedText = Buffer.from(text, 'utf8').toString('base64'); const powershell = spawn( 'powershell.exe', - ['-NoProfile', '-STA', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, '-EncodedText', encodedText], + [ + '-NoProfile', + '-STA', + '-ExecutionPolicy', + 'Bypass', + '-File', + scriptPath, + '-EncodedText', + encodedText, + '-Method', + getWindowsPasteMethod(), + ], { windowsHide: true }, ); @@ -3493,7 +3581,10 @@ async function handleCloudAudioPayload(payload, sessionId) { async function retryCloudTranscription(id) { const record = readCloudRetryRecord(id); - if (!record) { + if (!record || isCloudRetryExpired(record)) { + if (record) { + deleteCloudRetry(record.id); + } throw new Error('Saved recording was not found.'); } @@ -5407,6 +5498,7 @@ app.whenReady().then(() => { createWindow(); createOverlayWindow(); startOverlayVisibilityWatchdog(); + startCloudRetryPruneWatch(); bootAudioController(); bootDictationService(); bootHotkeyListener(); diff --git a/src/renderer/renderer.js b/src/renderer/renderer.js index 32523ac..3726818 100644 --- a/src/renderer/renderer.js +++ b/src/renderer/renderer.js @@ -1547,8 +1547,51 @@ function renderCloudModels(state) { } } +const CLOUD_RETRY_TTL_MS = 60 * 60 * 1000; +let cloudRetryExpiryTimer = null; + +function isCloudRetryFresh(retry, now = Date.now()) { + const createdAtMs = Date.parse(retry?.createdAt || ''); + return Number.isFinite(createdAtMs) && now - createdAtMs < CLOUD_RETRY_TTL_MS; +} + +function scheduleCloudRetryExpiryRefresh(retries) { + if (cloudRetryExpiryTimer) { + clearTimeout(cloudRetryExpiryTimer); + cloudRetryExpiryTimer = null; + } + + const now = Date.now(); + let nextExpiryMs = Infinity; + for (const retry of retries) { + const createdAtMs = Date.parse(retry?.createdAt || ''); + if (!Number.isFinite(createdAtMs)) { + continue; + } + const expiresAt = createdAtMs + CLOUD_RETRY_TTL_MS; + if (expiresAt > now && expiresAt < nextExpiryMs) { + nextExpiryMs = expiresAt; + } + } + + if (!Number.isFinite(nextExpiryMs)) { + return; + } + + cloudRetryExpiryTimer = setTimeout(() => { + cloudRetryExpiryTimer = null; + renderCache.cloudRetries = ''; + if (lastState) { + renderCloudRetries(lastState); + } + }, Math.max(250, nextExpiryMs - Date.now() + 50)); +} + function renderCloudRetries(state) { - const retries = Array.isArray(state.cloudRetries) ? state.cloudRetries : []; + const now = Date.now(); + const retries = (Array.isArray(state.cloudRetries) ? state.cloudRetries : []).filter((retry) => + isCloudRetryFresh(retry, now), + ); const signature = [ locale(), state.phase, @@ -1557,9 +1600,11 @@ function renderCloudRetries(state) { .join('|'), ].join('|'); if (renderCache.cloudRetries === signature) { + scheduleCloudRetryExpiryRefresh(retries); return; } renderCache.cloudRetries = signature; + scheduleCloudRetryExpiryRefresh(retries); els.cloudRetrySection.classList.toggle('hidden', retries.length === 0); if (retries.length === 0) {