diff --git a/packages/app/src/components/session/session-new-design-view.tsx b/packages/app/src/components/session/session-new-design-view.tsx index a324c64fa6..3925d568c5 100644 --- a/packages/app/src/components/session/session-new-design-view.tsx +++ b/packages/app/src/components/session/session-new-design-view.tsx @@ -4,7 +4,11 @@ import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout" export function NewSessionDesignView(props: { children: JSX.Element }) { return ( -
+
diff --git a/packages/app/src/components/settings-background-image.ts b/packages/app/src/components/settings-background-image.ts new file mode 100644 index 0000000000..eda0de2a52 --- /dev/null +++ b/packages/app/src/components/settings-background-image.ts @@ -0,0 +1,36 @@ +import { createStore } from "solid-js/store" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { showToast } from "@/utils/toast" + +export function useSettingsBackgroundImage() { + const platform = usePlatform() + const language = useLanguage() + const [state, setState] = createStore({ busy: false }) + + const run = async (action: (() => Promise) | undefined) => { + if (!action || state.busy) return + setState("busy", true) + try { + await action() + } catch (error) { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: error instanceof Error ? error.message : String(error), + }) + } finally { + setState("busy", false) + } + } + + return { + available: !!platform.selectBackgroundImage, + active: () => platform.backgroundImage?.() ?? false, + get busy() { + return state.busy + }, + select: () => run(platform.selectBackgroundImage), + clear: () => run(platform.clearBackgroundImage), + } +} diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 3beb97225a..1afb1866cb 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -31,6 +31,7 @@ import { decode64 } from "@/utils/base64" import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" import { Link } from "./link" import { SettingsList } from "./settings-list" +import { useSettingsBackgroundImage } from "./settings-background-image" let demoSoundState = { cleanup: undefined as (() => void) | undefined, @@ -87,6 +88,7 @@ export const SettingsGeneral: Component = () => { const language = useLanguage() const permission = usePermission() const platform = usePlatform() + const backgroundImage = useSettingsBackgroundImage() const dialog = useDialog() const params = useParams() const settings = useSettings() @@ -499,6 +501,24 @@ export const SettingsGeneral: Component = () => { /> + + +
+ + + + +
+
+
+ + + +
+ + {language.t("settings.general.row.backgroundImage.choose")} + + + + {language.t("settings.general.row.backgroundImage.remove")} + + +
+
+
+ + /** Load and apply the saved desktop background image. */ + loadBackgroundImage?(): Promise + + /** Whether the desktop currently has a background image. */ + backgroundImage?: Accessor + + /** Select and apply a desktop background image. */ + selectBackgroundImage?(): Promise + + /** Clear the saved desktop background image. */ + clearBackgroundImage?(): Promise + /** Export collected diagnostic logs (desktop only) */ exportDebugLogs?(): Promise diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index bdf7f080d3..ca8d0b1fec 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -704,6 +704,10 @@ export const dict = { "settings.general.row.font.description": "خصّص الخط المستخدم في كتل التعليمات البرمجية", "settings.general.row.terminalFont.title": "خط الطرفية", "settings.general.row.terminalFont.description": "خصّص الخط المستخدم في الطرفية", + "settings.general.row.backgroundImage.title": "Background image", + "settings.general.row.backgroundImage.description": "Choose an image for the desktop background.", + "settings.general.row.backgroundImage.choose": "Choose image", + "settings.general.row.backgroundImage.remove": "Remove", "settings.general.row.uiFont.title": "خط الواجهة", "settings.general.row.uiFont.description": "خصّص الخط المستخدم في الواجهة بأكملها", "settings.general.row.followup.title": "سلوك المتابعة", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index a256566ca8..49a95f5cc8 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -713,6 +713,10 @@ export const dict = { "settings.general.row.font.description": "Personalize a fonte usada em blocos de código", "settings.general.row.terminalFont.title": "Fonte do terminal", "settings.general.row.terminalFont.description": "Personalize a fonte usada no terminal", + "settings.general.row.backgroundImage.title": "Background image", + "settings.general.row.backgroundImage.description": "Choose an image for the desktop background.", + "settings.general.row.backgroundImage.choose": "Choose image", + "settings.general.row.backgroundImage.remove": "Remove", "settings.general.row.uiFont.title": "Fonte da interface", "settings.general.row.uiFont.description": "Personalize a fonte usada em toda a interface", "settings.general.row.followup.title": "Comportamento de acompanhamento", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index e786f93f63..1961072084 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -778,6 +778,10 @@ export const dict = { "settings.general.row.font.description": "Prilagodi font koji se koristi u blokovima koda", "settings.general.row.terminalFont.title": "Font terminala", "settings.general.row.terminalFont.description": "Prilagodite font koji se koristi u terminalu", + "settings.general.row.backgroundImage.title": "Background image", + "settings.general.row.backgroundImage.description": "Choose an image for the desktop background.", + "settings.general.row.backgroundImage.choose": "Choose image", + "settings.general.row.backgroundImage.remove": "Remove", "settings.general.row.uiFont.title": "UI font", "settings.general.row.uiFont.description": "Prilagodi font koji se koristi u cijelom interfejsu", "settings.general.row.followup.title": "Ponašanje nadovezivanja", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 15a6514415..7b068e3da6 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -773,6 +773,10 @@ export const dict = { "settings.general.row.font.description": "Tilpas skrifttypen, der bruges i kodeblokke", "settings.general.row.terminalFont.title": "Terminalskrifttype", "settings.general.row.terminalFont.description": "Tilpas den skrifttype, der bruges i terminalen", + "settings.general.row.backgroundImage.title": "Background image", + "settings.general.row.backgroundImage.description": "Choose an image for the desktop background.", + "settings.general.row.backgroundImage.choose": "Choose image", + "settings.general.row.backgroundImage.remove": "Remove", "settings.general.row.uiFont.title": "UI-skrifttype", "settings.general.row.uiFont.description": "Tilpas skrifttypen, der bruges i hele brugerfladen", "settings.general.row.followup.title": "Opfølgningsadfærd", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 5041b42ee8..82d87e20db 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -724,6 +724,10 @@ export const dict = { "settings.general.row.font.description": "Die in Codeblöcken verwendete Schriftart anpassen", "settings.general.row.terminalFont.title": "Terminalschriftart", "settings.general.row.terminalFont.description": "Passe die im Terminal verwendete Schriftart an", + "settings.general.row.backgroundImage.title": "Background image", + "settings.general.row.backgroundImage.description": "Choose an image for the desktop background.", + "settings.general.row.backgroundImage.choose": "Choose image", + "settings.general.row.backgroundImage.remove": "Remove", "settings.general.row.uiFont.title": "UI-Schriftart", "settings.general.row.uiFont.description": "Die im gesamten Interface verwendete Schriftart anpassen", "settings.general.row.followup.title": "Verhalten bei Folgefragen", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 3b5543e15c..42680e1202 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -862,6 +862,10 @@ export const dict = { "settings.general.row.colorScheme.description": "Choose whether OpenCode follows the system, light, or dark theme", "settings.general.row.theme.title": "Theme", "settings.general.row.theme.description": "Customise how OpenCode is themed.", + "settings.general.row.backgroundImage.title": "Background image", + "settings.general.row.backgroundImage.description": "Choose an image for the desktop background.", + "settings.general.row.backgroundImage.choose": "Choose image", + "settings.general.row.backgroundImage.remove": "Remove", "settings.general.row.font.title": "Code Font", "settings.general.row.font.description": "Customise the font used in code blocks", "settings.general.row.terminalFont.title": "Terminal Font", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index a43bc1c41b..cccdb2e9f0 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -781,6 +781,10 @@ export const dict = { "settings.general.row.font.description": "Personaliza la fuente usada en bloques de código", "settings.general.row.terminalFont.title": "Fuente del terminal", "settings.general.row.terminalFont.description": "Personaliza la fuente utilizada en el terminal", + "settings.general.row.backgroundImage.title": "Background image", + "settings.general.row.backgroundImage.description": "Choose an image for the desktop background.", + "settings.general.row.backgroundImage.choose": "Choose image", + "settings.general.row.backgroundImage.remove": "Remove", "settings.general.row.uiFont.title": "Fuente de la interfaz", "settings.general.row.uiFont.description": "Personaliza la fuente usada en toda la interfaz", "settings.general.row.followup.title": "Comportamiento de seguimiento", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index f836b810f2..249e04ff27 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -720,6 +720,10 @@ export const dict = { "settings.general.row.font.description": "Personnaliser la police utilisée dans les blocs de code", "settings.general.row.terminalFont.title": "Police du terminal", "settings.general.row.terminalFont.description": "Personnalisez la police utilisée dans le terminal", + "settings.general.row.backgroundImage.title": "Background image", + "settings.general.row.backgroundImage.description": "Choose an image for the desktop background.", + "settings.general.row.backgroundImage.choose": "Choose image", + "settings.general.row.backgroundImage.remove": "Remove", "settings.general.row.uiFont.title": "Police de l'interface", "settings.general.row.uiFont.description": "Personnaliser la police utilisée dans toute l'interface", "settings.general.row.followup.title": "Comportement de suivi", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 98692efef4..7dfd031f73 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -709,6 +709,10 @@ export const dict = { "settings.general.row.font.description": "コードブロックで使用するフォントをカスタマイズします", "settings.general.row.terminalFont.title": "ターミナルのフォント", "settings.general.row.terminalFont.description": "ターミナルで使用するフォントをカスタマイズ", + "settings.general.row.backgroundImage.title": "Background image", + "settings.general.row.backgroundImage.description": "Choose an image for the desktop background.", + "settings.general.row.backgroundImage.choose": "Choose image", + "settings.general.row.backgroundImage.remove": "Remove", "settings.general.row.uiFont.title": "UIフォント", "settings.general.row.uiFont.description": "インターフェース全体で使用するフォントをカスタマイズします", "settings.general.row.followup.title": "フォローアップの動作", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index be27f810ad..e6ecae4c9f 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -580,6 +580,10 @@ export const dict = { "settings.general.row.font.description": "코드 블록에 사용되는 글꼴을 사용자 지정", "settings.general.row.terminalFont.title": "터미널 글꼴", "settings.general.row.terminalFont.description": "터미널에서 사용할 글꼴을 설정합니다", + "settings.general.row.backgroundImage.title": "Background image", + "settings.general.row.backgroundImage.description": "Choose an image for the desktop background.", + "settings.general.row.backgroundImage.choose": "Choose image", + "settings.general.row.backgroundImage.remove": "Remove", "settings.general.row.uiFont.title": "UI 글꼴", "settings.general.row.uiFont.description": "인터페이스 전반에 사용되는 글꼴을 사용자 지정", "settings.general.row.followup.title": "후속 조치 동작", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 26029d750c..8ab2b32424 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -654,6 +654,10 @@ export const dict = { "settings.general.row.font.description": "Tilpass skrifttypen som brukes i kodeblokker", "settings.general.row.terminalFont.title": "Terminalskrift", "settings.general.row.terminalFont.description": "Tilpass skrifttypen som brukes i terminalen", + "settings.general.row.backgroundImage.title": "Background image", + "settings.general.row.backgroundImage.description": "Choose an image for the desktop background.", + "settings.general.row.backgroundImage.choose": "Choose image", + "settings.general.row.backgroundImage.remove": "Remove", "settings.general.row.uiFont.title": "UI-skrift", "settings.general.row.uiFont.description": "Tilpass skrifttypen som brukes i hele grensesnittet", "settings.general.row.followup.title": "Oppfølgingsadferd", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 8b49dcfa63..8f01e698b9 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -714,6 +714,10 @@ export const dict = { "settings.general.row.font.description": "Dostosuj czcionkę używaną w blokach kodu", "settings.general.row.terminalFont.title": "Czcionka terminala", "settings.general.row.terminalFont.description": "Dostosuj czcionkę używaną w terminalu", + "settings.general.row.backgroundImage.title": "Background image", + "settings.general.row.backgroundImage.description": "Choose an image for the desktop background.", + "settings.general.row.backgroundImage.choose": "Choose image", + "settings.general.row.backgroundImage.remove": "Remove", "settings.general.row.uiFont.title": "Czcionka interfejsu", "settings.general.row.uiFont.description": "Dostosuj czcionkę używaną w całym interfejsie", "settings.general.row.followup.title": "Zachowanie kontynuacji", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index b0bf904406..3e2518b5be 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -778,6 +778,10 @@ export const dict = { "settings.general.row.font.description": "Настройте шрифт, используемый в блоках кода", "settings.general.row.terminalFont.title": "Шрифт терминала", "settings.general.row.terminalFont.description": "Настройте шрифт, используемый в терминале", + "settings.general.row.backgroundImage.title": "Background image", + "settings.general.row.backgroundImage.description": "Choose an image for the desktop background.", + "settings.general.row.backgroundImage.choose": "Choose image", + "settings.general.row.backgroundImage.remove": "Remove", "settings.general.row.uiFont.title": "Шрифт интерфейса", "settings.general.row.uiFont.description": "Настройте шрифт, используемый во всем интерфейсе", "settings.general.row.followup.title": "Поведение уточняющих вопросов", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 388921e1bc..352b2e01a3 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -771,6 +771,10 @@ export const dict = { "settings.general.row.font.description": "ปรับแต่งฟอนต์ที่ใช้ในบล็อกโค้ด", "settings.general.row.terminalFont.title": "ฟอนต์เทอร์มินัล", "settings.general.row.terminalFont.description": "ปรับแต่งฟอนต์ที่ใช้ในเทอร์มินัล", + "settings.general.row.backgroundImage.title": "Background image", + "settings.general.row.backgroundImage.description": "Choose an image for the desktop background.", + "settings.general.row.backgroundImage.choose": "Choose image", + "settings.general.row.backgroundImage.remove": "Remove", "settings.general.row.uiFont.title": "ฟอนต์ UI", "settings.general.row.uiFont.description": "ปรับแต่งฟอนต์ที่ใช้ทั่วทั้งอินเทอร์เฟซ", "settings.general.row.followup.title": "พฤติกรรมการติดตามผล", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index b0295660db..fb13377fd4 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -784,6 +784,10 @@ export const dict = { "settings.general.row.font.description": "Kod bloklarında kullanılan yazı tipini özelleştirin", "settings.general.row.terminalFont.title": "Terminal yazı tipi", "settings.general.row.terminalFont.description": "Terminalde kullanılan yazı tipini özelleştirin", + "settings.general.row.backgroundImage.title": "Background image", + "settings.general.row.backgroundImage.description": "Choose an image for the desktop background.", + "settings.general.row.backgroundImage.choose": "Choose image", + "settings.general.row.backgroundImage.remove": "Remove", "settings.general.row.uiFont.title": "Arayüz Yazı Tipi", "settings.general.row.uiFont.description": "Arayüz genelinde kullanılan yazı tipini özelleştirin", "settings.general.row.followup.title": "Takip davranışı", diff --git a/packages/app/src/i18n/uk.ts b/packages/app/src/i18n/uk.ts index 61baf625b9..4c35ca9c39 100644 --- a/packages/app/src/i18n/uk.ts +++ b/packages/app/src/i18n/uk.ts @@ -870,6 +870,10 @@ export const dict = { "settings.general.row.font.description": "Налаштуйте шрифт, який використовується в блоках коду", "settings.general.row.terminalFont.title": "Шрифт термінала", "settings.general.row.terminalFont.description": "Налаштуйте шрифт, який використовується в терміналі", + "settings.general.row.backgroundImage.title": "Background image", + "settings.general.row.backgroundImage.description": "Choose an image for the desktop background.", + "settings.general.row.backgroundImage.choose": "Choose image", + "settings.general.row.backgroundImage.remove": "Remove", "settings.general.row.uiFont.title": "Шрифт інтерфейсу", "settings.general.row.uiFont.description": "Налаштуйте шрифт, який використовується в інтерфейсі", "settings.general.row.followup.title": "Поведінка продовження", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index b6342e22d3..84eeae9843 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -768,6 +768,10 @@ export const dict = { "settings.general.row.font.description": "自定义代码块使用的字体", "settings.general.row.terminalFont.title": "终端字体", "settings.general.row.terminalFont.description": "自定义终端使用的字体", + "settings.general.row.backgroundImage.title": "Background image", + "settings.general.row.backgroundImage.description": "Choose an image for the desktop background.", + "settings.general.row.backgroundImage.choose": "Choose image", + "settings.general.row.backgroundImage.remove": "Remove", "settings.general.row.uiFont.title": "界面字体", "settings.general.row.uiFont.description": "自定义整个界面使用的字体", "settings.general.row.followup.title": "跟进消息行为", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 7195a5264d..5e5be498bb 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -763,6 +763,10 @@ export const dict = { "settings.general.row.font.description": "自訂程式碼區塊使用的字型", "settings.general.row.terminalFont.title": "終端機字型", "settings.general.row.terminalFont.description": "自訂終端機使用的字型", + "settings.general.row.backgroundImage.title": "Background image", + "settings.general.row.backgroundImage.description": "Choose an image for the desktop background.", + "settings.general.row.backgroundImage.choose": "Choose image", + "settings.general.row.backgroundImage.remove": "Remove", "settings.general.row.uiFont.title": "介面字型", "settings.general.row.uiFont.description": "自訂整個介面使用的字型", "settings.general.row.followup.title": "後續追問行為", diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 278032a4ca..7875a00396 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -598,7 +598,10 @@ export function NewHome() { } return ( -
+
+
{autoselecting() ?? ""} @@ -2344,6 +2347,7 @@ export default function LegacyLayout(props: ParentProps) { }} >
) { function SessionPanelFrame(props: ParentProps<{ newLayout: boolean; raised?: boolean }>) { return (
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) { diff --git a/packages/desktop/src/main/background-image.test.ts b/packages/desktop/src/main/background-image.test.ts new file mode 100644 index 0000000000..1f892df56f --- /dev/null +++ b/packages/desktop/src/main/background-image.test.ts @@ -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, "") + + 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") +}) diff --git a/packages/desktop/src/main/background-image.ts b/packages/desktop/src/main/background-image.ts new file mode 100644 index 0000000000..32076dc151 --- /dev/null +++ b/packages/desktop/src/main/background-image.ts @@ -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 }))) +} diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 1103401c49..d9c6e30b29 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -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), diff --git a/packages/desktop/src/main/windows.ts b/packages/desktop/src/main/windows.ts index 693690c9c4..61ce34f195 100644 --- a/packages/desktop/src/main/windows.ts +++ b/packages/desktop/src/main/windows.ts @@ -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)) { diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index d14dd0d00a..e41ae550a8 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -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[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), } diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index da10f7008d..350b8d6282 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -41,6 +41,10 @@ export type FatalRendererError = { os?: string } +export type BackgroundImage = { + revision: string +} + export type ElectronAPI = { killSidecar: () => Promise installCli: () => Promise @@ -103,6 +107,10 @@ export type ElectronAPI = { setTitlebar: (theme: TitlebarTheme) => Promise runDesktopMenuAction: (action: DesktopMenuAction) => Promise setBackgroundColor: (color: string) => Promise + loadBackgroundImage: () => Promise + selectBackgroundImage: () => Promise + clearBackgroundImage: () => Promise + onBackgroundImageChanged: (cb: (image: BackgroundImage | null) => void) => () => void exportDebugLogs: () => Promise recordFatalRendererError: (error: FatalRendererError) => Promise } diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index 13c122cb2e..e178173d01 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -112,6 +112,21 @@ function DesktopMemoryRouter(props: BaseRouterProps & { windowID: string }) { const createPlatform = (windowState: DesktopWindowState): Platform => { const attachmentPaths = new WeakMap() + const [backgroundImage, setBackgroundImage] = createSignal(false) + const applyBackgroundImage = (image: Awaited>) => { + 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") diff --git a/packages/desktop/src/renderer/styles.css b/packages/desktop/src/renderer/styles.css index e69de29bb2..0b485ed0f3 100644 --- a/packages/desktop/src/renderer/styles.css +++ b/packages/desktop/src/renderer/styles.css @@ -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; +}