feat: introduce electron desktop app (#405)

* refactor: use Electron instead of Tauri

* feat: two level windows and self-managed vite project

* feat(desktop): macOS hidden-inset chrome and floating chat title

Apply hiddenInset titleBarStyle on darwin so the system titlebar is hidden
but native traffic lights remain. Reusable web sidebars inject a new
DesktopShellKey to reserve a 36px TopBar that holds the traffic-light
inset (drag region + right border) without colliding with the bot list,
and the sidebar stays pinned open in the Electron shell so window resize
doesn't fight the layout.

Overlay a centered "Title - BotName" header above the chat content with a
bottom shadow gradient that obscures scrolling messages, and reserve top
padding so the first message stays visible when content fits the viewport.
Route the sidebar + action by path (/settings/bots) so the chat router's
/settings/* interception forwards it to the settings window cleanly while
remaining a normal navigation in web.

* docs(desktop): add AGENTS.md for Electron shell

Document the multi-window architecture, web reuse strategy, type-stubbing
trick, macOS chrome, IPC surface, routing, icons, and packaging — captures
the non-obvious bits that bit us during the Tauri → Electron refactor so
future agents don't relearn them.
This commit is contained in:
Acbox
2026-04-25 12:16:23 +08:00
committed by GitHub
parent e4aca0db13
commit 46365726b9
107 changed files with 3691 additions and 6033 deletions
+64
View File
@@ -0,0 +1,64 @@
name: Electron CI
on:
push:
branches: ["main"]
paths:
- "apps/desktop/**"
- "apps/web/**"
- "packages/**"
- "pnpm-lock.yaml"
- ".github/workflows/electron-ci.yml"
pull_request:
branches: ["main"]
paths:
- "apps/desktop/**"
- "apps/web/**"
- "packages/**"
- "pnpm-lock.yaml"
- ".github/workflows/electron-ci.yml"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: Build (${{ matrix.platform }})
strategy:
fail-fast: false
matrix:
include:
- platform: macos-latest
- platform: ubuntu-22.04
- platform: windows-latest
runs-on: ${{ matrix.platform }}
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: lts/*
cache: pnpm
- name: Install JS dependencies
run: pnpm install --frozen-lockfile
- name: Typecheck desktop
run: pnpm --filter @memohai/desktop typecheck
- name: Build desktop (unpacked smoke)
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
run: pnpm --filter @memohai/desktop build:dir
+28 -74
View File
@@ -129,7 +129,7 @@ jobs:
gh release upload "$TAG_NAME" "${files[@]}" --clobber --repo "$GH_REPO"
desktop-build:
name: Build desktop ${{ matrix.target }}
name: Build desktop (${{ matrix.platform }})
runs-on: ${{ matrix.platform }}
timeout-minutes: 60
strategy:
@@ -137,19 +137,17 @@ jobs:
matrix:
include:
- platform: macos-latest
target: aarch64-apple-darwin
- platform: macos-latest
target: x86_64-apple-darwin
build_cmd: build:mac
- platform: ubuntu-22.04
target: x86_64-unknown-linux-gnu
build_cmd: build:linux
- platform: windows-latest
target: x86_64-pc-windows-msvc
build_cmd: build:win
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
steps:
- uses: actions/checkout@v4
- name: Sync desktop version files
- name: Sync desktop version
shell: bash
run: |
set -euo pipefail
@@ -159,21 +157,9 @@ jobs:
const fs = require('fs');
const version = process.env.VERSION;
const packageJsonPath = 'apps/desktop/package.json';
const tauriConfPath = 'apps/desktop/src-tauri/tauri.conf.json';
const cargoTomlPath = 'apps/desktop/src-tauri/Cargo.toml';
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
packageJson.version = version;
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
const tauriConf = JSON.parse(fs.readFileSync(tauriConfPath, 'utf8'));
tauriConf.version = version;
fs.writeFileSync(tauriConfPath, `${JSON.stringify(tauriConf, null, 2)}\n`);
const cargoToml = fs
.readFileSync(cargoTomlPath, 'utf8')
.replace(/^version = ".*"$/m, `version = "${version}"`);
fs.writeFileSync(cargoTomlPath, cargoToml);
EOF
- uses: pnpm/action-setup@v4
with:
@@ -182,31 +168,15 @@ jobs:
with:
node-version: lts/*
cache: pnpm
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: apps/desktop/src-tauri -> target
- name: Install Linux dependencies
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libappindicator3-dev \
librsvg2-dev \
patchelf
- name: Install JS dependencies
run: pnpm install --frozen-lockfile
- name: Prepare macOS code signing
id: mac-signing
if: ${{ matrix.platform == 'macos-latest' && env.APPLE_CERTIFICATE != '' && env.APPLE_CERTIFICATE_PASSWORD != '' }}
shell: bash
run: |
set -euo pipefail
KEYCHAIN_PASSWORD="github-actions-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT:-1}"
CSC_PATH="$RUNNER_TEMP/certificate.p12"
python3 - <<'EOF'
import base64
import os
@@ -215,46 +185,29 @@ jobs:
target = Path(os.environ["RUNNER_TEMP"]) / "certificate.p12"
target.write_bytes(base64.b64decode(os.environ["APPLE_CERTIFICATE"]))
EOF
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security set-keychain-settings -t 3600 -u build.keychain
security import "$RUNNER_TEMP/certificate.p12" -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
curl -fsSL -o "$RUNNER_TEMP/DeveloperIDG2CA.cer" https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer
security add-certificates -k build.keychain "$RUNNER_TEMP/DeveloperIDG2CA.cer"
security find-identity -v -p codesigning build.keychain
IDENTITY=$(security find-identity -v -p codesigning build.keychain | awk -F'"' '/Developer ID Application/ { print $2; exit }')
if [[ -z "$IDENTITY" ]]; then
echo "No Developer ID Application identity in build keychain"
exit 1
fi
echo "APPLE_SIGNING_IDENTITY=$IDENTITY" >> "$GITHUB_ENV"
echo "CSC_LINK=$CSC_PATH" >> "$GITHUB_ENV"
echo "CSC_KEY_PASSWORD=$APPLE_CERTIFICATE_PASSWORD" >> "$GITHUB_ENV"
echo "has_cert=true" >> "$GITHUB_OUTPUT"
- name: Build desktop bundles
shell: bash
run: |
set -euo pipefail
ARGS=(build --target "${{ matrix.target }}")
if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then
VERSION="${RELEASE_TAG#v}"
NUMERIC_VERSION="${VERSION%%-*}"
NUMERIC_VERSION="${NUMERIC_VERSION%%+*}"
ARGS+=(--config "{\"bundle\":{\"windows\":{\"wix\":{\"version\":\"${NUMERIC_VERSION}\"}}}}")
fi
pnpm --filter @memohai/desktop tauri "${ARGS[@]}"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_IDENTITY_AUTO_DISCOVERY: ${{ steps.mac-signing.outputs.has_cert == 'true' && 'true' || 'false' }}
run: pnpm --filter @memohai/desktop ${{ matrix.build_cmd }}
- name: Upload desktop artifacts
uses: actions/upload-artifact@v4
with:
name: desktop-${{ matrix.target }}
name: desktop-${{ matrix.platform }}
path: |
apps/desktop/src-tauri/target/${{ matrix.target }}/release/bundle/**/*.AppImage
apps/desktop/src-tauri/target/${{ matrix.target }}/release/bundle/**/*.app.tar.gz
apps/desktop/src-tauri/target/${{ matrix.target }}/release/bundle/**/*.deb
apps/desktop/src-tauri/target/${{ matrix.target }}/release/bundle/**/*.dmg
apps/desktop/src-tauri/target/${{ matrix.target }}/release/bundle/**/*.exe
apps/desktop/src-tauri/target/${{ matrix.target }}/release/bundle/**/*.msi
apps/desktop/src-tauri/target/${{ matrix.target }}/release/bundle/**/*.rpm
apps/desktop/src-tauri/target/${{ matrix.target }}/release/bundle/**/*.sig
apps/desktop/dist/**/*.AppImage
apps/desktop/dist/**/*.deb
apps/desktop/dist/**/*.rpm
apps/desktop/dist/**/*.dmg
apps/desktop/dist/**/*.zip
apps/desktop/dist/**/*.exe
apps/desktop/dist/**/*.msi
apps/desktop/dist/**/*.blockmap
apps/desktop/dist/**/latest*.yml
if-no-files-found: error
desktop-upload:
@@ -280,13 +233,14 @@ jobs:
shopt -s nullglob globstar
files=(
release-artifacts/desktop/**/*.AppImage
release-artifacts/desktop/**/*.app.tar.gz
release-artifacts/desktop/**/*.deb
release-artifacts/desktop/**/*.rpm
release-artifacts/desktop/**/*.dmg
release-artifacts/desktop/**/*.zip
release-artifacts/desktop/**/*.exe
release-artifacts/desktop/**/*.msi
release-artifacts/desktop/**/*.rpm
release-artifacts/desktop/**/*.sig
release-artifacts/desktop/**/*.blockmap
release-artifacts/desktop/**/latest*.yml
)
if [[ ${#files[@]} -eq 0 ]]; then
echo "No desktop artifacts found" >&2
-90
View File
@@ -1,90 +0,0 @@
name: Tauri CI
on:
push:
branches: ["main"]
paths:
- "apps/desktop/**"
- "apps/web/**"
- "packages/**"
- "pnpm-lock.yaml"
- ".github/workflows/tauri-ci.yml"
pull_request:
branches: ["main"]
paths:
- "apps/desktop/**"
- "apps/web/**"
- "packages/**"
- "pnpm-lock.yaml"
- ".github/workflows/tauri-ci.yml"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: Build (${{ matrix.platform }})
strategy:
fail-fast: false
matrix:
include:
- platform: macos-latest
target: aarch64-apple-darwin
- platform: macos-latest
target: x86_64-apple-darwin
- platform: ubuntu-22.04
target: x86_64-unknown-linux-gnu
- platform: windows-latest
target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.platform }}
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: lts/*
cache: pnpm
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: apps/desktop/src-tauri -> target
- name: Install Linux dependencies
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libappindicator3-dev \
librsvg2-dev \
patchelf
- name: Install JS dependencies
run: pnpm install --frozen-lockfile
- name: Build Tauri app
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
projectPath: apps/desktop
args: --target ${{ matrix.target }}
tauriScript: pnpm tauri
+13 -4
View File
@@ -40,7 +40,7 @@ Infrastructure dependencies:
- **Icons**: lucide-vue-next + `@memohai/icon` (brand/provider icons)
- **i18n**: vue-i18n
- **Markdown**: markstream-vue + Shiki + Mermaid + KaTeX
- **Desktop**: Tauri (wraps `@memohai/web`)
- **Desktop**: Electron + [electron-vite](https://electron-vite.github.io/) (thin shell whose renderer imports `@memohai/web`'s bootstrap)
- **Package Manager**: pnpm monorepo
### Browser Gateway (TypeScript)
@@ -169,7 +169,7 @@ Memoh/
│ │ ├── types/ # TypeScript type definitions
│ │ ├── storage.ts # Browser context storage
│ │ └── models.ts # Zod request schemas
│ ├── desktop/ # Tauri desktop app (@memohai/desktop, wraps @memohai/web)
│ ├── desktop/ # Electron desktop app (@memohai/desktop, electron-vite; renderer imports @memohai/web)
│ └── web/ # Main web app (@memohai/web, Vue 3) — see apps/web/AGENTS.md
├── packages/ # Shared TypeScript libraries
│ ├── ui/ # Shared UI component library (@memohai/ui)
@@ -235,8 +235,8 @@ Memoh/
| `mise run build-embedded-assets` | Build and stage embedded web assets |
| `mise run build-unified` | Build memoh CLI locally |
| `mise run bridge:build` | Rebuild bridge binary in dev container |
| `mise run desktop:dev` | Start Tauri desktop app in dev mode |
| `mise run desktop:build` | Build Tauri desktop app for release |
| `mise run desktop:dev` | Start Electron desktop app in dev mode (renderer reuses @memohai/web) |
| `mise run desktop:build` | Build Electron desktop app for release (electron-builder) |
| `mise run lint` | Run all linters (Go + ESLint) |
| `mise run lint:fix` | Run all linters with auto-fix |
| `mise run release` | Release new version (bumpp) |
@@ -310,6 +310,15 @@ Migrations live in `db/migrations/` and follow a dual-update convention:
- i18n via vue-i18n.
- See `apps/web/AGENTS.md` for detailed frontend conventions.
### Desktop App
- `apps/desktop/` is an [electron-vite](https://electron-vite.github.io/) project (`@memohai/desktop`).
- The renderer is intentionally a **thin shell**: `src/renderer/src/main.ts` is a single-line `import '@memohai/web/main'` that defers the full bootstrap (router, Pinia, api-client, `App.vue`) to `@memohai/web`.
- `@memohai/web`'s `package.json` exposes an `exports` map (`./main`, `./App.vue`, `./style.css`, `./*`) so downstream consumers can reuse web modules.
- `electron.vite.config.ts` mirrors `apps/web/vite.config.ts`: same `@` / `#` path aliases, same `/api` proxy (driven by `MEMOH_WEB_PROXY_TARGET` / `config.toml` via `@memohai/config`).
- Packaging is handled by `electron-builder` (config in `apps/desktop/electron-builder.yml`); output lands in `apps/desktop/dist/`.
- When desktop needs to diverge from the web experience, replace the re-export in `renderer/src/main.ts` with an inline copy of web's `main.ts` and customize from there — do **not** fork `apps/web` itself.
### Container / Workspace Management
- Each bot can have an isolated **workspace container** for file editing, command execution, and MCP tool hosting.
+11 -18
View File
@@ -1,24 +1,17 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
out
dist
dist-ssr
*.local
*.log
.DS_Store
.env
.env.*
!.env.example
# Editor directories and files
# bundle assets (icons etc.) — root .gitignore has `build/`, re-include here.
!build/
!build/**
# editor
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
-7
View File
@@ -1,7 +0,0 @@
{
"recommendations": [
"Vue.volar",
"tauri-apps.tauri-vscode",
"rust-lang.rust-analyzer"
]
}
+392
View File
@@ -0,0 +1,392 @@
# Desktop App (apps/desktop)
## Overview
`@memohai/desktop` is the Memoh Electron desktop application. It does **not**
re-implement the UI — it reuses Vue components, stores, router pieces, and
styles from `@memohai/web` and assembles its own multi-window Electron shell
on top.
The app boots two independent `BrowserWindow`s:
| Window | Renderer entry | HTML | Router routes |
|--------|----------------|------|---------------|
| **Chat** (primary) | `src/renderer/src/main.ts` | `index.html` | `/`, `/chat/:botId?/:sessionId?`, `/login`, `/oauth/mcp/callback` |
| **Settings** (satellite) | `src/renderer/src/settings.ts` | `settings.html` | `/settings/*` (bots, providers, memory, …) |
The two windows are isolated renderer processes — separate Pinia, separate
Vue Router, separate Vite chunks — but share user state via the
`pinia-plugin-persistedstate` localStorage stores (chat-selection, user
token, settings, etc.). Settings is a satellite of chat: chat hosts login,
settings closes itself on 401.
## Tech Stack
| Category | Technology |
|----------|-----------|
| Shell | [Electron](https://www.electronjs.org/) (^34) |
| Bundler | [electron-vite](https://electron-vite.github.io/) (^4) — orchestrates main + preload + renderer Vite builds |
| Renderer build | Vite 8 + `@vitejs/plugin-vue` + `@tailwindcss/vite` |
| Packager | electron-builder (^26) → `.dmg` / `.zip` (mac), `.AppImage` / `.deb` / `.rpm` (linux), NSIS (win) |
| Vue ecosystem | Vue 3, Vue Router 4 (`createMemoryHistory`), Pinia 3, `@pinia/colada`, vue-i18n, vue-sonner |
| Reused workspace packages | `@memohai/web`, `@memohai/ui`, `@memohai/sdk`, `@memohai/icon`, `@memohai/config` |
| Preload helpers | `@electron-toolkit/preload`, `@electron-toolkit/utils` |
| Icon pipeline | `sharp` (PNG / resize) + `png-to-ico` (Windows) + `iconutil` (macOS, system tool) |
| Type checking | TypeScript ~5.9 strict + `vue-tsc` for the renderer |
## Directory Structure
```
apps/desktop/
├── electron.vite.config.ts # Single config file: main / preload / renderer Vite configs
├── electron-builder.yml # Packager config (appId, targets, icons, asarUnpack)
├── package.json
├── tsconfig.json # Solution file (references node + web)
├── tsconfig.node.json # Main + preload typecheck (NodeNext-bundler)
├── tsconfig.web.json # Renderer typecheck (DOM, with @memohai/* path stubs)
├── README.md # User-facing dev/build guide
├── AGENTS.md # This file
├── src/
│ ├── main/
│ │ └── index.ts # Main process: BrowserWindow factories, IPC, app lifecycle
│ ├── preload/
│ │ ├── index.ts # Preload bridge — exposes `window.electron` + `window.api`
│ │ └── global.d.ts # Window augmentation for renderer typecheck
│ └── renderer/
│ ├── index.html # Chat window root
│ ├── settings.html # Settings window root
│ ├── src/
│ │ ├── main.ts # Chat renderer bootstrap (createApp, plugins, router, i18n)
│ │ ├── settings.ts # Settings renderer bootstrap (parallel chain)
│ │ ├── env.d.ts # vite/client + *.vue ambient module
│ │ ├── chat/
│ │ │ ├── App.vue # Chat root (provides DesktopShellKey, Toaster)
│ │ │ └── router.ts # Chat routes + /settings/* IPC interception + auth guard
│ │ └── settings/
│ │ ├── App.vue # Settings root (provides DesktopShellKey, MainLayout)
│ │ └── router.ts # Settings routes (mirrors web's /settings/* paths)
│ └── types/
│ ├── web-stubs.d.ts # Path-mapped stub for @memohai/web/* — see "Type Stubbing"
│ └── ui-stubs.d.ts # Path-mapped stub for @memohai/ui (Toaster, SidebarInset)
├── scripts/
│ └── build-icons.mjs # Regenerates icns / ico / png from apps/web/public/logo.svg
├── resources/
│ └── icon.png # 512×512 — runtime BrowserWindow.icon + macOS dock.setIcon
├── build/ # Packager input assets (gitignored at root, re-included here)
│ ├── icon.icns # macOS bundle icon
│ ├── icon.ico # Windows installer icon
│ └── icon.png # 1024×1024 source for Linux + ico master
├── out/ # electron-vite output (main, preload, renderer bundles) — gitignored
└── dist/ # electron-builder output (installers / unpacked apps) — gitignored
```
## Reuse from @memohai/web
The renderer is **not** a thin shell that imports `@memohai/web/main`
desktop owns its own bootstrap. It reuses building blocks via subpath
exports declared in `apps/web/package.json`:
| Subpath | Purpose |
|---------|---------|
| `@memohai/web/style.css` | Tailwind + design tokens |
| `@memohai/web/i18n` | vue-i18n instance |
| `@memohai/web/api-client` | `setupApiClient({ onUnauthorized })` SDK setup |
| `@memohai/web/store/settings` | Theme + locale store (registered for side effects) |
| `@memohai/web/lib/desktop-shell` | `DesktopShellKey` injection key |
| `@memohai/web/layout/main-layout/index.vue` | Outer sidebar layout |
| `@memohai/web/components/sidebar/index.vue` | Bot list sidebar (chat shell) |
| `@memohai/web/components/settings-sidebar/index.vue` | Settings nav (settings shell) |
| `@memohai/web/pages/**/*.vue` | Routed pages (home, bots, providers, …) |
Vite resolves these at bundle time via the package's `exports` field.
**TypeScript does not** — see [Type Stubbing](#type-stubbing) below.
### Why managed bootstrap, not full reuse
Desktop needs to do things `@memohai/web/main.ts` cannot: provide an
`InjectionKey` so reusable components know they're in the Electron shell,
swap the 401 handler (settings closes itself; chat redirects to `/login`),
own its own router with memory history and the `/settings/*` IPC hijack,
and be free to register desktop-only Pinia plugins without polluting the
web bundle.
`@memohai/web/api-client`'s `setupApiClient(options)` accepts an
`onUnauthorized` callback for exactly this reason — never hard-code redirect
behaviour into `@memohai/web`.
## Type Stubbing
`vue-tsc` follows symlinks. Without intervention, typechecking the desktop
renderer would descend into `apps/web/src/` and `packages/ui/src/`, surfacing
strict-template warnings in code that isn't desktop's responsibility (each
of those packages has its own CI scope).
Solution: `tsconfig.web.json` sets `paths` to redirect `@memohai/web/*` to
`src/renderer/types/web-stubs.d.ts` and `@memohai/ui` to
`src/renderer/types/ui-stubs.d.ts`. The stubs declare just enough surface
(component shape, store shape, router/i18n types) for desktop's own code to
typecheck.
**Vite ignores `tsconfig` `paths`** — at runtime it follows the package's
real `exports` field. So bundle behaviour is unchanged; only `vue-tsc`
follows the stubs.
When you add a new `@memohai/web/*` import in the desktop renderer, add a
matching `declare module` to `web-stubs.d.ts`. The wildcard
`declare module '@memohai/web/*.vue'` already covers any `.vue` SFC reached
through the wildcard `./*` export.
## Multi-Window Lifecycle
### Main process (`src/main/index.ts`)
- `chatWindow: BrowserWindow | null` is the persistent primary window. `app.on('activate')` recreates it on macOS dock click.
- `settingsWindow: BrowserWindow | null` is created lazily by IPC and is parented to the chat window (not modal).
- Both windows share `webPreferences` (sandbox: false, contextIsolation: true, nodeIntegration: false) and the same preload script. The renderer is therefore strictly browser-grade — anything that needs node/Electron APIs must go through IPC.
### IPC surface (`src/preload/index.ts`)
The preload bridge exposes a small, fixed surface to renderers via
`contextBridge.exposeInMainWorld('api', api)`:
```ts
window.api = {
window: {
openSettings(): Promise<void> // ipc → main:'window:open-settings'
closeSelf(): Promise<void> // ipc → main:'window:close-self'
},
}
```
Plus `window.electron` (from `@electron-toolkit/preload`) for the standard
toolkit utilities. Keep this surface intentionally tiny — every entry is
part of the security boundary.
### Cross-window navigation
Settings actions invoked from the chat window's reused
`@memohai/web/components/sidebar/...` (e.g. the gear footer link, the `+`
button) are intercepted in the chat router's `beforeEach`:
```ts
if (to.path === '/settings' || to.path.startsWith('/settings/')) {
void window.api?.window?.openSettings()
return false // abort in-place navigation
}
```
This is why shared chat-sidebar components must navigate by **path** (e.g.
`router.push('/settings/bots')`), never by `name``name: 'bots'` only
exists in the settings router.
### Settings 401 handling
The chat renderer's `setupApiClient` calls `router.replace({ name: 'Login' })`
on 401. The settings renderer instead calls `window.api.window.closeSelf()`.
The chat window's own auth guard then takes over and routes to `/login`.
## Desktop Shell Awareness — `DesktopShellKey`
Reusable web layouts/components need to know when they're hosted inside the
Electron shell — to reserve space for the macOS traffic lights, disable
small-screen auto-collapse, etc. — without depending on Electron at runtime.
Pattern:
1. `apps/web/src/lib/desktop-shell.ts` defines and exports
`DesktopShellKey: InjectionKey<boolean>`.
2. Web (`apps/web/src/main.ts`) does **not** provide it → `inject(...,
false)` falls back to `false`.
3. Desktop renderer roots (`chat/App.vue`, `settings/App.vue`) call
`provide(DesktopShellKey, true)`.
4. Consumers (`components/sidebar`, `components/settings-sidebar`,
`layout/main-layout`, `pages/home/components/chat-area`) inject and gate
their desktop affordances on the result.
Adding a new desktop affordance to a web component is a four-step
checklist: `inject(DesktopShellKey, false)` → conditional template branch →
add a `declare module '@memohai/web/...'` stub if it's a new export →
update both Web/CI and desktop typecheck.
## macOS Chrome
On `process.platform === 'darwin'` only, both `BrowserWindow`s opt in to:
```ts
{ titleBarStyle: 'hiddenInset', trafficLightPosition: { x: 14, y: 12 } }
```
The native titlebar is hidden but the traffic lights remain at a fixed
position. The reusable web sidebars reserve a 36px-tall (h-9) header above
the sidebar content, marked `[-webkit-app-region:drag]` so the window can
be dragged from there. Inside that strip, interactive elements (e.g. the
chat sidebar `+` button) need an explicit
`[-webkit-app-region:no-drag]` wrapper to stay clickable.
The chat content also gets a floating "Title - BotName" header overlaid
with a bottom-fade gradient. It's `pointer-events:none` so scroll/clicks
fall through to messages, and the message list reserves the equivalent
top padding so the first message isn't obscured at rest.
## electron-vite Configuration
`electron.vite.config.ts` defines three Vite configs in one file:
| Section | Notable settings |
|---------|------------------|
| `main` | `externalizeDepsPlugin()` (don't bundle node_modules into the main bundle) |
| `preload` | Same `externalizeDepsPlugin()` |
| `renderer` | `root: src/renderer`, `publicDir: ../web/public` (so `/logo.svg` resolves), `resolve.alias` mirrors `apps/web/vite.config.ts` (`@` → `apps/web/src`, `#` → `packages/ui/src`), two HTML entries (`index` + `settings`), `optimizeDeps.entries` includes web sources, dev-only `/api` proxy reading port + base URL from `@memohai/config` |
### Important runtime gotcha — preload extension
`apps/desktop/package.json` has `"type": "module"`, so electron-vite emits
the preload as `out/preload/index.mjs` (not `.js`). The main process **must**
load `../preload/index.mjs`. Loading the wrong extension does not throw —
Electron silently fails to attach the preload, and `window.api` ends up
`undefined` in the renderer. This is captured in:
```ts
const PRELOAD_FILE = '../preload/index.mjs'
```
If you ever change the package's module type, also update this constant.
### Dev-server proxy
In dev (`pnpm --filter @memohai/desktop dev`), the renderer Vite server
listens on the port configured in `config.toml` (default 8082) and proxies
`/api/*` to the backend's `getBaseUrl(config)`. `MEMOH_WEB_PROXY_TARGET` env
var overrides the proxy target.
## Routing
Both windows use `createMemoryHistory()` — the `file://` runtime makes
`createWebHistory()` impractical.
### Chat router (`src/renderer/src/chat/router.ts`)
| Path | Name | Component |
|------|------|-----------|
| `/` | `home` | `@memohai/web/pages/home/index.vue` |
| `/chat/:botId?/:sessionId?` | `chat` | `@memohai/web/pages/home/index.vue` |
| `/login` | `Login` | `@memohai/web/pages/login/index.vue` |
| `/oauth/mcp/callback` | `oauth-mcp-callback` | `@memohai/web/pages/oauth/mcp-callback.vue` |
Three guards in `beforeEach`:
1. `/settings*` → IPC `openSettings()` → return `false`.
2. `/login` while already logged in → redirect to `/`.
3. Any other route without `localStorage.token` → redirect to `Login`.
Plus an `onError` handler that reloads the window on dynamic-import chunk
load failures (covers the case where the dev server restarts mid-session).
### Settings router (`src/renderer/src/settings/router.ts`)
Mirrors the path layout under `/settings/*` from `@memohai/web/router` so
the reused `SettingsSidebar`'s `route.path.startsWith('/settings/...')`
active-state checks keep working. Route names mirror web exactly:
`bots`, `bot-detail`, `providers`, `web-search`, `memory`, `speech`,
`transcription`, `email`, `browser`, `usage`, `profile`, `platform`,
`supermarket`, `about`. Default redirect: `/` → `/settings/bots`.
The settings window has **no auth guard** — by design. If the chat window
isn't authenticated yet, it owns login. Any 401 returned to a settings
request closes the settings window (see "Settings 401 handling" above).
## Icon Pipeline
`scripts/build-icons.mjs` rasterizes `apps/web/public/logo.svg` into every
icon asset the packager needs. Run via:
```bash
pnpm --filter @memohai/desktop icons
```
Outputs (all derived from a single 1024×1024 master with 14% safe-area
padding to clear macOS Big Sur+ squircle masks):
| File | Used by |
|------|---------|
| `build/icon.png` (1024) | electron-builder Linux (`.AppImage` / `.deb` / `.rpm`) + ico master |
| `build/icon.icns` | electron-builder macOS bundle |
| `build/icon.ico` | electron-builder Windows installer |
| `resources/icon.png` (512) | Runtime `BrowserWindow.icon` + macOS `app.dock.setIcon` |
`build/icon.icns` requires `iconutil` (macOS only); the script logs and
skips it on other platforms. `resources/` is `asarUnpack`ed by
electron-builder so the runtime icon is dereferenceable from disk.
## Build & Distribution
| Command | Output | Notes |
|---------|--------|-------|
| `pnpm --filter @memohai/desktop dev` | dev server + main process watch | Renderer hot-reload; main needs window restart on changes |
| `pnpm --filter @memohai/desktop build` | `dist/` installers (current platform) | Runs electron-vite build, then electron-builder |
| `pnpm --filter @memohai/desktop build:dir` | `dist/<platform>-unpacked/` | Skip installer; smoke-test packaged app |
| `pnpm --filter @memohai/desktop build:mac` | DMG + ZIP (arm64 + x64) | Requires darwin |
| `pnpm --filter @memohai/desktop build:linux` | AppImage + deb + rpm | x64 |
| `pnpm --filter @memohai/desktop build:win` | NSIS installer | x64 |
| `pnpm --filter @memohai/desktop typecheck` | (no output) | Runs `typecheck:node` then `typecheck:web` |
Ports / hosts during dev come from the same `config.toml` the rest of the
stack reads (via `@memohai/config`). The repo-level `mise run desktop:dev`
task is the recommended entrypoint when contributing.
## Native Dependencies
`electron`, `electron-winstaller`, `esbuild`, and `sharp` all require
`postinstall` scripts to be allowed (they install or compile native
binaries). They are listed in the **root** `pnpm-workspace.yaml` under
`onlyBuiltDependencies` — if `pnpm install` ever fails with `Error:
Electron uninstall` or `sharp` missing its prebuilt binary, that's the
list to check.
## Development Rules
- **Do not edit `@memohai/web` to make desktop work.** If web doesn't
expose what you need, add a new subpath export to
`apps/web/package.json` and a matching stub in
`src/renderer/types/web-stubs.d.ts`. Web should remain shippable as a
pure browser app.
- **Path-based navigation in shared components.** Any reusable
chat-sidebar (or other web component embedded in the chat window) that
navigates to settings must use `router.push('/settings/...')`, never
`router.push({ name: '...' })` — the chat router only knows the chat
routes.
- **Provide `DesktopShellKey` at the renderer App root, not deeper.** Web
must keep injecting `false` (the default fallback) — never provide it
from any web component.
- **All renderer code is browser-grade.** Need a node/Electron API in the
renderer? Add an IPC handler in `src/main/index.ts`, a passthrough in
`src/preload/index.ts`, then update `MemohApi` (the type derived from
`api`) and `src/preload/global.d.ts`. Don't reach for `nodeIntegration:
true`.
- **Persist user state through the existing web Pinia stores** (chat-
selection, user, settings) — they're already configured with
`pinia-plugin-persistedstate` and shared across both windows via
localStorage. Don't add desktop-only persistence layers without a
compelling reason.
- **Update both `tsconfig.web.json` paths and `web-stubs.d.ts`** when adding
a new `@memohai/web/foo` import. Forgetting the stub yields
`TS2307: Cannot find module` even though the bundle works.
- **Run `pnpm --filter @memohai/desktop typecheck` after every renderer
change.** It's fast (only types desktop's own code thanks to the stubs)
and catches the common drift cases (missing stub, wrong store/component
shape, untyped IPC arg).
- **Update this file** when you add a new window, IPC handler, subpath
reuse, build target, or platform-specific affordance — the desktop
shell is small enough that out-of-date docs become obviously wrong
fast.
## Cross-References
- Repo root: `/AGENTS.md` (overall architecture, server-side packages, db conventions).
- Web: `apps/web/AGENTS.md` (component / store / page conventions; consumed wholesale here).
- Design system: `packages/ui/DESIGN.md` (tokens, elevation, spacing — applies to anything rendered in either desktop window).
+61 -1
View File
@@ -1 +1,61 @@
# memohai/desktop
# @memohai/desktop
Memoh desktop application built with [electron-vite](https://electron-vite.github.io/).
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
```bash
# from repo root
pnpm --filter @memohai/desktop dev
# or via mise
mise run desktop:dev
```
`MEMOH_WEB_PROXY_TARGET` overrides the backend that the renderer's `/api` proxy points
at (defaults to whatever `config.toml` / `conf/app.docker.toml` declares).
## Build
```bash
pnpm --filter @memohai/desktop build # full platform installer
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.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+48
View File
@@ -0,0 +1,48 @@
appId: ai.memoh.desktop
productName: Memoh
copyright: Copyright © 2026 Memoh
directories:
buildResources: build
output: dist
files:
- "!**/.vscode/*"
- "!src/*"
- "!electron.vite.config.{js,ts,mjs,cjs}"
- "!{.eslintrc.cjs,.eslintignore,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}"
- "!{.env,.env.*,.npmrc,pnpm-lock.yaml}"
- "!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}"
asarUnpack:
- resources/**
mac:
category: public.app-category.productivity
target:
- target: dmg
arch: [arm64, x64]
- target: zip
arch: [arm64, x64]
icon: build/icon.icns
artifactName: ${productName}-${version}-${arch}.${ext}
notarize: false
linux:
category: Utility
target:
- target: AppImage
arch: [x64]
- target: deb
arch: [x64]
- target: rpm
arch: [x64]
maintainer: Memoh
icon: build/icon.png
artifactName: ${productName}-${version}-${arch}.${ext}
win:
target:
- target: nsis
arch: [x64]
icon: build/icon.ico
artifactName: ${productName}-${version}-${arch}-setup.${ext}
nsis:
oneClick: false
perMachine: false
allowToChangeInstallationDirectory: true
npmRebuild: false
+99
View File
@@ -0,0 +1,99 @@
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import { resolve } from 'node:path'
const require = createRequire(import.meta.url)
const defaultPort = 8082
const defaultHost = '127.0.0.1'
const defaultApiBaseUrl = process.env.VITE_API_URL ?? 'http://localhost:8080'
function resolveProxyTarget(command: 'build' | 'serve'): { port: number; host: string; baseUrl: string } {
const configuredProxyTarget = process.env.MEMOH_WEB_PROXY_TARGET?.trim()
const configuredPath = process.env.MEMOH_CONFIG_PATH?.trim() || process.env.CONFIG_PATH?.trim()
const configPath = configuredPath && configuredPath.length > 0 ? configuredPath : '../../config.toml'
let port = defaultPort
let host = defaultHost
let baseUrl = configuredProxyTarget || defaultApiBaseUrl
if (command !== 'build') {
try {
const { loadConfig, getBaseUrl } = require('@memohai/config') as {
loadConfig: (path: string) => { web?: { port?: number; host?: string } }
getBaseUrl: (config: unknown) => string
}
let config
try {
config = loadConfig(configPath)
} catch {
config = loadConfig('../../conf/app.docker.toml')
}
port = config.web?.port ?? defaultPort
host = config.web?.host ?? defaultHost
baseUrl = configuredProxyTarget || getBaseUrl(config)
} catch {
// fall back to env/default values when config.toml is unavailable.
}
}
return { port, host, baseUrl }
}
export default defineConfig(({ command }) => {
const { port, host, baseUrl } = resolveProxyTarget(command)
return {
main: {
plugins: [externalizeDepsPlugin()],
},
preload: {
plugins: [externalizeDepsPlugin()],
},
renderer: {
root: resolve(__dirname, 'src/renderer'),
// Reuse apps/web/public so absolute-path assets (e.g. /logo.svg) resolve
// when web modules are imported directly from the desktop renderer.
publicDir: resolve(__dirname, '../web/public'),
plugins: [vue(), tailwindcss()],
resolve: {
alias: {
'@renderer': fileURLToPath(new URL('./src/renderer/src', import.meta.url)),
// match apps/web/vite.config.ts aliases so imported web modules resolve correctly.
'@': fileURLToPath(new URL('../web/src', import.meta.url)),
'#': fileURLToPath(new URL('../../packages/ui/src', import.meta.url)),
},
},
optimizeDeps: {
entries: [
'src/renderer/src/main.ts',
'../web/src/main.ts',
'../web/src/pages/**/*.vue',
],
},
build: {
rollupOptions: {
input: {
index: resolve(__dirname, 'src/renderer/index.html'),
settings: resolve(__dirname, 'src/renderer/settings.html'),
},
},
},
server: {
port,
host,
proxy: {
'/api': {
target: baseUrl,
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/api/, ''),
ws: true,
},
},
},
},
}
})
+44 -6
View File
@@ -3,16 +3,54 @@
"private": true,
"version": "0.7.1",
"type": "module",
"description": "Memoh Electron desktop application (self-managed bootstrap reusing @memohai/web components)",
"main": "./out/main/index.js",
"scripts": {
"dev": "tauri dev",
"build": "tauri build",
"tauri": "tauri"
"dev": "electron-vite dev",
"start": "electron-vite preview",
"build": "electron-vite build && electron-builder",
"build:dir": "electron-vite build && electron-builder --dir",
"build:unpack": "electron-vite build && electron-builder --dir",
"build:mac": "electron-vite build && electron-builder --mac",
"build:linux": "electron-vite build && electron-builder --linux",
"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",
"icons": "node scripts/build-icons.mjs"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2"
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^4.0.0",
"@memohai/icon": "workspace:*",
"@memohai/sdk": "workspace:*",
"@memohai/ui": "workspace:*",
"@memohai/web": "workspace:*"
},
"devDependencies": {
"@tauri-apps/cli": "^2"
"@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)
})
-7
View File
@@ -1,7 +0,0 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
-5471
View File
File diff suppressed because it is too large Load Diff
-25
View File
@@ -1,25 +0,0 @@
[package]
name = "desktop"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
-3
View File
@@ -1,3 +0,0 @@
fn main() {
tauri_build::build()
}
@@ -1,10 +0,0 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default"
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

