feat(desktop): add image backgrounds
This commit is contained in:
parent
e8b19afa8f
commit
35fcf1ed58
37 changed files with 400 additions and 5 deletions
|
|
@ -37,11 +37,18 @@ export function assertAttachmentBudget(files: { size: number }[]) {
|
|||
}
|
||||
|
||||
export async function readAttachment(filePath: string, maxBytes = MAX_ATTACHMENT_BYTES) {
|
||||
return readBoundedFile(
|
||||
filePath,
|
||||
maxBytes,
|
||||
`Selected attachments exceed the ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB limit`,
|
||||
)
|
||||
}
|
||||
|
||||
export async function readBoundedFile(filePath: string, maxBytes: number, message: string) {
|
||||
const file = await open(filePath, "r")
|
||||
try {
|
||||
const info = await file.stat()
|
||||
if (info.size > maxBytes)
|
||||
throw new Error(`Selected attachments exceed the ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB limit`)
|
||||
if (info.size > maxBytes) throw new Error(message)
|
||||
const bytes = Buffer.allocUnsafe(info.size)
|
||||
let offset = 0
|
||||
while (offset < info.size) {
|
||||
|
|
|
|||
56
packages/desktop/src/main/background-image.test.ts
Normal file
56
packages/desktop/src/main/background-image.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { afterEach, expect, test } from "bun:test"
|
||||
import { mkdtemp, rm, truncate, writeFile } from "node:fs/promises"
|
||||
import { join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
import { clearBackgroundImage, findBackgroundImage, saveBackgroundImage } from "./background-image"
|
||||
|
||||
const directories: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
test("persists and clears a selected image", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-background-"))
|
||||
directories.push(directory)
|
||||
const source = join(directory, "source.png")
|
||||
await writeFile(source, new Uint8Array([1, 2, 3]))
|
||||
|
||||
expect((await saveBackgroundImage(directory, source))?.mime).toBe("image/png")
|
||||
expect((await findBackgroundImage(directory))?.path).toBe(join(directory, "background-image.png"))
|
||||
|
||||
await clearBackgroundImage(directory)
|
||||
expect(await findBackgroundImage(directory)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("rejects unsupported image formats", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-background-"))
|
||||
directories.push(directory)
|
||||
const source = join(directory, "source.svg")
|
||||
await writeFile(source, "<svg />")
|
||||
|
||||
expect(saveBackgroundImage(directory, source)).rejects.toThrow("Unsupported background image format")
|
||||
})
|
||||
|
||||
test("replaces an existing image", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-background-"))
|
||||
directories.push(directory)
|
||||
const first = join(directory, "first.png")
|
||||
const second = join(directory, "second.webp")
|
||||
await Promise.all([writeFile(first, new Uint8Array([1])), writeFile(second, new Uint8Array([2]))])
|
||||
|
||||
await saveBackgroundImage(directory, first)
|
||||
await saveBackgroundImage(directory, second)
|
||||
|
||||
expect((await findBackgroundImage(directory))?.mime).toBe("image/webp")
|
||||
})
|
||||
|
||||
test("rejects images larger than 20 MB", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-background-"))
|
||||
directories.push(directory)
|
||||
const source = join(directory, "large.png")
|
||||
await writeFile(source, "")
|
||||
await truncate(source, 20 * 1024 * 1024 + 1)
|
||||
|
||||
expect(saveBackgroundImage(directory, source)).rejects.toThrow("Background images must be 20 MB or smaller")
|
||||
})
|
||||
50
packages/desktop/src/main/background-image.ts
Normal file
50
packages/desktop/src/main/background-image.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { randomUUID } from "node:crypto"
|
||||
import { mkdir, rename, rm, stat, writeFile } from "node:fs/promises"
|
||||
import { extname, join } from "node:path"
|
||||
import { MAX_ATTACHMENT_BYTES, readBoundedFile } from "./attachment-picker"
|
||||
|
||||
const name = "background-image"
|
||||
const mime = {
|
||||
".avif": "image/avif",
|
||||
".bmp": "image/bmp",
|
||||
".gif": "image/gif",
|
||||
".jpeg": "image/jpeg",
|
||||
".jpg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
} as const
|
||||
|
||||
export const backgroundImageExtensions = Object.keys(mime).map((extension) => extension.slice(1))
|
||||
|
||||
export async function findBackgroundImage(directory: string) {
|
||||
const candidates = await Promise.all(
|
||||
Object.entries(mime).map(async ([extension, contentType]) => {
|
||||
const path = join(directory, `${name}${extension}`)
|
||||
const info = await stat(path).catch(() => undefined)
|
||||
if (!info?.isFile() || info.size > MAX_ATTACHMENT_BYTES) return
|
||||
return { path, mime: contentType, revision: `${info.mtimeMs}-${info.size}`, modified: info.mtimeMs }
|
||||
}),
|
||||
)
|
||||
return candidates.filter((candidate) => candidate !== undefined).sort((a, b) => b.modified - a.modified)[0]
|
||||
}
|
||||
|
||||
export async function saveBackgroundImage(directory: string, path: string) {
|
||||
const extension = extname(path).toLowerCase() as keyof typeof mime
|
||||
if (!mime[extension]) throw new Error("Unsupported background image format")
|
||||
const data = await readBoundedFile(path, MAX_ATTACHMENT_BYTES, "Background images must be 20 MB or smaller")
|
||||
await mkdir(directory, { recursive: true })
|
||||
const target = join(directory, `${name}${extension}`)
|
||||
const temporary = join(directory, `.${name}-${randomUUID()}`)
|
||||
await writeFile(temporary, new Uint8Array(data))
|
||||
await rename(temporary, target).finally(() => rm(temporary, { force: true }))
|
||||
await Promise.all(
|
||||
Object.keys(mime)
|
||||
.filter((candidate) => candidate !== extension)
|
||||
.map((candidate) => rm(join(directory, `${name}${candidate}`), { force: true })),
|
||||
)
|
||||
return findBackgroundImage(directory)
|
||||
}
|
||||
|
||||
export async function clearBackgroundImage(directory: string) {
|
||||
await Promise.all(Object.keys(mime).map((extension) => rm(join(directory, `${name}${extension}`), { force: true })))
|
||||
}
|
||||
|
|
@ -8,6 +8,12 @@ import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
|
|||
import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types"
|
||||
import { runDesktopMenuAction } from "./desktop-menu-actions"
|
||||
import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
|
||||
import {
|
||||
backgroundImageExtensions,
|
||||
clearBackgroundImage,
|
||||
findBackgroundImage,
|
||||
saveBackgroundImage,
|
||||
} from "./background-image"
|
||||
import { getStore, removeStoreFileIfEmpty } from "./store"
|
||||
import { getPinchZoomEnabled, getWindowID, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
|
||||
import type { UpdaterController } from "./updater-controller"
|
||||
|
|
@ -80,6 +86,29 @@ export function registerIpcHandlers(deps: Deps) {
|
|||
ipcMain.handle("updater-check", () => deps.updater.check())
|
||||
ipcMain.handle("updater-install", () => deps.updater.install())
|
||||
ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color))
|
||||
const backgroundImageState = async () => {
|
||||
const image = await findBackgroundImage(app.getPath("userData"))
|
||||
return image ? { revision: image.revision } : null
|
||||
}
|
||||
const publishBackgroundImage = (state: { revision: string } | null) => {
|
||||
BrowserWindow.getAllWindows().forEach((win) => win.webContents.send("background-image-changed", state))
|
||||
return state
|
||||
}
|
||||
ipcMain.handle("load-background-image", backgroundImageState)
|
||||
ipcMain.handle("select-background-image", async () => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openFile"],
|
||||
title: "Choose a background image",
|
||||
filters: [{ name: "Images", extensions: backgroundImageExtensions }],
|
||||
})
|
||||
if (result.canceled || !result.filePaths[0]) return null
|
||||
await saveBackgroundImage(app.getPath("userData"), result.filePaths[0])
|
||||
return publishBackgroundImage(await backgroundImageState())
|
||||
})
|
||||
ipcMain.handle("clear-background-image", async () => {
|
||||
await clearBackgroundImage(app.getPath("userData"))
|
||||
publishBackgroundImage(null)
|
||||
})
|
||||
ipcMain.handle("export-debug-logs", () => deps.exportDebugLogs())
|
||||
ipcMain.handle("record-fatal-renderer-error", (_event: IpcMainInvokeEvent, error: FatalRendererError) =>
|
||||
deps.recordFatalRendererError(error),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { dirname, isAbsolute, join, relative, resolve } from "node:path"
|
|||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
import type { TitlebarTheme } from "../preload/types"
|
||||
import { exportDebugLogs, write as writeLog } from "./logging"
|
||||
import { findBackgroundImage } from "./background-image"
|
||||
import { getStore, removeStoreFile } from "./store"
|
||||
import { PINCH_ZOOM_ENABLED_KEY, WINDOW_IDS_KEY } from "./store-keys"
|
||||
import { createUnresponsiveSampler } from "./unresponsive"
|
||||
|
|
@ -259,6 +260,12 @@ export function registerRendererProtocol() {
|
|||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
|
||||
if (url.pathname === "/background-image") {
|
||||
const image = await findBackgroundImage(app.getPath("userData"))
|
||||
if (!image) return new Response("Not found", { status: 404 })
|
||||
return net.fetch(pathToFileURL(image.path).toString())
|
||||
}
|
||||
|
||||
const file = resolve(rendererRoot, `.${decodeURIComponent(url.pathname)}`)
|
||||
const rel = relative(rendererRoot, file)
|
||||
if (rel.startsWith("..") || isAbsolute(rel)) {
|
||||
|
|
|
|||
|
|
@ -120,6 +120,14 @@ const api: ElectronAPI = {
|
|||
setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme),
|
||||
runDesktopMenuAction: (action) => ipcRenderer.invoke("run-desktop-menu-action", action),
|
||||
setBackgroundColor: (color: string) => ipcRenderer.invoke("set-background-color", color),
|
||||
loadBackgroundImage: () => ipcRenderer.invoke("load-background-image"),
|
||||
selectBackgroundImage: () => ipcRenderer.invoke("select-background-image"),
|
||||
clearBackgroundImage: () => ipcRenderer.invoke("clear-background-image"),
|
||||
onBackgroundImageChanged: (cb) => {
|
||||
const handler = (_: unknown, image: Parameters<typeof cb>[0]) => cb(image)
|
||||
ipcRenderer.on("background-image-changed", handler)
|
||||
return () => ipcRenderer.removeListener("background-image-changed", handler)
|
||||
},
|
||||
exportDebugLogs: () => ipcRenderer.invoke("export-debug-logs"),
|
||||
recordFatalRendererError: (error) => ipcRenderer.invoke("record-fatal-renderer-error", error),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,10 @@ export type FatalRendererError = {
|
|||
os?: string
|
||||
}
|
||||
|
||||
export type BackgroundImage = {
|
||||
revision: string
|
||||
}
|
||||
|
||||
export type ElectronAPI = {
|
||||
killSidecar: () => Promise<void>
|
||||
installCli: () => Promise<string>
|
||||
|
|
@ -103,6 +107,10 @@ export type ElectronAPI = {
|
|||
setTitlebar: (theme: TitlebarTheme) => Promise<void>
|
||||
runDesktopMenuAction: (action: DesktopMenuAction) => Promise<void>
|
||||
setBackgroundColor: (color: string) => Promise<void>
|
||||
loadBackgroundImage: () => Promise<BackgroundImage | null>
|
||||
selectBackgroundImage: () => Promise<BackgroundImage | null>
|
||||
clearBackgroundImage: () => Promise<void>
|
||||
onBackgroundImageChanged: (cb: (image: BackgroundImage | null) => void) => () => void
|
||||
exportDebugLogs: () => Promise<string>
|
||||
recordFatalRendererError: (error: FatalRendererError) => Promise<void>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,6 +112,21 @@ function DesktopMemoryRouter(props: BaseRouterProps & { windowID: string }) {
|
|||
|
||||
const createPlatform = (windowState: DesktopWindowState): Platform => {
|
||||
const attachmentPaths = new WeakMap<File, string>()
|
||||
const [backgroundImage, setBackgroundImage] = createSignal(false)
|
||||
const applyBackgroundImage = (image: Awaited<ReturnType<typeof window.api.loadBackgroundImage>>) => {
|
||||
setBackgroundImage(!!image)
|
||||
document.documentElement.toggleAttribute("data-background-image", !!image)
|
||||
if (image) {
|
||||
document.documentElement.style.setProperty(
|
||||
"--desktop-background-image",
|
||||
`url("oc://renderer/background-image?revision=${encodeURIComponent(image.revision)}")`,
|
||||
)
|
||||
return true
|
||||
}
|
||||
document.documentElement.style.removeProperty("--desktop-background-image")
|
||||
return false
|
||||
}
|
||||
window.api.onBackgroundImageChanged(applyBackgroundImage)
|
||||
const os = (() => {
|
||||
const ua = navigator.userAgent
|
||||
if (ua.includes("Mac")) return "macos"
|
||||
|
|
@ -310,6 +325,23 @@ const createPlatform = (windowState: DesktopWindowState): Platform => {
|
|||
type: "image/png",
|
||||
})
|
||||
},
|
||||
|
||||
async loadBackgroundImage() {
|
||||
return applyBackgroundImage(await window.api.loadBackgroundImage())
|
||||
},
|
||||
|
||||
async selectBackgroundImage() {
|
||||
const image = await window.api.selectBackgroundImage()
|
||||
if (!image) return backgroundImage()
|
||||
return applyBackgroundImage(image)
|
||||
},
|
||||
|
||||
async clearBackgroundImage() {
|
||||
await window.api.clearBackgroundImage()
|
||||
applyBackgroundImage(null)
|
||||
},
|
||||
|
||||
backgroundImage,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -329,6 +361,7 @@ function LoadingSplash() {
|
|||
|
||||
function DesktopRoot(props: { windowState: DesktopWindowState }) {
|
||||
const platform = createPlatform(props.windowState)
|
||||
void platform.loadBackgroundImage?.()
|
||||
const loadLocale = async () => {
|
||||
const current = await platform.storage?.("opencode.global.dat").getItem("language")
|
||||
const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
html[data-background-image] body {
|
||||
background-image: linear-gradient(rgb(0 0 0 / 18%), rgb(0 0 0 / 18%)), var(--desktop-background-image);
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
html[data-background-image] [data-background-surface="shell"] {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
html[data-background-image] [data-background-surface="content"] {
|
||||
background-color: color-mix(in srgb, var(--background-base) 76%, transparent) !important;
|
||||
}
|
||||
|
||||
html[data-background-image] [data-background-surface="panel"] {
|
||||
background-color: color-mix(in srgb, var(--v2-background-bg-base) 70%, transparent) !important;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue