Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1044,6 +1044,7 @@ interface Window {
replayOnboardingIntro?: () => Promise<boolean>;
closeOnboardingWindow: () => Promise<boolean>;
reportOnboardingIntro: (stage: "ready" | "finished" | "skipped") => void;
onOnboardingIntroShown: (callback: () => void) => () => void;
createProjectFile: (
data: unknown,
thumbnail?: string | null,
Expand Down
44 changes: 29 additions & 15 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
17 changes: 17 additions & 0 deletions electron/onboardingIntro.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
});
18 changes: 11 additions & 7 deletions electron/onboardingIntro.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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;
}
36 changes: 34 additions & 2 deletions electron/onboardingWindow.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { EventEmitter } from "node:events";
import { beforeEach, expect, it, vi } from "vitest";
import { beforeEach, expect, it, onTestFinished, vi } from "vitest";

const electron = vi.hoisted(() => ({
app: { isPackaged: false, isReady: () => true, focus: vi.fn() },
Expand All @@ -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();
Expand Down Expand Up @@ -156,3 +163,28 @@ it.each([
expect(onboarding.focus).toHaveBeenCalled();
expect(other.close).toHaveBeenCalledOnce();
});

it.each([
"darwin",
"win32",
] as const)("loads the reveal before packaged onboarding on %s", async (platform) => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform")!;
Object.defineProperty(process, "platform", { ...originalPlatform, value: platform });
onTestFinished(() => {
Object.defineProperty(process, "platform", originalPlatform);
});
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");
});
5 changes: 5 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
14 changes: 8 additions & 6 deletions electron/windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"),
Expand Down
75 changes: 30 additions & 45 deletions src/components/auth/OnboardingIntroWindow.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLCanvasElement>(null);
const wordmark = useRef<HTMLDivElement>(null);
const wordmarkBlur = useRef<SVGAnimationElement>(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<typeof setTimeout>;
let preparationDeadline: ReturnType<typeof setTimeout>;
let unsubscribe: (() => void) | undefined;
const finish = () => {
setPresentation("releasing");
next = setTimeout(() => report?.("finished"), 180);
Expand All @@ -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?.();
Expand All @@ -80,39 +87,17 @@ export default function OnboardingIntroWindow() {

return (
<div className="onboarding-intro" aria-hidden="true">
<svg className="absolute" width="0" height="0">
<filter id="onboarding-intro-motion-blur" x="-25%" width="150%">
<feGaussianBlur stdDeviation="0 0">
<animate
ref={wordmarkBlur}
attributeName="stdDeviation"
begin="indefinite"
dur={WORDMARK_SLIDE}
keyTimes="0;0.18;0.5;0.82;1"
values="0 0;0 0;1.25 0;0 0;0 0"
/>
</feGaussianBlur>
</filter>
</svg>
<div className="onboarding-intro-backdrop" />
<div className="onboarding-intro-brand" data-presentation={presentation}>
<div className="onboarding-intro-logo-track">
<div className="onboarding-intro-logo" data-mode={mode}>
<canvas ref={canvas} />
{mode === "fallback" && (
<img
alt=""
className="onboarding-intro-fallback"
src={desktopIconUrl}
/>
<img alt="" className="onboarding-intro-fallback" src={iconUrl} />
)}
</div>
</div>
<div
ref={wordmark}
className="onboarding-intro-wordmark"
style={{ transitionDuration: WORDMARK_SLIDE }}
>
<div className="onboarding-intro-wordmark">
<span>{WORDMARK}</span>
</div>
</div>
Expand Down
Loading
Loading