diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 992da1a6c..413b4d676 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -84,11 +84,14 @@ jobs: runner: macos-15-intel - arch: arm64 arch_tag: darwin-arm64 - runner: macos-14 + runner: macos-15 steps: - name: Checkout code uses: actions/checkout@v4 + - name: Select Xcode for Icon Composer + run: sudo xcode-select --switch /Applications/Xcode_26.3.app/Contents/Developer + - name: Setup Node.js uses: actions/setup-node@v4 with: diff --git a/.github/workflows/macos-release-candidate.yml b/.github/workflows/macos-release-candidate.yml index 53df570b3..bb728ed1f 100644 --- a/.github/workflows/macos-release-candidate.yml +++ b/.github/workflows/macos-release-candidate.yml @@ -96,6 +96,9 @@ jobs: persist-credentials: false ref: ${{ needs.authorize-source.outputs.source_sha }} + - name: Select Xcode for Icon Composer + run: sudo xcode-select --switch /Applications/Xcode_26.3.app/Contents/Developer + - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 741aa7d9d..a75f402ea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -127,6 +127,9 @@ jobs: with: ref: ${{ needs.prepare-release.outputs.source_sha }} + - name: Select Xcode for Icon Composer + run: sudo xcode-select --switch /Applications/Xcode_26.3.app/Contents/Developer + - name: Setup Node.js uses: actions/setup-node@v4 with: @@ -234,7 +237,7 @@ jobs: name: Build macOS arm64 needs: prepare-release if: needs.prepare-release.outputs.release_scope == 'all' - runs-on: macos-14 + runs-on: macos-15 env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} APPLE_ID: ${{ secrets.APPLE_ID }} @@ -246,6 +249,9 @@ jobs: with: ref: ${{ needs.prepare-release.outputs.source_sha }} + - name: Select Xcode for Icon Composer + run: sudo xcode-select --switch /Applications/Xcode_26.3.app/Contents/Developer + - name: Setup Node.js uses: actions/setup-node@v4 with: diff --git a/README.md b/README.md index 40d88ccf3..2aaab3965 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ PKGBUILD, desktop entry, release sync, and optional **local-from-source** packag ### Prerequisites -**macOS:** Xcode Command Line Tools (`xcode-select --install`). +**macOS:** Install Xcode Command Line Tools (`xcode-select --install`) for development. To package the app, select a full Xcode 26 or later installation with `xcode-select` so Electron Builder can compile the icon with `actool`. Xcode 26 requires macOS 15.6 or later. **Linux (Ubuntu/Debian):** diff --git a/electron-builder.json5 b/electron-builder.json5 index c396ed2ed..6df313467 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -86,7 +86,7 @@ "arch": ["x64", "arm64"] } ], - "icon": "icons/icons/mac/icon.icns", + "icon": "icons/icons/mac/Recordly.icon", "artifactName": "${productName}-${arch}.${ext}", "extendInfo": { // The main app restores its Dock icon through desktopPresence. Declaring diff --git a/electron/authStorage.test.ts b/electron/authStorage.test.ts index dd4a2abf0..99f5f5e94 100644 --- a/electron/authStorage.test.ts +++ b/electron/authStorage.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { createClient } from "@supabase/supabase-js"; import { safeStorage } from "electron"; -import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, expect, it, onTestFinished, vi } from "vitest"; const native = vi.hoisted(() => ({ directory: "", @@ -42,6 +42,30 @@ afterEach(async () => { await fs.rm(native.directory, { recursive: true, force: true }); }); +it("does not check Keychain on macOS until there are credentials to store", async () => { + const platform = Object.getOwnPropertyDescriptor(process, "platform")!; + Object.defineProperty(process, "platform", { ...platform, value: "darwin" }); + const checkEncryption = vi.spyOn(safeStorage, "isEncryptionAvailable"); + onTestFinished(() => { + checkEncryption.mockRestore(); + Object.defineProperty(process, "platform", platform); + }); + const key = "sb-recordlytest-auth-token"; + expect(await invoke("available")).toBe(true); + await invoke("migrate", {}); + expect(await invoke("get", key)).toBeNull(); + await invoke("remove", key); + expect(checkEncryption).not.toHaveBeenCalled(); + checkEncryption.mockReturnValue(false); + expect(await invoke("set", key, "session")).toBe(false); + await expect( + fs.stat(path.join(native.directory, "private-auth", "session.enc")), + ).rejects.toMatchObject({ code: "ENOENT" }); + checkEncryption.mockReturnValue(true); + await invoke("set", key, "session"); + expect(await invoke("get", key)).toBe("session"); +}); + it("freshly reads another process's generation before reading, writing or migrating", async () => { const key = "sb-recordlytest-auth-token"; await invoke("set", key, "old-session"); diff --git a/electron/authStorage.ts b/electron/authStorage.ts index a300543d1..95e26cb65 100644 --- a/electron/authStorage.ts +++ b/electron/authStorage.ts @@ -137,12 +137,13 @@ function storagePath(): string { } async function load(): Promise<{ entries: AuthEntries; persisted: boolean }> { - if (!encryptionAvailable()) throw new Error("Secure authentication storage is unavailable."); try { const filePath = storagePath(); const stat = await fs.stat(filePath); if (stat.size > MAX_STORE_LENGTH * 8) throw new Error("Authentication storage is too large."); + if (!encryptionAvailable()) + throw new Error("Secure authentication storage is unavailable."); const payload = JSON.parse(safeStorage.decryptString(await fs.readFile(filePath))); if (payload.version !== 1) throw new Error("Unsupported authentication storage version."); return { entries: validateEntries(payload.entries), persisted: true }; @@ -157,8 +158,13 @@ async function load(): Promise<{ entries: AuthEntries; persisted: boolean }> { } } +class AuthEncryptionUnavailableError extends Error {} + async function save(next: AuthEntries): Promise { const validated = validateEntries(next); + if (!encryptionAvailable()) { + throw new AuthEncryptionUnavailableError("Secure authentication storage is unavailable."); + } const encrypted = safeStorage.encryptString(JSON.stringify({ version: 1, entries: validated })); const filePath = storagePath(); const directory = path.dirname(filePath); @@ -203,13 +209,24 @@ export function readNativeAuthStorageValue(key: string): Promise } export function registerAuthStorageHandlers(flowHooks?: AuthFlowStorageHooks): void { - ipcMain.handle("auth-storage:available", (event) => { + ipcMain.handle("auth-storage:available", async (event) => { assertTrustedRenderer(event); + if (process.platform === "darwin") { + try { + await fs.stat(storagePath()); + } catch (error) { + // Checking safeStorage can prompt for Keychain access. Defer it until + // we have credentials to read or save. + if ((error as NodeJS.ErrnoException).code === "ENOENT") return true; + throw error; + } + } return encryptionAvailable(); }); ipcMain.handle("auth-storage:migrate", (event, snapshot: unknown) => { assertTrustedRenderer(event); const validated = validateEntries(snapshot); + if (Object.keys(validated).length === 0) return; return serialize(() => transaction(async () => { const current = await load(); @@ -246,8 +263,9 @@ export function registerAuthStorageHandlers(flowHooks?: AuthFlowStorageHooks): v completedCallback = { key, fingerprint: validated[key] }; } try { - return await transaction(async () => { - const next = { ...(await load()).entries, ...validated }; + const result = await transaction(async () => { + const current = await load(); + const next = { ...current.entries, ...validated }; if (completedCallback) for (const candidate of Object.keys(next)) { if ( @@ -266,8 +284,22 @@ export function registerAuthStorageHandlers(flowHooks?: AuthFlowStorageHooks): v ); for (const expired of retries.slice(0, -MAX_CONSENT_RETRIES)) delete next[expired]; - await save(next); + try { + await save(next); + } catch (error) { + // Let the renderer use temporary sign-in only if there is no store to unlock. + if ( + process.platform === "darwin" && + !current.persisted && + error instanceof AuthEncryptionUnavailableError + ) { + return false as const; + } + throw error; + } }); + if (result === false) await rollback?.().catch(() => undefined); + return result; } catch (error) { await rollback?.().catch(() => undefined); throw error; @@ -279,9 +311,11 @@ export function registerAuthStorageHandlers(flowHooks?: AuthFlowStorageHooks): v assertKey(key); return serialize(async () => { await transaction(async () => { - const next = { ...(await load()).entries }; - delete next[key]; - await save(next); + const { entries } = await load(); + if (key in entries) { + delete entries[key]; + await save(entries); + } if (completedCallback?.key === key) completedCallback = undefined; }); // The verifier is already removed durably. Optional route cleanup must diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 03343504e..0f4b05dd2 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -242,7 +242,7 @@ interface Window { authStorageAvailable: () => Promise; authStorageMigrate: (items: Record) => Promise; authStorageGetItem: (key: string) => Promise; - authStorageSetItem: (key: string, value: string) => Promise; + authStorageSetItem: (key: string, value: string) => Promise; authStorageRemoveItem: (key: string) => Promise; hudOverlaySetIgnoreMouse: (ignore: boolean) => void; hudOverlaySetSourceSelectionActive: (active: boolean) => void; diff --git a/electron/main.ts b/electron/main.ts index efdd4c82e..028341c7e 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -727,13 +727,12 @@ function getTrayIcon(filename: string) { } function syncDockIcon() { - if (process.platform !== "darwin" || !app.dock) { + // The packaged app uses the native icon from its macOS bundle. + if (process.platform !== "darwin" || app.isPackaged || !app.dock) { return; } - const dockIcon = getAppImage( - IS_DEV ? "app-icons/recordly-solid-blue-512.png" : getPlatformAppIconFilename(512), - ); + const dockIcon = getAppImage("app-icons/recordly-solid-blue-512.png"); if (!dockIcon.isEmpty()) { app.dock.setIcon(dockIcon); } diff --git a/electron/onboardingWindow.test.ts b/electron/onboardingWindow.test.ts index 1b6751e6c..5b80b9b8d 100644 --- a/electron/onboardingWindow.test.ts +++ b/electron/onboardingWindow.test.ts @@ -2,7 +2,13 @@ import { EventEmitter } from "node:events"; import { beforeEach, expect, it, onTestFinished, vi } from "vitest"; const electron = vi.hoisted(() => ({ - app: { isPackaged: false, isReady: () => true, focus: vi.fn() }, + app: { + isPackaged: false, + isReady: () => true, + focus: vi.fn(), + on: vi.fn(), + removeListener: vi.fn(), + }, ipcMain: { on: vi.fn(), handle: vi.fn() }, screen: { getPrimaryDisplay: () => { @@ -18,6 +24,8 @@ class FakeWindow extends EventEmitter { return windows; } destroyed = false; + visible = false; + preventClose = false; url = ""; webContents = Object.assign(new EventEmitter(), { isDestroyed: () => this.destroyed, @@ -28,14 +36,21 @@ class FakeWindow extends EventEmitter { }); setBounds = vi.fn(); getBounds = () => ({ x: 1600, y: 10, width: 500, height: 500 }); - show = vi.fn(); + show = vi.fn(() => { + this.visible = true; + this.emit("show"); + }); + hide = vi.fn(() => { + this.visible = false; + }); focus = vi.fn(); moveTop = vi.fn(); setMenu = vi.fn(); isMinimized = () => false; - isVisible = () => this.show.mock.calls.length > 0; + isVisible = () => this.visible; isDestroyed = () => this.destroyed; close = vi.fn(() => { + if (this.preventClose) return; this.destroyed = true; this.emit("closed"); }); @@ -64,11 +79,49 @@ vi.mock("./rendererServer", () => ({ getPackagedRendererBaseUrl: () => "http://l beforeEach(() => { vi.resetModules(); + vi.clearAllMocks(); windows.length = 0; electron.app.isPackaged = false; vi.stubEnv("VITE_DEV_SERVER_URL", "http://localhost:5173"); }); +it("preserves existing work without closing its window and suppresses attempts to show it", async () => { + const { showOnboardingWindow, closeOnboardingWindow } = await import("./windows"); + const editor = new FakeWindow(); + editor.preventClose = true; + editor.show(); + showOnboardingWindow(); + expect(editor.close).not.toHaveBeenCalled(); + expect(editor.isDestroyed()).toBe(false); + expect(editor.isVisible()).toBe(false); + editor.show(); + expect(editor.isVisible()).toBe(false); + closeOnboardingWindow(); + expect(editor.isVisible()).toBe(true); + editor.show(); + expect(editor.isVisible()).toBe(true); +}); + +it("suppresses late-created windows until onboarding closes", async () => { + const { showOnboardingWindow, closeOnboardingWindow } = await import("./windows"); + showOnboardingWindow(); + const lateWindow = new FakeWindow(); + const [, onCreated] = electron.app.on.mock.calls.find( + ([event]) => event === "browser-window-created", + )!; + onCreated({}, lateWindow); + lateWindow.show(); + expect(lateWindow.isVisible()).toBe(false); + lateWindow.visible = true; + lateWindow.emit("restore"); + expect(lateWindow.isVisible()).toBe(false); + closeOnboardingWindow(); + expect(electron.app.removeListener).toHaveBeenCalledWith("browser-window-created", onCreated); + expect(lateWindow.isVisible()).toBe(true); + lateWindow.show(); + expect(lateWindow.isVisible()).toBe(true); +}); + it("replays only the reveal, keeps existing windows open, and lets it play again", async () => { const { getOnboardingWindow, replayOnboardingIntro } = await import("./windows"); const editor = new FakeWindow(); @@ -100,13 +153,16 @@ it("replays only the reveal, keeps existing windows open, and lets it play again expect(windows.at(-1)?.isDestroyed()).toBe(true); }); -it("closes every other app window and loads the dev preview", async () => { +it("hides every other app window and loads the dev preview", async () => { const { focusOnboardingWindow, showOnboardingWindow } = await import("./windows"); const editor = new FakeWindow(); const hud = new FakeWindow(); const selector = new FakeWindow(); const onboarding = showOnboardingWindow({ preview: true }) as unknown as FakeWindow; - for (const other of [editor, hud, selector]) expect(other.close).toHaveBeenCalledOnce(); + for (const other of [editor, hud, selector]) { + expect(other.hide).toHaveBeenCalled(); + expect(other.close).not.toHaveBeenCalled(); + } expect(onboarding.isDestroyed()).toBe(false); expect(onboarding.loadURL).toHaveBeenCalledWith( "http://localhost:5173?windowType=onboarding&onboardingPreview=1", @@ -120,12 +176,12 @@ it("closes every other app window and loads the dev preview", async () => { } }); -it("restarts an existing onboarding preview and closes newly opened windows", async () => { +it("restarts an existing onboarding preview and hides newly opened windows", async () => { const { showOnboardingWindow } = await import("./windows"); const onboarding = showOnboardingWindow() as unknown as FakeWindow; const other = new FakeWindow(); expect(showOnboardingWindow({ preview: true })).toBe(onboarding); - expect(other.close).toHaveBeenCalledOnce(); + expect(other.hide).toHaveBeenCalled(); expect(onboarding.loadURL).toHaveBeenLastCalledWith( "http://localhost:5173?windowType=onboarding&onboardingPreview=1", ); @@ -161,7 +217,7 @@ it.each([ expect(onboarding.loadURL).toHaveBeenCalledTimes(1); expect(onboarding.show).toHaveBeenCalled(); expect(onboarding.focus).toHaveBeenCalled(); - expect(other.close).toHaveBeenCalledOnce(); + expect(other.hide).toHaveBeenCalled(); }); it.each([ diff --git a/electron/windows.ts b/electron/windows.ts index 12e501c5f..1acce9328 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -1212,12 +1212,48 @@ export function getOnboardingWindow(): BrowserWindow | null { return onboardingWindow && !onboardingWindow.isDestroyed() ? onboardingWindow : null; } -function closeOtherOnboardingWindows(win: BrowserWindow) { +function hideOtherOnboardingWindows(win: BrowserWindow) { for (const other of BrowserWindow.getAllWindows()) { - if (other !== win && other !== onboardingIntroWindow && !other.isDestroyed()) other.close(); + if (other !== win && other !== onboardingIntroWindow && !other.isDestroyed()) { + other.hide(); + } } } +/** Keep existing windows, delayed loads and tray restores behind onboarding. */ +function enforceOnboardingWindowExclusivity(win: BrowserWindow): void { + const listeners = new Map void>(); + const deferredWindows = new Set(); + const watch = (other: BrowserWindow) => { + if (other === win || other.isDestroyed() || listeners.has(other)) return; + const hide = () => { + if (!win.isDestroyed() && other !== onboardingIntroWindow && !other.isDestroyed()) { + if (other.isVisible()) deferredWindows.add(other); + other.hide(); + } + }; + listeners.set(other, hide); + other.on("show", hide); + other.on("restore", hide); + hide(); + }; + const onCreated = (_event: Electron.Event, other: BrowserWindow) => watch(other); + app.on("browser-window-created", onCreated); + for (const other of BrowserWindow.getAllWindows()) watch(other); + win.once("closed", () => { + app.removeListener("browser-window-created", onCreated); + for (const [other, hide] of listeners) { + other.removeListener("show", hide); + other.removeListener("restore", hide); + } + // Permissions completion prepares the HUD before closing onboarding. Show + // that destination only after onboarding is gone, preserving existing work. + for (const other of deferredWindows) { + if (!other.isDestroyed()) other.show(); + } + }); +} + export function replayOnboardingIntro(owner: BrowserWindow): boolean { if ( app.isPackaged || @@ -1273,7 +1309,7 @@ export function showOnboardingWindow(options?: { } }; if (onboardingWindow && !onboardingWindow.isDestroyed()) { - closeOtherOnboardingWindows(onboardingWindow); + hideOtherOnboardingWindows(onboardingWindow); if (!app.isPackaged && typeof options?.preview === "boolean") load(onboardingWindow, query); onboardingWindow.setBounds(getOnboardingWindowBounds()); focusOnboardingWindow(); @@ -1305,11 +1341,11 @@ export function showOnboardingWindow(options?: { registerDesktopPresenceWindow(win); if (process.platform !== "darwin") win.setMenu(null); onboardingWindow = win; + enforceOnboardingWindowExclusivity(win); win.once("closed", () => { if (onboardingWindow === win) onboardingWindow = null; }); - // Keep onboarding alive before closing other windows to avoid window-all-closed. - closeOtherOnboardingWindows(win); + hideOtherOnboardingWindows(win); const show = () => { win.setBounds(bounds); bringOnboardingWindowForward(win); diff --git a/icons/icons/mac/Recordly.icon/Assets/RecordlyMark.svg b/icons/icons/mac/Recordly.icon/Assets/RecordlyMark.svg new file mode 100644 index 000000000..437be0611 --- /dev/null +++ b/icons/icons/mac/Recordly.icon/Assets/RecordlyMark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/icons/icons/mac/Recordly.icon/icon.json b/icons/icons/mac/Recordly.icon/icon.json new file mode 100644 index 000000000..f5a254441 --- /dev/null +++ b/icons/icons/mac/Recordly.icon/icon.json @@ -0,0 +1,27 @@ +{ + "fill": { + "solid": "srgb:0.01569,0.52157,0.96863,1.00000" + }, + "groups": [ + { + "layers": [ + { + "image-name": "RecordlyMark.svg", + "name": "Recordly mark" + } + ], + "shadow": { + "kind": "neutral", + "opacity": 0.22 + }, + "specular": true, + "translucency": { + "enabled": true, + "value": 0.12 + } + } + ], + "supported-platforms": { + "squares": ["macOS"] + } +} diff --git a/index.html b/index.html index ffa958e77..1f0282742 100644 --- a/index.html +++ b/index.html @@ -137,9 +137,14 @@ .loading-playback-volume { width: 36px; height: 36px; justify-self: end; } .loading-playback-center { border-radius: 24px; } @container (max-width: 760px) { - .loading-playback-tools { grid-row: 2; grid-column: 1 / -1; justify-self: center; height: 44px; margin-bottom: 0; } - .loading-playback-center { grid-row: 1; grid-column: 2; } - .loading-playback-volume { grid-row: 1; grid-column: 3; } + .loading-playback { grid-template-columns: auto minmax(max-content, 1fr) auto; } + .loading-playback-center { justify-self: center; } + } + @container (max-width: 520px) { + .loading-playback { gap: 8px; padding-inline: 8px; } + .loading-playback-tools { width: 112px; } + .loading-playback-center { width: 168px; } + .loading-playback-volume { width: 28px; height: 28px; } } .boot-home-section-title { width: 32px; height: 28px; margin-bottom: 16px; } diff --git a/public/app-icons/recordlymac-1024.png b/public/app-icons/recordlymac-1024.png index c8b9fe951..9a90d4601 100644 Binary files a/public/app-icons/recordlymac-1024.png and b/public/app-icons/recordlymac-1024.png differ diff --git a/public/app-icons/recordlymac-128.png b/public/app-icons/recordlymac-128.png index 31d83516a..743c8498d 100644 Binary files a/public/app-icons/recordlymac-128.png and b/public/app-icons/recordlymac-128.png differ diff --git a/public/app-icons/recordlymac-16.png b/public/app-icons/recordlymac-16.png index ea827a787..bbf49032d 100644 Binary files a/public/app-icons/recordlymac-16.png and b/public/app-icons/recordlymac-16.png differ diff --git a/public/app-icons/recordlymac-256.png b/public/app-icons/recordlymac-256.png index 0d472c863..eb0c129ba 100644 Binary files a/public/app-icons/recordlymac-256.png and b/public/app-icons/recordlymac-256.png differ diff --git a/public/app-icons/recordlymac-32.png b/public/app-icons/recordlymac-32.png index e4107c884..229de550c 100644 Binary files a/public/app-icons/recordlymac-32.png and b/public/app-icons/recordlymac-32.png differ diff --git a/public/app-icons/recordlymac-512.png b/public/app-icons/recordlymac-512.png index 132874bf8..4746fdaf9 100644 Binary files a/public/app-icons/recordlymac-512.png and b/public/app-icons/recordlymac-512.png differ diff --git a/public/app-icons/recordlymac-64.png b/public/app-icons/recordlymac-64.png index 58945af1c..181b6d43d 100644 Binary files a/public/app-icons/recordlymac-64.png and b/public/app-icons/recordlymac-64.png differ diff --git a/src/components/video-editor/layout/EditorPreviewPanel.tsx b/src/components/video-editor/layout/EditorPreviewPanel.tsx index d651bacd4..37ea82531 100644 --- a/src/components/video-editor/layout/EditorPreviewPanel.tsx +++ b/src/components/video-editor/layout/EditorPreviewPanel.tsx @@ -142,7 +142,7 @@ export function EditorPreviewPanel(props: Props) { const timestampWidth = `${Math.max(6, durationTimestamp.length)}ch`; return ( -
+
@@ -283,10 +283,18 @@ export function EditorPreviewPanel(props: Props) {
- diff --git a/src/index.css b/src/index.css index 76df3b40b..028bdc2d7 100644 --- a/src/index.css +++ b/src/index.css @@ -198,23 +198,41 @@ display: none; } } +.editor-preview-panel { + container: editor-preview / inline-size; +} .editor-playback { container-type: inline-size; } -@container (max-width: 760px) { +@container editor-preview (max-width: 760px) { + .editor-playback { + grid-template-columns: auto minmax(max-content, 1fr) auto; + } + .editor-playback-add-layer { + width: 36px; + min-width: 36px; + padding-inline: 0; + } + .editor-playback-add-label, + .editor-playback-add-caret { + display: none; + } +} +@container editor-preview (max-width: 520px) { + .editor-playback { + gap: 4px; + padding-inline: 8px; + } + .editor-playback button { + width: 28px; + min-width: 28px; + height: 28px; + } .editor-playback-tools { - grid-row: 2; - grid-column: 1 / -1; - justify-content: center; - padding-bottom: 8px; - } - .editor-playback-center { - grid-row: 1; - grid-column: 2; - } - .editor-playback-volume { - grid-row: 1; - grid-column: 3; + gap: 4px; + } + .editor-playback-center > div { + gap: 2px; } } @container (max-width: 520px) { diff --git a/src/lib/auth/recordlyAuth.ts b/src/lib/auth/recordlyAuth.ts index b525c6d5a..3da02a93f 100644 --- a/src/lib/auth/recordlyAuth.ts +++ b/src/lib/auth/recordlyAuth.ts @@ -111,7 +111,7 @@ function createAuthStorage() { removeLegacyCopies(local, owned); return null; } - // Main migrates once, including an empty marker. Remove old copies so + // Main migrates existing credentials once. Remove old copies so // logging out cannot restore a stale browser session. removeLegacyCopies(local, owned); return bridge; @@ -134,14 +134,14 @@ function createAuthStorage() { }, async setItem(key: string, value: string): Promise { const native = await nativeStorage(); - if (oauthFlowIdRedirectsEnabled && !native) - throw new Error(translate("support.authInsecure")); - if (native) await native.authStorageSetItem(key, value); - else { - const local = memoryOnly ? undefined : browserStorage(); - if (local) local.setItem(key, value); - else memory.set(key, value); + if (native) { + if ((await native.authStorageSetItem(key, value)) !== false) return; + nativeReady = Promise.resolve(null); } + if (oauthFlowIdRedirectsEnabled) throw new Error(translate("support.authInsecure")); + const local = memoryOnly ? undefined : browserStorage(); + if (local) local.setItem(key, value); + else memory.set(key, value); }, async removeItem(key: string): Promise { const native = await nativeStorage(); diff --git a/src/lib/auth/recordlyAuthStorage.test.ts b/src/lib/auth/recordlyAuthStorage.test.ts index 71ca36384..e42713297 100644 --- a/src/lib/auth/recordlyAuthStorage.test.ts +++ b/src/lib/auth/recordlyAuthStorage.test.ts @@ -20,7 +20,9 @@ function bridge() { authStorageAvailable: vi.fn(async () => true), authStorageMigrate: vi.fn(async (_snapshot: Record) => undefined), authStorageGetItem: vi.fn(async (_key: string): Promise => null), - authStorageSetItem: vi.fn(async (_key: string, _value: string) => undefined), + authStorageSetItem: vi.fn( + async (_key: string, _value: string): Promise => undefined, + ), authStorageRemoveItem: vi.fn(async (_key: string) => undefined), }; } @@ -124,6 +126,34 @@ it("keeps normal browser-preview persistence when no desktop bridge exists", asy expect(values.has(project)).toBe(false); }); +it("keeps temporary sign-in in memory when the first native write cannot be encrypted", async () => { + vi.stubEnv("VITE_OAUTH_FLOW_ID_REDIRECTS", "false"); + const native = bridge(); + native.authStorageSetItem.mockResolvedValue(false); + const auth = await storage(native); + + expect(await auth.getItem(project)).toBeNull(); + await auth.setItem(project, "temporary-session"); + await auth.setItem(`${project}-code-verifier`, "temporary-verifier"); + expect(await auth.getItem(project)).toBe("temporary-session"); + expect(native.authStorageSetItem).toHaveBeenCalledTimes(1); + expect(values.size).toBe(0); + await auth.removeItem(project); + expect(await auth.getItem(project)).toBeNull(); + expect(native.authStorageRemoveItem).not.toHaveBeenCalled(); +}); + +it("requires encrypted storage when the first write fails in explicit flow routing mode", async () => { + vi.stubEnv("VITE_OAUTH_FLOW_ID_REDIRECTS", "true"); + const native = bridge(); + native.authStorageSetItem.mockResolvedValue(false); + const auth = await storage(native); + + await expect(auth.setItem(project, "must-not-save")).rejects.toThrow(); + await expect(auth.getItem(project)).rejects.toThrow(); + expect(values.size).toBe(0); +}); + it("never falls back to browser or memory credentials when explicit flow routing requires secure native storage", async () => { vi.stubEnv("VITE_OAUTH_FLOW_ID_REDIRECTS", "true"); const native = bridge(); diff --git a/tests/ui/editor-layout.spec.ts b/tests/ui/editor-layout.spec.ts index b837ae267..c23a8b675 100644 --- a/tests/ui/editor-layout.spec.ts +++ b/tests/ui/editor-layout.spec.ts @@ -141,6 +141,16 @@ test("header breadcrumb fits long names, native chrome and compact windows", asy a.right <= b.x || b.right <= a.x || a.bottom <= b.y || b.bottom <= a.y, ).toBe(true); } + const playbackBounds = (await page.locator(".editor-playback").boundingBox())!; + for (const button of buttons) { + expect(button.x).toBeGreaterThanOrEqual(playbackBounds.x); + expect(button.right).toBeLessThanOrEqual(playbackBounds.x + playbackBounds.width); + expect( + Math.abs( + (button.y + button.bottom) / 2 - (buttons[0].y + buttons[0].bottom) / 2, + ), + ).toBeLessThan(1); + } await expect( page.getByRole("button", { name: "Publish", exact: true }), ).toBeInViewport(); diff --git a/tests/ui/startup-loading.spec.ts b/tests/ui/startup-loading.spec.ts index f74a3d57b..21e89f193 100644 --- a/tests/ui/startup-loading.spec.ts +++ b/tests/ui/startup-loading.spec.ts @@ -1,6 +1,32 @@ import { expect, test } from "@playwright/test"; import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge"; +test("wireframe playback controls fit on one row at supported window widths", async ({ page }) => { + await page.route("**/src/main.tsx*", (route) => route.abort()); + await page.goto("/?windowType=editor"); + for (const width of [800, 1060, 1440]) { + await page.setViewportSize({ width, height: 670 }); + const bar = page.locator(".boot-editor .loading-playback"); + const bounds = (await bar.boundingBox())!; + const boxes = await bar.locator("i").evaluateAll((items) => + items.map((item) => { + const { x, y, width, height } = item.getBoundingClientRect(); + return { x, y, width, height }; + }), + ); + for (const [index, box] of boxes.entries()) { + expect(box.width).toBeGreaterThan(0); + expect( + Math.abs(box.y + box.height / 2 - boxes[0].y - boxes[0].height / 2), + ).toBeLessThan(1); + expect(box.x).toBeGreaterThanOrEqual(bounds.x); + expect(box.x + box.width).toBeLessThanOrEqual(bounds.x + bounds.width); + if (index > 0) + expect(box.x).toBeGreaterThanOrEqual(boxes[index - 1].x + boxes[index - 1].width); + } + } +}); + test("editor paints a wireframe before JavaScript loads", async ({ page }) => { await page.route("**/src/main.tsx*", (route) => route.abort()); await page.goto("/?windowType=editor");