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
13 changes: 13 additions & 0 deletions packages/desktop-electron/src/main/ipc-window-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,17 @@ describe("desktop startup IPC", () => {
expect(source).toContain("exportRendererDiagnostics")
expect(source).toContain("rendererDiagnosticsSlice")
})

test("store-get returns null when persisted store reads fail", () => {
const start = source.indexOf('ipcMain.handle("store-get"')
const end = source.indexOf('ipcMain.handle("store-set"', start)
expect(start).toBeGreaterThanOrEqual(0)
expect(end).toBeGreaterThan(start)
const handler = source.slice(start, end)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

expect(handler).toContain("try {")
expect(handler).toContain("getStore(name)")
expect(handler).toContain("catch")
expect(handler).toContain("return null")
})
})
12 changes: 8 additions & 4 deletions packages/desktop-electron/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,10 +170,14 @@ export function registerIpcHandlers(deps: Deps) {
return deps.setDesktopContext(context, win)
})
ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => {
const store = getStore(name)
const value = store.get(key)
if (value === undefined || value === null) return null
return typeof value === "string" ? value : JSON.stringify(value)
try {
const store = getStore(name)
const value = store.get(key)
if (value === undefined || value === null) return null
return typeof value === "string" ? value : JSON.stringify(value)
} catch {
return null
}
})
ipcMain.handle("store-set", (_event: IpcMainInvokeEvent, name: string, key: string, value: string) => {
getStore(name).set(key, value)
Expand Down
124 changes: 124 additions & 0 deletions packages/desktop-electron/src/main/logging.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { afterAll, afterEach, describe, expect, mock, test } from "bun:test"
import { mkdtempSync, rmSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"

let logDir = ""
const fakeLog: {
transports: {
file: {
maxSize: number
getFile: () => { path: string }
}
console: {
level: string | false
wrapCount: number
writeFn: (options: unknown) => void
}
}
} = {
transports: {
file: {
maxSize: 0,
getFile: () => ({ path: join(tmpdir(), "desktop.log") }),
},
console: {
level: "info",
wrapCount: 0,
writeFn: () => undefined,
},
},
}

mock.module("electron-log/main.js", () => ({
default: fakeLog,
}))

afterEach(() => {
if (logDir) rmSync(logDir, { recursive: true, force: true })
logDir = ""
})

afterAll(() => {
mock.restore()
})

function setupLog(writeFn: (options: unknown) => void) {
logDir = mkdtempSync(join(tmpdir(), "pawwork-logging-test-"))
let currentWriteFn = writeFn
let wrapCount = 0
fakeLog.transports.file = {
maxSize: 0,
getFile: () => ({ path: join(logDir, "desktop.log") }),
}
fakeLog.transports.console = {
level: "info",
get wrapCount() {
return wrapCount
},
get writeFn() {
return currentWriteFn
},
set writeFn(next) {
wrapCount++
currentWriteFn = next
},
}
return fakeLog.transports.console
}

function brokenPipe() {
return Object.assign(new Error("broken pipe"), { code: "EPIPE" })
}

function otherWriteError() {
return Object.assign(new Error("write failed"), { code: "ENOENT" })
}

describe("desktop logging", () => {
test("disables the console transport after a broken pipe", async () => {
const consoleTransport = setupLog(() => {
throw brokenPipe()
})
const { initLogging } = await import(`./logging?logging-test=${crypto.randomUUID()}`)

initLogging()
consoleTransport.writeFn({})

expect(consoleTransport.level).toBe(false)
})

test("rethrows non-broken-pipe console transport errors", async () => {
const err = otherWriteError()
const consoleTransport = setupLog(() => {
throw err
})
const { initLogging } = await import(`./logging?logging-test=${crypto.randomUUID()}`)

initLogging()

expect(() => consoleTransport.writeFn({})).toThrow(err)
expect(consoleTransport.level).toBe("info")
})

test("does not wrap the console transport more than once", async () => {
const consoleTransport = setupLog(() => undefined)
const { initLogging } = await import(`./logging?logging-test=${crypto.randomUUID()}`)

initLogging()
initLogging()

expect(consoleTransport.wrapCount).toBe(1)
})

test("does not wrap the console transport again across fresh module imports", async () => {
const consoleTransport = setupLog(() => undefined)
const first = await import(`./logging?logging-test=${crypto.randomUUID()}`)
const second = await import(`./logging?logging-test=${crypto.randomUUID()}`)

first.initLogging()
second.initLogging()

expect(consoleTransport.wrapCount).toBe(1)
})
})
24 changes: 24 additions & 0 deletions packages/desktop-electron/src/main/logging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import { dirname, join } from "node:path"

const MAX_LOG_AGE_DAYS = 7
const TAIL_LINES = 1000
const CONSOLE_TRANSPORT_INITIALIZED = Symbol.for("pawwork.consoleTransportInitialized")

export function initLogging() {
log.transports.file.maxSize = 5 * 1024 * 1024
initConsoleTransport()
cleanup()
return log
}
Expand Down Expand Up @@ -41,3 +43,25 @@ function cleanup() {
}
}
}

function initConsoleTransport() {
const transport = log.transports.console as typeof log.transports.console & {
[CONSOLE_TRANSPORT_INITIALIZED]?: boolean
}
if (transport[CONSOLE_TRANSPORT_INITIALIZED]) return
transport[CONSOLE_TRANSPORT_INITIALIZED] = true

const write = transport.writeFn.bind(transport)
transport.writeFn = (options) => {
try {
write(options)
} catch (err) {
if (!isBrokenPipe(err)) throw err
transport.level = false
}
}
}

function isBrokenPipe(err: unknown) {
return typeof err === "object" && err !== null && "code" in err && err.code === "EPIPE"
}
121 changes: 121 additions & 0 deletions packages/desktop-electron/src/renderer/webview-zoom.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { afterEach, describe, expect, mock, test } from "bun:test"

type KeydownHandler = (event: {
key: string
ctrlKey: boolean
metaKey: boolean
preventDefault: () => void
}) => void

const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, "navigator")
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window")

