Skip to content
Closed
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
2 changes: 2 additions & 0 deletions apps/desktop/src/main/ipc-methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ export const IPC_METHODS = [
'project.remove',
'project.move',
'dialog.pickFolder',
'dialog.pickImages',
'wallpaper.read',
'shell.revealPath',
'shell.openUrl',
'files.index',
Expand Down
76 changes: 75 additions & 1 deletion apps/desktop/src/main/rpc.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { open, readFile, readdir, stat } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join, resolve } from 'node:path'
import { extname, join, resolve } from 'node:path'
import { app, BrowserWindow, dialog, ipcMain, shell, type WebContents } from 'electron'
import type { IPty, IPtyForkOptions } from '@lydell/node-pty'
import type { JournalEvent } from '@ari/contracts/events'
Expand Down Expand Up @@ -99,6 +99,26 @@ import { todoFilenameFor } from '@ari/ari-core/todo'

const log = createLogger('desktop:rpc')

/** Image types the custom-background picker offers and `wallpaper.read` serves. */
const WALLPAPER_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'avif'] as const

const WALLPAPER_MIME_TYPES: Readonly<Record<string, string>> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
'.gif': 'image/gif',
'.avif': 'image/avif',
}

/** Ceiling on a background file; it is inlined into the renderer as a data URL. */
const MAX_WALLPAPER_BYTES = 24 * 1024 * 1024

/** Data-URL mime for a background path, or null when it is not a served type. */
function wallpaperMimeType(path: string): string | null {
return WALLPAPER_MIME_TYPES[extname(path).toLowerCase()] ?? null
}

/**
* Kinds whose ACP server is probed for the agent's own model list. Adapters
* resolve through npx and are probed with `--no-install` so background
Expand Down Expand Up @@ -1178,6 +1198,60 @@ export function registerRpc(contents: WebContents, options: RegisterRpcOptions =
return { path: result.filePaths[0] ?? null }
})

r.register('dialog.pickImages', async () => {
const parent = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0] ?? null
const options: Electron.OpenDialogOptions = {
properties: ['openFile', 'multiSelections'],
filters: [{ name: 'Images', extensions: [...WALLPAPER_EXTENSIONS] }],
}
const result = parent
? await dialog.showOpenDialog(parent, options)
: await dialog.showOpenDialog(options)
if (result.canceled) return { paths: [] }
// The dialog filter is a hint the user can defeat by typing a filename, so
// the type is checked again, and so is the size: `wallpaper.read` refuses
// anything over MAX_WALLPAPER_BYTES, and a path saved here that can never
// load would sit in the library as a blank background. The reader keeps
// its own checks for files that change after they were picked.
const accepted: string[] = []
for (const path of result.filePaths) {
if (wallpaperMimeType(path) === null) continue
try {
const info = await stat(path)
if (info.isFile() && info.size <= MAX_WALLPAPER_BYTES) accepted.push(path)
} catch {
// Gone between the dialog closing and now: nothing to save.
}
}
return { paths: accepted }
})

// Custom backgrounds live outside every project root, so the path jail does
// not apply here. The stored list is the allowlist instead: a path the user
// has not picked through the dialog above is never opened, so this cannot
// become a "read any file on disk" primitive for the renderer.
r.register('wallpaper.read', async (params) => {
const mimeType = wallpaperMimeType(params.path)
if (mimeType === null) return { dataUrl: null }
const store = getSettingsStore()
await store.load()
if (!store.current.appearance.customWallpapers.includes(params.path)) {
log.warn('refused a background read outside the saved list')
return { dataUrl: null }
}
try {
const info = await stat(params.path)
// A data URL costs roughly 4/3 the file size in renderer memory, so an
// enormous image is refused rather than silently wedging the window.
if (!info.isFile() || info.size > MAX_WALLPAPER_BYTES) return { dataUrl: null }
const bytes = await readFile(params.path)
return { dataUrl: `data:${mimeType};base64,${bytes.toString('base64')}` }
} catch {
// Deleted, renamed, or unreadable: the renderer falls back to the theme.
return { dataUrl: null }
}
})

r.register('shell.revealPath', async (params) => {
shell.showItemInFolder(await jailPath(params.path))
return { revealed: true }
Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/src/renderer/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Mock } from 'vitest'
import { fireEvent } from '@testing-library/react'
import { useToast } from '@ari/ui/toast'
import { defaultSettings } from '@ari/contracts/settings'
import { AppProviders, App } from './App'
import { BRANCH_POLL_MS, SessionBranchChip } from './features/session/SessionBranchChip'
import { splitLayoutActions, splitLayoutSnapshot } from './features/split/use-split-layout'
Expand All @@ -19,6 +20,17 @@ function ToastProbe() {
}

describe('AppProviders', () => {
beforeEach(() => {
// AppProviders mounts headless watchers that read the engine: the bare
// `vi.fn()` below answers every method with undefined, which no caller
// can await.
invokeMock.mockReset()
invokeMock.mockImplementation(async (method) => {
if (method === 'settings.get') return { appearance: defaultSettings.appearance }
return undefined
})
})

it('lets useToast consumers fire without a wrapping gallery', async () => {
const user = userEvent.setup()
render(
Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import {
useSidebarWidth,
} from './shell/use-pane-width'
import { WelcomePanel } from './features/welcome'
import { useCustomBackground } from './features/appearance/useCustomBackground'
import './features/transcript/transcript.css'

type InspectorId = Exclude<SidebarNavId, 'session' | 'settings'>
Expand Down Expand Up @@ -1064,6 +1065,7 @@ export function AppProviders({ children }: { children: React.ReactNode }) {
<MotionProvider>
<ToastProvider>
<UpdateToastWatcher />
<BackgroundWatcher />
{children}
</ToastProvider>
</MotionProvider>
Expand All @@ -1078,6 +1080,16 @@ function UpdateToastWatcher() {
return null
}

/**
* Headless: paints the user's own background and rotates it. Mounted here
* rather than in the settings screen because rotation has to keep running
* while the user is working in a session.
*/
function BackgroundWatcher() {
useCustomBackground()
return null
}

