feat: two level windows and self-managed vite project

This commit is contained in:
Acbox
2026-04-25 11:31:29 +08:00
parent f18f9a7231
commit fb8614a016
27 changed files with 1074 additions and 56 deletions
+36 -5
View File
@@ -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.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 36 KiB

+1
View File
@@ -78,6 +78,7 @@ export default defineConfig(({ command }) => {
rollupOptions: {
input: {
index: resolve(__dirname, 'src/renderer/index.html'),
settings: resolve(__dirname, 'src/renderer/settings.html'),
},
},
},
+15 -2
View File
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+141
View File
@@ -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)
})
+103 -25
View File
@@ -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()
}
})
})
@@ -1,9 +1,10 @@
import type { ElectronAPI } from '@electron-toolkit/preload'
import type { MemohApi } from './index'
declare global {
interface Window {
electron: ElectronAPI
api: Record<string, never>
api: MemohApi
}
}
+12 -7
View File
@@ -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<void> => ipcRenderer.invoke('window:open-settings'),
closeSelf: (): Promise<void> => 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
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Memoh · Settings</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/settings.ts"></script>
</body>
</html>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
import { Toaster } from '@memohai/ui'
import 'vue-sonner/style.css'
import { useSettingsStore } from '@memohai/web/store/settings'
useSettingsStore()
</script>
<template>
<section class="[&_input]:shadow-none!">
<RouterView />
<Toaster position="top-center" />
</section>
</template>
@@ -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
+30 -5
View File
@@ -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')
+34
View File
@@ -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')
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { Toaster, SidebarInset } from '@memohai/ui'
import 'vue-sonner/style.css'
import MainLayout from '@memohai/web/layout/main-layout/index.vue'
import SettingsSidebar from '@memohai/web/components/settings-sidebar/index.vue'
import { useSettingsStore } from '@memohai/web/store/settings'
useSettingsStore()
</script>
<template>
<section class="[&_input]:shadow-none!">
<MainLayout>
<template #sidebar>
<!-- Desktop hosts settings in a dedicated window, so the sidebar's
"← Settings" header (back-to-chat affordance) is suppressed. -->
<SettingsSidebar :hide-header="true" />
</template>
<template #main>
<SidebarInset class="flex flex-col overflow-hidden">
<section class="flex-1 overflow-y-auto">
<router-view v-slot="{ Component }">
<KeepAlive>
<component :is="Component" />
</KeepAlive>
</router-view>
</section>
</SidebarInset>
</template>
</MainLayout>
<Toaster position="top-center" />
</section>
</template>
@@ -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
+21
View File
@@ -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<string, unknown>,
Record<string, unknown>,
unknown
>
export const Toaster: LooseComponent
export const SidebarInset: LooseComponent
}
+47
View File
@@ -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<string, never>, Record<string, never>, unknown>
export default component
}
+5 -3
View File
@@ -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"]
}
}
}
+3
View File
@@ -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/*"
},
@@ -1,7 +1,10 @@
<template>
<aside>
<Sidebar collapsible="icon">
<SidebarHeader class="p-0 border-0">
<SidebarHeader
v-if="!hideHeader"
class="p-0 border-0"
>
<button
class="h-[53px] flex items-center gap-2.5 px-3.5 w-full border-b border-border text-foreground hover:bg-accent/50 transition-colors group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0"
@click="router.push(backToChatRoute)"
@@ -66,6 +69,15 @@ import {
SidebarRail,
} from '@memohai/ui'
withDefaults(defineProps<{
// When true, the back-to-chat button in the sidebar header is hidden.
// Used by the desktop shell where settings lives in a dedicated window
// and "back to chat" is not a meaningful action.
hideHeader?: boolean
}>(), {
hideHeader: false,
})
const router = useRouter()
const route = useRoute()
const { t } = useI18n()
+10 -3
View File
@@ -1,11 +1,18 @@
import { client } from '@memohai/sdk/client'
import router from '@/router'
export interface SetupApiClientOptions {
// Called after the access token is cleared on a 401. Hosts (web / desktop
// chat window / desktop settings window) decide what to do — usually a
// router redirect to the login screen, but desktop satellite windows may
// prefer to close themselves and let the chat window take over auth.
onUnauthorized?: () => void
}
/**
* Configure the SDK client with base URL, auth interceptor, and 401 handling.
* Call this once at app startup (main.ts).
*/
export function setupApiClient() {
export function setupApiClient(options: SetupApiClientOptions = {}) {
const apiBaseUrl = import.meta.env.VITE_API_URL?.trim() || '/api'
const agentBaseUrl = import.meta.env.VITE_AGENT_URL?.trim() || '/agent'
void agentBaseUrl
@@ -25,7 +32,7 @@ export function setupApiClient() {
client.interceptors.response.use((response) => {
if (response.status === 401) {
localStorage.removeItem('token')
router.replace({ name: 'Login' })
options.onUnauthorized?.()
}
return response
})
+4 -3
View File
@@ -4,9 +4,6 @@ import App from './App.vue'
import router from './router'
import { setupApiClient } from './lib/api-client'
import 'animate.css'
// Configure SDK client before anything else
setupApiClient()
import { createPinia } from 'pinia'
import i18n from './i18n'
import { PiniaColada } from '@pinia/colada'
@@ -14,6 +11,10 @@ import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import 'markstream-vue/index.css'
import 'katex/dist/katex.min.css'
setupApiClient({
onUnauthorized: () => router.replace({ name: 'Login' }),
})
createApp(App)
.use(createPinia().use(piniaPluginPersistedstate))
.use(PiniaColada)
+344
View File
@@ -104,6 +104,9 @@ importers:
'@memohai/config':
specifier: workspace:*
version: link:../../packages/config
'@pinia/colada':
specifier: ^0.21.2
version: 0.21.2(pinia@3.0.4(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3)))(vue@3.5.26(typescript@5.9.3))
'@tailwindcss/vite':
specifier: ^4.2.2
version: 4.2.2(vite@8.0.1(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))
@@ -113,6 +116,12 @@ importers:
'@vitejs/plugin-vue':
specifier: ^6.0.5
version: 6.0.5(vite@8.0.1(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))
'@vue/tsconfig':
specifier: ^0.8.1
version: 0.8.1(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3))
animate.css:
specifier: ^4.1.1
version: 4.1.1
electron:
specifier: ^34.5.0
version: 34.5.8
@@ -122,6 +131,24 @@ importers:
electron-vite:
specifier: ^4.0.0
version: 4.0.1(vite@8.0.1(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))
katex:
specifier: ^0.16.37
version: 0.16.37
markstream-vue:
specifier: 0.0.7-beta.2
version: 0.0.7-beta.2(katex@0.16.37)(mermaid@11.12.3)(shiki@3.23.0)(stream-markdown@0.0.13(shiki@3.23.0))(stream-monaco@0.0.18(monaco-editor@0.52.2))(vue-i18n@11.2.8(vue@3.5.26(typescript@5.9.3)))(vue@3.5.26(typescript@5.9.3))
pinia:
specifier: ^3.0.4
version: 3.0.4(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3))
pinia-plugin-persistedstate:
specifier: ^4.7.1
version: 4.7.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3)))
png-to-ico:
specifier: ^3.0.1
version: 3.0.1
sharp:
specifier: ^0.34.5
version: 0.34.5
typescript:
specifier: ~5.9.3
version: 5.9.3
@@ -131,6 +158,15 @@ importers:
vue:
specifier: ^3.5.24
version: 3.5.26(typescript@5.9.3)
vue-i18n:
specifier: ^11.2.8
version: 11.2.8(vue@3.5.26(typescript@5.9.3))
vue-router:
specifier: ^4.6.4
version: 4.6.4(vue@3.5.26(typescript@5.9.3))
vue-sonner:
specifier: ^2.0.9
version: 2.0.9
vue-tsc:
specifier: ^3.1.4
version: 3.2.2(typescript@5.9.3)
@@ -1512,6 +1548,159 @@ packages:
'@iconify/utils@3.1.0':
resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==}
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.34.5':
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.34.5':
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.2.4':
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.2.4':
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.2.4':
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.34.5':
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.34.5':
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.34.5':
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
'@internationalized/date@3.10.1':
resolution: {integrity: sha512-oJrXtQiAXLvT9clCf1K4kxp3eKsQhIaZqxEyowkBcsvZDdZkbWrVmnGknxs5flTD0VGsxrxKgBCZty1EzoiMzA==}
@@ -2222,6 +2411,9 @@ packages:
'@types/node@20.19.39':
resolution: {integrity: sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==}
'@types/node@22.19.17':
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
'@types/node@24.10.4':
resolution: {integrity: sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==}
@@ -4529,10 +4721,19 @@ packages:
resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==}
engines: {node: '>=10.4.0'}
png-to-ico@3.0.1:
resolution: {integrity: sha512-S8BOAoaGd9gT5uaemQ62arIY3Jzco7Uc7LwUTqRyqJDTsKqOAiyfyN4dSdT0D+Zf8XvgztgpRbM5wnQd7EgYwg==}
engines: {node: '>=20'}
hasBin: true
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
pngjs@7.0.0:
resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==}
engines: {node: '>=14.19.0'}
points-on-curve@0.2.0:
resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==}
@@ -4761,6 +4962,10 @@ packages:
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -6599,6 +6804,102 @@ snapshots:
'@iconify/types': 2.0.0
mlly: 1.8.0
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.2.4
optional: true
'@img/sharp-darwin-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.2.4
optional: true
'@img/sharp-libvips-darwin-arm64@1.2.4':
optional: true
'@img/sharp-libvips-darwin-x64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm@1.2.4':
optional: true
'@img/sharp-libvips-linux-ppc64@1.2.4':
optional: true
'@img/sharp-libvips-linux-riscv64@1.2.4':
optional: true
'@img/sharp-libvips-linux-s390x@1.2.4':
optional: true
'@img/sharp-libvips-linux-x64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
optional: true
'@img/sharp-linux-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.2.4
optional: true
'@img/sharp-linux-arm@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.2.4
optional: true
'@img/sharp-linux-ppc64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.2.4
optional: true
'@img/sharp-linux-riscv64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.2.4
optional: true
'@img/sharp-linux-s390x@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.2.4
optional: true
'@img/sharp-linux-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.2.4
optional: true
'@img/sharp-linuxmusl-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
optional: true
'@img/sharp-linuxmusl-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
optional: true
'@img/sharp-wasm32@0.34.5':
dependencies:
'@emnapi/runtime': 1.9.1
optional: true
'@img/sharp-win32-arm64@0.34.5':
optional: true
'@img/sharp-win32-ia32@0.34.5':
optional: true
'@img/sharp-win32-x64@0.34.5':
optional: true
'@internationalized/date@3.10.1':
dependencies:
'@swc/helpers': 0.5.18
@@ -7283,6 +7584,10 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/node@22.19.17':
dependencies:
undici-types: 6.21.0
'@types/node@24.10.4':
dependencies:
undici-types: 7.16.0
@@ -9840,8 +10145,16 @@ snapshots:
base64-js: 1.5.1
xmlbuilder: 15.1.1
png-to-ico@3.0.1:
dependencies:
'@types/node': 22.19.17
minimist: 1.2.8
pngjs: 7.0.0
pngjs@5.0.0: {}
pngjs@7.0.0: {}
points-on-curve@0.2.0: {}
points-on-path@0.2.1:
@@ -10107,6 +10420,37 @@ snapshots:
set-blocking@2.0.0: {}
sharp@0.34.5:
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.7.3
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
'@img/sharp-libvips-darwin-arm64': 1.2.4
'@img/sharp-libvips-darwin-x64': 1.2.4
'@img/sharp-libvips-linux-arm': 1.2.4
'@img/sharp-libvips-linux-arm64': 1.2.4
'@img/sharp-libvips-linux-ppc64': 1.2.4
'@img/sharp-libvips-linux-riscv64': 1.2.4
'@img/sharp-libvips-linux-s390x': 1.2.4
'@img/sharp-libvips-linux-x64': 1.2.4
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
'@img/sharp-linux-arm': 0.34.5
'@img/sharp-linux-arm64': 0.34.5
'@img/sharp-linux-ppc64': 0.34.5
'@img/sharp-linux-riscv64': 0.34.5
'@img/sharp-linux-s390x': 0.34.5
'@img/sharp-linux-x64': 0.34.5
'@img/sharp-linuxmusl-arm64': 0.34.5
'@img/sharp-linuxmusl-x64': 0.34.5
'@img/sharp-wasm32': 0.34.5
'@img/sharp-win32-arm64': 0.34.5
'@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
+2 -1
View File
@@ -7,4 +7,5 @@ onlyBuiltDependencies:
- sqlite3
- electron
- electron-winstaller
- esbuild
- esbuild
- sharp