| 1 | import * as Effect from "effect/Effect"; |
| 2 | import type * as Fiber from "effect/Fiber"; |
| 3 | |
| 4 | import * as Electron from "electron"; |
| 5 | |
| 6 | const MIN_DURATION_MS = 280; |
| 7 | const MAX_DURATION_MS = 680; |
| 8 | const DURATION_DISTANCE_SCALE = 2_000; |
| 9 | const EASING = "cubic-bezier(.2,.8,.2,1)"; |
| 10 | const TIMEOUT_MS = 6_000; |
| 11 | |
| 12 | type SnapShotAnimationDetails = { |
| 13 | readonly appName: string; |
| 14 | readonly windowTitle: string; |
| 15 | readonly appIconDataUrl?: string | undefined; |
| 16 | }; |
| 17 | |
| 18 | export type SnapShotAnimationDestination = { |
| 19 | readonly frame: Electron.Rectangle; |
| 20 | /** Unit coordinates in T3's content area; GNOME supplies the real compositor origin. */ |
| 21 | readonly relativeFrame?: Electron.Rectangle | undefined; |
| 22 | readonly backgroundColor: string; |
| 23 | readonly borderColor: string; |
| 24 | readonly borderWidth: number; |
| 25 | readonly cornerRadius: number; |
| 26 | readonly scaleFactor: number; |
| 27 | readonly details?: SnapShotAnimationDetails | undefined; |
| 28 | }; |
| 29 | |
| 30 | export function snapShotAnimationDurationMs( |
| 31 | source: Electron.Rectangle, |
| 32 | target: Electron.Rectangle, |
| 33 | ): number { |
| 34 | const distance = Math.hypot( |
| 35 | source.x + source.width / 2 - target.x - target.width / 2, |
| 36 | source.y + source.height / 2 - target.y - target.height / 2, |
| 37 | ); |
| 38 | return ( |
| 39 | MAX_DURATION_MS - |
| 40 | (MAX_DURATION_MS - MIN_DURATION_MS) * Math.exp(-distance / DURATION_DISTANCE_SCALE) |
| 41 | ); |
| 42 | } |
| 43 | |
| 44 | type ActiveTransition = { |
| 45 | readonly id: string; |
| 46 | readonly source: Electron.Rectangle; |
| 47 | readonly snapshotDataUrl: string; |
| 48 | readonly overlays: Array<{ |
| 49 | readonly window: Electron.BrowserWindow; |
| 50 | readonly requestedBounds: Electron.Rectangle; |
| 51 | bounds: Electron.Rectangle; |
| 52 | }>; |
| 53 | details?: SnapShotAnimationDetails | undefined; |
| 54 | timer?: Fiber.Fiber<void> | undefined; |
| 55 | flight?: Promise<void> | undefined; |
| 56 | }; |
| 57 | |
| 58 | type SnapShotTransitionOptions = { |
| 59 | readonly showWindow?: ((window: Electron.BaseWindow) => void) | undefined; |
| 60 | readonly boundOverlayToCaptureDisplays?: boolean | undefined; |
| 61 | readonly waitForCompositorFrame?: boolean | undefined; |
| 62 | readonly alwaysOnTopLevel?: |
| 63 | | NonNullable<Parameters<Electron.BrowserWindow["setAlwaysOnTop"]>[1]> |
| 64 | | undefined; |
| 65 | }; |
| 66 | |
| 67 | export function snapShotAnimationOverlayBounds( |
| 68 | displays: ReadonlyArray<Pick<Electron.Display, "bounds">>, |
| 69 | ): Electron.Rectangle { |
| 70 | const firstDisplay = displays[0]; |
| 71 | const first = firstDisplay?.bounds ?? { x: 0, y: 0, width: 1, height: 1 }; |
| 72 | let left = first.x; |
| 73 | let top = first.y; |
| 74 | let right = first.x + first.width; |
| 75 | let bottom = first.y + first.height; |
| 76 | for (const display of displays.slice(1)) { |
| 77 | const bounds = display.bounds; |
| 78 | left = Math.min(left, bounds.x); |
| 79 | top = Math.min(top, bounds.y); |
| 80 | right = Math.max(right, bounds.x + bounds.width); |
| 81 | bottom = Math.max(bottom, bounds.y + bounds.height); |
| 82 | } |
| 83 | return { x: left, y: top, width: right - left, height: bottom - top }; |
| 84 | } |
| 85 | |
| 86 | export function snapShotAnimationDisplayBounds( |
| 87 | displays: ReadonlyArray<Pick<Electron.Display, "bounds">>, |
| 88 | source: Electron.Rectangle, |
| 89 | destination: Electron.Rectangle, |
| 90 | ): Array<Electron.Rectangle> { |
| 91 | const flight = snapShotAnimationOverlayBounds([{ bounds: source }, { bounds: destination }]); |
| 92 | return displays |
| 93 | .map((display) => display.bounds) |
| 94 | .filter( |
| 95 | (bounds) => |
| 96 | bounds.x < flight.x + flight.width && |
| 97 | bounds.x + bounds.width > flight.x && |
| 98 | bounds.y < flight.y + flight.height && |
| 99 | bounds.y + bounds.height > flight.y, |
| 100 | ); |
| 101 | } |
| 102 | |
| 103 | function createWindow( |
| 104 | bounds: Electron.Rectangle, |
| 105 | alwaysOnTopLevel: SnapShotTransitionOptions["alwaysOnTopLevel"], |
| 106 | ): Electron.BrowserWindow { |
| 107 | const window = new Electron.BrowserWindow({ |
| 108 | ...bounds, |
| 109 | alwaysOnTop: true, |
| 110 | backgroundColor: "#00000000", |
| 111 | focusable: false, |
| 112 | frame: false, |
| 113 | hasShadow: false, |
| 114 | resizable: false, |
| 115 | show: false, |
| 116 | skipTaskbar: true, |
| 117 | title: "The Knife Snapshot Animation", |
| 118 | transparent: true, |
| 119 | webPreferences: { |
| 120 | backgroundThrottling: false, |
| 121 | contextIsolation: true, |
| 122 | nodeIntegration: false, |
| 123 | sandbox: true, |
| 124 | }, |
| 125 | }); |
| 126 | if (alwaysOnTopLevel) window.setAlwaysOnTop(true, alwaysOnTopLevel); |
| 127 | window.setIgnoreMouseEvents(true); |
| 128 | return window; |
| 129 | } |
| 130 | |
| 131 | function transitionHtml( |
| 132 | sourceBounds: Electron.Rectangle, |
| 133 | overlayBounds: Electron.Rectangle, |
| 134 | flash: boolean, |
| 135 | ): string { |
| 136 | const source = { |
| 137 | x: sourceBounds.x - overlayBounds.x, |
| 138 | y: sourceBounds.y - overlayBounds.y, |
| 139 | width: sourceBounds.width, |
| 140 | height: sourceBounds.height, |
| 141 | }; |
| 142 | return `<!doctype html><style> |
| 143 | html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent} |
| 144 | #card{position:absolute;left:${source.x}px;top:${source.y}px;width:${source.width}px;height:${source.height}px;contain:strict;overflow:hidden;border-radius:12px;background:#fff;box-shadow:0 24px 70px rgba(0,0,0,.24);transform-origin:center;will-change:transform} |
| 145 | #content{position:absolute;inset:0;overflow:hidden;border-radius:inherit;transform-origin:center;will-change:transform} |
| 146 | #snapshot{position:absolute;inset:0;width:100%;height:100%;object-fit:fill;transform-origin:center;will-change:transform} |
| 147 | #flash{position:absolute;inset:0;background:#fff;opacity:0;${flash ? "" : "display:none"}} |
| 148 | #details{--scale:1;position:absolute;inset-inline:0;bottom:0;display:flex;align-items:center;gap:calc(6px * var(--scale));padding:calc(24px * var(--scale)) calc(10px * var(--scale)) calc(8px * var(--scale));background:linear-gradient(to top,rgba(0,0,0,.85),rgba(0,0,0,.55),transparent);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;opacity:0} |
| 149 | #details:not([data-ready]){visibility:hidden} |
| 150 | #icon,#fallback{width:calc(28px * var(--scale));height:calc(28px * var(--scale));flex:none;border-radius:calc(6px * var(--scale))} |
| 151 | #icon{display:none;object-fit:cover} |
| 152 | #fallback{display:flex;align-items:center;justify-content:center;background:rgba(255,255,255,.2);color:#fff;font-size:calc(10px * var(--scale));font-weight:500;text-transform:uppercase} |
| 153 | #copy{min-width:0;flex:1}#app,#title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:calc(14px * var(--scale))} |
| 154 | #app{color:#fff;font-size:calc(11px * var(--scale));font-weight:500}#title{color:rgba(255,255,255,.7);font-size:calc(9px * var(--scale))} |
| 155 | #border{position:absolute;z-index:2;inset:0;box-sizing:border-box;border:0 solid transparent;border-radius:inherit;opacity:0} |
| 156 | </style><div id="card"><div id="content"><img id="snapshot"><div id="details"><img id="icon"><div id="fallback"></div><div id="copy"><div id="app"></div><div id="title"></div></div></div></div><div id="border"></div><div id="flash"></div></div><script> |
| 157 | const source=${JSON.stringify(source)},card=document.getElementById("card"),content=document.getElementById("content"),snapshot=document.getElementById("snapshot"),details=document.getElementById("details"),border=document.getElementById("border"); |
| 158 | const afterPaint=()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))); |
| 159 | window.setCaptureSnapshot=async src=>{snapshot.src=src;await snapshot.decode();await afterPaint()}; |
| 160 | window.rebaseCaptureSource=next=>{Object.assign(source,next);Object.assign(card.style,{left:source.x+"px",top:source.y+"px",width:source.width+"px",height:source.height+"px"})}; |
| 161 | window.startCaptureFlash=()=>document.getElementById("flash").animate([{opacity:.08},{offset:.38,opacity:.08},{offset:.68,opacity:.02},{opacity:0}],{duration:300,fill:"forwards",easing:"${EASING}"}); |
| 162 | const applyDetails=value=>{ |
| 163 | if(!value)return; |
| 164 | details.dataset.ready=""; |
| 165 | document.getElementById("app").textContent=value.appName; |
| 166 | document.getElementById("title").textContent=value.windowTitle||"Captured window"; |
| 167 | document.getElementById("fallback").textContent=value.appName.slice(0,1); |
| 168 | if(value.appIconDataUrl){ |
| 169 | const icon=document.getElementById("icon");icon.src=value.appIconDataUrl;icon.style.display="block";document.getElementById("fallback").style.display="none"; |
| 170 | } |
| 171 | }; |
| 172 | window.updateCaptureDetails=applyDetails; |
| 173 | window.prepareCaptureTransition=async destination=>{ |
| 174 | const target=destination.frame,borderWidth=Math.min(Math.max(0,destination.borderWidth),Math.max(0,(Math.min(target.width,target.height)-1)/2)); |
| 175 | const inner={width:Math.max(1,target.width-borderWidth*2),height:Math.max(1,target.height-borderWidth*2)}; |
| 176 | const sourceCenter={x:source.x+source.width/2,y:source.y+source.height/2},targetCenter={x:target.x+target.width/2,y:target.y+target.height/2}; |
| 177 | const initial="translate3d("+(sourceCenter.x-targetCenter.x)+"px,"+(sourceCenter.y-targetCenter.y)+"px,0) scale("+(source.width/target.width)+","+(source.height/target.height)+")"; |
| 178 | const imageWidth=Math.max(1,snapshot.naturalWidth||source.width),imageHeight=Math.max(1,snapshot.naturalHeight||source.height),cover=Math.max(inner.width/imageWidth,inner.height/imageHeight); |
| 179 | Object.assign(card.style,{left:target.x+"px",top:target.y+"px",width:target.width+"px",height:target.height+"px",borderRadius:destination.cornerRadius+"px",backgroundColor:destination.backgroundColor,boxShadow:"none"}); |
| 180 | Object.assign(content.style,{inset:borderWidth+"px",borderRadius:Math.max(0,destination.cornerRadius-borderWidth)+"px"}); |
| 181 | Object.assign(border.style,{borderWidth:borderWidth+"px",borderColor:destination.borderColor}); |
| 182 | details.style.setProperty("--scale",String(destination.scaleFactor)); |
| 183 | applyDetails(destination.details); |
| 184 | const options={duration:destination.durationMs,fill:"forwards",easing:"${EASING}"}; |
| 185 | const animations=[ |
| 186 | card.animate([{transform:initial},{transform:"translate3d(0,0,0) scale(1)"}],options), |
| 187 | content.animate([{transform:"scale("+(target.width/inner.width)+","+(target.height/inner.height)+")"},{transform:"scale(1)"}],options), |
| 188 | snapshot.animate([{transform:"scale(1)"},{transform:"scale("+((imageWidth*cover)/inner.width)+","+((imageHeight*cover)/inner.height)+")"}],options), |
| 189 | border.animate([{opacity:0},{opacity:1}],options), |
| 190 | details.animate([{opacity:0},{opacity:1}],{delay:Math.max(0,destination.durationMs-125),duration:125,fill:"both",easing:"ease-in"}) |
| 191 | ]; |
| 192 | for(const animation of animations){animation.pause();animation.currentTime=0} |
| 193 | window.playCaptureTransition=async()=>{ |
| 194 | for(const animation of animations)animation.play(); |
| 195 | await Promise.all(animations.map(animation=>animation.finished.catch(()=>undefined))); |
| 196 | await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))); |
| 197 | }; |
| 198 | await afterPaint(); |
| 199 | }; |
| 200 | </script>`; |
| 201 | } |
| 202 | |
| 203 | export class SnapShotTransition { |
| 204 | private active: ActiveTransition | undefined; |
| 205 | private readonly boundOverlayToCaptureDisplays: boolean; |
| 206 | private readonly waitForCompositorFrame: boolean; |
| 207 | private readonly alwaysOnTopLevel: SnapShotTransitionOptions["alwaysOnTopLevel"]; |
| 208 | private readonly showWindow: (window: Electron.BaseWindow) => void; |
| 209 | |
| 210 | constructor(options: SnapShotTransitionOptions = {}) { |
| 211 | this.boundOverlayToCaptureDisplays = options.boundOverlayToCaptureDisplays ?? false; |
| 212 | this.waitForCompositorFrame = options.waitForCompositorFrame ?? false; |
| 213 | this.alwaysOnTopLevel = options.alwaysOnTopLevel; |
| 214 | this.showWindow = options.showWindow ?? ((window) => window.showInactive()); |
| 215 | } |
| 216 | |
| 217 | async begin( |
| 218 | id: string, |
| 219 | source: Electron.Rectangle, |
| 220 | snapshotDataUrl: string, |
| 221 | flash: boolean, |
| 222 | destinationWindowBounds?: Electron.Rectangle, |
| 223 | ): Promise<void> { |
| 224 | this.dispose(); |
| 225 | const sourceDisplay = Electron.screen.getDisplayMatching(source); |
| 226 | const requestedBounds = this.boundOverlayToCaptureDisplays |
| 227 | ? snapShotAnimationDisplayBounds( |
| 228 | [ |
| 229 | sourceDisplay, |
| 230 | ...Electron.screen.getAllDisplays().filter((d) => d.id !== sourceDisplay.id), |
| 231 | ], |
| 232 | source, |
| 233 | destinationWindowBounds ?? source, |
| 234 | ) |
| 235 | : [snapShotAnimationOverlayBounds(Electron.screen.getAllDisplays())]; |
| 236 | const active: ActiveTransition = { |
| 237 | id, |
| 238 | source, |
| 239 | snapshotDataUrl, |
| 240 | overlays: [], |
| 241 | }; |
| 242 | active.timer = Effect.runFork( |
| 243 | Effect.sleep(TIMEOUT_MS).pipe( |
| 244 | Effect.andThen( |
| 245 | Effect.sync(() => { |
| 246 | if (this.active === active) this.dispose(); |
| 247 | }), |
| 248 | ), |
| 249 | ), |
| 250 | ); |
| 251 | this.active = active; |
| 252 | try { |
| 253 | await this.showOverlays(active, requestedBounds, flash); |
| 254 | } catch (error) { |
| 255 | if (this.active === active) this.dispose(); |
| 256 | throw error; |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | private async showOverlays( |
| 261 | active: ActiveTransition, |
| 262 | requestedBounds: ReadonlyArray<Electron.Rectangle>, |
| 263 | flash: boolean, |
| 264 | ): Promise<void> { |
| 265 | const overlays: ActiveTransition["overlays"] = []; |
| 266 | try { |
| 267 | for (const bounds of requestedBounds) { |
| 268 | const window = createWindow(bounds, this.alwaysOnTopLevel); |
| 269 | const overlay = { window, requestedBounds: bounds, bounds: window.getBounds() }; |
| 270 | active.overlays.push(overlay); |
| 271 | overlays.push(overlay); |
| 272 | } |
| 273 | await Promise.all( |
| 274 | overlays.map(async (overlay) => { |
| 275 | await overlay.window.loadURL( |
| 276 | "data:text/html;charset=utf-8," + |
| 277 | encodeURIComponent(transitionHtml(active.source, overlay.bounds, flash)), |
| 278 | ); |
| 279 | if (this.active === active && !overlay.window.isDestroyed()) { |
| 280 | await overlay.window.webContents.executeJavaScript( |
| 281 | `window.setCaptureSnapshot(${JSON.stringify(active.snapshotDataUrl)})`, |
| 282 | ); |
| 283 | } |
| 284 | }), |
| 285 | ); |
| 286 | for (const overlay of overlays) { |
| 287 | if (this.active !== active) return; |
| 288 | if (overlay.window.isDestroyed()) continue; |
| 289 | this.showWindow(overlay.window); |
| 290 | const bounds = overlay.window.getBounds(); |
| 291 | if ( |
| 292 | bounds.x !== overlay.bounds.x || |
| 293 | bounds.y !== overlay.bounds.y || |
| 294 | bounds.width !== overlay.bounds.width || |
| 295 | bounds.height !== overlay.bounds.height |
| 296 | ) { |
| 297 | overlay.bounds = bounds; |
| 298 | await overlay.window.webContents.executeJavaScript( |
| 299 | `window.rebaseCaptureSource(${JSON.stringify({ |
| 300 | x: active.source.x - bounds.x, |
| 301 | y: active.source.y - bounds.y, |
| 302 | width: active.source.width, |
| 303 | height: active.source.height, |
| 304 | })})`, |
| 305 | ); |
| 306 | } |
| 307 | } |
| 308 | if (this.waitForCompositorFrame) { |
| 309 | // Cover the source with a composited snapshot before the caller reveals T3. |
| 310 | // Decoding and renderer animation frames alone can leave a transparent gap. |
| 311 | await Promise.all( |
| 312 | overlays.map(async (overlay) => { |
| 313 | if (this.active !== active || overlay.window.isDestroyed()) return; |
| 314 | await overlay.window.webContents.capturePage({ x: 0, y: 0, width: 1, height: 1 }); |
| 315 | }), |
| 316 | ); |
| 317 | } |
| 318 | if (this.active !== active) return; |
| 319 | if (flash) { |
| 320 | await Promise.all( |
| 321 | overlays.map(async (overlay) => { |
| 322 | if (overlay.window.isDestroyed()) return; |
| 323 | await overlay.window.webContents |
| 324 | .executeJavaScript("window.startCaptureFlash()") |
| 325 | .catch(() => undefined); |
| 326 | }), |
| 327 | ); |
| 328 | } |
| 329 | } catch (error) { |
| 330 | for (const overlay of overlays) { |
| 331 | if (!overlay.window.isDestroyed()) overlay.window.destroy(); |
| 332 | } |
| 333 | throw error; |
| 334 | } |
| 335 | } |
| 336 | |
| 337 | animateTo(id: string, destination: SnapShotAnimationDestination): void { |
| 338 | const active = this.active; |
| 339 | if (!active || active.id !== id) return; |
| 340 | if (destination.details) active.details = destination.details; |
| 341 | if (active.flight) { |
| 342 | if (destination.details) { |
| 343 | for (const overlay of active.overlays) { |
| 344 | if (overlay.window.isDestroyed()) continue; |
| 345 | void overlay.window.webContents |
| 346 | .executeJavaScript( |
| 347 | "window.updateCaptureDetails(" + JSON.stringify(destination.details) + ")", |
| 348 | ) |
| 349 | .catch(() => undefined); |
| 350 | } |
| 351 | } |
| 352 | return; |
| 353 | } |
| 354 | active.flight = this.runFlight(active, destination); |
| 355 | } |
| 356 | |
| 357 | private async runFlight( |
| 358 | active: ActiveTransition, |
| 359 | destination: SnapShotAnimationDestination, |
| 360 | ): Promise<void> { |
| 361 | if (this.active !== active) return; |
| 362 | if (this.boundOverlayToCaptureDisplays) { |
| 363 | const missingBounds = snapShotAnimationDisplayBounds( |
| 364 | Electron.screen.getAllDisplays(), |
| 365 | active.source, |
| 366 | destination.frame, |
| 367 | ).filter( |
| 368 | (bounds) => |
| 369 | !active.overlays.some( |
| 370 | (overlay) => |
| 371 | !overlay.window.isDestroyed() && |
| 372 | overlay.requestedBounds.x === bounds.x && |
| 373 | overlay.requestedBounds.y === bounds.y && |
| 374 | overlay.requestedBounds.width === bounds.width && |
| 375 | overlay.requestedBounds.height === bounds.height, |
| 376 | ), |
| 377 | ); |
| 378 | if (missingBounds.length > 0) { |
| 379 | await this.showOverlays(active, missingBounds, false).catch(() => undefined); |
| 380 | } |
| 381 | } |
| 382 | if (this.active !== active) return; |
| 383 | const durationMs = snapShotAnimationDurationMs(active.source, destination.frame); |
| 384 | const prepared = await Promise.allSettled( |
| 385 | active.overlays.map(async (overlay) => { |
| 386 | if (overlay.window.isDestroyed()) return; |
| 387 | await overlay.window.webContents.executeJavaScript( |
| 388 | `window.prepareCaptureTransition(${JSON.stringify({ |
| 389 | ...destination, |
| 390 | frame: { |
| 391 | x: destination.frame.x - overlay.bounds.x, |
| 392 | y: destination.frame.y - overlay.bounds.y, |
| 393 | width: destination.frame.width, |
| 394 | height: destination.frame.height, |
| 395 | }, |
| 396 | borderWidth: Math.max(0, destination.borderWidth), |
| 397 | cornerRadius: Math.max(0, destination.cornerRadius), |
| 398 | scaleFactor: Math.max(0.1, destination.scaleFactor), |
| 399 | details: active.details, |
| 400 | durationMs, |
| 401 | })})`, |
| 402 | ); |
| 403 | if (this.active !== active || overlay.window.isDestroyed()) return; |
| 404 | if (this.waitForCompositorFrame) { |
| 405 | // Read back one pixel after preparation to flush queued compositor work |
| 406 | // before the animation clock starts on any of the display surfaces. |
| 407 | await overlay.window.webContents.capturePage({ x: 0, y: 0, width: 1, height: 1 }); |
| 408 | } |
| 409 | return overlay; |
| 410 | }), |
| 411 | ); |
| 412 | if (this.active !== active) return; |
| 413 | // One display failing must not cut the flight short on the others. |
| 414 | await Promise.allSettled( |
| 415 | prepared.map((result) => { |
| 416 | if (result.status !== "fulfilled" || !result.value || result.value.window.isDestroyed()) { |
| 417 | return; |
| 418 | } |
| 419 | return result.value.window.webContents.executeJavaScript("window.playCaptureTransition()"); |
| 420 | }), |
| 421 | ); |
| 422 | } |
| 423 | |
| 424 | async waitForLanding(id: string): Promise<void> { |
| 425 | const active = this.active; |
| 426 | if (!active || active.id !== id) return; |
| 427 | await active.flight?.catch(() => undefined); |
| 428 | } |
| 429 | |
| 430 | async complete(id: string): Promise<void> { |
| 431 | const active = this.active; |
| 432 | if (!active || active.id !== id) return; |
| 433 | await this.waitForLanding(id); |
| 434 | if (this.active === active) this.dispose(); |
| 435 | } |
| 436 | |
| 437 | dismiss(id: string): void { |
| 438 | if (this.active?.id === id) this.dispose(); |
| 439 | } |
| 440 | |
| 441 | dispose(): void { |
| 442 | const active = this.active; |
| 443 | this.active = undefined; |
| 444 | if (!active) return; |
| 445 | active.timer?.interruptUnsafe(); |
| 446 | for (const overlay of active.overlays) { |
| 447 | if (!overlay.window.isDestroyed()) overlay.window.destroy(); |
| 448 | } |
| 449 | } |
| 450 | } |