const log = createLogger('app:shell')

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, expect, it } from 'vitest'
import {
CLARITY_VAR_NAMES,
MS_PER_MINUTE,
clampRotationIndex,
clarityVars,
nextRotationIndex,
rotationIntervalMs,
} from './custom-background'

/** Percentage/pixel number out of a `34%` / `6px` custom-property value. */
function amount(value: string): number {
return Number(value.replace(/[^0-9.]/g, ''))
}

describe('clarityVars', () => {
/** The plate properties, which thin out as clarity rises. */
const PLATE_VARS = CLARITY_VAR_NAMES.filter((name) => !name.includes('halo'))

it('leaves the original frosted look at clarity 0', () => {
const vars = clarityVars(0)
expect(vars['--ari-wallpaper-tint']).toBe('72%')
expect(vars['--ari-wallpaper-tint-overlay']).toBe('82%')
expect(vars['--ari-wallpaper-tint-input']).toBe('85%')
// The same values wallpaper.css falls back to, so 0 is a no-op.
expect(vars['--ari-wallpaper-blur']).toBe('28px')
// The plate alone keeps text legible here, so no halo behind it.
expect(vars['--ari-wallpaper-halo']).toBe('0%')
expect(vars['--ari-wallpaper-halo-offset']).toBe('0px')
expect(vars['--ari-wallpaper-halo-blur']).toBe('0px')
})

it('thins tint and blur as clarity rises', () => {
const dim = clarityVars(0)
const mid = clarityVars(0.5)
const clear = clarityVars(1)
for (const name of PLATE_VARS) {
expect(amount(mid[name] ?? '')).toBeLessThan(amount(dim[name] ?? ''))
expect(amount(clear[name] ?? '')).toBeLessThan(amount(mid[name] ?? ''))
}
})

it('strengthens the text halo as the plate thins, to keep text readable', () => {
// The halo runs opposite the plate: whatever legibility the tint and blur
// stop providing, it has to take over.
const dim = clarityVars(0)
const mid = clarityVars(0.5)
const clear = clarityVars(1)
expect(amount(mid['--ari-wallpaper-halo'] ?? '')).toBeGreaterThan(
amount(dim['--ari-wallpaper-halo'] ?? ''),
)
expect(amount(clear['--ari-wallpaper-halo'] ?? '')).toBeGreaterThan(
amount(mid['--ari-wallpaper-halo'] ?? ''),
)
expect(amount(clear['--ari-wallpaper-halo-blur'] ?? '')).toBeGreaterThan(0)
})

it('keeps inputs and overlays findable while the pane goes clear', () => {
const clear = clarityVars(1)
const pane = amount(clear['--ari-wallpaper-tint'] ?? '')
// The composer is a thing you aim at: it has to stay a plate on top of the
// picture, not dissolve into it along with the window behind it.
expect(amount(clear['--ari-wallpaper-tint-input'] ?? '')).toBeGreaterThan(pane + 30)
expect(amount(clear['--ari-wallpaper-tint-overlay'] ?? '')).toBeGreaterThan(pane + 30)
})

it('keeps a readable plate even at the clearest setting', () => {
const clear = clarityVars(1)
// A trace of tint and no blur failed over bright, busy photos: text was
// hard to read even outlined. The floor keeps real tint behind the text
// and enough blur to strip the fine detail directly behind the glyphs.
expect(amount(clear['--ari-wallpaper-tint'] ?? '')).toBeGreaterThanOrEqual(25)
expect(amount(clear['--ari-wallpaper-blur'] ?? '')).toBeGreaterThan(0)
})

it('clamps out-of-range and non-finite input instead of emitting junk CSS', () => {
expect(clarityVars(5)).toEqual(clarityVars(1))
expect(clarityVars(-2)).toEqual(clarityVars(0))
expect(clarityVars(Number.NaN)).toEqual(clarityVars(0))
})
})

