diff --git a/apps/desktop/README.md b/apps/desktop/README.md index a97382e2..f8947acd 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -2,11 +2,21 @@ Memoh desktop application built with [electron-vite](https://electron-vite.github.io/). -The renderer is intentionally a thin shell — its `main.ts` imports `@memohai/web`'s own -bootstrap (router / Pinia / api-client / `App.vue`) so the desktop app runs the same -experience as the web app out of the box. Future desktop-only customization should -happen in this package (by replacing or composing parts of the `@memohai/web` surface), -not by forking the web app. +The renderer owns its own `src/renderer/src/main.ts` — it imports the reusable +building blocks from `@memohai/web` (`App.vue`, `router`, `i18n`, `api-client`, +`style.css`) but assembles the Vue app locally. Desktop-only customization (extra +Pinia plugins, Electron-specific stores / providers, alternate routers, etc.) +belongs in this `main.ts`, not in `@memohai/web`. + +### How the reuse is wired + +- `@memohai/web/package.json` exposes `App.vue`, `router`, `i18n`, `api-client`, + and `style.css` through its `exports` field. +- Vite (via `electron.vite.config.ts`) resolves those subpaths to the real + files in `apps/web/src/` at bundle time. +- `vue-tsc` is pointed at local type stubs in `src/renderer/types/web-stubs.d.ts` + via tsconfig `paths`, so desktop's typecheck does **not** descend into + `apps/web/src/` or `packages/ui/src/` (those have their own CI). ## Development @@ -28,3 +38,24 @@ pnpm --filter @memohai/desktop build:dir # unpacked app dir (CI smoke test ``` Output goes to `apps/desktop/dist/`. + +## Icons + +All app icons are generated from `apps/web/public/logo.svg` (the brand mark) by +`scripts/build-icons.mjs`. Re-run after the logo changes: + +```bash +pnpm --filter @memohai/desktop icons +``` + +Outputs: + +| File | Purpose | +|---|---| +| `build/icon.icns` | macOS bundle icon (16…1024 + @2x) — packaged into `.app/Contents/Resources/` | +| `build/icon.ico` | Windows installer / `.exe` icon (16/24/32/48/64/128/256) | +| `build/icon.png` | Linux `.deb`/`.rpm`/`.AppImage` icon (1024×1024) | +| `resources/icon.png` | Runtime `BrowserWindow.icon` + macOS dev `app.dock.setIcon` (512×512); bundled via `asarUnpack` | + +`build/icon.icns` requires macOS (`iconutil`); the script skips it on other +platforms. diff --git a/apps/desktop/build/icon.icns b/apps/desktop/build/icon.icns index 4e67767b..b8f231bf 100644 Binary files a/apps/desktop/build/icon.icns and b/apps/desktop/build/icon.icns differ diff --git a/apps/desktop/build/icon.ico b/apps/desktop/build/icon.ico index 81f89e89..6dff21ae 100644 Binary files a/apps/desktop/build/icon.ico and b/apps/desktop/build/icon.ico differ diff --git a/apps/desktop/build/icon.png b/apps/desktop/build/icon.png index e0f69d43..2e864a5a 100644 Binary files a/apps/desktop/build/icon.png and b/apps/desktop/build/icon.png differ diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index b64e3e87..d946d1d6 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -78,6 +78,7 @@ export default defineConfig(({ command }) => { rollupOptions: { input: { index: resolve(__dirname, 'src/renderer/index.html'), + settings: resolve(__dirname, 'src/renderer/settings.html'), }, }, }, diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 24138fd4..f0a3b909 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -3,7 +3,7 @@ "private": true, "version": "0.7.1", "type": "module", - "description": "Memoh Electron desktop application (thin shell reusing @memohai/web)", + "description": "Memoh Electron desktop application (self-managed bootstrap reusing @memohai/web components)", "main": "./out/main/index.js", "scripts": { "dev": "electron-vite dev", @@ -16,7 +16,8 @@ "build:win": "electron-vite build && electron-builder --win", "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", "typecheck:web": "vue-tsc --noEmit -p tsconfig.web.json --composite false", - "typecheck": "pnpm run typecheck:node && pnpm run typecheck:web" + "typecheck": "pnpm run typecheck:node && pnpm run typecheck:web", + "icons": "node scripts/build-icons.mjs" }, "dependencies": { "@electron-toolkit/preload": "^3.0.1", @@ -29,15 +30,27 @@ "devDependencies": { "@electron-toolkit/tsconfig": "^1.0.1", "@memohai/config": "workspace:*", + "@pinia/colada": "^0.21.2", "@tailwindcss/vite": "^4.2.2", "@types/node": "^24.10.1", "@vitejs/plugin-vue": "^6.0.5", + "@vue/tsconfig": "^0.8.1", + "animate.css": "^4.1.1", "electron": "^34.5.0", "electron-builder": "^26.0.12", "electron-vite": "^4.0.0", + "katex": "^0.16.37", + "markstream-vue": "0.0.7-beta.2", + "pinia": "^3.0.4", + "pinia-plugin-persistedstate": "^4.7.1", + "png-to-ico": "^3.0.1", + "sharp": "^0.34.5", "typescript": "~5.9.3", "vite": "^8.0.1", "vue": "^3.5.24", + "vue-i18n": "^11.2.8", + "vue-router": "^4.6.4", + "vue-sonner": "^2.0.9", "vue-tsc": "^3.1.4" } } diff --git a/apps/desktop/resources/icon.png b/apps/desktop/resources/icon.png new file mode 100644 index 00000000..030c6919 Binary files /dev/null and b/apps/desktop/resources/icon.png differ diff --git a/apps/desktop/scripts/build-icons.mjs b/apps/desktop/scripts/build-icons.mjs new file mode 100644 index 00000000..bf42bb93 --- /dev/null +++ b/apps/desktop/scripts/build-icons.mjs @@ -0,0 +1,141 @@ +#!/usr/bin/env node +// Regenerate every desktop icon asset from apps/web/public/logo.svg. +// +// Outputs: +// build/icon.png 1024x1024 (electron-builder linux + canonical raster) +// build/icon.icns multi-resolution macOS bundle icon +// build/icon.ico multi-resolution Windows icon +// resources/icon.png 512x512 runtime BrowserWindow.icon / dock.setIcon +// +// Run: pnpm --filter @memohai/desktop icons +// +// The brand logo is non-square (~1.135:1); we render it centered onto a +// transparent square canvas with PADDING_RATIO of headroom on each side so +// macOS Big Sur+ squircle masks and Windows 1px borders don't clip the mark. + +import { execFile } from 'node:child_process' +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' + +import pngToIco from 'png-to-ico' +import sharp from 'sharp' + +const execFileAsync = promisify(execFile) + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(__dirname, '..') +const SRC_SVG = resolve(ROOT, '../web/public/logo.svg') +const BUILD_DIR = resolve(ROOT, 'build') +const RESOURCES_DIR = resolve(ROOT, 'resources') + +const MASTER_SIZE = 1024 +// 14% padding on each side ≈ matches Apple's ~14% safe area for icon glyphs. +const PADDING_RATIO = 0.14 + +const ICONSET_SIZES = [ + { name: 'icon_16x16.png', size: 16 }, + { name: 'icon_16x16@2x.png', size: 32 }, + { name: 'icon_32x32.png', size: 32 }, + { name: 'icon_32x32@2x.png', size: 64 }, + { name: 'icon_128x128.png', size: 128 }, + { name: 'icon_128x128@2x.png', size: 256 }, + { name: 'icon_256x256.png', size: 256 }, + { name: 'icon_256x256@2x.png', size: 512 }, + { name: 'icon_512x512.png', size: 512 }, + { name: 'icon_512x512@2x.png', size: 1024 }, +] + +const ICO_SIZES = [16, 24, 32, 48, 64, 128, 256] + +async function renderMaster() { + const svg = await readFile(SRC_SVG) + // Derive `inset` from `margin` (not the other way) to guarantee + // inset + 2*margin === MASTER_SIZE exactly, avoiding off-by-one. + const margin = Math.floor(MASTER_SIZE * PADDING_RATIO) + const inset = MASTER_SIZE - margin * 2 + + const inner = await sharp(svg, { density: 600 }) + .resize(inset, inset, { + fit: 'contain', + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }) + .png() + .toBuffer() + + return sharp(inner) + .extend({ + top: margin, + bottom: margin, + left: margin, + right: margin, + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }) + .png() + .toBuffer() +} + +async function writeResized(master, size, outPath) { + await sharp(master).resize(size, size, { fit: 'contain' }).png().toFile(outPath) +} + +async function buildIcns(master) { + const iconset = resolve(BUILD_DIR, 'icon.iconset') + await rm(iconset, { recursive: true, force: true }) + await mkdir(iconset, { recursive: true }) + + await Promise.all( + ICONSET_SIZES.map(({ name, size }) => + writeResized(master, size, resolve(iconset, name)), + ), + ) + + await execFileAsync('iconutil', [ + '-c', 'icns', + iconset, + '-o', resolve(BUILD_DIR, 'icon.icns'), + ]) + await rm(iconset, { recursive: true, force: true }) +} + +async function buildIco(master) { + const buffers = await Promise.all( + ICO_SIZES.map(size => + sharp(master).resize(size, size, { fit: 'contain' }).png().toBuffer(), + ), + ) + const ico = await pngToIco(buffers) + await writeFile(resolve(BUILD_DIR, 'icon.ico'), ico) +} + +async function main() { + await mkdir(BUILD_DIR, { recursive: true }) + await mkdir(RESOURCES_DIR, { recursive: true }) + + console.log(`source: ${SRC_SVG}`) + const master = await renderMaster() + + await Promise.all([ + sharp(master).png().toFile(resolve(BUILD_DIR, 'icon.png')), + sharp(master).resize(512, 512, { fit: 'contain' }).png() + .toFile(resolve(RESOURCES_DIR, 'icon.png')), + ]) + console.log(' -> build/icon.png (1024)') + console.log(' -> resources/icon.png (512)') + + if (process.platform === 'darwin') { + await buildIcns(master) + console.log(' -> build/icon.icns (16…1024 + @2x)') + } else { + console.warn(' ! skipping icon.icns: iconutil only available on macOS') + } + + await buildIco(master) + console.log(` -> build/icon.ico (${ICO_SIZES.join('/')})`) +} + +main().catch(err => { + console.error(err) + process.exit(1) +}) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 25bbab37..c672b7de 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1,23 +1,47 @@ -import { app, shell, BrowserWindow } from 'electron' +import { app, shell, BrowserWindow, ipcMain } from 'electron' import { join } from 'node:path' import { electronApp, optimizer, is } from '@electron-toolkit/utils' +import iconPng from '../../resources/icon.png?asset' -const DEFAULT_WIDTH = 1280 -const DEFAULT_HEIGHT = 800 -const MIN_WIDTH = 960 -const MIN_HEIGHT = 600 +const CHAT_DEFAULTS = { width: 1280, height: 800, minWidth: 960, minHeight: 600 } +const SETTINGS_DEFAULTS = { width: 1080, height: 720, minWidth: 880, minHeight: 560 } -function createWindow(): BrowserWindow { +type WindowKind = 'chat' | 'settings' + +let chatWindow: BrowserWindow | null = null +let settingsWindow: BrowserWindow | null = null + +function applyExternalLinkHandler(window: BrowserWindow): void { + window.webContents.setWindowOpenHandler(({ url }) => { + shell.openExternal(url) + return { action: 'deny' } + }) +} + +function loadRendererEntry(window: BrowserWindow, entry: 'index' | 'settings'): void { + const base = process.env.ELECTRON_RENDERER_URL + if (is.dev && base) { + window.loadURL(`${base}/${entry}.html`) + return + } + window.loadFile(join(__dirname, `../renderer/${entry}.html`)) +} + +// `electron-vite` emits the preload bundle as `index.mjs` because the +// package is ESM (`"type": "module"`). Electron silently no-ops if this +// path doesn't exist — keeping the file name in sync with the build +// output is what wires the IPC bridge into the renderer. +const PRELOAD_FILE = '../preload/index.mjs' + +function createChatWindow(): BrowserWindow { const window = new BrowserWindow({ - width: DEFAULT_WIDTH, - height: DEFAULT_HEIGHT, - minWidth: MIN_WIDTH, - minHeight: MIN_HEIGHT, + ...CHAT_DEFAULTS, show: false, autoHideMenuBar: true, title: 'Memoh', + icon: iconPng, webPreferences: { - preload: join(__dirname, '../preload/index.js'), + preload: join(__dirname, PRELOAD_FILE), sandbox: false, contextIsolation: true, nodeIntegration: false, @@ -26,34 +50,88 @@ function createWindow(): BrowserWindow { window.on('ready-to-show', () => { window.show() - if (is.dev) window.webContents.openDevTools({ mode: 'detach' }) + }) + window.on('closed', () => { + chatWindow = null }) - window.webContents.setWindowOpenHandler(({ url }) => { - shell.openExternal(url) - return { action: 'deny' } - }) - - if (is.dev && process.env.ELECTRON_RENDERER_URL) { - window.loadURL(process.env.ELECTRON_RENDERER_URL) - } else { - window.loadFile(join(__dirname, '../renderer/index.html')) - } - + applyExternalLinkHandler(window) + loadRendererEntry(window, 'index') return window } +function createSettingsWindow(parent: BrowserWindow | null): BrowserWindow { + const window = new BrowserWindow({ + ...SETTINGS_DEFAULTS, + show: false, + autoHideMenuBar: true, + title: 'Memoh · Settings', + icon: iconPng, + parent: parent ?? undefined, + // Not modal — user can still interact with the chat window while settings is open. + modal: false, + webPreferences: { + preload: join(__dirname, PRELOAD_FILE), + sandbox: false, + contextIsolation: true, + nodeIntegration: false, + }, + }) + + window.on('ready-to-show', () => { + window.show() + }) + window.on('closed', () => { + settingsWindow = null + }) + + applyExternalLinkHandler(window) + loadRendererEntry(window, 'settings') + return window +} + +function ensureWindow(kind: WindowKind): BrowserWindow { + if (kind === 'chat') { + if (!chatWindow || chatWindow.isDestroyed()) chatWindow = createChatWindow() + return chatWindow + } + if (!settingsWindow || settingsWindow.isDestroyed()) { + settingsWindow = createSettingsWindow(chatWindow) + } + return settingsWindow +} + +function focusWindow(window: BrowserWindow): void { + if (window.isMinimized()) window.restore() + window.show() + window.focus() +} + app.whenReady().then(() => { electronApp.setAppUserModelId('ai.memoh.desktop') + if (process.platform === 'darwin' && app.dock) { + app.dock.setIcon(iconPng) + } + app.on('browser-window-created', (_, window) => { optimizer.watchWindowShortcuts(window) }) - createWindow() + ipcMain.handle('window:open-settings', () => { + focusWindow(ensureWindow('settings')) + }) + ipcMain.handle('window:close-self', (event) => { + const sender = BrowserWindow.fromWebContents(event.sender) + sender?.close() + }) + + chatWindow = createChatWindow() app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) createWindow() + if (BrowserWindow.getAllWindows().length === 0) { + chatWindow = createChatWindow() + } }) }) diff --git a/apps/desktop/src/preload/index.d.ts b/apps/desktop/src/preload/global.d.ts similarity index 71% rename from apps/desktop/src/preload/index.d.ts rename to apps/desktop/src/preload/global.d.ts index 8cfac244..d50e234f 100644 --- a/apps/desktop/src/preload/index.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -1,9 +1,10 @@ import type { ElectronAPI } from '@electron-toolkit/preload' +import type { MemohApi } from './index' declare global { interface Window { electron: ElectronAPI - api: Record + api: MemohApi } } diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 947d1fe1..c022c14f 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -1,10 +1,17 @@ -import { contextBridge } from 'electron' +import { contextBridge, ipcRenderer } from 'electron' import { electronAPI } from '@electron-toolkit/preload' -// Renderer-facing API surface. Intentionally minimal for now — extend here when -// the desktop shell needs to expose native capabilities (window control, file -// dialogs, deep links, etc.). -const api = {} +// Renderer-facing API surface. Keep this intentionally small — it is the +// full security boundary between chromium renderer processes and the +// node-privileged main process. +const api = { + window: { + openSettings: (): Promise => ipcRenderer.invoke('window:open-settings'), + closeSelf: (): Promise => ipcRenderer.invoke('window:close-self'), + }, +} + +export type MemohApi = typeof api if (process.contextIsolated) { try { @@ -14,8 +21,6 @@ if (process.contextIsolated) { console.error(error) } } else { - // @ts-expect-error — fall-through for sandbox-less builds window.electron = electronAPI - // @ts-expect-error — fall-through for sandbox-less builds window.api = api } diff --git a/apps/desktop/src/renderer/settings.html b/apps/desktop/src/renderer/settings.html new file mode 100644 index 00000000..a6523707 --- /dev/null +++ b/apps/desktop/src/renderer/settings.html @@ -0,0 +1,12 @@ + + + + + + Memoh · Settings + + +
+ + + diff --git a/apps/desktop/src/renderer/src/chat/App.vue b/apps/desktop/src/renderer/src/chat/App.vue new file mode 100644 index 00000000..a6f6e8c3 --- /dev/null +++ b/apps/desktop/src/renderer/src/chat/App.vue @@ -0,0 +1,15 @@ + + + diff --git a/apps/desktop/src/renderer/src/chat/router.ts b/apps/desktop/src/renderer/src/chat/router.ts new file mode 100644 index 00000000..5725a627 --- /dev/null +++ b/apps/desktop/src/renderer/src/chat/router.ts @@ -0,0 +1,89 @@ +import { createRouter, createMemoryHistory, type RouteLocationNormalized } from 'vue-router' + +// Chat-window router. Owns ONLY chat-related routes — visiting `/settings` +// (e.g. via the chat sidebar's settings button reused from @memohai/web) +// is intercepted in a navigation guard and forwarded to the main process, +// which opens the dedicated settings BrowserWindow instead of routing +// in-place. Memory history matches Electron's file:// runtime cleanly and +// keeps the URL bar irrelevant. + +const routes = [ + { + path: '/', + component: () => import('@memohai/web/pages/main-section/index.vue'), + children: [ + { + name: 'home', + path: '', + component: () => import('@memohai/web/pages/home/index.vue'), + }, + { + name: 'chat', + path: '/chat/:botId?/:sessionId?', + component: () => import('@memohai/web/pages/home/index.vue'), + }, + ], + }, + { + name: 'Login', + path: '/login', + component: () => import('@memohai/web/pages/login/index.vue'), + }, + { + name: 'oauth-mcp-callback', + path: '/oauth/mcp/callback', + component: () => import('@memohai/web/pages/oauth/mcp-callback.vue'), + }, +] + +const router = createRouter({ + history: createMemoryHistory(), + routes, +}) + +router.onError((error: Error) => { + const isChunkLoadError = + error.message.includes('Failed to fetch dynamically imported module') || + error.message.includes('Importing a module script failed') || + error.message.includes('error loading dynamically imported module') + if (isChunkLoadError) { + console.warn('[Router] Chunk load failed, reloading...', error.message) + window.location.reload() + return + } + throw error +}) + +router.beforeEach((to: RouteLocationNormalized) => { + // Settings lives in its own BrowserWindow. Any in-app `router.push('/settings')` + // (e.g. from @memohai/web's chat sidebar) is hijacked here and forwarded to + // the main process. Returning `false` aborts the navigation so the chat + // window stays where it was — must happen unconditionally, otherwise the + // router falls through to the auth check below and bounces to `/login`. + if (to.path === '/settings' || to.path.startsWith('/settings/')) { + const openSettings = window.api?.window?.openSettings + if (typeof openSettings === 'function') { + void openSettings() + } else { + // Most common cause: a long-running `electron-vite dev` session is + // serving a renderer page paired with a preload bundle that pre-dates + // the IPC surface. Restart the dev process or reload the window. + console.warn( + '[chat-router] window.api.window.openSettings unavailable; ' + + 'preload may be stale (restart electron-vite dev) or running outside Electron', + ) + } + return false + } + + const token = localStorage.getItem('token') + if (to.fullPath === '/login') { + return token ? { path: '/' } : true + } + if (to.path.startsWith('/oauth/')) { + return true + } + return token ? true : { name: 'Login' } +}) + +export default router diff --git a/apps/desktop/src/renderer/src/main.ts b/apps/desktop/src/renderer/src/main.ts index 23f17710..69c79176 100644 --- a/apps/desktop/src/renderer/src/main.ts +++ b/apps/desktop/src/renderer/src/main.ts @@ -1,5 +1,30 @@ -// Thin desktop renderer entry — defers the full bootstrap (Pinia, router, -// i18n, api-client, App.vue) to @memohai/web. When the desktop shell needs -// to diverge, replace this import with an inline copy of web's main.ts and -// customize as needed. -import '@memohai/web/main' +// Chat-window renderer entry. Owns its bootstrap chain so desktop can layer +// on Electron-specific plugins / stores / providers without touching +// @memohai/web. Pairs with `src/renderer/index.html`. + +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import { PiniaColada } from '@pinia/colada' +import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' + +import i18n from '@memohai/web/i18n' +import { setupApiClient } from '@memohai/web/api-client' + +import '@memohai/web/style.css' +import 'animate.css' +import 'markstream-vue/index.css' +import 'katex/dist/katex.min.css' + +import App from './chat/App.vue' +import router from './chat/router' + +setupApiClient({ + onUnauthorized: () => router.replace({ name: 'Login' }), +}) + +createApp(App) + .use(createPinia().use(piniaPluginPersistedstate)) + .use(PiniaColada) + .use(router) + .use(i18n) + .mount('#app') diff --git a/apps/desktop/src/renderer/src/settings.ts b/apps/desktop/src/renderer/src/settings.ts new file mode 100644 index 00000000..cac5f24d --- /dev/null +++ b/apps/desktop/src/renderer/src/settings.ts @@ -0,0 +1,34 @@ +// Settings-window renderer entry. Loaded by the secondary BrowserWindow +// created on demand from the chat window. Shares Pinia-persisted state with +// the chat window via localStorage. Pairs with `src/renderer/settings.html`. + +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import { PiniaColada } from '@pinia/colada' +import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' + +import i18n from '@memohai/web/i18n' +import { setupApiClient } from '@memohai/web/api-client' + +import '@memohai/web/style.css' +import 'animate.css' +import 'markstream-vue/index.css' +import 'katex/dist/katex.min.css' + +import App from './settings/App.vue' +import router from './settings/router' + +setupApiClient({ + // Settings is a satellite window — it doesn't host the login screen. + // On 401 we close ourselves and let the chat window route to login. + onUnauthorized: () => { + void window.api.window.closeSelf() + }, +}) + +createApp(App) + .use(createPinia().use(piniaPluginPersistedstate)) + .use(PiniaColada) + .use(router) + .use(i18n) + .mount('#app') diff --git a/apps/desktop/src/renderer/src/settings/App.vue b/apps/desktop/src/renderer/src/settings/App.vue new file mode 100644 index 00000000..c7cb7884 --- /dev/null +++ b/apps/desktop/src/renderer/src/settings/App.vue @@ -0,0 +1,33 @@ + + + diff --git a/apps/desktop/src/renderer/src/settings/router.ts b/apps/desktop/src/renderer/src/settings/router.ts new file mode 100644 index 00000000..d25f5969 --- /dev/null +++ b/apps/desktop/src/renderer/src/settings/router.ts @@ -0,0 +1,102 @@ +import { createRouter, createMemoryHistory } from 'vue-router' + +// Settings-window router. Mirrors the path layout under `/settings/*` from +// @memohai/web's main router so the reused @memohai/web `SettingsSidebar` +// (whose `isItemActive` checks `route.path.startsWith('/settings/bots')`) +// keeps highlighting the active item correctly. The window boots straight +// into `/settings/bots`. + +const routes = [ + { path: '/', redirect: '/settings/bots' }, + { path: '/settings', redirect: '/settings/bots' }, + { + path: '/settings/bots', + name: 'bots', + component: () => import('@memohai/web/pages/bots/index.vue'), + }, + { + path: '/settings/bots/:botId', + name: 'bot-detail', + component: () => import('@memohai/web/pages/bots/detail.vue'), + }, + { + path: '/settings/providers', + name: 'providers', + component: () => import('@memohai/web/pages/providers/index.vue'), + }, + { + path: '/settings/web-search', + name: 'web-search', + component: () => import('@memohai/web/pages/web-search/index.vue'), + }, + { + path: '/settings/memory', + name: 'memory', + component: () => import('@memohai/web/pages/memory/index.vue'), + }, + { + path: '/settings/speech', + name: 'speech', + component: () => import('@memohai/web/pages/speech/index.vue'), + }, + { + path: '/settings/transcription', + name: 'transcription', + component: () => import('@memohai/web/pages/transcription/index.vue'), + }, + { + path: '/settings/email', + name: 'email', + component: () => import('@memohai/web/pages/email/index.vue'), + }, + { + path: '/settings/browser', + name: 'browser', + component: () => import('@memohai/web/pages/browser/index.vue'), + }, + { + path: '/settings/usage', + name: 'usage', + component: () => import('@memohai/web/pages/usage/index.vue'), + }, + { + path: '/settings/profile', + name: 'profile', + component: () => import('@memohai/web/pages/profile/index.vue'), + }, + { + path: '/settings/platform', + name: 'platform', + component: () => import('@memohai/web/pages/platform/index.vue'), + }, + { + path: '/settings/supermarket', + name: 'supermarket', + component: () => import('@memohai/web/pages/supermarket/index.vue'), + }, + { + path: '/settings/about', + name: 'about', + component: () => import('@memohai/web/pages/about/index.vue'), + }, +] + +const router = createRouter({ + history: createMemoryHistory(), + routes, +}) + +router.onError((error: Error) => { + const isChunkLoadError = + error.message.includes('Failed to fetch dynamically imported module') || + error.message.includes('Importing a module script failed') || + error.message.includes('error loading dynamically imported module') + if (isChunkLoadError) { + console.warn('[Router] Chunk load failed, reloading...', error.message) + window.location.reload() + return + } + throw error +}) + +export default router diff --git a/apps/desktop/src/renderer/types/ui-stubs.d.ts b/apps/desktop/src/renderer/types/ui-stubs.d.ts new file mode 100644 index 00000000..c8185f69 --- /dev/null +++ b/apps/desktop/src/renderer/types/ui-stubs.d.ts @@ -0,0 +1,21 @@ +// Minimal type stub for @memohai/ui consumed directly by the desktop +// renderer's own files. @memohai/ui ships a barrel `index.ts` that imports +// every component; pulling those into desktop's typecheck program surfaces +// pre-existing strict-template warnings unrelated to desktop. Routing the +// typecheck through this stub keeps the desktop's surface small. +// +// Vite ignores `paths` and resolves the real `@memohai/ui` package at bundle +// time, so runtime behavior is unchanged. + +declare module '@memohai/ui' { + import type { DefineComponent } from 'vue' + + type LooseComponent = DefineComponent< + Record, + Record, + unknown + > + + export const Toaster: LooseComponent + export const SidebarInset: LooseComponent +} diff --git a/apps/desktop/src/renderer/types/web-stubs.d.ts b/apps/desktop/src/renderer/types/web-stubs.d.ts new file mode 100644 index 00000000..b675aa65 --- /dev/null +++ b/apps/desktop/src/renderer/types/web-stubs.d.ts @@ -0,0 +1,47 @@ +// Type stubs for @memohai/web subpath imports consumed by the renderer. +// We route typechecking through these stubs (via tsconfig `paths`) so vue-tsc +// does not recursively typecheck @memohai/web's source tree — @memohai/web +// owns its own types/CI. Vite ignores `paths` and resolves the real `exports` +// entries at bundle time, so runtime behavior is unchanged. + +// Marker to make this file a module (not an ambient script). Required so +// that paths-mapped dynamic `import('@memohai/web/...')` calls succeed — +// TS otherwise complains that the resolved file "is not a module". +export {} + +declare module '@memohai/web/router' { + import type { Router } from 'vue-router' + const router: Router + export default router +} + +declare module '@memohai/web/i18n' { + import type { I18n } from 'vue-i18n' + const i18n: I18n + export default i18n +} + +declare module '@memohai/web/api-client' { + export interface SetupApiClientOptions { + onUnauthorized?: () => void + } + export function setupApiClient(options?: SetupApiClientOptions): void +} + +declare module '@memohai/web/store/settings' { + // We don't need the concrete Pinia store type here — desktop just calls the + // composable for its registration side-effect. + export function useSettingsStore(): unknown +} + +declare module '@memohai/web/style.css' + +// Fallback for every Vue SFC reachable through the @memohai/web/* wildcard +// export. The TS ambient-module `*` token matches multi-segment paths +// (slashes included), so this single declaration covers `pages/.../*.vue`, +// `components/.../*.vue`, `layout/.../*.vue`, etc. +declare module '@memohai/web/*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent, Record, unknown> + export default component +} diff --git a/apps/desktop/tsconfig.web.json b/apps/desktop/tsconfig.web.json index b5af2cfb..66066a01 100644 --- a/apps/desktop/tsconfig.web.json +++ b/apps/desktop/tsconfig.web.json @@ -1,17 +1,19 @@ { - "extends": "@electron-toolkit/tsconfig/tsconfig.web.json", + "extends": "@vue/tsconfig/tsconfig.dom.json", "include": [ "src/renderer/src/**/*", "src/renderer/src/**/*.vue", + "src/renderer/types/**/*.d.ts", "src/preload/*.d.ts" ], "compilerOptions": { "composite": true, + "types": ["vite/client"], "baseUrl": ".", "paths": { "@renderer/*": ["src/renderer/src/*"], - "@/*": ["../web/src/*"], - "#/*": ["../../packages/ui/src/*"] + "@memohai/web/*": ["src/renderer/types/web-stubs.d.ts"], + "@memohai/ui": ["src/renderer/types/ui-stubs.d.ts"] } } } diff --git a/apps/web/package.json b/apps/web/package.json index 069a553d..f838ce88 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,6 +7,9 @@ ".": "./src/main.ts", "./main": "./src/main.ts", "./App.vue": "./src/App.vue", + "./router": "./src/router.ts", + "./i18n": "./src/i18n.ts", + "./api-client": "./src/lib/api-client.ts", "./style.css": "./src/style.css", "./*": "./src/*" }, diff --git a/apps/web/src/components/settings-sidebar/index.vue b/apps/web/src/components/settings-sidebar/index.vue index 66e81ea0..ca08b28c 100644 --- a/apps/web/src/components/settings-sidebar/index.vue +++ b/apps/web/src/components/settings-sidebar/index.vue @@ -1,7 +1,10 @@