Skip to content
Draft
Show file tree
Hide file tree
Changes from 8 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
10 changes: 10 additions & 0 deletions apps/desktop/src/main/database/drizzle-data/0035_custom_themes.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
CREATE TABLE `custom_themes` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`slug` text NOT NULL,
`base` text NOT NULL,
`variables` text NOT NULL,
`clock` text,
`created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL,
`modified_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL
);
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,13 @@
"when": 1783206622572,
"tag": "0034_tag_nocase",
"breakpoints": true
},
{
"idx": 35,
"version": "6",
"when": 1783634400000,
"tag": "0035_custom_themes",
"breakpoints": true
}
]
}
16 changes: 10 additions & 6 deletions apps/desktop/src/main/ipc/generated-ipc-invoke-map.ts

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions apps/desktop/src/main/ipc/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const hoisted = vi.hoisted(() => ({
unregisterTasksHandlers: vi.fn(),
registerSavedFiltersHandlers: vi.fn(),
unregisterSavedFiltersHandlers: vi.fn(),
registerThemesHandlers: vi.fn(),
unregisterThemesHandlers: vi.fn(),
registerTemplatesHandlers: vi.fn(),
unregisterTemplatesHandlers: vi.fn(),
registerJournalHandlers: vi.fn(),
Expand Down Expand Up @@ -73,6 +75,10 @@ vi.mock('./saved-filters-handlers', () => ({
registerSavedFiltersHandlers: hoisted.registerSavedFiltersHandlers,
unregisterSavedFiltersHandlers: hoisted.unregisterSavedFiltersHandlers
}))
vi.mock('./themes-handlers', () => ({
registerThemesHandlers: hoisted.registerThemesHandlers,
unregisterThemesHandlers: hoisted.unregisterThemesHandlers
}))
vi.mock('./templates-handlers', () => ({
registerTemplatesHandlers: hoisted.registerTemplatesHandlers,
unregisterTemplatesHandlers: hoisted.unregisterTemplatesHandlers
Expand Down Expand Up @@ -188,6 +194,7 @@ describe('ipc index registration lifecycle', () => {
expect(hoisted.registerFeedbackHandlers).toHaveBeenCalledTimes(1)
expect(hoisted.registerAgentMcpHandlers).toHaveBeenCalledTimes(1)
expect(hoisted.registerImportHandlers).toHaveBeenCalledTimes(1)
expect(hoisted.registerThemesHandlers).toHaveBeenCalledTimes(1)
})

it('prevents duplicate registration', () => {
Expand All @@ -213,6 +220,7 @@ describe('ipc index registration lifecycle', () => {
expect(hoisted.unregisterUpdaterHandlers).toHaveBeenCalledTimes(1)
expect(hoisted.unregisterAgentMcpHandlers).toHaveBeenCalledTimes(1)
expect(hoisted.unregisterImportHandlers).toHaveBeenCalledTimes(1)
expect(hoisted.unregisterThemesHandlers).toHaveBeenCalledTimes(1)
})

it('is a no-op to unregister when handlers are not registered', () => {
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/main/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
registerSavedFiltersHandlers,
unregisterSavedFiltersHandlers
} from './saved-filters-handlers'
import { registerThemesHandlers, unregisterThemesHandlers } from './themes-handlers'
import { registerTemplatesHandlers, unregisterTemplatesHandlers } from './templates-handlers'
import { registerJournalHandlers, unregisterJournalHandlers } from './journal-handlers'
import { registerSettingsHandlers, unregisterSettingsHandlers } from './settings-handlers'
Expand Down Expand Up @@ -77,6 +78,7 @@ export function registerAllHandlers(deps?: IpcDeps): void {

// Register saved filters handlers
registerSavedFiltersHandlers()
registerThemesHandlers()

// Register templates handlers
registerTemplatesHandlers()
Expand Down Expand Up @@ -169,6 +171,7 @@ export function unregisterAllHandlers(): void {
unregisterNotesHandlers()
unregisterTasksHandlers()
unregisterSavedFiltersHandlers()
unregisterThemesHandlers()
unregisterTemplatesHandlers()
unregisterJournalHandlers()
unregisterSettingsHandlers()
Expand Down
23 changes: 21 additions & 2 deletions apps/desktop/src/main/ipc/settings-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
type TerminalCommandOptions,
type TerminalCommandStatus as BaseTerminalCommandStatus
} from '../cli/terminal-command'
import { getTheme } from '../themes/theme-store'

// ============================================================================
// Settings Keys
Expand All @@ -68,6 +69,7 @@ const logger = createLogger('IPC:Settings')

const GENERAL_SYNCABLE_FIELDS: (keyof GeneralSettings)[] = [
'theme',
'customThemeId',
'fontSize',
'fontFamily',
'accentColor',
Expand Down Expand Up @@ -214,14 +216,31 @@ export function getCalendarSettings(): CalendarSettings {
return readGroupSettings('calendar', CALENDAR_SETTINGS_DEFAULTS)
}

function getStartupTheme(): { theme: GeneralSettings['theme']; accentColor?: string } {
interface StartupTheme {
theme: GeneralSettings['theme']
accentColor?: string
customTheme?: { id: string; base: string; variables: Record<string, string> }
}

function getStartupTheme(): StartupTheme {
const settings = readGroupSettings('general', GENERAL_SETTINGS_DEFAULTS)
const result: { theme: GeneralSettings['theme']; accentColor?: string } = {
const result: StartupTheme = {
theme: settings.theme
}
if (settings.accentColor) {
result.accentColor = settings.accentColor
}
if (settings.customThemeId) {
const db = getDbOrNull()
const customTheme = db ? getTheme(db, settings.customThemeId) : null
if (customTheme) {
result.customTheme = {
id: customTheme.id,
base: customTheme.base,
variables: customTheme.variables
}
}
}
return result
}

Expand Down
122 changes: 122 additions & 0 deletions apps/desktop/src/main/ipc/themes-handlers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import fs from 'fs'
import os from 'os'
import path from 'path'
import { createTestDataDb, type TestDatabaseResult } from '@tests/utils/test-db'

let vaultPath: string

vi.mock('electron', () => ({
ipcMain: {
handle: vi.fn(),
removeHandler: vi.fn()
},
BrowserWindow: {
getAllWindows: vi.fn(() => [])
}
}))

vi.mock('../database', () => ({
requireDatabase: vi.fn(() => {
throw new Error('No vault is open')
})
}))

vi.mock('../vault/index', () => ({
getStatus: () => ({ path: vaultPath })
}))

vi.mock('../sync/local-mutations', () => ({
enqueueLocalSyncCreate: vi.fn(),
enqueueLocalSyncUpdate: vi.fn(),
enqueueLocalSyncDelete: vi.fn()
}))

vi.mock('../lib/logger', () => ({
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() })
}))

import { makeThemesHandlers, registerThemesHandlers } from './themes-handlers'
import type { DataDb } from '../database'

describe('registerThemesHandlers — lazy DB resolution', () => {
it('does not throw at registration and registers all 4 channels', async () => {
const { ipcMain } = await import('electron')
const handleMock = vi.mocked(ipcMain.handle)
handleMock.mockClear()

expect(() => registerThemesHandlers()).not.toThrow()
expect(handleMock.mock.calls.length).toBe(4)
})
})

describe('themes handlers', () => {
let dbResult: TestDatabaseResult
let h: ReturnType<typeof makeThemesHandlers>

beforeEach(() => {
vaultPath = fs.mkdtempSync(path.join(os.tmpdir(), 'memry-themes-ipc-'))
dbResult = createTestDataDb()
h = makeThemesHandlers(dbResult.db as unknown as DataDb)
})

afterEach(() => {
dbResult.close()
fs.rmSync(vaultPath, { recursive: true, force: true })
})

it('creates and lists themes', () => {
const created = h.create({ name: 'Tema 1', base: 'dark' })
expect(created.success).toBe(true)
expect(created.theme?.name).toBe('Tema 1')

const themes = h.list()
expect(themes).toHaveLength(1)
expect(themes[0].base).toBe('dark')
})

it('updates a theme and returns the updated DTO', () => {
const created = h.create({ name: 'Tema 1', base: 'dark' })

const result = h.update({
id: created.theme!.id,
variables: { '--background': '#101010' }
})

expect(result.success).toBe(true)
expect(result.theme?.variables).toEqual({ '--background': '#101010' })
})

it('returns an error for updating an unknown theme', () => {
const result = h.update({ id: 'missing', name: 'X' })
expect(result.success).toBe(false)
expect(result.error).toBeTruthy()
})

it('deletes a theme', () => {
const created = h.create({ name: 'Tema 1', base: 'dark' })

expect(h.delete({ id: created.theme!.id }).success).toBe(true)
expect(h.list()).toHaveLength(0)
expect(h.delete({ id: created.theme!.id }).success).toBe(false)
})

it('list adopts orphan vault files', () => {
fs.mkdirSync(path.join(vaultPath, '.memry', 'themes'), { recursive: true })
fs.writeFileSync(
path.join(vaultPath, '.memry', 'themes', 'orphan.json'),
JSON.stringify({
id: 'orphan-id',
name: 'Orphan',
base: 'light',
variables: {},
createdAt: '2026-07-09T10:00:00.000Z',
modifiedAt: '2026-07-09T10:00:00.000Z'
}),
'utf-8'
)

const themes = h.list()
expect(themes.map((t) => t.id)).toContain('orphan-id')
})
})
103 changes: 103 additions & 0 deletions apps/desktop/src/main/ipc/themes-handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* Custom theme IPC handlers.
*
* @module ipc/themes-handlers
*/

import { ipcMain, BrowserWindow } from 'electron'
import { ThemesChannels } from '@memry/contracts/ipc-channels'
import {
CreateThemeInputSchema,
DeleteThemePayloadSchema,
UpdateThemePayloadSchema,
type CreateThemeInput,
type CustomTheme,
type DeleteThemePayload,
type ThemeDeleteResult,
type ThemeMutationResult,
type UpdateThemePayload
} from '@memry/contracts/themes-api'
import { createHandler, createValidatedHandler } from './validate'
import { requireDatabase } from '../database'
import {
adoptThemeFiles,
createTheme,
deleteTheme,
listThemes,
updateTheme
} from '../themes/theme-store'
import type { DataDb } from '../database'

function emitThemeEvent(channel: string, data: unknown): void {
BrowserWindow.getAllWindows().forEach((win) => {
win.webContents.send(channel, data)
})
}

export function makeThemesHandlers(db: DataDb): {
list: () => CustomTheme[]
create: (input: CreateThemeInput) => ThemeMutationResult
update: (input: UpdateThemePayload) => ThemeMutationResult
delete: (input: DeleteThemePayload) => ThemeDeleteResult
} {
return {
list: () => {
adoptThemeFiles(db)
return listThemes(db)
},
create: (input) => {
const theme = createTheme(db, input)
emitThemeEvent(ThemesChannels.events.CREATED, { id: theme.id })
return { success: true, theme }
},
update: ({ id, ...updates }) => {
const theme = updateTheme(db, id, updates)
if (!theme) {
return { success: false, error: 'Theme not found' }
}
emitThemeEvent(ThemesChannels.events.UPDATED, { id })
return { success: true, theme }
},
delete: ({ id }) => {
if (!deleteTheme(db, id)) {
return { success: false, error: 'Theme not found' }
}
emitThemeEvent(ThemesChannels.events.DELETED, { id })
return { success: true }
}
}
}

export function registerThemesHandlers(): void {
ipcMain.handle(
ThemesChannels.invoke.LIST,
createHandler(() => makeThemesHandlers(requireDatabase()).list())
)

ipcMain.handle(
ThemesChannels.invoke.CREATE,
createValidatedHandler(CreateThemeInputSchema, (input) =>
makeThemesHandlers(requireDatabase()).create(input)
)
)

ipcMain.handle(
ThemesChannels.invoke.UPDATE,
createValidatedHandler(UpdateThemePayloadSchema, (input) =>
makeThemesHandlers(requireDatabase()).update(input)
)
)

ipcMain.handle(
ThemesChannels.invoke.DELETE,
createValidatedHandler(DeleteThemePayloadSchema, (input) =>
makeThemesHandlers(requireDatabase()).delete(input)
)
)
}

export function unregisterThemesHandlers(): void {
for (const channel of Object.values(ThemesChannels.invoke)) {
ipcMain.removeHandler(channel)
}
}
2 changes: 2 additions & 0 deletions apps/desktop/src/main/sync/item-handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { noteHandler } from './note-handler'
import { journalHandler } from './journal-handler'
import { tagDefinitionHandler } from './tag-definition-handler'
import { folderConfigHandler } from './folder-config-handler'
import { themeHandler } from './theme-handler'
import { calendarEventHandler } from './calendar-event-handler'
import { calendarSourceHandler } from './calendar-source-handler'
import { calendarBindingHandler } from './calendar-binding-handler'
Expand All @@ -31,6 +32,7 @@ const handlers = new Map<SyncItemType, SyncItemHandler>([
['journal', journalHandler],
['tag_definition', tagDefinitionHandler],
['folder_config', folderConfigHandler],
['theme', themeHandler],
['calendar_event', calendarEventHandler],
['calendar_source', calendarSourceHandler],
['calendar_binding', calendarBindingHandler],
Expand Down
Loading
Loading