describe('rotationIntervalMs', () => {
it('converts minutes to milliseconds', () => {
expect(rotationIntervalMs(10)).toBe(10 * MS_PER_MINUTE)
})

it('never schedules a timer below one minute', () => {
// A zero here would spin the interval as fast as the event loop allows.
expect(rotationIntervalMs(0)).toBe(MS_PER_MINUTE)
expect(rotationIntervalMs(-5)).toBe(MS_PER_MINUTE)
expect(rotationIntervalMs(Number.NaN)).toBe(MS_PER_MINUTE)
})
})

describe('nextRotationIndex', () => {
it('advances and wraps', () => {
expect(nextRotationIndex(0, 3)).toBe(1)
expect(nextRotationIndex(2, 3)).toBe(0)
})

it('answers 0 with no images rather than dividing by zero', () => {
expect(nextRotationIndex(3, 0)).toBe(0)
})
})

describe('clampRotationIndex', () => {
it('leaves an index that still points at an image', () => {
// Adding a picture must not move the one on screen.
expect(clampRotationIndex(2, 5)).toBe(2)
})

it('resets an index that fell off the end', () => {
expect(clampRotationIndex(4, 2)).toBe(0)
expect(clampRotationIndex(0, 0)).toBe(0)
expect(clampRotationIndex(-1, 3)).toBe(0)
})
})
106 changes: 106 additions & 0 deletions apps/desktop/src/renderer/src/features/appearance/custom-background.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* Custom background math, kept out of the hook so it can be tested without a
* DOM: how clear the scene reads, and which image rotation is due.
*/

/** The frosted end of the clarity range — exactly wallpaper.css's fallbacks. */
const OPAQUE = { tint: 72, overlay: 82, input: 85, blur: 28 }

/**
* The clear end, and a floor on legibility rather than a picture with text
* scattered over it.
*
* A trace of tint and no blur looked striking and failed at the one job the
* window has: over a bright or busy photo, text was hard to read even with an
* outline. So the clearest setting still keeps about a third of the plate —
* enough to pull any photo toward the theme's own tone behind the text — and a
* light blur, which strips the fine detail sitting directly behind glyphs (the
* thing that actually fights reading) while the picture stays recognisable.
*
* Inputs and floating surfaces deliberately sit well above that. The composer,
* popovers and menus are things you aim at: they have to stay findable against
* any picture, and they keep their own blur from glass.css, so holding their
* tint high reads as a dark glossy plate resting on the scene.
*/
const CLEAR = { tint: 32, overlay: 70, input: 78, blur: 6 }