afterEach(() => {
if (originalNavigator) Object.defineProperty(globalThis, "navigator", originalNavigator)
else delete (globalThis as { navigator?: Navigator }).navigator
if (originalWindow) Object.defineProperty(globalThis, "window", originalWindow)
else delete (globalThis as { window?: Window }).window
})

function deferred() {
let resolve!: () => void
let reject!: (err: unknown) => void
const promise = new Promise<void>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve, reject }
}

async function loadZoomModule(options?: { userAgent?: string; setZoomFactor?: (factor: number) => Promise<void> }) {
const handlers: KeydownHandler[] = []
const setZoomFactor = mock(options?.setZoomFactor ?? (() => Promise.resolve()))

Object.defineProperty(globalThis, "navigator", {
value: { userAgent: options?.userAgent ?? "Windows" },
configurable: true,
writable: true,
})
Object.defineProperty(globalThis, "window", {
value: {
api: { setZoomFactor },
addEventListener: (type: string, handler: KeydownHandler) => {
if (type === "keydown") handlers.push(handler)
},
},
configurable: true,
writable: true,
})

const module = await import(`./webview-zoom?webview-zoom-test=${crypto.randomUUID()}`)
return {
handler: handlers[0]!,
setZoomFactor,
webviewZoom: module.webviewZoom,
}
}

function keyEvent(key: string, overrides?: Partial<Parameters<KeydownHandler>[0]>) {
return {
key,
ctrlKey: true,
metaKey: false,
preventDefault: mock(() => undefined),
...overrides,
}
}

describe("desktop renderer webview zoom", () => {
test("only consumes keydown events that actually change zoom", async () => {
const { handler, setZoomFactor } = await loadZoomModule()
const unrelated = keyEvent("a")
const zoomOut = keyEvent("-")

handler(unrelated)
handler(zoomOut)

expect(unrelated.preventDefault).toHaveBeenCalledTimes(0)
expect(zoomOut.preventDefault).toHaveBeenCalledTimes(1)
expect(setZoomFactor).toHaveBeenCalledTimes(1)
expect(setZoomFactor).toHaveBeenCalledWith(0.8)
})

test("keeps requested zoom separate until Electron accepts the zoom change", async () => {
const zoom = deferred()
const { handler, webviewZoom } = await loadZoomModule({ setZoomFactor: () => zoom.promise })

handler(keyEvent("-"))

expect(webviewZoom()).toBe(1)
zoom.resolve()
await zoom.promise
await Promise.resolve()
expect(webviewZoom()).toBe(0.8)
})

test("keeps the current zoom when Electron rejects the zoom change", async () => {
const zoom = deferred()
const { handler, webviewZoom } = await loadZoomModule({ setZoomFactor: () => zoom.promise })

handler(keyEvent("-"))

zoom.reject(new Error("zoom failed"))
await zoom.promise.catch(() => undefined)
await Promise.resolve()
expect(webviewZoom()).toBe(1)
})

test("uses requested zoom for rapid repeated shortcuts", async () => {
const { handler, setZoomFactor } = await loadZoomModule()

handler(keyEvent("-"))
handler(keyEvent("-"))
handler(keyEvent("0"))

const factors = setZoomFactor.mock.calls.map(([factor]) => factor)
expect(factors).toHaveLength(3)
expect(factors[0]).toBeCloseTo(0.8, 10)
expect(factors[1]).toBeCloseTo(0.6, 10)
expect(factors[2]).toBe(1)
})
})
36 changes: 27 additions & 9 deletions packages/desktop-electron/src/renderer/webview-zoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,27 +12,45 @@ const OS_NAME = (() => {
})()

const [webviewZoom, setWebviewZoom] = createSignal(1)
let requestedZoom = 1

const MAX_ZOOM_LEVEL = 10
const MIN_ZOOM_LEVEL = 0.2

const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL)

const applyZoom = (next: number) => {
setWebviewZoom(next)
void window.api.setZoomFactor(next)
requestedZoom = next
void window.api
.setZoomFactor(next)
.then(() => {
if (requestedZoom !== next) return
setWebviewZoom(next)
})
.catch(() => {
if (requestedZoom !== next) return
requestedZoom = webviewZoom()
})
}

window.addEventListener("keydown", (event) => {
if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return

let newZoom = webviewZoom()

if (event.key === "-") newZoom -= 0.2
if (event.key === "=" || event.key === "+") newZoom += 0.2
if (event.key === "0") newZoom = 1

applyZoom(clamp(newZoom))
if (event.key === "-") {
event.preventDefault()
applyZoom(clamp(requestedZoom - 0.2))
return
}
if (event.key === "=" || event.key === "+") {
event.preventDefault()
applyZoom(clamp(requestedZoom + 0.2))
return
}
if (event.key === "0") {
event.preventDefault()
applyZoom(1)
return
}
Comment thread
Astro-Han marked this conversation as resolved.
})

export { webviewZoom }
Loading
Loading