-23
View File
@@ -1,23 +0,0 @@
use tauri::LogicalSize;
#[tauri::command]
fn resize_for_route(window: tauri::Window, route: String) {
let is_login = route == "/login";
if is_login {
let _ = window.set_min_size(None::<tauri::Size>);
let _ = window.set_size(tauri::Size::Logical(LogicalSize::new(480.0, 700.0)));
} else {
let _ = window.set_size(tauri::Size::Logical(LogicalSize::new(1280.0, 800.0)));
let _ = window.set_min_size(Some(tauri::Size::Logical(LogicalSize::new(960.0, 600.0))));
}
let _ = window.center();
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![resize_for_route])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
-6
View File
@@ -1,6 +0,0 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
desktop_lib::run()
}
-35
View File
@@ -1,35 +0,0 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Memoh",
"version": "0.1.0",
"identifier": "ai.memoh.desktop",
"build": {
"beforeDevCommand": "pnpm --filter @memohai/web exec vite --port 1420 --strictPort",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "pnpm --filter @memohai/web build",
"frontendDist": "../../web/dist"
},
"app": {
"windows": [
{
"title": "Memoh",
"width": 1280,
"height": 800
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
+149
View File
@@ -0,0 +1,149 @@
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 CHAT_DEFAULTS = { width: 1280, height: 800, minWidth: 960, minHeight: 600 }
const SETTINGS_DEFAULTS = { width: 1080, height: 720, minWidth: 880, minHeight: 560 }
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'
// On macOS we hide the system titlebar but keep the native traffic lights
// (`hiddenInset`). Renderers reserve space for them via a custom TopBar.
const macTitleBarOptions: Partial<Electron.BrowserWindowConstructorOptions>
= process.platform === 'darwin'
? { titleBarStyle: 'hiddenInset', trafficLightPosition: { x: 14, y: 12 } }
: {}
function createChatWindow(): BrowserWindow {
const window = new BrowserWindow({
...CHAT_DEFAULTS,
...macTitleBarOptions,
show: false,
autoHideMenuBar: true,
title: 'Memoh',
icon: iconPng,
webPreferences: {
preload: join(__dirname, PRELOAD_FILE),
sandbox: false,
contextIsolation: true,
nodeIntegration: false,
},
})
window.on('ready-to-show', () => {
window.show()
})
window.on('closed', () => {
chatWindow = null
})
applyExternalLinkHandler(window)
loadRendererEntry(window, 'index')
return window
}
function createSettingsWindow(parent: BrowserWindow | null): BrowserWindow {
const window = new BrowserWindow({
...SETTINGS_DEFAULTS,
...macTitleBarOptions,
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)
})
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) {
chatWindow = createChatWindow()
}
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
+11
View File
@@ -0,0 +1,11 @@
import type { ElectronAPI } from '@electron-toolkit/preload'
import type { MemohApi } from './index'
declare global {
interface Window {
electron: ElectronAPI
api: MemohApi
}
}
export {}
+26
View File
@@ -0,0 +1,26 @@
import { contextBridge, ipcRenderer } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
// 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 {
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('api', api)
} catch (error) {
console.error(error)
}
} else {
window.electron = electronAPI
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</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+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,18 @@
<script setup lang="ts">
import { provide } from 'vue'
import { RouterView } from 'vue-router'
import { Toaster } from '@memohai/ui'
import 'vue-sonner/style.css'
import { useSettingsStore } from '@memohai/web/store/settings'
import { DesktopShellKey } from '@memohai/web/lib/desktop-shell'
provide(DesktopShellKey, true)
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
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}
+30
View File
@@ -0,0 +1,30 @@
// 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,36 @@
<script setup lang="ts">
import { provide } from 'vue'
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'
import { DesktopShellKey } from '@memohai/web/lib/desktop-shell'
provide(DesktopShellKey, true)
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
}
+52
View File
@@ -0,0 +1,52 @@
// 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/lib/desktop-shell' {
import type { InjectionKey } from 'vue'
export const DesktopShellKey: InjectionKey<boolean>
}
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
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.web.json" }
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": [
"electron.vite.config.ts",
"src/main/**/*",
"src/preload/**/*"
],
"compilerOptions": {
"composite": true,
"moduleResolution": "bundler",
"module": "esnext",
"types": ["node", "electron-vite/node"]
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"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/*"],
"@memohai/web/*": ["src/renderer/types/web-stubs.d.ts"],
"@memohai/ui": ["src/renderer/types/ui-stubs.d.ts"]
}
}
}
-1
View File
@@ -250,7 +250,6 @@ Both routes render the same `home/index.vue` component. The `home` route shows a
- All routes except `/login` and `/oauth/*` require `localStorage.getItem('token')`.
- Logged-in users accessing `/login` are redirected to `/`.
- Chunk load errors (dynamic import failures) trigger an automatic page reload.
- Tauri integration: `afterEach` hook calls `resize_for_route` via `@tauri-apps/api/core` when running inside Tauri.
## Layout System
+11 -3
View File
@@ -3,6 +3,17 @@
"private": true,
"version": "0.7.1",
"type": "module",
"exports": {
".": "./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",
"./lib/desktop-shell": "./src/lib/desktop-shell.ts",
"./style.css": "./src/style.css",
"./*": "./src/*"
},
"scripts": {
"dev": "vite",
"build": "vite build",
@@ -51,9 +62,6 @@
"vue-sonner": "^2.0.9",
"zod": "^4.3.5"
},
"optionalDependencies": {
"@tauri-apps/api": "^2"
},
"devDependencies": {
"@memohai/config": "workspace:*",
"@types/moment": "^2.13.0",
@@ -1,7 +1,18 @@
<template>
<aside>
<Sidebar collapsible="icon">
<SidebarHeader class="p-0 border-0">
<aside class="relative h-full">
<header
v-if="topInset"
class="fixed top-0 left-0 z-20 h-9 w-(--sidebar-width) bg-sidebar border-r border-sidebar-border [-webkit-app-region:drag]"
/>
<Sidebar
:collapsible="topInset ? 'none' : 'icon'"
:class="topInset ? 'pt-9 h-dvh border-r border-sidebar-border' : ''"
>
<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)"
@@ -42,13 +53,13 @@
</SidebarGroup>
</SidebarContent>
<SidebarRail />
<SidebarRail v-if="!topInset" />
</Sidebar>
</aside>
</template>
<script setup lang="ts">
import { computed, type Component } from 'vue'
import { computed, inject, type Component } from 'vue'
import { storeToRefs } from 'pinia'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
@@ -65,6 +76,18 @@ import {
SidebarMenuItem,
SidebarRail,
} from '@memohai/ui'
import { DesktopShellKey } from '@/lib/desktop-shell'
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 topInset = inject(DesktopShellKey, false)
const router = useRouter()
const route = useRoute()
+31 -36
View File
@@ -1,7 +1,30 @@
<template>
<aside>
<Sidebar collapsible="icon">
<SidebarHeader class="p-0 border-0">
<aside class="relative h-full">
<header
v-if="topInset"
class="fixed top-0 left-0 z-20 h-9 w-(--sidebar-width) flex items-center pl-[78px] pr-2 gap-1 bg-sidebar border-r border-sidebar-border [-webkit-app-region:drag]"
>
<div class="ml-auto flex items-center gap-1 [-webkit-app-region:no-drag]">
<Button
variant="ghost"
size="icon"
class="size-6 text-muted-foreground hover:text-foreground shrink-0"
:aria-label="t('bots.createBot')"
@click="router.push('/settings/bots')"
>
<Plus class="size-3.5" />
</Button>
</div>
</header>
<Sidebar
:collapsible="topInset ? 'none' : 'icon'"
:class="topInset ? 'pt-9 h-dvh border-r border-sidebar-border' : ''"
>
<SidebarHeader
v-if="!topInset"
class="p-0 border-0"
>
<div class="h-10 flex items-center pl-2 group-data-[collapsible=icon]:pl-3 transition-[padding] duration-200 ease-linear">
<Button
variant="ghost"
@@ -58,33 +81,9 @@
</SidebarGroup>
</SidebarContent>
<SidebarRail />
<SidebarFooter class="relative border-0 px-2 pb-3.5 pt-2.5">
<div class="pointer-events-none absolute -top-30 left-0 h-38.25 w-full bg-linear-to-t from-(--sidebar-background) from-18% to-transparent z-10 group-data-[collapsible=icon]:hidden" />
<SidebarMenu class="gap-2.5">
<!-- <SidebarMenuItem>
<SidebarMenuButton
:tooltip="displayTitle"
class="h-10 px-2.5"
@click="router.push({ name: 'profile' })"
>
<div class="size-9 shrink-0 rounded-full border border-border bg-accent overflow-hidden p-[1.385px]">
<img
v-if="userInfo.avatarUrl"
:src="userInfo.avatarUrl"
:alt="displayTitle"
class="size-full rounded-full object-cover"
>
<span
v-else
class="size-full flex items-center justify-center text-[10px] font-medium text-muted-foreground"
>
{{ avatarFallback }}
</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem> -->
<SidebarMenuItem>
<SidebarMenuButton
:tooltip="t('sidebar.settings')"
@@ -100,19 +99,19 @@
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
<SidebarRail v-if="!topInset" />
</Sidebar>
</aside>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, inject } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useQuery } from '@pinia/colada'
import { getBotsQuery } from '@memohai/sdk/colada'
import type { BotsBot } from '@memohai/sdk'
// import { useUserStore } from '@/store/user'
// import { useAvatarInitials } from '@/composables/useAvatarInitials'
import {
Button,
Sidebar,
@@ -130,21 +129,17 @@ import {
import { Plus, LoaderCircle, Settings, PanelLeftClose, PanelLeftOpen } from 'lucide-vue-next'
import BotItem from './bot-item.vue'
import { usePinnedBots } from '@/composables/usePinnedBots'
import { DesktopShellKey } from '@/lib/desktop-shell'
const router = useRouter()
const route = useRoute()
const { t } = useI18n()
const { toggleSidebar } = useSidebar()
// const { userInfo } = useUserStore()
const topInset = inject(DesktopShellKey, false)
const { sortBots } = usePinnedBots()
const { data: botData, isLoading } = useQuery(getBotsQuery())
const bots = computed<BotsBot[]>(() => sortBots(botData.value?.items ?? []))
const isSettingsActive = computed(() => route.path.startsWith('/settings'))
// const displayTitle = computed(() =>
// userInfo.displayName || userInfo.username || userInfo.id || t('settings.user'),
// )
// const avatarFallback = useAvatarInitials(() => displayTitle.value, 'U')
</script>
+9 -2
View File
@@ -16,16 +16,23 @@
</section>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ref, watch, inject } from 'vue'
import { SidebarProvider } from '@memohai/ui'
import { useMediaQuery } from '@vueuse/core'
import { DesktopShellKey } from '@/lib/desktop-shell'
const sidebarDefaultOpen = !document.cookie.includes('sidebar_state=false')
// In the desktop shell the sidebar collapse affordance is intentionally
// disabled we keep the sidebar pinned open and skip the small-screen
// auto-collapse watcher so window resizes don't fight the layout.
const desktopShell = inject(DesktopShellKey, false)
const sidebarDefaultOpen = desktopShell || !document.cookie.includes('sidebar_state=false')
const isOpen = ref(sidebarDefaultOpen)
const isSmallScreen = useMediaQuery('(max-width: 1024px)')
watch(isSmallScreen, (isSmall) => {
if (desktopShell) return
if (isSmall) {
isOpen.value = false
} else {
+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
})
+6
View File
@@ -0,0 +1,6 @@
import type { InjectionKey } from 'vue'
// Provided by the Electron desktop shell to enable a macOS-style top inset
// (traffic-light reserve + custom TopBar) inside reusable web sidebars.
// Web (browser) does not provide this key, so consumers fall back to false.
export const DesktopShellKey: InjectionKey<boolean> = Symbol('memohai:desktop-shell')

Some files were not shown because too many files have changed in this diff Show More