/**
* Text halo at each end. The plate is what keeps text legible at clarity 0, so
* there is nothing to add there; as the plate thins out, a halo in the theme's
* own background color fades in behind the text. Background, not a fixed
* black: that darkens text in light themes and lightens it in dark ones, which
* is the direction legibility actually needs in each.
*
* The halo is mostly an *outline*, not a glow. A wide soft shadow spreads out
* from the glyph and the edge goes mushy exactly where it needs to be sharp;
* a one-pixel hard offset on each side reads as a crisp contour instead. The
* small blur that remains only pads the contour so it does not look stencilled.
*/
const HALO = { opaque: 0, clear: 100 }
const HALO_OFFSET = { opaque: 0, clear: 1 }
const HALO_BLUR = { opaque: 0, clear: 3 }

/** CSS custom properties wallpaper.css reads, for a clarity in [0, 1]. */
export function clarityVars(clarity: number): Record<string, string> {
const t = Math.min(1, Math.max(0, Number.isFinite(clarity) ? clarity : 0))
const between = (from: number, to: number): number => from + (to - from) * t
return {
'--ari-wallpaper-tint': `${String(Math.round(between(OPAQUE.tint, CLEAR.tint)))}%`,
'--ari-wallpaper-tint-overlay': `${String(Math.round(between(OPAQUE.overlay, CLEAR.overlay)))}%`,
'--ari-wallpaper-tint-input': `${String(Math.round(between(OPAQUE.input, CLEAR.input)))}%`,
'--ari-wallpaper-blur': `${String(Math.round(between(OPAQUE.blur, CLEAR.blur)))}px`,
'--ari-wallpaper-halo': `${String(Math.round(between(HALO.opaque, HALO.clear)))}%`,
'--ari-wallpaper-halo-offset': `${String(Math.round(between(HALO_OFFSET.opaque, HALO_OFFSET.clear)))}px`,
'--ari-wallpaper-halo-blur': `${String(Math.round(between(HALO_BLUR.opaque, HALO_BLUR.clear)))}px`,
}
}

/** Every property `clarityVars` sets, for clearing them again. */
export const CLARITY_VAR_NAMES = Object.keys(clarityVars(0))

export const MS_PER_MINUTE = 60_000

/** Rotation period in ms; at least one minute however the setting is stored. */
export function rotationIntervalMs(minutes: number): number {
const safe = Number.isFinite(minutes) ? Math.floor(minutes) : 1
return Math.max(1, safe) * MS_PER_MINUTE
}

/**
* Index of the image to show next. Wraps, and answers 0 for an empty library
* so a caller that just lost its last image still has a defined index.
*/
export function nextRotationIndex(current: number, count: number): number {
if (count <= 0) return 0
return (current + 1) % count
}

/**
* Keeps an index pointing at a real image after the library changes. Picking
* images is not rotation, so the shown image should not jump when the user
* adds one — only an index that fell off the end moves.
*/
export function clampRotationIndex(index: number, count: number): number {
if (count <= 0) return 0
if (!Number.isFinite(index) || index < 0) return 0
return index >= count ? 0 : Math.floor(index)
}

/**
* The pictures worth holding decoded: the one on screen and, while rotating,
* the one due next, so the change lands without a blank frame. Never more than
* two whatever the library size — memory stays flat as pictures are added,
* where caching every picture visited grew with the library.
*/
export function retainedPaths(
images: readonly string[],
index: number,
rotating: boolean,
): string[] {
const at = clampRotationIndex(index, images.length)
const current = images[at]
if (current === undefined) return []
if (!rotating) return [current]
const next = images[nextRotationIndex(at, images.length)]
return next === undefined || next === current ? [current] : [current, next]
}
Loading