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.
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"tauri-apps.tauri-vscode",
|
||||
"rust-lang.rust-analyzer"
|
||||
]
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
|
||||
|
After Width: | Height: | Size: 364 KiB |
|
After Width: | Height: | Size: 36 KiB |
@@ -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
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 29 KiB |
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 8.6 KiB |
|
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>
|
||||
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
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>
|
||||
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 982 B |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 18 KiB |
@@ -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");
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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 {}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
{ "path": "./tsconfig.web.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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')
|
||||