From 72c2b27d208bc460d70e8e7ddcf183cb99a2e73f Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Fri, 13 Nov 2026 21:35:51 +1100 Subject: [PATCH 1/2] Play the onboarding reveal once in packaged builds --- electron/electron-env.d.ts | 1 + electron/main.ts | 44 +++++++---- electron/onboardingIntro.test.ts | 17 +++++ electron/onboardingIntro.ts | 18 +++-- electron/onboardingWindow.test.ts | 27 ++++++- electron/preload.ts | 5 ++ electron/windows.ts | 14 ++-- src/components/auth/OnboardingIntroWindow.tsx | 75 ++++++++----------- src/components/auth/onboardingGlass.ts | 3 +- 9 files changed, 129 insertions(+), 75 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index b26e25c58..03343504e 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -1044,6 +1044,7 @@ interface Window { replayOnboardingIntro?: () => Promise; closeOnboardingWindow: () => Promise; reportOnboardingIntro: (stage: "ready" | "finished" | "skipped") => void; + onOnboardingIntroShown: (callback: () => void) => () => void; createProjectFile: ( data: unknown, thumbnail?: string | null, diff --git a/electron/main.ts b/electron/main.ts index c8719044c..efdd4c82e 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -97,6 +97,8 @@ import { const electronMainDir = path.dirname(fileURLToPath(import.meta.url)); const IS_SMOKE_EXPORT = process.env.RECORDLY_SMOKE_EXPORT === "1"; +let startupWindowsReady = false; +let firstRunIntroStarted = false; initializeInstanceSession(); function ignoreBrokenConsolePipe(stream: NodeJS.WritableStream | undefined) { @@ -410,14 +412,7 @@ function canRestartForUpdate() { } function createWindow() { - if (!app.isReady()) { - void app.whenReady().then(() => { - if (!mainWindow || mainWindow.isDestroyed()) { - createWindow(); - } - }); - return; - } + if (!startupWindowsReady) return; if (isCreatingMainWindow) { return; @@ -435,6 +430,11 @@ function createWindow() { return; } + if (app.isPackaged && readAppSetting("recordly.onboarding.v1.seen") !== true) { + showOnboardingWindow({ intro: claimFirstRunIntro() }); + return; + } + isCreatingMainWindow = true; const createdHudWindow = createHudOverlayWindow(); mainWindow = createdHudWindow; @@ -447,19 +447,27 @@ function createWindow() { isCreatingMainWindow = false; } +function claimFirstRunIntro() { + if (firstRunIntroStarted) return false; + firstRunIntroStarted = true; + const introStartedKey = "recordly.onboarding.introStarted"; + if (readAppSetting(introStartedKey) === true) return false; + const resumingPermissions = readAppSetting("recordly.onboarding.permissionsRequested") === true; + try { + writeAppSetting(introStartedKey, true); + } catch (error) { + console.warn("[onboarding] Could not save reveal progress", error); + } + return !resumingPermissions; +} + function focusOrCreateMainWindow() { + if (!startupWindowsReady) return; const onboarding = getOnboardingWindow(); if (onboarding) { focusOnboardingWindow(); return; } - if (!app.isReady()) { - void app.whenReady().then(() => { - focusOrCreateMainWindow(); - }); - return; - } - const updateToast = getUpdateToastWindow(); if (updateToast?.isVisible()) { updateToast.show(); @@ -534,6 +542,11 @@ function isOwnedEditorWindow(window: BrowserWindow): boolean { } function sendEditorMenuAction(channel: ProjectMenuAction, capturedTarget?: BrowserWindow) { + if (!startupWindowsReady) return; + if (getOnboardingWindow()) { + focusOnboardingWindow(); + return; + } if (capturedTarget) { if (isOwnedEditorWindow(capturedTarget)) capturedTarget.webContents.send(channel); return; @@ -1305,6 +1318,7 @@ app.whenReady().then(async () => { }, authCallbacks.flowStorageHooks, ); + startupWindowsReady = true; if (IS_SMOKE_EXPORT || process.env.RECORDLY_DEV_OPEN_RECORDING_INPUT) { await logSmokeExportGpuDiagnostics(); diff --git a/electron/onboardingIntro.test.ts b/electron/onboardingIntro.test.ts index 7673ba2e6..ac93b614d 100644 --- a/electron/onboardingIntro.test.ts +++ b/electron/onboardingIntro.test.ts @@ -6,6 +6,7 @@ class TestWindow extends EventEmitter { webContents = Object.assign(new EventEmitter(), { ipc: new EventEmitter(), mainFrame: {}, + send: vi.fn(), }); private destroyed = false; private visible = false; @@ -80,3 +81,19 @@ it.each(["skip", "timeout"])("shows a ready popup after intro %s", (mode) => { expect(backdrop.isDestroyed()).toBe(true); expect(popup.setOpacity).not.toHaveBeenCalled(); }); + +it("allows a late renderer to play past the original startup deadline", () => { + const { popup, show, backdrop, stage } = start(); + popup.emit("ready-to-show"); + vi.advanceTimersByTime(5900); + stage("ready"); + expect(backdrop.isVisible()).toBe(true); + expect(backdrop.webContents.send).toHaveBeenCalledExactlyOnceWith("onboarding-intro-shown"); + vi.advanceTimersByTime(4500); + expect(backdrop.isDestroyed()).toBe(false); + expect(show).not.toHaveBeenCalled(); + stage("finished"); + expect(show).toHaveBeenCalledOnce(); + vi.advanceTimersByTime(660); + expect(backdrop.isDestroyed()).toBe(true); +}); diff --git a/electron/onboardingIntro.ts b/electron/onboardingIntro.ts index 45be644a1..f5f217629 100644 --- a/electron/onboardingIntro.ts +++ b/electron/onboardingIntro.ts @@ -1,6 +1,7 @@ import { BrowserWindow, type Rectangle } from "electron"; const INTRO_TIMEOUT_MS = 6000; +const INTRO_PLAYBACK_TIMEOUT_MS = 10000; const POPUP_FADE_MS = 280; const BACKDROP_FADE_MS = 360; @@ -89,24 +90,27 @@ export function startOnboardingIntro( finish(false); }); backdrop.webContents.once("render-process-gone", () => finish(false)); + const onTimeout = () => { + if (!startedHandoff) { + closeBackdrop(); + finish(false); + } + }; backdrop.webContents.ipc.on("onboarding-intro", (event, stage: unknown) => { if (introFinished || backdrop.isDestroyed()) return; if (event.senderFrame !== backdrop.webContents.mainFrame) return; if (stage === "ready" && !backdrop.isVisible()) { + clearTimeout(deadline); + deadline = setTimeout(onTimeout, INTRO_PLAYBACK_TIMEOUT_MS); backdrop.showInactive(); backdrop.moveTop(); + backdrop.webContents.send("onboarding-intro-shown"); } else if (stage === "finished" || stage === "skipped") { finish(stage === "finished"); } }); - deadline = setTimeout(() => { - // An unavailable intro or a slow popup should never leave a desktop overlay behind. - if (!startedHandoff) { - closeBackdrop(); - finish(false); - } - }, INTRO_TIMEOUT_MS); + deadline = setTimeout(onTimeout, INTRO_TIMEOUT_MS); load(backdrop); return backdrop; } diff --git a/electron/onboardingWindow.test.ts b/electron/onboardingWindow.test.ts index f84b58a3e..c1adc338a 100644 --- a/electron/onboardingWindow.test.ts +++ b/electron/onboardingWindow.test.ts @@ -18,7 +18,14 @@ class FakeWindow extends EventEmitter { return windows; } destroyed = false; - loadURL = vi.fn(async () => undefined); + url = ""; + webContents = Object.assign(new EventEmitter(), { + isDestroyed: () => this.destroyed, + getURL: () => this.url, + }); + loadURL = vi.fn(async (url: string) => { + this.url = url; + }); setBounds = vi.fn(); getBounds = () => ({ x: 1600, y: 10, width: 500, height: 500 }); show = vi.fn(); @@ -156,3 +163,21 @@ it.each([ expect(onboarding.focus).toHaveBeenCalled(); expect(other.close).toHaveBeenCalledOnce(); }); + +it("loads the reveal before showing real packaged first-run onboarding", async () => { + if (process.platform !== "darwin" && process.platform !== "win32") return; + electron.app.isPackaged = true; + vi.stubEnv("VITE_DEV_SERVER_URL", undefined); + const { focusOnboardingWindow, showOnboardingWindow } = await import("./windows"); + const onboarding = showOnboardingWindow({ intro: true }) as unknown as FakeWindow; + const intro = windows.at(-1)!; + expect(intro).not.toBe(onboarding); + expect(intro.loadURL).toHaveBeenCalledWith( + "http://localhost:9999/?windowType=onboarding-intro", + ); + expect(onboarding.loadURL).toHaveBeenCalledWith("http://localhost:9999/?windowType=onboarding"); + focusOnboardingWindow(); + expect(onboarding.show).not.toHaveBeenCalled(); + intro.webContents.emit("did-finish-load"); + onboarding.webContents.emit("did-finish-load"); +}); diff --git a/electron/preload.ts b/electron/preload.ts index 51e4eac12..2100858ae 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -563,6 +563,11 @@ contextBridge.exposeInMainWorld("electronAPI", { closeOnboardingWindow: () => ipcRenderer.invoke("close-onboarding-window"), reportOnboardingIntro: (stage: "ready" | "finished" | "skipped") => ipcRenderer.send("onboarding-intro", stage), + onOnboardingIntroShown: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("onboarding-intro-shown", listener); + return () => ipcRenderer.removeListener("onboarding-intro-shown", listener); + }, createProjectFile: (data: unknown, thumbnail?: string | null) => ipcRenderer.invoke("create-project-file", data, thumbnail), renameLibraryProject: (path: string, name: string) => diff --git a/electron/windows.ts b/electron/windows.ts index c32423e94..12e501c5f 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -1253,7 +1253,10 @@ export function replayOnboardingIntro(owner: BrowserWindow): boolean { return true; } -export function showOnboardingWindow(options?: { preview?: boolean }): BrowserWindow { +export function showOnboardingWindow(options?: { + preview?: boolean; + intro?: boolean; +}): BrowserWindow { const query = { windowType: "onboarding", ...(!app.isPackaged && typeof options?.preview === "boolean" @@ -1311,11 +1314,10 @@ export function showOnboardingWindow(options?: { preview?: boolean }): BrowserWi win.setBounds(bounds); bringOnboardingWindowForward(win); }; - const previewIntro = - !app.isPackaged && - options?.preview && - (process.platform === "darwin" || process.platform === "win32"); - if (previewIntro) { + const playIntro = + (process.platform === "darwin" || process.platform === "win32") && + (options?.intro || (!app.isPackaged && options?.preview)); + if (playIntro) { const intro = startOnboardingIntro( getScreen().getPrimaryDisplay().bounds, path.join(electronWindowsDir, "preload.js"), diff --git a/src/components/auth/OnboardingIntroWindow.tsx b/src/components/auth/OnboardingIntroWindow.tsx index fa561a77f..2430c723e 100644 --- a/src/components/auth/OnboardingIntroWindow.tsx +++ b/src/components/auth/OnboardingIntroWindow.tsx @@ -1,34 +1,28 @@ import { useEffect, useRef, useState } from "react"; +import { isMac } from "@/utils/platformUtils"; import desktopIconUrl from "../../../icons/icons/png/512x512.png"; import { loadOnboardingLogo, playOnboardingGlass } from "./onboardingGlass"; import "./OnboardingIntroWindow.css"; const WORDMARK = "Recordly"; -const WORDMARK_SLIDE = "520ms"; const WORDMARK_DISPLAY_MS = 1580; +const macIconUrl = `${import.meta.env.BASE_URL}app-icons/recordlymac-512.png`; export default function OnboardingIntroWindow() { const canvas = useRef(null); - const wordmark = useRef(null); - const wordmarkBlur = useRef(null); + const [iconUrl, setIconUrl] = useState(desktopIconUrl); const [mode, setMode] = useState<"loading" | "glass" | "fallback">("loading"); const [presentation, setPresentation] = useState<"mark" | "wordmark" | "releasing">("mark"); useEffect(() => { const report = window.electronAPI?.reportOnboardingIntro; - const wordmarkElement = wordmark.current; - const startWordmarkBlur = (event: TransitionEvent) => { - if (event.target === wordmarkElement && event.propertyName === "transform") { - wordmarkBlur.current?.beginElement(); - } - }; - wordmarkElement?.addEventListener("transitionstart", startWordmarkBlur); let disposed = false; let usingFallback = false; let wordmarkStarted = false; let stopGlass: (() => void) | undefined; let next: ReturnType; let preparationDeadline: ReturnType; + let unsubscribe: (() => void) | undefined; const finish = () => { setPresentation("releasing"); next = setTimeout(() => report?.("finished"), 180); @@ -51,27 +45,40 @@ export default function OnboardingIntroWindow() { } }; - const startFrame = requestAnimationFrame(() => { - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { - report?.("skipped"); - return; - } - report?.("ready"); - preparationDeadline = setTimeout(fallback, 1200); - void loadOnboardingLogo(desktopIconUrl) + const play = () => { + preparationDeadline = setTimeout(fallback, 3000); + void isMac() + .then((mac) => { + const url = mac ? macIconUrl : desktopIconUrl; + if (!disposed) setIconUrl(url); + return loadOnboardingLogo(url); + }) .then((logo) => { if (disposed || usingFallback || !canvas.current) return; stopGlass = playOnboardingGlass(canvas.current, logo, showWordmark, fallback); clearTimeout(preparationDeadline); setMode("glass"); }) - .catch(fallback); + .catch((error) => { + console.warn("[onboarding] Could not start glass reveal", error); + fallback(); + }); + }; + // Let the effect settle without waiting for a frame in a hidden window. + queueMicrotask(() => { + if (disposed) return; + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + report?.("skipped"); + return; + } + unsubscribe = window.electronAPI?.onOnboardingIntroShown?.(play); + report?.("ready"); + if (!unsubscribe) play(); }); return () => { disposed = true; - wordmarkElement?.removeEventListener("transitionstart", startWordmarkBlur); - cancelAnimationFrame(startFrame); + unsubscribe?.(); clearTimeout(preparationDeadline); clearTimeout(next); stopGlass?.(); @@ -80,39 +87,17 @@ export default function OnboardingIntroWindow() { return (