diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 64457164..1786a6cb 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,6 +17,8 @@ jobs:
build:
name: Build (${{ matrix.os }})
runs-on: ${{ matrix.os }}
+ env:
+ GOCACHE: ${{ runner.temp }}/go-build-cache
strategy:
fail-fast: false
matrix:
@@ -34,6 +36,12 @@ jobs:
node-version: 22
cache: npm
+ - name: Set up Go
+ uses: actions/setup-go@v6
+ with:
+ go-version-file: apps/server/go.mod
+ cache-dependency-path: apps/server/go.sum
+
- name: Install dependencies
run: npm ci
diff --git a/.gitignore b/.gitignore
index ba1e798d..a327a86f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,10 @@
node_modules
out
dist
+.turbo
+apps/*/.turbo
+apps/web/dist
+apps/server/bin
.DS_Store
*.log
.env
@@ -11,3 +15,4 @@ dist
.idea
.claude/
*.tsbuildinfo
+data/
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 00000000..f6fc1c6b
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,64 @@
+# syntax=docker/dockerfile:1.7
+
+# ZenNotes self-hosted build from the monorepo.
+#
+# Stages:
+# 1. web-build -> npm workspace install + Vite build for apps/web
+# 2. server-build -> Go build for apps/server with the web bundle embedded
+# 3. runtime -> minimal image with only the server binary
+
+FROM node:22-alpine AS web-build
+WORKDIR /app
+
+COPY package.json package-lock.json turbo.json tsconfig.base.json tsconfig.json tailwind.config.js postcss.config.js ./
+COPY apps/web/package.json apps/web/package.json
+COPY packages/app-core/package.json packages/app-core/package.json
+COPY packages/bridge-contract/package.json packages/bridge-contract/package.json
+COPY packages/shared-domain/package.json packages/shared-domain/package.json
+COPY packages/shared-ui/package.json packages/shared-ui/package.json
+COPY apps/desktop/package.json apps/desktop/package.json
+COPY apps/server/package.json apps/server/package.json
+
+RUN npm ci --no-audit --no-fund --loglevel=error
+
+COPY apps apps
+COPY packages packages
+
+RUN npm run build --workspace @zennotes/web
+
+FROM golang:1.22-alpine AS server-build
+WORKDIR /app
+
+COPY apps/server/go.mod apps/server/go.sum ./apps/server/
+WORKDIR /app/apps/server
+RUN go mod download
+
+WORKDIR /app
+COPY apps/server apps/server
+COPY --from=web-build /app/apps/web/dist/ /app/apps/server/web/dist/
+
+ENV CGO_ENABLED=0 \
+ GOOS=linux \
+ GOFLAGS=-trimpath
+
+WORKDIR /app/apps/server
+RUN go build -ldflags="-s -w" -o /out/zennotes-server ./cmd/zennotes-server
+
+FROM scratch
+LABEL org.opencontainers.image.title="ZenNotes" \
+ org.opencontainers.image.description="Self-hosted ZenNotes web/server bundle from the monorepo." \
+ org.opencontainers.image.source="https://github.com/ZenNotes/zennotes"
+
+COPY --from=server-build /out/zennotes-server /zennotes-server
+
+ENV ZENNOTES_BIND=0.0.0.0:7878 \
+ ZENNOTES_CONFIG_PATH=/data/server.json \
+ ZENNOTES_DEFAULT_VAULT_PATH=/workspace \
+ ZENNOTES_BROWSE_ROOTS=/workspace
+
+USER 65532:65532
+
+EXPOSE 7878
+VOLUME ["/workspace", "/data"]
+
+ENTRYPOINT ["/zennotes-server"]
diff --git a/Makefile b/Makefile
new file mode 100644
index 00000000..6aee6b6e
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,163 @@
+IMAGE ?= zennotes-selfhosted:local
+PORT ?= 7878
+CONTENT_ROOT ?= ./vault
+DATA ?= ./data
+APP_URL ?= http://localhost:$(PORT)
+ALLOW_INSECURE_NOAUTH ?= 0
+COMPOSE := $(shell docker compose version >/dev/null 2>&1 && echo "docker compose" || echo "docker-compose")
+OPEN_BROWSER := $(shell command -v open 2>/dev/null || command -v xdg-open 2>/dev/null)
+
+.PHONY: help install dev desktop web-dev server-dev web-stack \
+ build desktop-build web-build server-build \
+ up down restart logs status open rebuild nuke clean
+
+help:
+ @echo ""
+ @echo " Setup"
+ @echo " make install — install npm workspace dependencies"
+ @echo ""
+ @echo " Local development"
+ @echo " make desktop — run the Electron desktop app in dev mode"
+ @echo " make web-dev — run the Vite web client in dev mode"
+ @echo " make server-dev — run the Go server in dev mode"
+ @echo " make web-stack — run server + web dev together"
+ @echo ""
+ @echo " Local builds"
+ @echo " make build — build the full monorepo"
+ @echo " make desktop-build — build the Electron desktop app"
+ @echo " make web-build — build apps/web"
+ @echo " make server-build — build apps/server with the latest embedded web bundle"
+ @echo ""
+ @echo " Docker"
+ @echo " make up — build and start the self-hosted server"
+ @echo " make down — stop the container"
+ @echo " make restart — restart the container"
+ @echo " make logs — follow logs"
+ @echo " make status — show compose status"
+ @echo " make open — open the app in your browser"
+ @echo " make rebuild — force a full rebuild"
+ @echo " make nuke — tear down and remove local image/build output"
+ @echo " make clean — remove local web/server build output"
+ @echo ""
+ @echo " Useful Docker vars"
+ @echo " CONTENT_ROOT=~/iCloud Drive/Obsidian — host folder used as the live vault root"
+ @echo " PORT=7878 — host port"
+ @echo " ALLOW_INSECURE_NOAUTH=1 — opt out of generated auth token (not recommended)"
+ @echo ""
+
+install:
+ npm ci
+
+dev: desktop
+
+desktop:
+ npm run dev:desktop
+
+web-dev:
+ npm run dev:web
+
+server-dev:
+ npm run dev:server
+
+web-stack:
+ npm run dev:web-stack
+
+build:
+ npm run build
+
+desktop-build:
+ npm run build --workspace @zennotes/desktop
+
+web-build:
+ npm run build --workspace @zennotes/web
+
+up:
+ @mkdir -p "$(CONTENT_ROOT)" "$(DATA)"
+ @ABS_CONTENT_ROOT="$$(cd "$(CONTENT_ROOT)" && pwd)"; \
+ ABS_DATA="$$(cd "$(DATA)" && pwd)"; \
+ AUTH_TOKEN=""; \
+ if [ "$(ALLOW_INSECURE_NOAUTH)" != "1" ]; then \
+ AUTH_TOKEN_FILE="$$ABS_DATA/auth-token"; \
+ if [ ! -f "$$AUTH_TOKEN_FILE" ]; then \
+ node -e "process.stdout.write(require('node:crypto').randomBytes(32).toString('hex'))" > "$$AUTH_TOKEN_FILE"; \
+ chmod 600 "$$AUTH_TOKEN_FILE"; \
+ fi; \
+ AUTH_TOKEN="$$(cat "$$AUTH_TOKEN_FILE")"; \
+ fi; \
+ ZENNOTES_IMAGE="$(IMAGE)" \
+ ZENNOTES_HOST_PORT="$(PORT)" \
+ ZENNOTES_HOST_CONTENT_ROOT="$$ABS_CONTENT_ROOT" \
+ ZENNOTES_CONTAINER_CONTENT_ROOT="$$ABS_CONTENT_ROOT" \
+ ZENNOTES_CONTAINER_DEFAULT_VAULT_PATH="$$ABS_CONTENT_ROOT" \
+ ZENNOTES_BROWSE_ROOTS="$$ABS_CONTENT_ROOT" \
+ ZENNOTES_ALLOWED_ORIGINS="http://localhost:$(PORT),http://127.0.0.1:$(PORT)" \
+ ZENNOTES_AUTH_TOKEN="$$AUTH_TOKEN" \
+ ZENNOTES_ALLOW_INSECURE_NOAUTH="$(ALLOW_INSECURE_NOAUTH)" \
+ ZENNOTES_HOST_DATA="$$ABS_DATA" \
+ ZENNOTES_CONTAINER_UID="$$(id -u)" \
+ ZENNOTES_CONTAINER_GID="$$(id -g)" \
+ $(COMPOSE) up --build -d
+ @printf "\nZenNotes is running at $(APP_URL)\n\n"
+ifneq ($(ALLOW_INSECURE_NOAUTH),1)
+ @printf "Auth token: $(DATA)/auth-token\n\n"
+endif
+ifneq ($(strip $(OPEN_BROWSER)),)
+ @$(OPEN_BROWSER) $(APP_URL) >/dev/null 2>&1 || true
+endif
+
+down:
+ @$(COMPOSE) down
+
+restart:
+ @$(COMPOSE) restart zennotes 2>/dev/null || $(MAKE) --no-print-directory up
+
+logs:
+ @$(COMPOSE) logs -f
+
+status:
+ @$(COMPOSE) ps
+
+open:
+ifneq ($(strip $(OPEN_BROWSER)),)
+ @$(OPEN_BROWSER) $(APP_URL)
+else
+ @echo "Open $(APP_URL) manually."
+endif
+
+rebuild:
+ @mkdir -p "$(CONTENT_ROOT)" "$(DATA)"
+ @ABS_CONTENT_ROOT="$$(cd "$(CONTENT_ROOT)" && pwd)"; \
+ ABS_DATA="$$(cd "$(DATA)" && pwd)"; \
+ AUTH_TOKEN=""; \
+ if [ "$(ALLOW_INSECURE_NOAUTH)" != "1" ]; then \
+ AUTH_TOKEN_FILE="$$ABS_DATA/auth-token"; \
+ if [ ! -f "$$AUTH_TOKEN_FILE" ]; then \
+ node -e "process.stdout.write(require('node:crypto').randomBytes(32).toString('hex'))" > "$$AUTH_TOKEN_FILE"; \
+ chmod 600 "$$AUTH_TOKEN_FILE"; \
+ fi; \
+ AUTH_TOKEN="$$(cat "$$AUTH_TOKEN_FILE")"; \
+ fi; \
+ ZENNOTES_IMAGE="$(IMAGE)" \
+ ZENNOTES_HOST_PORT="$(PORT)" \
+ ZENNOTES_HOST_CONTENT_ROOT="$$ABS_CONTENT_ROOT" \
+ ZENNOTES_CONTAINER_CONTENT_ROOT="$$ABS_CONTENT_ROOT" \
+ ZENNOTES_CONTAINER_DEFAULT_VAULT_PATH="$$ABS_CONTENT_ROOT" \
+ ZENNOTES_BROWSE_ROOTS="$$ABS_CONTENT_ROOT" \
+ ZENNOTES_ALLOWED_ORIGINS="http://localhost:$(PORT),http://127.0.0.1:$(PORT)" \
+ ZENNOTES_AUTH_TOKEN="$$AUTH_TOKEN" \
+ ZENNOTES_ALLOW_INSECURE_NOAUTH="$(ALLOW_INSECURE_NOAUTH)" \
+ ZENNOTES_HOST_DATA="$$ABS_DATA" \
+ ZENNOTES_CONTAINER_UID="$$(id -u)" \
+ ZENNOTES_CONTAINER_GID="$$(id -g)" \
+ $(COMPOSE) build --no-cache
+ @$(MAKE) --no-print-directory up
+
+nuke:
+ @$(COMPOSE) down --rmi local --volumes || true
+ @rm -rf apps/web/dist apps/server/bin apps/server/web/dist $(DATA)
+
+server-build: web-build
+ npm run build --workspace @zennotes/server
+
+clean:
+ rm -rf apps/web/dist apps/server/bin apps/server/web/dist
diff --git a/README.md b/README.md
index 7eacf4f6..ec2f4d98 100644
--- a/README.md
+++ b/README.md
@@ -1,47 +1,56 @@
# ZenNotes
-
+
-ZenNotes is a keyboard-first desktop notes app built on Electron,
-React, TypeScript, and CodeMirror 6. It keeps notes as ordinary local
-Markdown files, adds a Vim-friendly editing and navigation model, and
-ships a first-party MCP server so tools like Claude Code, Claude
-Desktop, and Codex can work directly against the same vault.
+ZenNotes is a keyboard-first Markdown notes app with a shared product core and multiple runtimes:
-The goal is not "another markdown renderer." The goal is a local-first
-notes environment that still feels fast and intentional when you live in
-it all day.
+- a desktop app built with Electron
+- a self-hosted web app backed by a Go server
+- a future hosted deployment mode built on the same web/server stack
-Download the latest build from [GitHub Releases](https://github.com/ZenNotes/zennotes/releases/latest).
+ZenNotes keeps your notes as ordinary Markdown files on disk. It adds Vim-friendly editing, split and preview workflows, tasks, tags, archive/trash, diagrams, search, daily notes, and MCP integration on top of the files you already own.
+
+Download the latest desktop build from [GitHub Releases](https://github.com/ZenNotes/zennotes/releases/latest).
+Website: [zennotes.org](https://zennotes.org)
## What ZenNotes is for
- writing and organizing plain-file Markdown notes without a database
- moving quickly with keyboard-first navigation and Vim motions
- working across edit, split, and preview modes without losing context
-- keeping task management, tags, search, archive, and trash inside the
- same vault
+- keeping tasks, tags, search, archive, trash, and quick capture inside the same vault
- rendering math and diagrams directly from Markdown
-- letting MCP-capable coding / note-taking tools read and write the
- vault safely through a bundled server
+- exposing the vault to MCP-capable tools through a first-party server
+- self-hosting the app on your own machine or home server
+
+## Product modes
+
+ZenNotes now ships from one monorepo with one shared app core.
+
+- `desktop`: Electron shell, native menus, updater, floating windows, desktop packaging
+- `self-hosted`: browser frontend plus Go server, suitable for home servers and LAN use
+- `hosted`: planned as the same web/server stack with auth and multi-user storage added later
-## Core product ideas
+The source of truth for user-facing features is the shared UI in `packages/app-core`.
+
+## Core ideas
### Plain files first
-Every note lives on disk as a normal `.md` file inside a chosen vault.
-ZenNotes does not invent a hidden store for note content. The app adds
-views, metadata extraction, search, and rendering on top of the files
-you already own.
+Every note is a normal `.md` file inside a chosen vault. ZenNotes does not store note content in a hidden database.
### Keyboard-first by default
-The app assumes you want fast navigation, not pointer-heavy chrome.
-There is first-class Vim mode, leader-key flows, remappable shortcuts,
-buffer switching, pane motion, command palette access, local ex prompts,
-and which-key style hint overlays.
+ZenNotes assumes you want to move fast:
+
+- first-class Vim mode
+- leader-key flows
+- command palette
+- pane and tab motion
+- local ex commands
+- built-in help
### Preview is part of the workflow
@@ -51,36 +60,39 @@ ZenNotes supports:
- preview mode
- split mode
- pinned reference panes
-- detached note windows
-
-That makes it useful both as a writing tool and as a reading /
-researching tool.
+- detached note windows on desktop
-### AI tooling should work with the real vault
+### Shared vault, shared tooling
-ZenNotes includes a standalone MCP server entry and a settings UI that
-can install the server into supported clients. The intent is simple:
-your assistant should operate on the same notes you do, using normal
-Markdown files and safe vault operations.
+ZenNotes includes a first-party MCP server and desktop install flows for compatible clients, so tools can work on the same vault you do instead of a copy.
## Feature overview
### Notes, folders, and lifecycle
-Each vault is organized into four top-level folders:
+ZenNotes can:
-- `inbox/` for active work
-- `quick/` for fast capture
-- `archive/` for cold storage
-- `trash/` for recoverable deletion
+- create, rename, duplicate, move, archive, unarchive, trash, restore, and reveal notes and folders
+- watch the vault for external changes
+- reopen your workspace layout with tabs and panes
-ZenNotes can create, rename, duplicate, move, archive, unarchive, trash,
-restore, and reveal notes and folders. The app also watches the vault on
-disk, so external edits are reflected back into the UI.
+System folders still exist, but the vault model is more flexible now:
-Attachments are stored under a vault-local attachments directory
-(`attachements/` in the current implementation, with legacy `_assets/`
-support still recognized).
+- `quick`, `archive`, and `trash` remain built-in lifecycle areas
+- the main notes area can be either:
+ - `inbox/`
+ - the vault root directly, for Obsidian-style flat vaults
+
+The built-in folder labels are also customizable in the UI without changing the underlying internal ids.
+
+### Daily notes
+
+Daily notes are optional and can be enabled from Settings.
+
+- when enabled, ZenNotes can open or create today's note automatically
+- the title is a simple ISO date like `2026-04-21`
+- daily notes live in a dedicated directory under your primary notes area
+- the default directory is `Daily Notes`
### Editor and preview
@@ -89,13 +101,13 @@ The editor stack is CodeMirror 6 with a Markdown-oriented workflow:
- live preview behavior in the editor
- heading folding
- outline extraction and jumps
-- word wrap controls
- configurable line numbers
+- configurable line-height and typography controls
- syntax highlighting for fenced code blocks
-- local asset embedding
-- inline PDF support
+- wiki links, callouts, tables, footnotes, and local embeds
+- Vim block cursor and keyboard navigation
-Preview mode renders:
+Preview and split mode support:
- GitHub-flavored Markdown
- KaTeX math
@@ -107,254 +119,395 @@ Preview mode renders:
- footnotes
- wiki links and backlinks
-Expanded diagram viewing is built in for diagram-heavy notes.
-
-### Search, tags, tasks, and built-in views
+### Search, tasks, tags, and built-in views
ZenNotes includes:
-- note search by title / path
+- note search by title and path
- vault-wide text search
- tags view
- tasks view
- archive view
- trash view
- quick notes view
-- built-in help view
+- built-in help/manual
+
+Vault text search can use the built-in engine, `ripgrep`, or `fzf`, with auto-detection and optional custom binary paths.
-Vault text search can use the built-in engine, `ripgrep`, or `fzf`,
-with auto-detection and optional custom binary paths exposed in
-Settings.
+### Obsidian-friendly vault support
-Task parsing is markdown-native: checkboxes stay as normal `- [ ]` /
-`- [x]` lines in the note body and are surfaced into the global Tasks
-view.
+ZenNotes now works better with existing Obsidian-style vaults.
-### Themes, fonts, and app customization
+- primary notes can live at the vault root instead of requiring `inbox/`
+- loose files anywhere in the vault are surfaced as files/assets
+- embedded files like `![[image.png]]` resolve more like Obsidian
+- new referenced files default to the vault root instead of a required attachments folder
+- legacy `attachements/` and `_assets/` folders are still recognized
-The app exposes a substantial settings surface:
+This means imported vaults with top-level notes, folders, and loose images/files behave much more naturally.
-- multiple theme families and light / dark / auto modes
-- independent interface, text, and monospace font selection
+### Files and local assets
+
+ZenNotes supports local files in notes and in the sidebar/list views.
+
+- local images and files can appear directly in the vault tree
+- desktop context menus include reveal-in-file-manager actions
+- desktop uses Finder on macOS and the platform file manager on Windows/Linux
+- watcher updates now include non-Markdown file changes, so deleting files externally updates the UI without a manual refresh
+
+### Themes, fonts, and customization
+
+The settings surface includes:
+
+- theme families and light/dark/auto modes
+- interface, text, and monospace font selection
- editor font size and line-height controls
- preview and editor width controls
-- dark-sidebar option
- content alignment
- keymap overrides
- Vim toggles and leader hint behavior
- search backend selection
+- vault layout settings
+- daily notes settings
+- system-folder display labels
-### MCP integration
+## Desktop vs web
-ZenNotes ships a dedicated MCP server and installation flows for:
+Both runtimes share the same core app, but they do not expose identical platform features.
-- Claude Code
-- Claude Desktop
-- Codex CLI
+Desktop-only features include:
-The app can:
+- native menus
+- app updater
+- floating note windows
+- MCP install/uninstall flows for supported clients
+- reveal in Finder / platform file manager
+- packaging and signed releases
-- detect whether the ZenNotes MCP entry is installed
-- install or uninstall it for each supported client
-- show the exact runtime used to launch the server
-- edit the server's default note-shaping instructions from Settings
+Web/self-hosted mode includes:
+
+- the same shared note UI and workflows
+- a Go backend for vault access and file watching
+- a server-side vault picker/browser
+- browser access on a LAN or home server
-The MCP server exposes vault operations like reading notes, creating
-notes, moving notes, appending to notes, searching text, listing notes,
-listing assets, toggling tasks, and related filesystem-safe actions.
+## Monorepo layout
-## Download
+ZenNotes now uses a single monorepo.
-The fastest way to get the app is the latest GitHub release:
+```text
+apps/
+ desktop/ Electron shell, preload, updater, packaging
+ web/ Vite/PWA shell and HTTP bridge
+ server/ Go server for self-hosted and hosted deployments
+packages/
+ app-core/ Shared React application and renderer logic
+ bridge-contract/ Typed runtime contract between UI and host
+ shared-domain/ Shared types and note/task/view models
+ shared-ui/ Reusable UI primitives
+tooling/
+ scripts/ Shared tooling hooks and migration scripts
+docs/
+```
-- [Latest release](https://github.com/ZenNotes/zennotes/releases/latest)
+Read [docs/monorepo-architecture.md](docs/monorepo-architecture.md) for the architectural boundary between the shared app core and the platform-specific shells.
-Release assets are built per platform in GitHub Actions and uploaded to
-the matching release automatically:
+## Quick start
-- macOS: `.dmg` and `.zip`
-- Windows: installer `.exe` and `.zip`
-- Linux: `.AppImage` and `.deb`
+### Requirements
-If the repository is private, GitHub access to the release assets follows
-the repository's normal permissions.
+- Node.js 22+
+- npm
+- Go 1.22+ for the server build path
+- Docker optional, for self-hosting
-## Vault model
+### Install dependencies
-ZenNotes expects a chosen vault root and will ensure the basic folder
-layout exists on first open. The app also seeds a welcome note the first
-time a vault is initialized.
+```bash
+npm ci
+```
-High-level behavior:
+## Local development
-- only `inbox`, `quick`, and `archive` are treated as searchable note
- folders
-- `trash` is recoverable deletion, not part of normal search
-- tags and wiki links are extracted from note bodies
-- attachment presence is inferred from local links in Markdown
-- vault changes are watched and pushed into the renderer over IPC
+### Desktop app
-## Development
+```bash
+npm run dev:desktop
+```
-### Requirements
+or:
-- Node.js 22+ recommended
-- npm
-- macOS, Windows, or Linux for Electron development
+```bash
+make desktop
+```
-### Install
+### Web client
```bash
-npm install
+npm run dev:web
```
-### Run the app in development
+or:
```bash
-npm run dev
+make web-dev
```
-This starts the Electron main process, preload bundle, and renderer via
-`electron-vite`.
+### Go server
-### Typecheck
+```bash
+npm run dev:server
+```
+
+or:
```bash
-npm run typecheck
+make server-dev
```
-### Build
+### Web + server together
```bash
-npm run build
+npm run dev:web-stack
```
-This produces:
+or:
-- `out/main` for the Electron main process
-- `out/preload` for the preload bridge
-- `out/renderer` for the renderer bundle
+```bash
+make web-stack
+```
-The standalone MCP server is built as `out/main/mcp.js`.
+Important dev note:
+
+- the browser app and the Go server are separate processes in dev mode
+- frontend-only changes usually need only the web dev server
+- backend changes need the Go server restarted
+- if the web client is newer than the running server, ZenNotes now shows a clearer error instead of raw 404 noise for newer API flows like the vault picker
+
+## Root scripts
+
+From the repository root:
+
+| Script | Purpose |
+| ----------------------- | ----------------------------------------------- |
+| `npm run dev` | Alias for `npm run dev:desktop` |
+| `npm run dev:desktop` | Run the Electron desktop app in development |
+| `npm run dev:web` | Run the Vite web client |
+| `npm run dev:server` | Run the Go server |
+| `npm run dev:web-stack` | Run web + server development together |
+| `npm run start` | Start the built desktop app |
+| `npm run typecheck` | Run monorepo typechecks |
+| `npm run test` | Run monorepo tests |
+| `npm run test:run` | Run the full test suite |
+| `npm run build` | Build the monorepo and then build the Go server |
+| `npm run build:prod` | Typecheck + test + build |
+| `npm run pack` | Desktop packaged output |
+| `npm run dist:mac` | Build macOS desktop distributables |
+| `npm run dist:win` | Build Windows desktop distributables |
+| `npm run dist:linux` | Build Linux desktop distributables |
+
+## Makefile commands
+
+The root `Makefile` provides a simpler interface:
+
+| Command | Purpose |
+| -------------------- | ------------------------------------------------------- |
+| `make install` | Install workspace dependencies |
+| `make desktop` | Run the Electron app in dev mode |
+| `make web-dev` | Run the web client |
+| `make server-dev` | Run the Go server |
+| `make web-stack` | Run web + server together |
+| `make build` | Build the full monorepo |
+| `make desktop-build` | Build the Electron app |
+| `make web-build` | Build `apps/web` |
+| `make server-build` | Build `apps/server` with the latest embedded web bundle |
+| `make up` | Build and start the self-hosted Docker stack |
+| `make down` | Stop the Docker stack |
+| `make restart` | Restart the Docker stack |
+| `make logs` | Follow Docker logs |
+| `make status` | Show Docker status |
+| `make open` | Open the self-hosted app in a browser |
+| `make rebuild` | Force a full Docker rebuild |
+| `make nuke` | Remove local Docker image/build output |
+| `make clean` | Remove local web/server build output |
+
+Run `make help` to print the same summary.
+
+## Self-hosting with Docker
+
+### Start the self-hosted app
-## Packaging
+```bash
+make up
+```
-Available package scripts:
+Then open:
-| Script | Purpose |
-| --- | --- |
-| `npm run dev` | Run the app in development mode |
-| `npm run build` | Build all Electron bundles |
-| `npm run start` | Preview the built app |
-| `npm run test:run` | Run the automated test suite |
-| `npm run typecheck` | Run node + web TypeScript checks |
-| `npm run pack` | Build and create unpacked app output |
-| `npm run dist:mac` | Build macOS distributables |
-| `npm run dist:win` | Build Windows distributables |
-| `npm run dist:linux` | Build Linux distributables |
+- [http://localhost:7878](http://localhost:7878)
-Icon packaging notes live in [build/README.md](build/README.md).
+### Default Docker mounts
-### Signed macOS releases
+When you start Docker with `make up`, ZenNotes mounts:
-Public macOS releases are wired for hardened runtime signing and
-notarization. The release workflow expects these GitHub Actions secrets:
+- host `./vault` -> container `./vault`'s absolute host path
+- host `./data` -> container `/data`
-- `MACOS_CERTIFICATE_P12`
-- `MACOS_CERTIFICATE_PASSWORD`
-- `APPLE_ID`
-- `APPLE_APP_SPECIFIC_PASSWORD`
-- `APPLE_TEAM_ID`
+In practice, that means the container sees the vault at the same absolute path you chose on the host, instead of rewriting it to `/workspace`.
-Optional Windows signing can be supplied with:
+The server stores its config under `/data/server.json` by default.
-- `WINDOWS_CERTIFICATE_P12`
-- `WINDOWS_CERTIFICATE_PASSWORD`
+### Default Docker security behavior
-Tagged releases fail the macOS release job if the required Apple signing
-or notarization secrets are missing, which prevents accidentally shipping
-an unsigned public mac build.
+The self-hosted Docker flow is now secure by default:
-## Repository layout
+- the published port binds to `127.0.0.1` unless you override it
+- ZenNotes generates a bootstrap auth token on first `make up` and stores it in `./data/auth-token`
+- the browser version signs in with that token once, then uses an `HttpOnly` session cookie
+- the container runs as your local UID/GID by default, with a read-only root filesystem, `no-new-privileges`, and dropped Linux capabilities
-```text
-src/
- main/ Electron main process, vault I/O, watchers, TikZ, MCP install management
- preload/ Context bridge / IPC surface exposed to the renderer
- renderer/ React app, editor UI, preview UI, settings, panes, styles
- mcp/ Standalone MCP server entry, tool definitions, default instructions
- shared/ Shared IPC contracts and cross-process types
-build/ Packaging resources (icons, installer assets)
-out/ Built output generated by electron-vite
+Useful env vars:
+
+- `ALLOW_INSECURE_NOAUTH=1`: only use this if you intentionally want to disable auth
+- `ZENNOTES_ALLOWED_ORIGINS`: explicit browser origin allowlist
+- `ZENNOTES_BROWSE_ROOTS`: restricts which server-side directories the picker can browse
+
+Recommended deployment model:
+
+- keep ZenNotes behind a reverse proxy, VPN, or private network gate
+- do not expose the raw Go server directly to the public internet unless you understand the tradeoffs
+
+### Choosing a different host folder
+
+You can mount a different host content root:
+
+```bash
+CONTENT_ROOT="$HOME/Library/Mobile Documents/com~apple~CloudDocs" make up
```
-## Architecture notes
+That works for paths with spaces too.
-### Main process
+Useful variables:
-`src/main/` is responsible for:
+- `CONTENT_ROOT`: host folder used as the live vault root
+- `DATA`: host directory used for persisted server config
+- `PORT`: published host port
+- `IMAGE`: Docker image tag
+- `ALLOW_INSECURE_NOAUTH`: disable the default auth requirement
-- window lifecycle
-- persisted app config
-- vault selection and layout bootstrapping
-- filesystem operations for notes, folders, archive, trash, and assets
-- vault watching
-- task scanning
-- vault-wide text search
-- TikZ rendering
-- MCP client install / uninstall flows
+### Docker browse model
+
+Important limitation:
+
+- the Docker container can only browse folders that are mounted into it
+- it cannot browse your entire host filesystem
+- by default, the picker is scoped to the mounted content root unless you explicitly relax it
-### Preload
+So if you want to browse an Obsidian vault, iCloud Drive, or another directory from the web picker, that directory needs to be mounted into the container first.
-`src/preload/index.ts` exposes the app's typed bridge through
-`window.zen`, including vault operations, search, task scanning, window
-controls, TikZ rendering, clipboard helpers, and MCP settings actions.
+### Relevant container env vars
-### Renderer
+The current compose/runtime flow supports:
-`src/renderer/` holds the desktop UI:
+- `ZENNOTES_BIND`
+- `ZENNOTES_CONFIG_PATH`
+- `ZENNOTES_DEFAULT_VAULT_PATH`
+- `ZENNOTES_BROWSE_ROOTS`
+- `ZENNOTES_VAULT_PATH`
-- sidebar, note list, editor, preview, floating note windows
-- command palette, help view, tags/tasks/archive/trash views
-- pane layout and tab state
-- settings UI
-- theme system
-- diagram rendering for Mermaid, TikZ, JSXGraph, and function-plot
+Behavior notes:
-State is managed with Zustand.
+- `ZENNOTES_VAULT_PATH` hard-locks the server to a specific vault path
+- `ZENNOTES_DEFAULT_VAULT_PATH` sets the starting vault when no saved selection exists
+- `ZENNOTES_BROWSE_ROOTS` limits what the web picker can browse
+- `ZENNOTES_ALLOWED_ORIGINS` restricts which browser origins can connect
+- `ZENNOTES_ALLOW_UNSCOPED_BROWSE=1` removes browse-root enforcement
+- `ZENNOTES_ALLOW_INSECURE_NOAUTH=1` disables the default auth guardrail
-### MCP server
+## Web vault picker
-`src/mcp/index.ts` is a standalone stdio server built on
-`@modelcontextprotocol/sdk`. It exposes vault operations to compatible
-clients and ships opinionated note-writing instructions tailored to
-ZenNotes' markdown features and vault model.
+The self-hosted web build now includes a server-backed vault chooser.
-Those instructions can be overridden by the user and are persisted as a
-plain Markdown file under the app's user-data directory.
+- it browses folders on the server, not the browser machine
+- it only browses configured allowed roots by default
+- it starts from sensible locations instead of requiring blind path typing
+- it supports a simpler folder-picker flow for choosing the active vault
+- on macOS-hosted servers, common shortcuts like iCloud Drive are supported when available
+
+If you start the server with `ZENNOTES_VAULT_PATH`, manual vault switching is intentionally disabled.
+
+If auth is enabled, the browser asks for the bootstrap token once and then switches to a secure session cookie. ZenNotes no longer relies on auth tokens in browser URLs or local storage.
+
+## MCP integration
+
+ZenNotes ships a dedicated MCP server and desktop install flows for:
+
+- Claude Code
+- Claude Desktop
+- Codex
+
+The desktop app can:
+
+- detect whether the ZenNotes MCP entry is installed
+- install or uninstall it for each supported client
+- show the exact runtime used to launch the server
+- edit the server's default instructions from Settings
-## Why the MCP story matters here
+The MCP server exposes vault operations such as:
-ZenNotes is intentionally opinionated about how assistants should write
-notes:
+- reading notes
+- creating notes
+- moving notes
+- appending to notes
+- listing notes
+- searching vault text
+- listing files/assets
+- toggling tasks
-- use the vault as shared storage, not as a scratchpad
-- prefer surgical edits over blind overwrites
-- lean on KaTeX and diagram fences instead of ASCII approximations
-- connect notes through wiki links
-- preserve user-owned content and frontmatter
+## Building and packaging desktop releases
-That behavior is encoded in the bundled MCP instructions and surfaced in
-Settings so users can tune it without forking the app.
+### Build everything
+
+```bash
+npm run build:prod
+```
+
+### Desktop package scripts
+
+```bash
+npm run pack
+npm run dist:mac
+npm run dist:win
+npm run dist:linux
+```
+
+### Signed macOS releases
+
+Public macOS releases are wired for hardened runtime signing and notarization.
+
+The GitHub Actions release workflow expects:
+
+- `MACOS_CERTIFICATE_P12`
+- `MACOS_CERTIFICATE_PASSWORD`
+- `APPLE_ID`
+- `APPLE_APP_SPECIFIC_PASSWORD`
+- `APPLE_TEAM_ID`
+
+Optional Windows signing can be supplied with:
+
+- `WINDOWS_CERTIFICATE_P12`
+- `WINDOWS_CERTIFICATE_PASSWORD`
+
+Tagged releases fail the macOS release job if the required Apple signing or notarization secrets are missing. That prevents accidentally shipping an unsigned public mac build.
## Current status
-ZenNotes is still an actively changing codebase. The app already has a
-meaningful feature surface, but the product and interaction model are
-still being refined quickly.
+ZenNotes is actively evolving. The desktop app is the more mature runtime today, while the self-hosted web/server path is being brought into parity through the shared monorepo core.
+
+That means:
-If you are contributing, expect UI polish, keyboard flows, diagram
-rendering, and MCP ergonomics to keep evolving.
+- many features are shared already
+- some platform-specific behavior still lives in the shell layers
+- the README will keep evolving as desktop, web, and self-hosted flows converge further
## License
diff --git a/build/Icon/Icon-iOS-Dark-1024x1024@1x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-1024x1024@1x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-1024x1024@1x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-1024x1024@1x.png
diff --git a/build/Icon/Icon-iOS-Dark-128x128@1x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-128x128@1x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-128x128@1x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-128x128@1x.png
diff --git a/build/Icon/Icon-iOS-Dark-128x128@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-128x128@2x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-128x128@2x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-128x128@2x.png
diff --git a/build/Icon/Icon-iOS-Dark-16x16@1x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-16x16@1x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-16x16@1x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-16x16@1x.png
diff --git a/build/Icon/Icon-iOS-Dark-16x16@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-16x16@2x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-16x16@2x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-16x16@2x.png
diff --git a/build/Icon/Icon-iOS-Dark-20x20@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-20x20@2x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-20x20@2x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-20x20@2x.png
diff --git a/build/Icon/Icon-iOS-Dark-20x20@3x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-20x20@3x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-20x20@3x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-20x20@3x.png
diff --git a/build/Icon/Icon-iOS-Dark-256x256@1x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-256x256@1x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-256x256@1x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-256x256@1x.png
diff --git a/build/Icon/Icon-iOS-Dark-256x256@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-256x256@2x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-256x256@2x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-256x256@2x.png
diff --git a/build/Icon/Icon-iOS-Dark-29x29@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-29x29@2x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-29x29@2x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-29x29@2x.png
diff --git a/build/Icon/Icon-iOS-Dark-29x29@3x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-29x29@3x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-29x29@3x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-29x29@3x.png
diff --git a/build/Icon/Icon-iOS-Dark-32x32@1x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-32x32@1x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-32x32@1x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-32x32@1x.png
diff --git a/build/Icon/Icon-iOS-Dark-32x32@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-32x32@2x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-32x32@2x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-32x32@2x.png
diff --git a/build/Icon/Icon-iOS-Dark-38x38@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-38x38@2x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-38x38@2x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-38x38@2x.png
diff --git a/build/Icon/Icon-iOS-Dark-38x38@3x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-38x38@3x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-38x38@3x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-38x38@3x.png
diff --git a/build/Icon/Icon-iOS-Dark-40x40@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-40x40@2x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-40x40@2x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-40x40@2x.png
diff --git a/build/Icon/Icon-iOS-Dark-40x40@3x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-40x40@3x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-40x40@3x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-40x40@3x.png
diff --git a/build/Icon/Icon-iOS-Dark-512x512@1x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-512x512@1x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-512x512@1x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-512x512@1x.png
diff --git a/build/Icon/Icon-iOS-Dark-60x60@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-60x60@2x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-60x60@2x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-60x60@2x.png
diff --git a/build/Icon/Icon-iOS-Dark-60x60@3x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-60x60@3x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-60x60@3x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-60x60@3x.png
diff --git a/build/Icon/Icon-iOS-Dark-64x64@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-64x64@2x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-64x64@2x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-64x64@2x.png
diff --git a/build/Icon/Icon-iOS-Dark-64x64@3x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-64x64@3x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-64x64@3x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-64x64@3x.png
diff --git a/build/Icon/Icon-iOS-Dark-68x68@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-68x68@2x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-68x68@2x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-68x68@2x.png
diff --git a/build/Icon/Icon-iOS-Dark-76x76@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-76x76@2x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-76x76@2x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-76x76@2x.png
diff --git a/build/Icon/Icon-iOS-Dark-83.5x83.5@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-83.5x83.5@2x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-83.5x83.5@2x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-83.5x83.5@2x.png
diff --git a/build/README.md b/apps/desktop/build/README.md
similarity index 100%
rename from build/README.md
rename to apps/desktop/build/README.md
diff --git a/build/after-install.sh b/apps/desktop/build/after-install.sh
similarity index 100%
rename from build/after-install.sh
rename to apps/desktop/build/after-install.sh
diff --git a/build/entitlements.mac.inherit.plist b/apps/desktop/build/entitlements.mac.inherit.plist
similarity index 100%
rename from build/entitlements.mac.inherit.plist
rename to apps/desktop/build/entitlements.mac.inherit.plist
diff --git a/build/entitlements.mac.plist b/apps/desktop/build/entitlements.mac.plist
similarity index 100%
rename from build/entitlements.mac.plist
rename to apps/desktop/build/entitlements.mac.plist
diff --git a/build/icon.png b/apps/desktop/build/icon.png
similarity index 100%
rename from build/icon.png
rename to apps/desktop/build/icon.png
diff --git a/build/icons/128x128.png b/apps/desktop/build/icons/128x128.png
similarity index 100%
rename from build/icons/128x128.png
rename to apps/desktop/build/icons/128x128.png
diff --git a/build/icons/16x16.png b/apps/desktop/build/icons/16x16.png
similarity index 100%
rename from build/icons/16x16.png
rename to apps/desktop/build/icons/16x16.png
diff --git a/build/icons/24x24.png b/apps/desktop/build/icons/24x24.png
similarity index 100%
rename from build/icons/24x24.png
rename to apps/desktop/build/icons/24x24.png
diff --git a/build/icons/256x256.png b/apps/desktop/build/icons/256x256.png
similarity index 100%
rename from build/icons/256x256.png
rename to apps/desktop/build/icons/256x256.png
diff --git a/build/icons/32x32.png b/apps/desktop/build/icons/32x32.png
similarity index 100%
rename from build/icons/32x32.png
rename to apps/desktop/build/icons/32x32.png
diff --git a/build/icons/48x48.png b/apps/desktop/build/icons/48x48.png
similarity index 100%
rename from build/icons/48x48.png
rename to apps/desktop/build/icons/48x48.png
diff --git a/build/icons/512x512.png b/apps/desktop/build/icons/512x512.png
similarity index 100%
rename from build/icons/512x512.png
rename to apps/desktop/build/icons/512x512.png
diff --git a/build/icons/64x64.png b/apps/desktop/build/icons/64x64.png
similarity index 100%
rename from build/icons/64x64.png
rename to apps/desktop/build/icons/64x64.png
diff --git a/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts
similarity index 82%
rename from electron.vite.config.ts
rename to apps/desktop/electron.vite.config.ts
index a62395df..89b3f699 100644
--- a/electron.vite.config.ts
+++ b/apps/desktop/electron.vite.config.ts
@@ -62,6 +62,7 @@ export default defineConfig({
build: {
outDir: 'out/main',
rollupOptions: {
+ external: ['keytar'],
// The MCP server is an independent Node entry point bundled
// alongside the main process. electron-vite\u2019s `main`
// section is the only slot whose output is plain ESM that
@@ -75,7 +76,8 @@ export default defineConfig({
},
resolve: {
alias: {
- '@shared': resolve(__dirname, 'src/shared')
+ '@shared': resolve(__dirname, '../../packages/shared-domain/src'),
+ '@bridge-contract': resolve(__dirname, '../../packages/bridge-contract/src')
}
}
},
@@ -89,7 +91,8 @@ export default defineConfig({
},
resolve: {
alias: {
- '@shared': resolve(__dirname, 'src/shared')
+ '@shared': resolve(__dirname, '../../packages/shared-domain/src'),
+ '@bridge-contract': resolve(__dirname, '../../packages/bridge-contract/src')
}
}
},
@@ -109,8 +112,9 @@ export default defineConfig({
},
resolve: {
alias: {
- '@renderer': resolve(__dirname, 'src/renderer/src'),
- '@shared': resolve(__dirname, 'src/shared')
+ '@renderer': resolve(__dirname, '../../packages/app-core/src'),
+ '@shared': resolve(__dirname, '../../packages/shared-domain/src'),
+ '@bridge-contract': resolve(__dirname, '../../packages/bridge-contract/src')
}
},
plugins: [react()]
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
new file mode 100644
index 00000000..52c231f8
--- /dev/null
+++ b/apps/desktop/package.json
@@ -0,0 +1,169 @@
+{
+ "name": "@zennotes/desktop",
+ "productName": "ZenNotes",
+ "version": "1.1.0",
+ "description": "ZenNotes desktop shell",
+ "private": true,
+ "main": "./out/main/index.js",
+ "author": {
+ "name": "Adib Hanna",
+ "email": "adibhanna@gmail.com"
+ },
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/ZenNotes/zennotes.git"
+ },
+ "bugs": {
+ "url": "https://github.com/ZenNotes/zennotes/issues"
+ },
+ "homepage": "https://github.com/ZenNotes/zennotes/releases/latest",
+ "engines": {
+ "node": ">=22"
+ },
+ "scripts": {
+ "dev": "electron-vite dev",
+ "build": "electron-vite build",
+ "build:prod": "npm run typecheck && npm run test:run && npm run build",
+ "start": "electron-vite preview",
+ "test": "vitest",
+ "test:run": "vitest run",
+ "typecheck:node": "tsc --noEmit -p tsconfig.node.json",
+ "typecheck:web": "tsc --noEmit -p tsconfig.web.json",
+ "typecheck": "npm run typecheck:node && npm run typecheck:web",
+ "pack": "npm run build:prod && electron-builder --dir",
+ "dist:mac": "npm run build:prod && electron-builder --mac --publish never",
+ "dist:win": "npm run build:prod && electron-builder --win --publish never",
+ "dist:linux": "npm run build:prod && electron-builder --linux --publish never"
+ },
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.18.3",
+ "@codemirror/commands": "^6.7.1",
+ "@codemirror/lang-markdown": "^6.3.1",
+ "@codemirror/language": "^6.10.6",
+ "@codemirror/language-data": "^6.5.1",
+ "@codemirror/search": "^6.5.8",
+ "@codemirror/state": "^6.5.0",
+ "@codemirror/view": "^6.35.3",
+ "@lezer/highlight": "^1.2.1",
+ "@modelcontextprotocol/sdk": "^1.29.0",
+ "@replit/codemirror-vim": "^6.3.0",
+ "@zennotes/app-core": "*",
+ "@zennotes/bridge-contract": "*",
+ "@zennotes/shared-domain": "*",
+ "chokidar": "^4.0.3",
+ "codemirror": "^6.0.1",
+ "dompurify": "^3.3.4",
+ "electron-updater": "^6.8.3",
+ "font-list": "^2.0.2",
+ "function-plot": "^1.25.3",
+ "fuse.js": "^7.0.0",
+ "gray-matter": "^4.0.3",
+ "highlight.js": "^11.10.0",
+ "jsxgraph": "^1.12.2",
+ "katex": "^0.16.15",
+ "mermaid": "^11.4.1",
+ "node-tikzjax": "^1.0.5",
+ "prettier": "^3.8.2",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "rehype-highlight": "^7.0.1",
+ "rehype-katex": "^7.0.1",
+ "rehype-raw": "^7.0.0",
+ "rehype-stringify": "^10.0.1",
+ "remark-breaks": "^4.0.0",
+ "remark-frontmatter": "^5.0.0",
+ "remark-gfm": "^4.0.0",
+ "remark-math": "^6.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-rehype": "^11.1.1",
+ "unified": "^11.0.5",
+ "unist-util-visit": "^5.0.0",
+ "ws": "^8.20.0",
+ "zustand": "^5.0.2"
+ },
+ "devDependencies": {
+ "@electron/notarize": "^3.1.0",
+ "@types/node": "^25.6.0",
+ "@types/react": "^18.3.17",
+ "@types/react-dom": "^18.3.5",
+ "@types/ws": "^8.18.1",
+ "@vitejs/plugin-react": "^4.3.4",
+ "autoprefixer": "^10.4.20",
+ "electron": "^41.2.1",
+ "electron-builder": "^25.1.8",
+ "electron-vite": "^2.3.0",
+ "jsdom": "^29.0.2",
+ "postcss": "^8.5.10",
+ "tailwindcss": "^3.4.17",
+ "typescript": "^5.7.2",
+ "vite": "^5.4.11",
+ "vitest": "^2.1.8"
+ },
+ "build": {
+ "appId": "com.adibhanna.zennotes",
+ "executableName": "ZenNotes",
+ "productName": "ZenNotes",
+ "artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
+ "asar": true,
+ "compression": "maximum",
+ "npmRebuild": false,
+ "buildDependenciesFromSource": false,
+ "directories": {
+ "buildResources": "build",
+ "output": "../../dist"
+ },
+ "files": [
+ "out/**/*",
+ "!**/*.map"
+ ],
+ "extraResources": [
+ {
+ "from": "build/icon.png",
+ "to": "icon.png"
+ }
+ ],
+ "afterSign": "scripts/notarize.cjs",
+ "publish": [
+ {
+ "provider": "github",
+ "owner": "ZenNotes",
+ "repo": "zennotes"
+ }
+ ],
+ "electronUpdaterCompatibility": ">=2.16",
+ "mac": {
+ "category": "public.app-category.productivity",
+ "hardenedRuntime": true,
+ "gatekeeperAssess": false,
+ "entitlements": "build/entitlements.mac.plist",
+ "entitlementsInherit": "build/entitlements.mac.inherit.plist",
+ "extendInfo": {
+ "CFBundleName": "ZenNotes",
+ "CFBundleDisplayName": "ZenNotes"
+ },
+ "target": [
+ "dmg",
+ "zip"
+ ]
+ },
+ "win": {
+ "target": [
+ "nsis",
+ "zip"
+ ]
+ },
+ "linux": {
+ "target": [
+ "AppImage",
+ "deb"
+ ],
+ "icon": "build/icons",
+ "maintainer": "Adib Hanna ",
+ "category": "Office"
+ },
+ "deb": {
+ "afterInstall": "build/after-install.sh"
+ }
+ }
+}
diff --git a/apps/desktop/postcss.config.js b/apps/desktop/postcss.config.js
new file mode 100644
index 00000000..85f717cc
--- /dev/null
+++ b/apps/desktop/postcss.config.js
@@ -0,0 +1,6 @@
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {}
+ }
+}
diff --git a/scripts/notarize.cjs b/apps/desktop/scripts/notarize.cjs
similarity index 100%
rename from scripts/notarize.cjs
rename to apps/desktop/scripts/notarize.cjs
diff --git a/src/main/demo-tour-data.ts b/apps/desktop/src/main/demo-tour-data.ts
similarity index 83%
rename from src/main/demo-tour-data.ts
rename to apps/desktop/src/main/demo-tour-data.ts
index 0f7078aa..cf306fa7 100644
--- a/src/main/demo-tour-data.ts
+++ b/apps/desktop/src/main/demo-tour-data.ts
@@ -6,7 +6,7 @@ export interface DemoTourTemplateFile {
export const DEMO_TOUR_NOTES: DemoTourTemplateFile[] = [
{
path: "inbox/demo/00 — Start Here.md",
- body: "# Start here — ZenNotes feature tour\n\nThis folder is a guided demo vault for ZenNotes as it exists today. It covers markdown rendering, keyboard-first workflows, search, views, settings, and the vault-level features that sit on top of plain files.\n\n## How to use this tour\n\n- Open notes in **Edit**, **Split**, and **Preview** to see where each feature is most useful.\n- Use `Space p` or the outline panel on longer notes.\n- Use `Space f` to search notes by title and path.\n- Use `Space s t` to fuzzy-search text across the vault.\n- Open **Help** from the footer or type `:help` from normal mode for the built-in manual.\n- Try `⌘.` to toggle **Zen mode** while reading any note here.\n\n## The tour\n\n1. [[01 — Markdown Basics]] — headings, emphasis, lists, blockquotes, frontmatter, and slash-command-friendly structure\n2. [[02 — Code Blocks]] — fenced code blocks, inline code, syntax highlighting, and code-writing workflows\n3. [[03 — Tables and Task Lists]] — tables, task metadata, and the vault-wide Tasks view\n4. [[04 — Math with KaTeX]] — inline math, block math, aligned equations, and formulas in preview\n5. [[05 — Mermaid Diagrams]] — flow, sequence, state, gantt, and graph diagrams rendered from markdown fences\n6. [[05b — Math Diagrams]] — TikZ, JSXGraph, and function-plot for paper-grade figures, interactive geometry, and quick plots\n7. [[06 — Callouts and Footnotes]] — callouts, footnotes, highlights, images, and local attachments\n8. [[07 — Wiki Links and Tags]] — wikilinks, tags, backlinks, connections, and search\n9. [[08 — Daily Notes]] — daily logs, quick capture, date shortcuts, and date-friendly note habits\n10. [[09 — Vim Cheat Sheet]] — the app-specific motions, leader flows, folds, and ex commands\n11. [[10 — Ideas and Tasks]] — a realistic note that composes multiple features at once\n12. [[11 — Workspace, Search, and Views]] — tabs, splits, outline, archive, trash, quick notes, and session restore\n13. [[12 — Settings and Keymaps]] — themes, fonts, leader hints, search backends, custom binary paths, and remappable shortcuts\n14. [[13 — Commands, Help, and Demo Tour]] — command palette discovery, ex commands, built-in Help, and starter-tour generation\n15. [[14 — Reference Pane and Floating Windows]] — pinned notes, research context, and detached note windows\n16. [[15 — Search Backends and Fuzzy Workflows]] — note search, vault text search, Auto resolution, fzf, ripgrep, and custom binary paths\n\n## What this demo folder covers\n\nZenNotes is more than a markdown renderer. Across this folder you can try:\n\n- plain file-based notes with no hidden database\n- live preview plus dedicated preview and split modes\n- heading folding and outline jumps\n- wikilinks, tags, backlinks, and unresolved-link discovery\n- quick capture via Quick Notes\n- Inbox, Archive, and Trash as separate lifecycle stages\n- vault-wide Tasks and Tags views\n- note search and vault text search\n- Mermaid, TikZ, JSXGraph, and function-plot diagram rendering\n- optional external search backends like `fzf` and `ripgrep`\n- slash commands and `@` date insertion\n- Vim mode, leader hints, ex commands, and pane motion\n- settings, keymap overrides, and appearance controls\n- command palette, built-in Help, and seeded onboarding content\n- reference-pane and floating-window workflows\n- session restore for panes, tabs, built-in views, and window bounds\n\n## The point\n\nEvery file here is ordinary markdown on disk. Open the folder in ZenNotes, `vim`, VS Code, or another markdown editor and the notes are still yours.\n\n#demo #reference #tour\n"
+ body: "# Start here — ZenNotes feature tour\n\nThis folder is a guided demo vault for ZenNotes as it exists today. It covers markdown rendering, keyboard-first workflows, search, views, settings, and the vault-level features that sit on top of plain files.\n\n## How to use this tour\n\n- Open notes in **Edit**, **Split**, and **Preview** to see where each feature is most useful.\n- Use `Space p` or the outline panel on longer notes.\n- Use `Space f` to search notes by title and path.\n- Use `Space s t` to fuzzy-search text across the vault.\n- Open **Help** from the footer or type `:help` from normal mode for the built-in manual.\n- Try `⌘.` to toggle **Zen mode** while reading any note here.\n\n## The tour\n\n1. [[01 — Markdown Basics]] — headings, emphasis, lists, blockquotes, frontmatter, and slash-command-friendly structure\n2. [[02 — Code Blocks]] — fenced code blocks, inline code, syntax highlighting, and code-writing workflows\n3. [[03 — Tables and Task Lists]] — tables, task metadata, and the vault-wide Tasks view\n4. [[04 — Math with KaTeX]] — inline math, block math, aligned equations, and formulas in preview\n5. [[05 — Mermaid Diagrams]] — flow, sequence, state, gantt, and graph diagrams rendered from markdown fences\n6. [[05b — Math Diagrams]] — TikZ, JSXGraph, and function-plot for paper-grade figures, interactive geometry, and quick plots\n7. [[06 — Callouts and Footnotes]] — callouts, footnotes, highlights, images, and local files\n8. [[07 — Wiki Links and Tags]] — wikilinks, tags, backlinks, connections, and search\n9. [[08 — Daily Notes]] — daily logs, quick capture, date shortcuts, and date-friendly note habits\n10. [[09 — Vim Cheat Sheet]] — the app-specific motions, leader flows, folds, and ex commands\n11. [[10 — Ideas and Tasks]] — a realistic note that composes multiple features at once\n12. [[11 — Workspace, Search, and Views]] — tabs, splits, outline, archive, trash, quick notes, and session restore\n13. [[12 — Settings and Keymaps]] — themes, fonts, leader hints, search backends, custom binary paths, and remappable shortcuts\n14. [[13 — Commands, Help, and Demo Tour]] — command palette discovery, ex commands, built-in Help, and starter-tour generation\n15. [[14 — Reference Pane and Floating Windows]] — pinned notes, research context, and detached note windows\n16. [[15 — Search Backends and Fuzzy Workflows]] — note search, vault text search, Auto resolution, fzf, ripgrep, and custom binary paths\n\n## What this demo folder covers\n\nZenNotes is more than a markdown renderer. Across this folder you can try:\n\n- plain file-based notes with no hidden database\n- live preview plus dedicated preview and split modes\n- heading folding and outline jumps\n- wikilinks, tags, backlinks, and unresolved-link discovery\n- quick capture via Quick Notes\n- Inbox, Archive, and Trash as separate lifecycle stages\n- vault-wide Tasks and Tags views\n- note search and vault text search\n- Mermaid, TikZ, JSXGraph, and function-plot diagram rendering\n- optional external search backends like `fzf` and `ripgrep`\n- slash commands and `@` date insertion\n- Vim mode, leader hints, ex commands, and pane motion\n- settings, keymap overrides, and appearance controls\n- command palette, built-in Help, and seeded onboarding content\n- reference-pane and floating-window workflows\n- session restore for panes, tabs, built-in views, and window bounds\n\n## The point\n\nEvery file here is ordinary markdown on disk. Open the folder in ZenNotes, `vim`, VS Code, or another markdown editor and the notes are still yours.\n\n#demo #reference #tour\n"
},
{
path: "inbox/demo/01 — Markdown Basics.md",
@@ -34,7 +34,7 @@ export const DEMO_TOUR_NOTES: DemoTourTemplateFile[] = [
},
{
path: "inbox/demo/06 — Callouts and Footnotes.md",
- body: "# Callouts, footnotes, attachments, and embeds\n\nThis note covers the rich block-level extras that still live comfortably inside markdown files.\n\n## Callouts\n\nCallouts are blockquotes that start with `> [!type]`.\n\n> [!note]\n> Use note callouts for extra context that should stand out without becoming a new section.\n\n> [!tip] Keyboard tip\n> Press `Space o` to open the buffer switcher when tabs are hidden or you want to jump fast between open buffers.\n\n> [!warning]\n> Moving a note to Trash asks for confirmation, but permanently deleting from Trash is still destructive.\n\n> [!info] Multi-line\n> Callouts can contain:\n> - lists\n> - `inline code`\n> - [[07 — Wiki Links and Tags|wikilinks]]\n> - and multiple paragraphs\n\n> [!quote] Portable by design\n> ZenNotes adds workflow around markdown, not lock-in around data.\n\n## Footnotes\n\nFootnotes link both ways and stay readable in the raw file.[^workflow]\n\nFootnotes are useful for side comments that should not interrupt the main flow.[^tip]\n\n[^workflow]: Footnote references use `[^label]` inline and `[^label]: text` at the bottom of the note.\n[^tip]: They work well in long writing, specs, and research notes where parenthetical digressions get noisy.\n\n## Strikethrough and highlights\n\n~~Legacy wording~~ can stay visible for history, while ==highlights== are good for passages you want to notice quickly during review.\n\n## Images and local attachments\n\nAttachments stay local to the vault. Dropping a file into the editor inserts a normal markdown reference to the file under the vault attachments folder.\n\nExample image:\n\n\n\nThat relative path is the recommended form because it keeps the note portable inside the vault:\n\n```md\n\n```\n\n## Attachment workflows\n\n- Use the footer **Assets** action to browse attachment files.\n- Image embeds render inline in preview and split mode.\n- PDFs can be opened in the pinned reference pane so you can read beside your notes.\n- Because attachments are just files, reveal them in Finder and manage them with normal tools if you want.\n\nFor the larger reading workflow around pinned notes, PDFs, and detached note windows, see [[14 — Reference Pane and Floating Windows]].\n\n## Why this matters\n\nZenNotes is strongest when prose, references, and assets live together:\n\n- callouts for guidance or warnings\n- footnotes for side context\n- images for screenshots and visual notes\n- PDFs in the reference pane for side-by-side reading\n\n#demo #reference #attachments\n"
+ body: "# Callouts, footnotes, files, and embeds\n\nThis note covers the rich block-level extras that still live comfortably inside markdown files.\n\n## Callouts\n\nCallouts are blockquotes that start with `> [!type]`.\n\n> [!note]\n> Use note callouts for extra context that should stand out without becoming a new section.\n\n> [!tip] Keyboard tip\n> Press `Space o` to open the buffer switcher when tabs are hidden or you want to jump fast between open buffers.\n\n> [!warning]\n> Moving a note to Trash asks for confirmation, but permanently deleting from Trash is still destructive.\n\n> [!info] Multi-line\n> Callouts can contain:\n> - lists\n> - `inline code`\n> - [[07 — Wiki Links and Tags|wikilinks]]\n> - and multiple paragraphs\n\n> [!quote] Portable by design\n> ZenNotes adds workflow around markdown, not lock-in around data.\n\n## Footnotes\n\nFootnotes link both ways and stay readable in the raw file.[^workflow]\n\nFootnotes are useful for side comments that should not interrupt the main flow.[^tip]\n\n[^workflow]: Footnote references use `[^label]` inline and `[^label]: text` at the bottom of the note.\n[^tip]: They work well in long writing, specs, and research notes where parenthetical digressions get noisy.\n\n## Strikethrough and highlights\n\n~~Legacy wording~~ can stay visible for history, while ==highlights== are good for passages you want to notice quickly during review.\n\n## Images and local files\n\nFiles stay local to the vault. Dropping a file into the editor inserts a normal markdown reference to the file, and by default ZenNotes places it in the vault root.\n\nExample image:\n\n\n\nThat relative path is the recommended form because it keeps the note portable inside the vault:\n\n```md\n\n```\n\n## File workflows\n\n- Use the footer **Files** action to browse files anywhere in the vault.\n- Image embeds render inline in preview and split mode.\n- PDFs can be opened in the pinned reference pane so you can read beside your notes.\n- Because these are just files, reveal them in Finder and manage them with normal tools if you want.\n\nFor the larger reading workflow around pinned notes, PDFs, and detached note windows, see [[14 — Reference Pane and Floating Windows]].\n\n## Why this matters\n\nZenNotes is strongest when prose, references, and files live together:\n\n- callouts for guidance or warnings\n- footnotes for side context\n- images for screenshots and visual notes\n- PDFs in the reference pane for side-by-side reading\n\n#demo #reference #attachments\n"
},
{
path: "inbox/demo/07 — Wiki Links and Tags.md",
@@ -54,7 +54,7 @@ export const DEMO_TOUR_NOTES: DemoTourTemplateFile[] = [
},
{
path: "inbox/demo/11 — Workspace, Search, and Views.md",
- body: "# Workspace, search, and views\n\nThis note covers the part of ZenNotes that is not just markdown rendering: how the workspace behaves while you are moving around a vault.\n\n## The three working zones\n\nZenNotes is organized around three persistent areas:\n\n1. **Sidebar** for folders, built-in rows, tags, and utility entry points\n2. **Note list** for the current folder, attachments, or list-like result sets\n3. **Editor pane** for tabs, splits, preview, built-in views, and focused writing\n\nThe useful part is that each zone has its own keyboard loop, so you can stay off the mouse without losing place.\n\n## Edit, split, and preview\n\nEach note can be viewed in three ways:\n\n- **Edit** for raw markdown authoring\n- **Split** for source and rendered output side by side\n- **Preview** for reading-only rendering\n\nYou can switch modes from the toolbar, from the command palette, or from ex commands like:\n\n```vim\n:view edit\n:view split\n:view preview\n```\n\n## Tabs, buffers, and panes\n\n- tabs can be on or off\n- panes can split right or down\n- if tabs are hidden, buffers are still open behind the scenes\n- `Space o` or `:buffers` opens the buffer switcher\n\nThis keeps ZenNotes usable for both tab-heavy and low-chrome workflows.\n\n## Search modes\n\n### Note search\n\n- `⌘P` globally\n- `Space f` in Vim mode\n- `⌘F` or `Ctrl+F` as an extra direct shortcut when Vim mode is off\n- searches note titles and paths\n\n### Vault text search\n\n- `Space s t`\n- searches matching text lines across note contents\n- opens the note and jumps to the matching line\n- can run on built-in search, `ripgrep`, or `fzf`\n- Settings show the runtime backend that is actually being used\n\n## Quick Notes, Inbox, Archive, Trash\n\nThese four areas represent different stages of note life:\n\n- **Quick Notes** for fast capture\n- **Inbox** for active notes\n- **Archive** for cold storage\n- **Trash** for recoverable deletion\n\nBehavior differs by design:\n\n- clicking **Quick Notes** still folds and unfolds the sidebar section\n- Quick Notes can also open as a dedicated list tab from its context menu\n- **Archive** opens as a main-pane list view\n- **Trash** opens as a main-pane recovery view\n\nThat keeps the sidebar singular instead of turning it into a second file browser.\n\n## Outline, connections, and references\n\n- **Outline** gives you a heading list for the active note\n- **Connections** show backlinks, outbound links, and unresolved links\n- **Reference pane** is for pinning a note or PDF beside your current work\n\nThis is the part of the app that becomes valuable once a vault turns into more than a pile of files.\n\n## Help, Settings, and Assets\n\nThe footer utilities keep the secondary surfaces discoverable:\n\n- **Assets** for local attachments\n- **Help** for the built-in manual\n- **Settings** for personalization, Vim behavior, search backends, fonts, layout, and keymaps\n\nFor the command palette and seeded onboarding flow, see [[13 — Commands, Help, and Demo Tour]].\nFor detached note workflows and side-by-side reading context, see [[14 — Reference Pane and Floating Windows]].\n\n## Zen mode\n\nZen mode hides:\n\n- title bar\n- sidebar\n- note list\n- tabs\n- pane header chrome\n- outline and connections\n- status bar\n\nOnly the active editor, preview, or split content remains. It is the cleanest way to focus on a single note.\n\n## Session restore\n\nZenNotes remembers:\n\n- open tabs\n- splits\n- built-in views like Help, Tasks, Archive, or Trash\n- sidebar layout\n- main window position, size, and maximized state\n\nClosing and reopening the app should bring you back to roughly where you left off instead of starting from a blank shell.\n\n#demo #workspace #search #reference\n"
+ body: "# Workspace, search, and views\n\nThis note covers the part of ZenNotes that is not just markdown rendering: how the workspace behaves while you are moving around a vault.\n\n## The three working zones\n\nZenNotes is organized around three persistent areas:\n\n1. **Sidebar** for folders, built-in rows, tags, and utility entry points\n2. **Note list** for the current folder, files, or list-like result sets\n3. **Editor pane** for tabs, splits, preview, built-in views, and focused writing\n\nThe useful part is that each zone has its own keyboard loop, so you can stay off the mouse without losing place.\n\n## Edit, split, and preview\n\nEach note can be viewed in three ways:\n\n- **Edit** for raw markdown authoring\n- **Split** for source and rendered output side by side\n- **Preview** for reading-only rendering\n\nYou can switch modes from the toolbar, from the command palette, or from ex commands like:\n\n```vim\n:view edit\n:view split\n:view preview\n```\n\n## Tabs, buffers, and panes\n\n- tabs can be on or off\n- panes can split right or down\n- if tabs are hidden, buffers are still open behind the scenes\n- `Space o` or `:buffers` opens the buffer switcher\n\nThis keeps ZenNotes usable for both tab-heavy and low-chrome workflows.\n\n## Search modes\n\n### Note search\n\n- `⌘P` globally\n- `Space f` in Vim mode\n- `⌘F` or `Ctrl+F` as an extra direct shortcut when Vim mode is off\n- searches note titles and paths\n\n### Vault text search\n\n- `Space s t`\n- searches matching text lines across note contents\n- opens the note and jumps to the matching line\n- can run on built-in search, `ripgrep`, or `fzf`\n- Settings show the runtime backend that is actually being used\n\n## Quick Notes, Inbox, Archive, Trash\n\nThese four areas represent different stages of note life:\n\n- **Quick Notes** for fast capture\n- **Inbox** for active notes\n- **Archive** for cold storage\n- **Trash** for recoverable deletion\n\nBehavior differs by design:\n\n- clicking **Quick Notes** still folds and unfolds the sidebar section\n- Quick Notes can also open as a dedicated list tab from its context menu\n- **Archive** opens as a main-pane list view\n- **Trash** opens as a main-pane recovery view\n\nThat keeps the sidebar singular instead of turning it into a second file browser.\n\n## Outline, connections, and references\n\n- **Outline** gives you a heading list for the active note\n- **Connections** show backlinks, outbound links, and unresolved links\n- **Reference pane** is for pinning a note or PDF beside your current work\n\nThis is the part of the app that becomes valuable once a vault turns into more than a pile of files.\n\n## Help, Settings, and Files\n\nThe footer utilities keep the secondary surfaces discoverable:\n\n- **Files** for local files\n- **Help** for the built-in manual\n- **Settings** for personalization, Vim behavior, search backends, fonts, layout, and keymaps\n\nFor the command palette and seeded onboarding flow, see [[13 — Commands, Help, and Demo Tour]].\nFor detached note workflows and side-by-side reading context, see [[14 — Reference Pane and Floating Windows]].\n\n## Zen mode\n\nZen mode hides:\n\n- title bar\n- sidebar\n- note list\n- tabs\n- pane header chrome\n- outline and connections\n- status bar\n\nOnly the active editor, preview, or split content remains. It is the cleanest way to focus on a single note.\n\n## Session restore\n\nZenNotes remembers:\n\n- open tabs\n- splits\n- built-in views like Help, Tasks, Archive, or Trash\n- sidebar layout\n- main window position, size, and maximized state\n\nClosing and reopening the app should bring you back to roughly where you left off instead of starting from a blank shell.\n\n#demo #workspace #search #reference\n"
},
{
path: "inbox/demo/12 — Settings and Keymaps.md",
@@ -62,11 +62,11 @@ export const DEMO_TOUR_NOTES: DemoTourTemplateFile[] = [
},
{
path: "inbox/demo/13 — Commands, Help, and Demo Tour.md",
- body: "# Commands, help, and demo tour\n\nZenNotes is keyboard-first, so discoverability matters. This note covers the command palette, the built-in Help manual, and the demo-tour commands that can seed a starter vault for new users.\n\n## Command palette\n\nOpen the command palette with:\n\n- `⇧⌘P`\n- `:commands`\n- `:cmd query`\n\nUse it when you cannot remember a shortcut, when Vim mode is off, or when you want to browse what the app can do without digging through menus.\n\nTypical commands worth trying:\n\n- `Open Help`\n- `Open Settings`\n- `Search notes`\n- `Generate Demo Tour Notes`\n- `Remove Demo Tour Notes`\n- `Switch to Edit Mode`\n- `Switch to Split Mode`\n- `Switch to Preview Mode`\n- `Open Tasks`\n- `Open Trash`\n\n## Ex commands\n\nIf you live in normal mode, the ex line is the fastest path for many actions:\n\n```vim\n:help\n:tasks\n:trash\n:buffers\n:view split\n:zen\n:cmd help\n```\n\nThe ex line also supports completion with `Tab`, including command arguments like `:view edit|split|preview` and `:zen toggle|on|off`.\n\n## Built-in Help\n\nZenNotes ships with an in-app manual instead of making you leave the app to learn it.\n\nWays to open it:\n\n- footer **Help**\n- `:help`\n- command palette → `Open Help`\n\nThe Help view covers:\n\n- quick start\n- core concepts\n- shortcuts\n- Vim flows\n- ex commands\n- settings\n- search backends\n\n## Demo tour commands\n\nThe demo vault itself is seedable from inside the app.\n\nUse:\n\n- command palette → `Generate Demo Tour Notes`\n- command palette → `Remove Demo Tour Notes`\n- `:demo_generate`\n- `:demo_remove`\n\n### What generation does\n\n- creates a guided note set under `inbox/demo`\n- adds the bundled demo attachment under the vault attachments folder\n- opens the tour start note so the onboarding flow begins immediately\n\n### What removal does\n\n- removes the seeded demo notes\n- removes the bundled demo attachment\n- leaves the rest of the vault alone\n\nThat makes the tour useful for:\n\n- first-time users\n- resettable demos\n- showing the product to someone else\n- smoke-testing renderer features in one place\n\n## Why this matters\n\nThe app can stay low-chrome and still be discoverable if:\n\n- commands are searchable\n- Help is built in\n- the starter content is one command away\n\nThat combination is a large part of what makes a keyboard-first app approachable instead of intimidating.\n\n## Try this now\n\n- Open the command palette and search for `help`\n- Run `:cmd zen`\n- Run `Generate Demo Tour Notes` in a test vault\n- Open [[12 — Settings and Keymaps]] after this note to see how the shortcuts behind these commands can be remapped\n\n#demo #commands #help #onboarding\n"
+ body: "# Commands, help, and demo tour\n\nZenNotes is keyboard-first, so discoverability matters. This note covers the command palette, the built-in Help manual, and the demo-tour commands that can seed a starter vault for new users.\n\n## Command palette\n\nOpen the command palette with:\n\n- `⇧⌘P`\n- `:commands`\n- `:cmd query`\n\nUse it when you cannot remember a shortcut, when Vim mode is off, or when you want to browse what the app can do without digging through menus.\n\nTypical commands worth trying:\n\n- `Open Help`\n- `Open Settings`\n- `Search notes`\n- `Generate Demo Tour Notes`\n- `Remove Demo Tour Notes`\n- `Switch to Edit Mode`\n- `Switch to Split Mode`\n- `Switch to Preview Mode`\n- `Open Tasks`\n- `Open Trash`\n\n## Ex commands\n\nIf you live in normal mode, the ex line is the fastest path for many actions:\n\n```vim\n:help\n:tasks\n:trash\n:buffers\n:view split\n:zen\n:cmd help\n```\n\nThe ex line also supports completion with `Tab`, including command arguments like `:view edit|split|preview` and `:zen toggle|on|off`.\n\n## Built-in Help\n\nZenNotes ships with an in-app manual instead of making you leave the app to learn it.\n\nWays to open it:\n\n- footer **Help**\n- `:help`\n- command palette → `Open Help`\n\nThe Help view covers:\n\n- quick start\n- core concepts\n- shortcuts\n- Vim flows\n- ex commands\n- settings\n- search backends\n\n## Demo tour commands\n\nThe demo vault itself is seedable from inside the app.\n\nUse:\n\n- command palette → `Generate Demo Tour Notes`\n- command palette → `Remove Demo Tour Notes`\n- `:demo_generate`\n- `:demo_remove`\n\n### What generation does\n\n- creates a guided note set under `inbox/demo`\n- adds the bundled demo file at the vault root\n- opens the tour start note so the onboarding flow begins immediately\n\n### What removal does\n\n- removes the seeded demo notes\n- removes the bundled demo file\n- leaves the rest of the vault alone\n\nThat makes the tour useful for:\n\n- first-time users\n- resettable demos\n- showing the product to someone else\n- smoke-testing renderer features in one place\n\n## Why this matters\n\nThe app can stay low-chrome and still be discoverable if:\n\n- commands are searchable\n- Help is built in\n- the starter content is one command away\n\nThat combination is a large part of what makes a keyboard-first app approachable instead of intimidating.\n\n## Try this now\n\n- Open the command palette and search for `help`\n- Run `:cmd zen`\n- Run `Generate Demo Tour Notes` in a test vault\n- Open [[12 — Settings and Keymaps]] after this note to see how the shortcuts behind these commands can be remapped\n\n#demo #commands #help #onboarding\n"
},
{
path: "inbox/demo/14 — Reference Pane and Floating Windows.md",
- body: "# Reference pane and floating windows\n\nZenNotes is strongest when you can keep context visible while still writing. This note covers the pinned reference pane, link preview workflows, and floating notes.\n\n## Reference pane\n\nThe reference pane is for keeping a second document visible while you work in the main note.\n\nGood uses:\n\n- drafting against a spec\n- reading a PDF while taking notes\n- comparing two notes side by side\n- keeping a glossary or checklist open while editing\n\n## What can live there\n\n- another markdown note\n- a PDF\n- a linked document opened from the current note\n\nThis keeps the main pane focused on writing while the side pane holds supporting material.\n\n## Link-following flows\n\nWhen the cursor is on a wikilink or markdown link:\n\n- `gd` follows it in Vim mode\n- PDFs can pin into the reference pane\n- missing notes can be created from the link target\n\nThat means links are not just navigation. They can become working context.\n\n## Connections + reference workflow\n\nThe **Connections** panel works well with the reference pane:\n\n- inspect backlinks\n- move to a related note\n- peek a backlink\n- pin the most useful one beside the current draft\n\nThis is especially useful for research notes and longer documentation trees.\n\n## Floating windows\n\nSometimes you do not want a second pane inside the same layout. In that case, a note can open in its own floating window from the context menu.\n\nFloating windows are useful when:\n\n- you want a scratch note on another monitor\n- you are comparing two notes without disturbing the main layout\n- you want a temporary detached reference\n\nThey are intentional, separate work surfaces, not just accidental duplicate tabs.\n\n## Research pattern\n\nOne practical pattern:\n\n1. Keep the current draft in **Edit** or **Split**\n2. Open **Connections**\n3. Find a related note or PDF\n4. Pin it in the reference pane or open it in a floating window\n5. Keep writing without losing context\n\n## Good companion notes in this tour\n\n- [[07 — Wiki Links and Tags]] for backlinks, tags, and search\n- [[11 — Workspace, Search, and Views]] for the larger pane model\n- [[06 — Callouts and Footnotes]] for local attachments\n- [[10 — Ideas and Tasks]] for a note that benefits from supporting context\n\n## Try this now\n\n- Open this note, then pin [[11 — Workspace, Search, and Views]]\n- Open **Connections** on [[10 — Ideas and Tasks]]\n- Follow a wikilink with `gd`\n- Open a note in a floating window from its context menu\n\n#demo #reference #research #windows\n"
+ body: "# Reference pane and floating windows\n\nZenNotes is strongest when you can keep context visible while still writing. This note covers the pinned reference pane, link preview workflows, and floating notes.\n\n## Reference pane\n\nThe reference pane is for keeping a second document visible while you work in the main note.\n\nGood uses:\n\n- drafting against a spec\n- reading a PDF while taking notes\n- comparing two notes side by side\n- keeping a glossary or checklist open while editing\n\n## What can live there\n\n- another markdown note\n- a PDF\n- a linked document opened from the current note\n\nThis keeps the main pane focused on writing while the side pane holds supporting material.\n\n## Link-following flows\n\nWhen the cursor is on a wikilink or markdown link:\n\n- `gd` follows it in Vim mode\n- PDFs can pin into the reference pane\n- missing notes can be created from the link target\n\nThat means links are not just navigation. They can become working context.\n\n## Connections + reference workflow\n\nThe **Connections** panel works well with the reference pane:\n\n- inspect backlinks\n- move to a related note\n- peek a backlink\n- pin the most useful one beside the current draft\n\nThis is especially useful for research notes and longer documentation trees.\n\n## Floating windows\n\nSometimes you do not want a second pane inside the same layout. In that case, a note can open in its own floating window from the context menu.\n\nFloating windows are useful when:\n\n- you want a scratch note on another monitor\n- you are comparing two notes without disturbing the main layout\n- you want a temporary detached reference\n\nThey are intentional, separate work surfaces, not just accidental duplicate tabs.\n\n## Research pattern\n\nOne practical pattern:\n\n1. Keep the current draft in **Edit** or **Split**\n2. Open **Connections**\n3. Find a related note or PDF\n4. Pin it in the reference pane or open it in a floating window\n5. Keep writing without losing context\n\n## Good companion notes in this tour\n\n- [[07 — Wiki Links and Tags]] for backlinks, tags, and search\n- [[11 — Workspace, Search, and Views]] for the larger pane model\n- [[06 — Callouts and Footnotes]] for local files\n- [[10 — Ideas and Tasks]] for a note that benefits from supporting context\n\n## Try this now\n\n- Open this note, then pin [[11 — Workspace, Search, and Views]]\n- Open **Connections** on [[10 — Ideas and Tasks]]\n- Follow a wikilink with `gd`\n- Open a note in a floating window from its context menu\n\n#demo #reference #research #windows\n"
},
{
path: "inbox/demo/15 — Search Backends and Fuzzy Workflows.md",
@@ -76,7 +76,7 @@ export const DEMO_TOUR_NOTES: DemoTourTemplateFile[] = [
export const DEMO_TOUR_ASSETS: DemoTourTemplateFile[] = [
{
- path: "attachements/zennotes-demo-card.svg",
+ path: "zennotes-demo-card.svg",
body: "\n"
},
]
\ No newline at end of file
diff --git a/src/main/index.ts b/apps/desktop/src/main/index.ts
similarity index 55%
rename from src/main/index.ts
rename to apps/desktop/src/main/index.ts
index 4eaeecf1..e9bbbbda 100644
--- a/src/main/index.ts
+++ b/apps/desktop/src/main/index.ts
@@ -1,11 +1,30 @@
-import { app, BrowserWindow, dialog, ipcMain, Menu, protocol, screen, session, shell } from 'electron'
+import {
+ app,
+ BrowserWindow,
+ dialog,
+ ipcMain,
+ Menu,
+ protocol,
+ screen,
+ session,
+ shell,
+ type IpcMainEvent,
+ type IpcMainInvokeEvent,
+ type WebContents
+} from 'electron'
import { execFile } from 'node:child_process'
+import { randomUUID } from 'node:crypto'
import { promises as fsp } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { IPC } from '@shared/ipc'
import type {
NoteFolder,
+ RemoteWorkspaceInfo,
+ RemoteWorkspaceProfile,
+ RemoteWorkspaceProfileInput,
+ ServerCapabilities,
+ VaultSettings,
VaultChangeEvent,
VaultInfo,
VaultTextSearchBackendPreference,
@@ -14,7 +33,6 @@ import type {
import {
absolutePath,
archiveNote,
- assetsAbsolutePath,
createFolder,
createNote,
deleteFolder,
@@ -25,6 +43,7 @@ import {
ensureVaultLayout,
folderAbsolutePath,
generateDemoTour,
+ getVaultSettings,
hasAssetsDir,
importFiles,
listAssets,
@@ -40,15 +59,24 @@ import {
restoreFromTrash,
searchVaultTextCapabilities,
searchVaultText,
+ setVaultSettings,
+ type PersistedRemoteWorkspaceConfig,
+ type PersistedRemoteWorkspaceProfile,
type PersistedWindowState,
updateConfig,
unarchiveNote,
vaultInfo,
writeNote
} from './vault'
+import {
+ deleteRemoteWorkspaceSecret,
+ getRemoteWorkspaceSecret,
+ setRemoteWorkspaceSecret
+} from './secret-store'
import { scanAllTasks, scanTasksForPath } from './tasks'
import { VaultWatcher } from './watcher'
import { renderTikz } from './tikz'
+import { RemoteServerClient } from './remote/server-client'
import {
getMcpClientStatuses,
getMcpServerRuntime,
@@ -90,6 +118,12 @@ protocol.registerSchemesAsPrivileged([
let mainWindow: BrowserWindow | null = null
let currentVault: VaultInfo | null = null
+let currentWorkspaceMode: 'local' | 'remote' = 'local'
+let remoteWorkspaceConfig: PersistedRemoteWorkspaceConfig | null = null
+let currentRemoteWorkspaceProfileId: string | null = null
+let remoteWorkspaceClient: RemoteServerClient | null = null
+let remoteServerCapabilities: ServerCapabilities | null = null
+let stopRemoteVaultWatch: (() => void) | null = null
const watcher = new VaultWatcher()
const DEFAULT_WINDOW_WIDTH = 1280
const DEFAULT_WINDOW_HEIGHT = 820
@@ -128,6 +162,7 @@ function decodeLocalAssetRequestPath(url: string): string | null {
try {
const parsed = new URL(url)
if (parsed.protocol !== `${LOCAL_ASSET_SCHEME}:`) return null
+ if (parsed.hostname && parsed.hostname !== 'local') return null
const encoded = parsed.searchParams.get('path')
if (!encoded) return null
return decodeURIComponent(encoded)
@@ -136,6 +171,20 @@ function decodeLocalAssetRequestPath(url: string): string | null {
}
}
+function decodeRemoteAssetRequest(url: string): { baseUrl: string; relPath: string } | null {
+ try {
+ const parsed = new URL(url)
+ if (parsed.protocol !== `${LOCAL_ASSET_SCHEME}:`) return null
+ if (parsed.hostname !== 'remote') return null
+ const baseUrl = parsed.searchParams.get('baseUrl')?.trim()
+ const relPath = parsed.searchParams.get('path')?.trim()
+ if (!baseUrl || !relPath) return null
+ return { baseUrl, relPath }
+ } catch {
+ return null
+ }
+}
+
function isPathInsideVault(absPath: string): boolean {
if (!currentVault) return false
const resolved = path.resolve(absPath)
@@ -216,6 +265,35 @@ function mimeTypeForPath(absPath: string): string {
}
}
+function isTrustedRendererUrl(url: string): boolean {
+ if (!url) return false
+ const devServerUrl = process.env['ELECTRON_RENDERER_URL']
+ if (devServerUrl) {
+ return url.startsWith(devServerUrl)
+ }
+ try {
+ const parsed = new URL(url)
+ return (
+ parsed.protocol === 'file:' &&
+ parsed.pathname.endsWith('/out/renderer/index.html')
+ )
+ } catch {
+ return false
+ }
+}
+
+function isTrustedIpcSender(sender: WebContents): boolean {
+ const ownerWindow = BrowserWindow.fromWebContents(sender)
+ if (!ownerWindow || ownerWindow.isDestroyed()) return false
+ return isTrustedRendererUrl(sender.getURL())
+}
+
+function assertTrustedIpcEvent(event: IpcMainEvent | IpcMainInvokeEvent): void {
+ if (!isTrustedIpcSender(event.sender)) {
+ throw new Error('Blocked IPC call from an untrusted renderer.')
+ }
+}
+
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max)
}
@@ -384,6 +462,9 @@ async function createWindow(): Promise {
}),
webPreferences: {
preload: path.join(__dirname, '../preload/index.js'),
+ // Keep the renderer isolated and node-free, but the current preload
+ // still relies on Node/Electron APIs that are not available inside a
+ // fully sandboxed preload context.
sandbox: false,
contextIsolation: true,
nodeIntegration: false
@@ -443,25 +524,406 @@ async function createWindow(): Promise {
}
}
+function currentRemoteWorkspaceInfo(): RemoteWorkspaceInfo | null {
+ if (!remoteWorkspaceConfig) return null
+ return {
+ mode: currentWorkspaceMode,
+ baseUrl: remoteWorkspaceConfig.baseUrl,
+ authConfigured: Boolean(remoteWorkspaceClient?.authToken),
+ capabilities: remoteServerCapabilities,
+ profileId: currentRemoteWorkspaceProfileId
+ }
+}
+
+function normalizeRemoteBaseUrl(value: string): string {
+ return value.trim().replace(/\/+$/, '')
+}
+
+function deriveRemoteWorkspaceProfileName(
+ input: {
+ id?: string
+ baseUrl: string
+ vaultPath?: string | null
+ },
+ existingProfiles: PersistedRemoteWorkspaceProfile[]
+): string {
+ const normalizedBaseUrl = normalizeRemoteBaseUrl(input.baseUrl)
+ let host = 'ZenNotes Server'
+ try {
+ const normalizedUrl = /^https?:\/\//i.test(normalizedBaseUrl)
+ ? normalizedBaseUrl
+ : `http://${normalizedBaseUrl}`
+ host = new URL(normalizedUrl).host || host
+ } catch {
+ if (normalizedBaseUrl) host = normalizedBaseUrl
+ }
+
+ const trimmedVaultPath = input.vaultPath?.trim() || null
+ let baseName = host
+ if (trimmedVaultPath) {
+ const normalizedVaultPath = trimmedVaultPath.replace(/\\/g, '/').replace(/\/+$/, '')
+ const vaultName = path.posix.basename(normalizedVaultPath)
+ if (vaultName && vaultName !== '.' && vaultName !== '/') {
+ baseName = `${vaultName} (${host})`
+ }
+ }
+
+ const otherProfiles = existingProfiles.filter((entry) => entry.id !== input.id)
+ if (!otherProfiles.some((entry) => entry.name === baseName)) return baseName
+
+ let suffix = 2
+ while (otherProfiles.some((entry) => entry.name === `${baseName} ${suffix}`)) suffix += 1
+ return `${baseName} ${suffix}`
+}
+
+function profileMatchesConnection(
+ profile: PersistedRemoteWorkspaceProfile,
+ connection: PersistedRemoteWorkspaceConfig,
+ vaultPath: string | null
+): boolean {
+ return (
+ normalizeRemoteBaseUrl(profile.baseUrl) === normalizeRemoteBaseUrl(connection.baseUrl) &&
+ (profile.vaultPath ?? null) === (vaultPath ?? null)
+ )
+}
+
+function findRemoteProfileById(
+ profiles: PersistedRemoteWorkspaceProfile[],
+ id: string | null
+): PersistedRemoteWorkspaceProfile | null {
+ if (!id) return null
+ return profiles.find((entry) => entry.id === id) ?? null
+}
+
+async function migrateLegacyRemoteWorkspaceSecrets(): Promise {
+ const cfg = await loadConfig()
+ let changed = false
+ let nextProfiles = [...cfg.remoteWorkspaceProfiles]
+ let nextRemoteWorkspace = cfg.remoteWorkspace
+ let nextProfileId = cfg.remoteWorkspaceProfileId
+
+ for (const profile of nextProfiles) {
+ if (profile.authToken && profile.authToken.trim()) {
+ await setRemoteWorkspaceSecret(profile.id, profile.authToken)
+ delete profile.authToken
+ changed = true
+ }
+ }
+
+ if (nextRemoteWorkspace?.authToken && nextRemoteWorkspace.authToken.trim()) {
+ let targetProfile =
+ findRemoteProfileById(nextProfiles, nextProfileId) ??
+ nextProfiles.find(
+ (entry) => normalizeRemoteBaseUrl(entry.baseUrl) === normalizeRemoteBaseUrl(nextRemoteWorkspace!.baseUrl)
+ ) ??
+ null
+
+ if (!targetProfile) {
+ targetProfile = {
+ id: randomUUID(),
+ name: deriveRemoteWorkspaceProfileName(
+ {
+ baseUrl: nextRemoteWorkspace.baseUrl,
+ vaultPath: currentVault?.root ?? null
+ },
+ nextProfiles
+ ),
+ baseUrl: normalizeRemoteBaseUrl(nextRemoteWorkspace.baseUrl),
+ vaultPath: currentVault?.root ?? null,
+ lastConnectedAt: null
+ }
+ nextProfiles = [...nextProfiles, targetProfile].sort((a, b) => a.name.localeCompare(b.name))
+ nextProfileId = targetProfile.id
+ }
+
+ await setRemoteWorkspaceSecret(targetProfile.id, nextRemoteWorkspace.authToken)
+ nextRemoteWorkspace = { baseUrl: nextRemoteWorkspace.baseUrl }
+ changed = true
+ }
+
+ if (!changed) return
+
+ await updateConfig((current) => ({
+ ...current,
+ remoteWorkspace: nextRemoteWorkspace
+ ? {
+ baseUrl: normalizeRemoteBaseUrl(nextRemoteWorkspace.baseUrl)
+ }
+ : null,
+ remoteWorkspaceProfiles: nextProfiles.map((profile) => ({
+ id: profile.id,
+ name: profile.name,
+ baseUrl: normalizeRemoteBaseUrl(profile.baseUrl),
+ vaultPath: profile.vaultPath ?? null,
+ lastConnectedAt: profile.lastConnectedAt ?? null
+ })),
+ remoteWorkspaceProfileId: nextProfileId
+ }))
+}
+
+function broadcastVaultChange(ev: VaultChangeEvent): void {
+ for (const win of BrowserWindow.getAllWindows()) {
+ win.webContents.send(IPC.VAULT_ON_CHANGE, ev)
+ }
+}
+
+function stopRemoteWatch(): void {
+ if (stopRemoteVaultWatch) {
+ stopRemoteVaultWatch()
+ stopRemoteVaultWatch = null
+ }
+}
+
+function startRemoteWatch(client: RemoteServerClient, capabilities: ServerCapabilities): void {
+ stopRemoteWatch()
+ if (!capabilities.supportsWatch) return
+ stopRemoteVaultWatch = client.watchVaultChanges((ev) => {
+ broadcastVaultChange(ev)
+ })
+}
+
async function setVault(root: string): Promise {
await ensureVaultLayout(root)
currentVault = vaultInfo(root)
- await updateConfig((cfg) => ({ ...cfg, vaultRoot: root }))
+ currentWorkspaceMode = 'local'
+ remoteWorkspaceClient = null
+ remoteWorkspaceConfig = null
+ currentRemoteWorkspaceProfileId = null
+ remoteServerCapabilities = null
+ stopRemoteWatch()
+ await updateConfig((cfg) => ({
+ ...cfg,
+ workspaceMode: 'local',
+ vaultRoot: root,
+ remoteWorkspaceProfileId: null
+ }))
watcher.start(root, (ev: VaultChangeEvent) => {
- // Broadcast to every open window — the main window, all floating
- // note windows — so each can refresh its in-memory state.
- for (const win of BrowserWindow.getAllWindows()) {
- win.webContents.send(IPC.VAULT_ON_CHANGE, ev)
- }
+ broadcastVaultChange(ev)
})
return currentVault
}
+async function setRemoteWorkspace(
+ baseUrl: string,
+ authToken?: string | null,
+ options: { persist?: boolean; profileId?: string | null; vaultPath?: string | null } = {}
+): Promise<{ vault: VaultInfo | null; capabilities: ServerCapabilities }> {
+ const client = new RemoteServerClient({ baseUrl, authToken })
+ const capabilities = await client.getCapabilities()
+ let vault = await client.getCurrentVault()
+ const preferredVaultPath = options.vaultPath?.trim() || null
+ if (
+ capabilities.supportsVaultSelection &&
+ preferredVaultPath &&
+ vault?.root !== preferredVaultPath
+ ) {
+ vault = await client.selectVaultPath(preferredVaultPath)
+ }
+
+ watcher.stop()
+ currentWorkspaceMode = 'remote'
+ currentVault = vault
+ remoteWorkspaceClient = client
+ remoteServerCapabilities = capabilities
+ currentRemoteWorkspaceProfileId = options.profileId ?? null
+ remoteWorkspaceConfig = {
+ baseUrl: client.baseUrl
+ }
+ startRemoteWatch(client, capabilities)
+
+ if (options.persist !== false) {
+ await updateConfig((cfg) => ({
+ ...cfg,
+ workspaceMode: 'remote',
+ remoteWorkspace: remoteWorkspaceConfig,
+ remoteWorkspaceProfileId: currentRemoteWorkspaceProfileId
+ }))
+ }
+
+ return { vault, capabilities }
+}
+
+async function disconnectRemoteWorkspace(): Promise {
+ const cfg = await loadConfig()
+ stopRemoteWatch()
+ remoteWorkspaceClient = null
+ remoteWorkspaceConfig = null
+ currentRemoteWorkspaceProfileId = null
+ remoteServerCapabilities = null
+ currentWorkspaceMode = 'local'
+
+ if (cfg.vaultRoot) {
+ return await setVault(cfg.vaultRoot)
+ }
+
+ watcher.stop()
+ currentVault = null
+ await updateConfig((current) => ({
+ ...current,
+ workspaceMode: 'local',
+ remoteWorkspaceProfileId: null
+ }))
+ return null
+}
+
+async function listRemoteWorkspaceProfiles(): Promise {
+ const cfg = await loadConfig()
+ return await Promise.all(
+ cfg.remoteWorkspaceProfiles.map(async (profile) => ({
+ id: profile.id,
+ name: profile.name,
+ baseUrl: profile.baseUrl,
+ vaultPath: profile.vaultPath ?? null,
+ lastConnectedAt: profile.lastConnectedAt ?? null,
+ hasCredential: Boolean(await getRemoteWorkspaceSecret(profile.id))
+ }))
+ )
+}
+
+async function saveRemoteWorkspaceProfile(
+ input: RemoteWorkspaceProfileInput & { lastConnectedAt?: number | null }
+): Promise {
+ const normalizedId = input.id?.trim() || randomUUID()
+ await updateConfig((cfg) => {
+ const normalizedBaseUrl = normalizeRemoteBaseUrl(input.baseUrl)
+ const trimmedName = input.name?.trim() || ''
+ const normalizedVaultPath = input.vaultPath?.trim() || null
+ if (!normalizedId || !normalizedBaseUrl) {
+ throw new Error('Remote workspace profiles need a server URL.')
+ }
+ const nextNormalized: PersistedRemoteWorkspaceProfile = {
+ id: normalizedId,
+ name:
+ trimmedName ||
+ deriveRemoteWorkspaceProfileName(
+ {
+ id: normalizedId,
+ baseUrl: normalizedBaseUrl,
+ vaultPath: normalizedVaultPath
+ },
+ cfg.remoteWorkspaceProfiles
+ ),
+ baseUrl: normalizedBaseUrl,
+ vaultPath: normalizedVaultPath,
+ lastConnectedAt:
+ typeof input.lastConnectedAt === 'number' && Number.isFinite(input.lastConnectedAt)
+ ? input.lastConnectedAt
+ : null
+ }
+ const others = cfg.remoteWorkspaceProfiles.filter((entry) => entry.id !== nextNormalized.id)
+ const nextProfiles = [...others, nextNormalized].sort((a, b) => a.name.localeCompare(b.name))
+ let nextCurrentProfileId = cfg.remoteWorkspaceProfileId
+ if (remoteWorkspaceConfig) {
+ if (
+ profileMatchesConnection(nextNormalized, remoteWorkspaceConfig, currentVault?.root ?? null)
+ ) {
+ nextCurrentProfileId = nextNormalized.id
+ } else if (cfg.remoteWorkspaceProfileId === nextNormalized.id) {
+ nextCurrentProfileId = null
+ }
+ }
+ currentRemoteWorkspaceProfileId = nextCurrentProfileId
+ return {
+ ...cfg,
+ remoteWorkspaceProfiles: nextProfiles,
+ remoteWorkspaceProfileId: nextCurrentProfileId
+ }
+ })
+ if (input.clearAuthToken) {
+ await deleteRemoteWorkspaceSecret(normalizedId)
+ } else if (typeof input.authToken === 'string' && input.authToken.trim()) {
+ await setRemoteWorkspaceSecret(normalizedId, input.authToken.trim())
+ }
+ const cfg = await loadConfig()
+ const normalized = findRemoteProfileById(cfg.remoteWorkspaceProfiles, normalizedId)
+ if (!normalized) {
+ throw new Error('Remote workspace profile could not be saved.')
+ }
+ return {
+ id: normalized.id,
+ name: normalized.name,
+ baseUrl: normalized.baseUrl,
+ vaultPath: normalized.vaultPath ?? null,
+ lastConnectedAt: normalized.lastConnectedAt ?? null,
+ hasCredential: input.clearAuthToken
+ ? false
+ : typeof input.authToken === 'string' && input.authToken.trim()
+ ? true
+ : Boolean(await getRemoteWorkspaceSecret(normalized.id))
+ }
+}
+
+async function deleteRemoteWorkspaceProfile(id: string): Promise {
+ const deletedSecret = await getRemoteWorkspaceSecret(id)
+ await updateConfig((cfg) => {
+ const deletedProfile = findRemoteProfileById(cfg.remoteWorkspaceProfiles, id)
+ const nextProfiles = cfg.remoteWorkspaceProfiles.filter((entry) => entry.id !== id)
+ const nextCurrentProfileId =
+ cfg.remoteWorkspaceProfileId === id ? null : cfg.remoteWorkspaceProfileId
+ const shouldClearLegacyRemoteWorkspace =
+ !!deletedProfile &&
+ !!cfg.remoteWorkspace &&
+ normalizeRemoteBaseUrl(cfg.remoteWorkspace.baseUrl) ===
+ normalizeRemoteBaseUrl(deletedProfile.baseUrl) &&
+ !nextProfiles.some(
+ (entry) =>
+ normalizeRemoteBaseUrl(entry.baseUrl) === normalizeRemoteBaseUrl(deletedProfile.baseUrl)
+ )
+ currentRemoteWorkspaceProfileId = nextCurrentProfileId
+ return {
+ ...cfg,
+ remoteWorkspace: shouldClearLegacyRemoteWorkspace ? null : cfg.remoteWorkspace,
+ remoteWorkspaceProfiles: nextProfiles,
+ remoteWorkspaceProfileId: nextCurrentProfileId
+ }
+ })
+ await deleteRemoteWorkspaceSecret(id)
+ if (deletedSecret && currentRemoteWorkspaceProfileId === id) {
+ remoteWorkspaceClient = null
+ }
+}
+
+async function connectRemoteWorkspaceProfile(
+ profileId: string
+): Promise<{ vault: VaultInfo | null; capabilities: ServerCapabilities }> {
+ const cfg = await loadConfig()
+ const profile = findRemoteProfileById(cfg.remoteWorkspaceProfiles, profileId)
+ if (!profile) {
+ throw new Error('That saved remote workspace no longer exists.')
+ }
+ const authToken = await getRemoteWorkspaceSecret(profile.id)
+ const result = await setRemoteWorkspace(profile.baseUrl, authToken, {
+ profileId: profile.id,
+ vaultPath: profile.vaultPath
+ })
+ const connectedAt = Date.now()
+ await updateConfig((current) => ({
+ ...current,
+ remoteWorkspaceProfileId: profile.id,
+ remoteWorkspaceProfiles: current.remoteWorkspaceProfiles.map((entry) =>
+ entry.id === profile.id ? { ...entry, lastConnectedAt: connectedAt } : entry
+ )
+ }))
+ currentRemoteWorkspaceProfileId = profile.id
+ return result
+}
+
function requireVault(): VaultInfo {
if (!currentVault) throw new Error('No vault is open')
return currentVault
}
+function isRemoteWorkspaceActive(): boolean {
+ return currentWorkspaceMode === 'remote' && remoteWorkspaceClient != null
+}
+
+function requireRemoteWorkspaceClient(): RemoteServerClient {
+ if (!isRemoteWorkspaceActive() || !remoteWorkspaceClient) {
+ throw new Error('No remote workspace is connected')
+ }
+ return remoteWorkspaceClient
+}
+
/**
* Enumerate installed font families for the font picker.
*
@@ -569,12 +1031,32 @@ async function listFontFamilies(): Promise {
}
function registerIpc(): void {
- ipcMain.handle(IPC.APP_PLATFORM, () => process.platform)
+ const handle = (
+ channel: string,
+ listener: (event: IpcMainInvokeEvent, ...args: Args) => Result | Promise
+ ): void => {
+ ipcMain.handle(channel, async (event, ...args) => {
+ assertTrustedIpcEvent(event)
+ return await listener(event, ...(args as Args))
+ })
+ }
- ipcMain.handle(IPC.APP_LIST_FONTS, async () => {
+ const on = (
+ channel: string,
+ listener: (event: IpcMainEvent, ...args: Args) => void
+ ): void => {
+ ipcMain.on(channel, (event, ...args) => {
+ assertTrustedIpcEvent(event)
+ listener(event, ...(args as Args))
+ })
+ }
+
+ handle(IPC.APP_PLATFORM, () => process.platform)
+
+ handle(IPC.APP_LIST_FONTS, async () => {
return await listFontFamilies()
})
- ipcMain.handle(IPC.APP_ICON_DATA_URL, async () => {
+ handle(IPC.APP_ICON_DATA_URL, async () => {
try {
const iconPath = path.join(__dirname, '../../build/icon.png')
const png = await fsp.readFile(iconPath)
@@ -583,28 +1065,68 @@ function registerIpc(): void {
return null
}
})
- ipcMain.handle(IPC.APP_ZOOM_IN, async (e) => {
+ handle(IPC.APP_ZOOM_IN, async (e) => {
return await adjustWindowZoom(BrowserWindow.fromWebContents(e.sender), ZOOM_STEP)
})
- ipcMain.handle(IPC.APP_ZOOM_OUT, async (e) => {
+ handle(IPC.APP_ZOOM_OUT, async (e) => {
return await adjustWindowZoom(BrowserWindow.fromWebContents(e.sender), -ZOOM_STEP)
})
- ipcMain.handle(IPC.APP_ZOOM_RESET, async (e) => {
+ handle(IPC.APP_ZOOM_RESET, async (e) => {
return await setWindowZoom(BrowserWindow.fromWebContents(e.sender), DEFAULT_ZOOM_FACTOR)
})
- ipcMain.handle(IPC.APP_UPDATER_GET_STATE, () => getAppUpdateState())
- ipcMain.handle(IPC.APP_UPDATER_CHECK, async () => await checkForAppUpdates())
- ipcMain.handle(IPC.APP_UPDATER_CHECK_WITH_UI, async () => {
+ handle(IPC.APP_UPDATER_GET_STATE, () => getAppUpdateState())
+ handle(IPC.APP_UPDATER_CHECK, async () => await checkForAppUpdates())
+ handle(IPC.APP_UPDATER_CHECK_WITH_UI, async () => {
await runMenuUpdateCheck()
})
- ipcMain.handle(IPC.APP_UPDATER_DOWNLOAD, async () => await downloadAppUpdate())
- ipcMain.handle(IPC.APP_UPDATER_INSTALL, () => {
+ handle(IPC.APP_UPDATER_DOWNLOAD, async () => await downloadAppUpdate())
+ handle(IPC.APP_UPDATER_INSTALL, () => {
installAppUpdate()
})
- ipcMain.handle(IPC.VAULT_GET_CURRENT, async () => {
+ handle(IPC.WORKSPACE_GET_INFO, async () => currentRemoteWorkspaceInfo())
+ handle(IPC.WORKSPACE_CONNECT_REMOTE, async (_e, baseUrl: string, authToken?: string | null) => {
+ return await setRemoteWorkspace(baseUrl, authToken)
+ })
+ handle(IPC.WORKSPACE_DISCONNECT_REMOTE, async () => {
+ return await disconnectRemoteWorkspace()
+ })
+ handle(IPC.WORKSPACE_LIST_REMOTE_PROFILES, async () => {
+ return await listRemoteWorkspaceProfiles()
+ })
+ handle(IPC.WORKSPACE_SAVE_REMOTE_PROFILE, async (_e, input: RemoteWorkspaceProfileInput) => {
+ return await saveRemoteWorkspaceProfile(input)
+ })
+ handle(IPC.WORKSPACE_DELETE_REMOTE_PROFILE, async (_e, id: string) => {
+ await deleteRemoteWorkspaceProfile(id)
+ })
+ handle(IPC.WORKSPACE_CONNECT_REMOTE_PROFILE, async (_e, id: string) => {
+ return await connectRemoteWorkspaceProfile(id)
+ })
+
+ handle(IPC.VAULT_GET_CURRENT, async () => {
if (currentVault) return currentVault
const cfg = await loadConfig()
+ remoteWorkspaceConfig = cfg.remoteWorkspace
+ currentRemoteWorkspaceProfileId = cfg.remoteWorkspaceProfileId
+ if (cfg.workspaceMode === 'remote' && cfg.remoteWorkspace?.baseUrl) {
+ const remoteProfile = findRemoteProfileById(cfg.remoteWorkspaceProfiles, cfg.remoteWorkspaceProfileId)
+ const authToken =
+ (remoteProfile && (await getRemoteWorkspaceSecret(remoteProfile.id))) ??
+ cfg.remoteWorkspace.authToken ??
+ null
+ try {
+ const result = await setRemoteWorkspace(cfg.remoteWorkspace.baseUrl, authToken, {
+ persist: false,
+ profileId: remoteProfile?.id ?? cfg.remoteWorkspaceProfileId,
+ vaultPath: remoteProfile?.vaultPath ?? null
+ })
+ return result.vault
+ } catch {
+ currentRemoteWorkspaceProfileId = null
+ return null
+ }
+ }
if (cfg.vaultRoot) {
try {
return await setVault(cfg.vaultRoot)
@@ -615,7 +1137,7 @@ function registerIpc(): void {
return null
})
- ipcMain.handle(IPC.VAULT_PICK, async () => {
+ handle(IPC.VAULT_PICK, async () => {
const result = await dialog.showOpenDialog({
title: 'Choose a vault folder',
properties: ['openDirectory', 'createDirectory'],
@@ -625,41 +1147,85 @@ function registerIpc(): void {
return await setVault(result.filePaths[0])
})
- ipcMain.handle(IPC.VAULT_LIST_NOTES, async () => {
+ handle(IPC.VAULT_SELECT_PATH, async (_e, targetPath: string) => {
+ const client = requireRemoteWorkspaceClient()
+ const vault = await client.selectVaultPath(targetPath)
+ currentVault = vault
+ if (remoteServerCapabilities) {
+ startRemoteWatch(client, remoteServerCapabilities)
+ }
+ return vault
+ })
+
+ handle(IPC.VAULT_BROWSE_SERVER_DIRECTORIES, async (_e, targetPath: string = '') => {
+ const client = requireRemoteWorkspaceClient()
+ return await client.browseDirectories(targetPath)
+ })
+
+ handle(IPC.VAULT_GET_SETTINGS, async () => {
+ if (isRemoteWorkspaceActive()) {
+ return await requireRemoteWorkspaceClient().getVaultSettings()
+ }
+ const v = requireVault()
+ return await getVaultSettings(v.root)
+ })
+
+ handle(IPC.VAULT_SET_SETTINGS, async (_e, next: VaultSettings) => {
+ if (isRemoteWorkspaceActive()) {
+ return await requireRemoteWorkspaceClient().setVaultSettings(next)
+ }
+ const v = requireVault()
+ return await setVaultSettings(v.root, next)
+ })
+
+ handle(IPC.VAULT_LIST_NOTES, async () => {
+ if (isRemoteWorkspaceActive()) return await requireRemoteWorkspaceClient().listNotes()
const v = requireVault()
return await listNotes(v.root)
})
- ipcMain.handle(IPC.VAULT_LIST_FOLDERS, async () => {
+ handle(IPC.VAULT_LIST_FOLDERS, async () => {
+ if (isRemoteWorkspaceActive()) return await requireRemoteWorkspaceClient().listFolders()
const v = requireVault()
return await listFolders(v.root)
})
- ipcMain.handle(IPC.VAULT_LIST_ASSETS, async () => {
+ handle(IPC.VAULT_LIST_ASSETS, async () => {
+ if (isRemoteWorkspaceActive()) return await requireRemoteWorkspaceClient().listAssets()
const v = requireVault()
return await listAssets(v.root)
})
- ipcMain.handle(IPC.VAULT_HAS_ASSETS_DIR, async () => {
+ handle(IPC.VAULT_HAS_ASSETS_DIR, async () => {
+ if (isRemoteWorkspaceActive()) return await requireRemoteWorkspaceClient().hasAssetsDir()
const v = requireVault()
return await hasAssetsDir(v.root)
})
- ipcMain.handle(IPC.VAULT_GENERATE_DEMO_TOUR, async () => {
+ handle(IPC.VAULT_GENERATE_DEMO_TOUR, async () => {
+ if (isRemoteWorkspaceActive()) {
+ return await requireRemoteWorkspaceClient().generateDemoTour()
+ }
const v = requireVault()
return await generateDemoTour(v.root)
})
- ipcMain.handle(IPC.VAULT_REMOVE_DEMO_TOUR, async () => {
+ handle(IPC.VAULT_REMOVE_DEMO_TOUR, async () => {
+ if (isRemoteWorkspaceActive()) {
+ return await requireRemoteWorkspaceClient().removeDemoTour()
+ }
const v = requireVault()
return await removeDemoTour(v.root)
})
- ipcMain.handle(IPC.VAULT_TEXT_SEARCH_CAPABILITIES, async (_e, paths: VaultTextSearchToolPaths = {}) => {
+ handle(IPC.VAULT_TEXT_SEARCH_CAPABILITIES, async (_e, paths: VaultTextSearchToolPaths = {}) => {
+ if (isRemoteWorkspaceActive()) {
+ return await requireRemoteWorkspaceClient().getVaultTextSearchCapabilities()
+ }
return await searchVaultTextCapabilities(paths)
})
- ipcMain.handle(
+ handle(
IPC.VAULT_SEARCH_TEXT,
async (
_e,
@@ -667,179 +1233,247 @@ function registerIpc(): void {
backend: VaultTextSearchBackendPreference = 'auto',
paths: VaultTextSearchToolPaths = {}
) => {
+ if (isRemoteWorkspaceActive()) {
+ return await requireRemoteWorkspaceClient().searchVaultText(query, backend, paths)
+ }
const v = requireVault()
return await searchVaultText(v.root, query, backend, paths)
}
)
- ipcMain.handle(IPC.VAULT_READ_NOTE, async (_e, relPath: string) => {
+ handle(IPC.VAULT_READ_NOTE, async (_e, relPath: string) => {
+ if (isRemoteWorkspaceActive()) return await requireRemoteWorkspaceClient().readNote(relPath)
const v = requireVault()
return await readNote(v.root, relPath)
})
- ipcMain.handle(IPC.VAULT_SCAN_TASKS, async () => {
+ handle(IPC.VAULT_SCAN_TASKS, async () => {
+ if (isRemoteWorkspaceActive()) return await requireRemoteWorkspaceClient().scanTasks()
const v = requireVault()
return await scanAllTasks(v.root)
})
- ipcMain.handle(IPC.VAULT_SCAN_TASKS_FOR, async (_e, relPath: string) => {
+ handle(IPC.VAULT_SCAN_TASKS_FOR, async (_e, relPath: string) => {
+ if (isRemoteWorkspaceActive()) {
+ return await requireRemoteWorkspaceClient().scanTasksForPath(relPath)
+ }
const v = requireVault()
return await scanTasksForPath(v.root, relPath)
})
- ipcMain.handle(IPC.VAULT_WRITE_NOTE, async (_e, relPath: string, body: string) => {
+ handle(IPC.VAULT_WRITE_NOTE, async (_e, relPath: string, body: string) => {
+ if (isRemoteWorkspaceActive()) {
+ return await requireRemoteWorkspaceClient().writeNote(relPath, body)
+ }
const v = requireVault()
return await writeNote(v.root, relPath, body)
})
- ipcMain.handle(
+ handle(
IPC.VAULT_CREATE_NOTE,
- async (_e, folder: NoteFolder, title?: string, subpath = '') => {
+ async (_e, folder: NoteFolder, title: string | undefined, subpath: string = '') => {
+ if (isRemoteWorkspaceActive()) {
+ return await requireRemoteWorkspaceClient().createNote(folder, title, subpath)
+ }
const v = requireVault()
return await createNote(v.root, folder, title, subpath)
}
)
- ipcMain.handle(IPC.VAULT_RENAME_NOTE, async (_e, relPath: string, nextTitle: string) => {
+ handle(IPC.VAULT_RENAME_NOTE, async (_e, relPath: string, nextTitle: string) => {
+ if (isRemoteWorkspaceActive()) {
+ return await requireRemoteWorkspaceClient().renameNote(relPath, nextTitle)
+ }
const v = requireVault()
return await renameNote(v.root, relPath, nextTitle)
})
- ipcMain.handle(IPC.VAULT_DELETE_NOTE, async (_e, relPath: string) => {
+ handle(IPC.VAULT_DELETE_NOTE, async (_e, relPath: string) => {
+ if (isRemoteWorkspaceActive()) {
+ await requireRemoteWorkspaceClient().deleteNote(relPath)
+ return
+ }
const v = requireVault()
await deleteNote(v.root, relPath)
})
- ipcMain.handle(IPC.VAULT_MOVE_TO_TRASH, async (_e, relPath: string) => {
+ handle(IPC.VAULT_MOVE_TO_TRASH, async (_e, relPath: string) => {
+ if (isRemoteWorkspaceActive()) {
+ return await requireRemoteWorkspaceClient().moveToTrash(relPath)
+ }
const v = requireVault()
return await moveToTrash(v.root, relPath)
})
- ipcMain.handle(IPC.VAULT_RESTORE_FROM_TRASH, async (_e, relPath: string) => {
+ handle(IPC.VAULT_RESTORE_FROM_TRASH, async (_e, relPath: string) => {
+ if (isRemoteWorkspaceActive()) {
+ return await requireRemoteWorkspaceClient().restoreFromTrash(relPath)
+ }
const v = requireVault()
return await restoreFromTrash(v.root, relPath)
})
- ipcMain.handle(IPC.VAULT_EMPTY_TRASH, async () => {
+ handle(IPC.VAULT_EMPTY_TRASH, async () => {
+ if (isRemoteWorkspaceActive()) {
+ await requireRemoteWorkspaceClient().emptyTrash()
+ return
+ }
const v = requireVault()
await emptyTrash(v.root)
})
- ipcMain.handle(IPC.VAULT_ARCHIVE_NOTE, async (_e, relPath: string) => {
+ handle(IPC.VAULT_ARCHIVE_NOTE, async (_e, relPath: string) => {
+ if (isRemoteWorkspaceActive()) {
+ return await requireRemoteWorkspaceClient().archiveNote(relPath)
+ }
const v = requireVault()
return await archiveNote(v.root, relPath)
})
- ipcMain.handle(IPC.VAULT_UNARCHIVE_NOTE, async (_e, relPath: string) => {
+ handle(IPC.VAULT_UNARCHIVE_NOTE, async (_e, relPath: string) => {
+ if (isRemoteWorkspaceActive()) {
+ return await requireRemoteWorkspaceClient().unarchiveNote(relPath)
+ }
const v = requireVault()
return await unarchiveNote(v.root, relPath)
})
- ipcMain.handle(IPC.VAULT_DUPLICATE_NOTE, async (_e, relPath: string) => {
+ handle(IPC.VAULT_DUPLICATE_NOTE, async (_e, relPath: string) => {
+ if (isRemoteWorkspaceActive()) {
+ return await requireRemoteWorkspaceClient().duplicateNote(relPath)
+ }
const v = requireVault()
return await duplicateNote(v.root, relPath)
})
- ipcMain.handle(IPC.VAULT_REVEAL_NOTE, async (_e, relPath: string) => {
+ handle(IPC.VAULT_REVEAL_NOTE, async (_e, relPath: string) => {
+ if (isRemoteWorkspaceActive()) {
+ throw new Error('Reveal in file manager is only available for local vaults.')
+ }
const v = requireVault()
const abs = absolutePath(v.root, relPath)
shell.showItemInFolder(abs)
})
- ipcMain.handle(
+ handle(
IPC.VAULT_MOVE_NOTE,
async (_e, relPath: string, targetFolder: NoteFolder, targetSubpath: string) => {
+ if (isRemoteWorkspaceActive()) {
+ return await requireRemoteWorkspaceClient().moveNote(relPath, targetFolder, targetSubpath)
+ }
const v = requireVault()
return await moveNote(v.root, relPath, targetFolder, targetSubpath)
}
)
- ipcMain.handle(
+ handle(
IPC.VAULT_IMPORT_FILES,
async (_e, notePath: string, sourcePaths: string[]) => {
+ if (isRemoteWorkspaceActive()) {
+ throw new Error('Desktop file import is only available for local vaults right now.')
+ }
const v = requireVault()
return await importFiles(v.root, notePath, sourcePaths)
}
)
- ipcMain.handle(
+ handle(
IPC.VAULT_CREATE_FOLDER,
async (_e, folder: NoteFolder, subpath: string) => {
+ if (isRemoteWorkspaceActive()) {
+ await requireRemoteWorkspaceClient().createFolder(folder, subpath)
+ return
+ }
const v = requireVault()
await createFolder(v.root, folder, subpath)
}
)
- ipcMain.handle(
+ handle(
IPC.VAULT_RENAME_FOLDER,
async (_e, folder: NoteFolder, oldSubpath: string, newSubpath: string) => {
+ if (isRemoteWorkspaceActive()) {
+ return await requireRemoteWorkspaceClient().renameFolder(folder, oldSubpath, newSubpath)
+ }
const v = requireVault()
return await renameFolder(v.root, folder, oldSubpath, newSubpath)
}
)
- ipcMain.handle(
+ handle(
IPC.VAULT_DELETE_FOLDER,
async (_e, folder: NoteFolder, subpath: string) => {
+ if (isRemoteWorkspaceActive()) {
+ await requireRemoteWorkspaceClient().deleteFolder(folder, subpath)
+ return
+ }
const v = requireVault()
await deleteFolder(v.root, folder, subpath)
}
)
- ipcMain.handle(
+ handle(
IPC.VAULT_DUPLICATE_FOLDER,
async (_e, folder: NoteFolder, subpath: string) => {
+ if (isRemoteWorkspaceActive()) {
+ return await requireRemoteWorkspaceClient().duplicateFolder(folder, subpath)
+ }
const v = requireVault()
return await duplicateFolder(v.root, folder, subpath)
}
)
- ipcMain.handle(
+ handle(
IPC.VAULT_REVEAL_FOLDER,
async (_e, folder: NoteFolder, subpath: string) => {
+ if (isRemoteWorkspaceActive()) {
+ throw new Error('Reveal in file manager is only available for local vaults.')
+ }
const v = requireVault()
- const abs = folderAbsolutePath(v.root, folder, subpath)
+ const abs = await folderAbsolutePath(v.root, folder, subpath)
await shell.openPath(abs)
}
)
- ipcMain.handle(IPC.VAULT_REVEAL_ASSETS_DIR, async () => {
+ handle(IPC.VAULT_REVEAL_ASSETS_DIR, async () => {
+ if (isRemoteWorkspaceActive()) {
+ throw new Error('Reveal in file manager is only available for local vaults.')
+ }
const v = requireVault()
- const abs = assetsAbsolutePath(v.root)
- await shell.openPath(abs)
+ await shell.openPath(v.root)
})
// Route window chrome controls to the window that actually sent the
// IPC (via `e.sender`) so that floating note windows can minimize /
// maximize / close themselves without hijacking the main window.
- ipcMain.on(IPC.WINDOW_MINIMIZE, (e) => {
+ on(IPC.WINDOW_MINIMIZE, (e) => {
BrowserWindow.fromWebContents(e.sender)?.minimize()
})
- ipcMain.on(IPC.WINDOW_TOGGLE_MAXIMIZE, (e) => {
+ on(IPC.WINDOW_TOGGLE_MAXIMIZE, (e) => {
const win = BrowserWindow.fromWebContents(e.sender)
if (!win) return
if (win.isMaximized()) win.unmaximize()
else win.maximize()
})
- ipcMain.on(IPC.WINDOW_CLOSE, (e) => {
+ on(IPC.WINDOW_CLOSE, (e) => {
BrowserWindow.fromWebContents(e.sender)?.close()
})
- ipcMain.handle(IPC.WINDOW_OPEN_NOTE, async (_e, relPath: string) => {
+ handle(IPC.WINDOW_OPEN_NOTE, async (_e, relPath: string) => {
openFloatingNoteWindow(relPath)
})
- ipcMain.handle(IPC.TIKZ_RENDER, async (_e, source: string) => {
+ handle(IPC.TIKZ_RENDER, async (_e, source: string) => {
const result = await renderTikz(source)
if (result.ok) return { ok: true, svg: result.svg }
return { ok: false, error: result.error }
})
- ipcMain.handle(IPC.MCP_RUNTIME, async () => await getMcpServerRuntime())
- ipcMain.handle(IPC.MCP_STATUS, async () => await getMcpClientStatuses())
- ipcMain.handle(IPC.MCP_INSTALL, async (_e, id: McpClientId) => await installMcpForClient(id))
- ipcMain.handle(IPC.MCP_UNINSTALL, async (_e, id: McpClientId) => await uninstallMcpForClient(id))
- ipcMain.handle(IPC.MCP_GET_INSTRUCTIONS, async (): Promise => {
+ handle(IPC.MCP_RUNTIME, async () => await getMcpServerRuntime())
+ handle(IPC.MCP_STATUS, async () => await getMcpClientStatuses())
+ handle(IPC.MCP_INSTALL, async (_e, id: McpClientId) => await installMcpForClient(id))
+ handle(IPC.MCP_UNINSTALL, async (_e, id: McpClientId) => await uninstallMcpForClient(id))
+ handle(IPC.MCP_GET_INSTRUCTIONS, async (): Promise => {
const custom = await readCustomInstructions()
return {
defaultValue: MCP_SERVER_INSTRUCTIONS,
@@ -848,7 +1482,7 @@ function registerIpc(): void {
filePath: instructionsFilePath()
}
})
- ipcMain.handle(
+ handle(
IPC.MCP_SET_INSTRUCTIONS,
async (_e, next: string | null): Promise => {
await writeCustomInstructions(next)
@@ -897,6 +1531,9 @@ function openFloatingNoteWindow(relPath: string): void {
}),
webPreferences: {
preload: path.join(__dirname, '../preload/index.js'),
+ // Keep the renderer isolated and node-free, but the current preload
+ // still relies on Node/Electron APIs that are not available inside a
+ // fully sandboxed preload context.
sandbox: false,
contextIsolation: true,
nodeIntegration: false
@@ -1160,7 +1797,19 @@ async function runMenuUpdateCheck(): Promise {
}
app.whenReady().then(async () => {
+ await migrateLegacyRemoteWorkspaceSecrets()
+
protocol.handle(LOCAL_ASSET_SCHEME, async (request) => {
+ const remote = decodeRemoteAssetRequest(request.url)
+ if (remote) {
+ const client = remoteWorkspaceClient
+ if (!client || client.baseUrl !== remote.baseUrl) {
+ throw new Error(`No remote workspace client for ${remote.baseUrl}`)
+ }
+ const response = await client.fetchAssetResponse(remote.relPath)
+ return response
+ }
+
const abs = decodeLocalAssetRequestPath(request.url)
if (!abs || !isPathInsideVault(abs)) {
throw new Error(`Invalid local asset URL: ${request.url}`)
diff --git a/apps/desktop/src/main/keytar.d.ts b/apps/desktop/src/main/keytar.d.ts
new file mode 100644
index 00000000..89687c46
--- /dev/null
+++ b/apps/desktop/src/main/keytar.d.ts
@@ -0,0 +1,23 @@
+declare module 'keytar' {
+ export function getPassword(
+ service: string,
+ account: string
+ ): Promise
+ export function setPassword(
+ service: string,
+ account: string,
+ password: string
+ ): Promise
+ export function deletePassword(
+ service: string,
+ account: string
+ ): Promise
+
+ const keytar: {
+ getPassword: typeof getPassword
+ setPassword: typeof setPassword
+ deletePassword: typeof deletePassword
+ }
+
+ export default keytar
+}
diff --git a/src/main/mcp-integrations.ts b/apps/desktop/src/main/mcp-integrations.ts
similarity index 100%
rename from src/main/mcp-integrations.ts
rename to apps/desktop/src/main/mcp-integrations.ts
diff --git a/src/main/perf.ts b/apps/desktop/src/main/perf.ts
similarity index 100%
rename from src/main/perf.ts
rename to apps/desktop/src/main/perf.ts
diff --git a/apps/desktop/src/main/remote/server-client.ts b/apps/desktop/src/main/remote/server-client.ts
new file mode 100644
index 00000000..ddbe3b0e
--- /dev/null
+++ b/apps/desktop/src/main/remote/server-client.ts
@@ -0,0 +1,324 @@
+import type {
+ AssetMeta,
+ DirectoryBrowseResult,
+ FolderEntry,
+ ImportedAsset,
+ NoteContent,
+ NoteFolder,
+ NoteMeta,
+ ServerCapabilities,
+ VaultChangeEvent,
+ VaultDemoTourResult,
+ VaultInfo,
+ VaultSettings,
+ VaultTextSearchBackendPreference,
+ VaultTextSearchCapabilities,
+ VaultTextSearchMatch,
+ VaultTextSearchToolPaths
+} from '@shared/ipc'
+import type { VaultTask } from '@shared/tasks'
+import WebSocket from 'ws'
+
+export interface RemoteServerClientOptions {
+ baseUrl: string
+ authToken?: string | null
+}
+
+type JsonRequestInit = Omit & { body?: unknown }
+
+export class RemoteServerClient {
+ readonly baseUrl: string
+ readonly authToken: string | null
+
+ constructor(options: RemoteServerClientOptions) {
+ this.baseUrl = normalizeBaseUrl(options.baseUrl)
+ this.authToken = options.authToken?.trim() || null
+ }
+
+ async getCapabilities(): Promise {
+ return this.jsonRequest('/api/capabilities')
+ }
+
+ async getCurrentVault(): Promise {
+ return this.jsonRequest('/api/vault')
+ }
+
+ async getVaultSettings(): Promise {
+ return this.jsonRequest('/api/vault/settings')
+ }
+
+ async setVaultSettings(next: VaultSettings): Promise {
+ return this.jsonRequest('/api/vault/settings', {
+ method: 'POST',
+ body: next
+ })
+ }
+
+ async selectVaultPath(path: string): Promise {
+ return this.jsonRequest('/api/vault/select', {
+ method: 'POST',
+ body: { path }
+ })
+ }
+
+ async browseDirectories(path = ''): Promise {
+ const query = path ? `?path=${encodeURIComponent(path)}` : ''
+ return this.jsonRequest(`/api/fs/browse${query}`)
+ }
+
+ async listNotes(): Promise {
+ return this.jsonRequest('/api/notes')
+ }
+
+ async listFolders(): Promise {
+ return this.jsonRequest('/api/folders')
+ }
+
+ async listAssets(): Promise {
+ return this.jsonRequest('/api/assets')
+ }
+
+ async hasAssetsDir(): Promise {
+ return this.jsonRequest<{ exists: boolean }>('/api/assets/exists').then((resp) => resp.exists)
+ }
+
+ async generateDemoTour(): Promise {
+ return this.jsonRequest('/api/demo/generate', { method: 'POST' })
+ }
+
+ async removeDemoTour(): Promise {
+ return this.jsonRequest('/api/demo/remove', { method: 'POST' })
+ }
+
+ async getVaultTextSearchCapabilities(): Promise {
+ return this.jsonRequest('/api/search/capabilities')
+ }
+
+ async searchVaultText(
+ query: string,
+ backend: VaultTextSearchBackendPreference = 'auto',
+ paths: VaultTextSearchToolPaths = {}
+ ): Promise {
+ const params = new URLSearchParams({ q: query, backend })
+ if (paths.ripgrepPath) params.set('ripgrepPath', paths.ripgrepPath)
+ if (paths.fzfPath) params.set('fzfPath', paths.fzfPath)
+ return this.jsonRequest(`/api/search/text?${params.toString()}`)
+ }
+
+ async readNote(relPath: string): Promise {
+ return this.jsonRequest(`/api/notes/read?path=${encodeURIComponent(relPath)}`)
+ }
+
+ async scanTasks(): Promise {
+ return this.jsonRequest('/api/tasks')
+ }
+
+ async scanTasksForPath(relPath: string): Promise {
+ return this.jsonRequest(`/api/tasks/for?path=${encodeURIComponent(relPath)}`)
+ }
+
+ async writeNote(relPath: string, body: string): Promise {
+ return this.jsonRequest('/api/notes/write', {
+ method: 'POST',
+ body: { path: relPath, body }
+ })
+ }
+
+ async createNote(folder: NoteFolder, title?: string, subpath = ''): Promise {
+ return this.jsonRequest('/api/notes/create', {
+ method: 'POST',
+ body: { folder, title, subpath }
+ })
+ }
+
+ async renameNote(relPath: string, nextTitle: string): Promise {
+ return this.jsonRequest('/api/notes/rename', {
+ method: 'POST',
+ body: { path: relPath, title: nextTitle }
+ })
+ }
+
+ async deleteNote(relPath: string): Promise {
+ await this.jsonRequest('/api/notes/delete', {
+ method: 'POST',
+ body: { path: relPath }
+ })
+ }
+
+ async moveToTrash(relPath: string): Promise {
+ return this.jsonRequest('/api/notes/trash', {
+ method: 'POST',
+ body: { path: relPath }
+ })
+ }
+
+ async restoreFromTrash(relPath: string): Promise {
+ return this.jsonRequest('/api/notes/restore', {
+ method: 'POST',
+ body: { path: relPath }
+ })
+ }
+
+ async emptyTrash(): Promise {
+ await this.jsonRequest('/api/notes/empty-trash', { method: 'POST' })
+ }
+
+ async archiveNote(relPath: string): Promise {
+ return this.jsonRequest('/api/notes/archive', {
+ method: 'POST',
+ body: { path: relPath }
+ })
+ }
+
+ async unarchiveNote(relPath: string): Promise {
+ return this.jsonRequest('/api/notes/unarchive', {
+ method: 'POST',
+ body: { path: relPath }
+ })
+ }
+
+ async duplicateNote(relPath: string): Promise {
+ return this.jsonRequest('/api/notes/duplicate', {
+ method: 'POST',
+ body: { path: relPath }
+ })
+ }
+
+ async moveNote(
+ relPath: string,
+ targetFolder: NoteFolder,
+ targetSubpath: string
+ ): Promise {
+ return this.jsonRequest('/api/notes/move', {
+ method: 'POST',
+ body: { path: relPath, targetFolder, targetSubpath }
+ })
+ }
+
+ async createFolder(folder: NoteFolder, subpath: string): Promise {
+ await this.jsonRequest('/api/folders/create', {
+ method: 'POST',
+ body: { folder, subpath }
+ })
+ }
+
+ async renameFolder(folder: NoteFolder, oldSubpath: string, newSubpath: string): Promise {
+ return this.jsonRequest<{ subpath: string }>('/api/folders/rename', {
+ method: 'POST',
+ body: { folder, oldSubpath, newSubpath }
+ }).then((resp) => resp.subpath)
+ }
+
+ async deleteFolder(folder: NoteFolder, subpath: string): Promise {
+ await this.jsonRequest('/api/folders/delete', {
+ method: 'POST',
+ body: { folder, subpath }
+ })
+ }
+
+ async duplicateFolder(folder: NoteFolder, subpath: string): Promise {
+ return this.jsonRequest<{ subpath: string }>('/api/folders/duplicate', {
+ method: 'POST',
+ body: { folder, subpath }
+ }).then((resp) => resp.subpath)
+ }
+
+ async fetchAssetResponse(assetPath: string): Promise {
+ const headers = new Headers()
+ if (this.authToken) {
+ headers.set('Authorization', `Bearer ${this.authToken}`)
+ }
+ const response = await fetch(
+ `${this.baseUrl}/api/assets/raw?path=${encodeURIComponent(assetPath)}`,
+ { headers }
+ )
+ if (!response.ok) {
+ const text = await response.text().catch(() => '')
+ throw new Error(
+ `Remote asset request failed (${response.status} ${response.statusText}) for ${assetPath}${text ? `: ${text}` : ''}`
+ )
+ }
+ return response
+ }
+
+ watchVaultChanges(onEvent: (event: VaultChangeEvent) => void): () => void {
+ const url = new URL('/api/watch', `${this.baseUrl}/`)
+ const headers: Record = {}
+ if (this.authToken) {
+ headers['Authorization'] = `Bearer ${this.authToken}`
+ }
+ const ws = new WebSocket(url, { headers })
+
+ ws.on('message', (data: WebSocket.RawData) => {
+ const text =
+ typeof data === 'string'
+ ? data
+ : data instanceof ArrayBuffer
+ ? Buffer.from(data).toString('utf8')
+ : Buffer.isBuffer(data)
+ ? data.toString('utf8')
+ : ''
+ if (!text) return
+ try {
+ onEvent(JSON.parse(text) as VaultChangeEvent)
+ } catch {
+ // ignore malformed watcher payloads
+ }
+ })
+
+ return () => {
+ try {
+ ws.close()
+ } catch {
+ // ignore close errors
+ }
+ }
+ }
+
+ private async jsonRequest(path: string, init?: JsonRequestInit): Promise {
+ const headers = new Headers(init?.headers)
+ if (this.authToken && !headers.has('Authorization')) {
+ headers.set('Authorization', `Bearer ${this.authToken}`)
+ }
+ const hasBody = init?.body !== undefined
+ if (hasBody && !headers.has('Content-Type')) {
+ headers.set('Content-Type', 'application/json')
+ }
+
+ let response: Response
+ try {
+ response = await fetch(`${this.baseUrl}${path}`, {
+ ...init,
+ headers,
+ body: hasBody ? JSON.stringify(init!.body) : undefined
+ })
+ } catch (error) {
+ const message =
+ error instanceof Error && error.message
+ ? ` Could not reach the server: ${error.message}.`
+ : ''
+ throw new Error(
+ `Could not connect to the ZenNotes server at ${this.baseUrl}. Make sure the server is running and the URL is correct.${message}`
+ )
+ }
+ if (!response.ok) {
+ const text = await response.text().catch(() => '')
+ if (response.status === 401) {
+ throw new Error(
+ `The ZenNotes server rejected the connection. Check the auth token for ${this.baseUrl} and try again.`
+ )
+ }
+ throw new Error(
+ `Remote server request failed (${response.status} ${response.statusText}) for ${path}${text ? `: ${text}` : ''}`
+ )
+ }
+ if (response.status === 204) return undefined as T
+ return (await response.json()) as T
+ }
+}
+
+function normalizeBaseUrl(value: string): string {
+ const trimmed = value.trim()
+ const normalized = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`
+ return normalized.replace(/\/+$/, '')
+}
diff --git a/apps/desktop/src/main/secret-store.ts b/apps/desktop/src/main/secret-store.ts
new file mode 100644
index 00000000..30744267
--- /dev/null
+++ b/apps/desktop/src/main/secret-store.ts
@@ -0,0 +1,145 @@
+import { app, safeStorage } from 'electron'
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+
+const KEYTAR_SERVICE = 'ZenNotes Remote Workspace'
+const KEYTAR_ACCOUNT_PREFIX = 'remote-workspace:'
+const FALLBACK_FILE = 'remote-workspace-secrets.json'
+
+let warnedAboutFallback = false
+let warnedAboutMissingSecureStorage = false
+
+type KeytarModule = {
+ getPassword(service: string, account: string): Promise
+ setPassword(service: string, account: string, password: string): Promise
+ deletePassword(service: string, account: string): Promise
+}
+
+type SecretMap = Record
+
+function accountName(id: string): string {
+ return `${KEYTAR_ACCOUNT_PREFIX}${id}`
+}
+
+function fallbackPath(): string {
+ return path.join(app.getPath('userData'), FALLBACK_FILE)
+}
+
+async function loadKeytar(): Promise {
+ try {
+ const mod = (await import('keytar')) as Partial | { default?: Partial }
+ const candidate = ('default' in mod ? mod.default : mod) as Partial | undefined
+ if (
+ candidate &&
+ typeof candidate.getPassword === 'function' &&
+ typeof candidate.setPassword === 'function' &&
+ typeof candidate.deletePassword === 'function'
+ ) {
+ return candidate as KeytarModule
+ }
+ } catch {
+ // keytar is optional; safeStorage fallback handles the common case.
+ }
+ return null
+}
+
+async function loadFallbackSecrets(): Promise {
+ try {
+ const raw = await fs.readFile(fallbackPath(), 'utf8')
+ const parsed = JSON.parse(raw) as Record
+ const out: SecretMap = {}
+ for (const [key, value] of Object.entries(parsed)) {
+ if (typeof value === 'string' && value.trim()) out[key] = value
+ }
+ return out
+ } catch {
+ return {}
+ }
+}
+
+async function saveFallbackSecrets(next: SecretMap): Promise {
+ await fs.mkdir(path.dirname(fallbackPath()), { recursive: true })
+ await fs.writeFile(fallbackPath(), JSON.stringify(next, null, 2), 'utf8')
+}
+
+function encodeSecret(secret: string): string | null {
+ if (!safeStorage.isEncryptionAvailable()) {
+ if (!warnedAboutMissingSecureStorage) {
+ warnedAboutMissingSecureStorage = true
+ console.warn(
+ 'ZenNotes could not persist a remote workspace token securely because no OS secret store is available.'
+ )
+ }
+ return null
+ }
+ if (!warnedAboutFallback) {
+ warnedAboutFallback = true
+ console.warn('ZenNotes is using Electron safeStorage as the fallback remote credential store.')
+ }
+ return safeStorage.encryptString(secret).toString('base64')
+}
+
+function decodeSecret(encoded: string): string | null {
+ if (!safeStorage.isEncryptionAvailable()) return null
+ try {
+ return safeStorage.decryptString(Buffer.from(encoded, 'base64'))
+ } catch {
+ return null
+ }
+}
+
+export async function getRemoteWorkspaceSecret(id: string): Promise {
+ const normalizedId = id.trim()
+ if (!normalizedId) return null
+
+ const keytar = await loadKeytar()
+ if (keytar) {
+ return await keytar.getPassword(KEYTAR_SERVICE, accountName(normalizedId))
+ }
+
+ const fallback = await loadFallbackSecrets()
+ const encoded = fallback[normalizedId]
+ return encoded ? decodeSecret(encoded) : null
+}
+
+export async function setRemoteWorkspaceSecret(id: string, secret: string | null): Promise {
+ const normalizedId = id.trim()
+ if (!normalizedId) return false
+
+ const keytar = await loadKeytar()
+ if (keytar) {
+ if (secret && secret.trim()) {
+ await keytar.setPassword(KEYTAR_SERVICE, accountName(normalizedId), secret.trim())
+ } else {
+ await keytar.deletePassword(KEYTAR_SERVICE, accountName(normalizedId))
+ }
+ return Boolean(secret && secret.trim())
+ }
+
+ const fallback = await loadFallbackSecrets()
+ if (secret && secret.trim()) {
+ const encoded = encodeSecret(secret.trim())
+ if (!encoded) return false
+ fallback[normalizedId] = encoded
+ } else {
+ delete fallback[normalizedId]
+ }
+ await saveFallbackSecrets(fallback)
+ return Boolean(secret && secret.trim())
+}
+
+export async function deleteRemoteWorkspaceSecret(id: string): Promise {
+ const normalizedId = id.trim()
+ if (!normalizedId) return
+
+ const keytar = await loadKeytar()
+ if (keytar) {
+ await keytar.deletePassword(KEYTAR_SERVICE, accountName(normalizedId))
+ return
+ }
+
+ const fallback = await loadFallbackSecrets()
+ if (!(normalizedId in fallback)) return
+ delete fallback[normalizedId]
+ await saveFallbackSecrets(fallback)
+}
diff --git a/src/main/tasks.ts b/apps/desktop/src/main/tasks.ts
similarity index 89%
rename from src/main/tasks.ts
rename to apps/desktop/src/main/tasks.ts
index 555a3fa6..8bfe2ba1 100644
--- a/src/main/tasks.ts
+++ b/apps/desktop/src/main/tasks.ts
@@ -2,7 +2,7 @@ import { promises as fs } from 'node:fs'
import path from 'node:path'
import type { NoteFolder, NoteMeta } from '@shared/ipc'
import { parseTasksFromBody, type VaultTask } from '@shared/tasks'
-import { listNotes } from './vault'
+import { folderForRelativePath, listNotes } from './vault'
// Trash is excluded — trashed notes should never surface as live tasks.
function includesFolder(folder: NoteFolder): boolean {
@@ -49,8 +49,8 @@ export async function scanTasksForPath(
relPath: string
): Promise {
const posix = relPath.split(path.sep).join('/')
- const firstSeg = posix.split('/', 1)[0] as NoteFolder
- if (!LIVE_FOLDERS.has(firstSeg)) return []
+ const folder = folderForRelativePath(posix)
+ if (!folder || !LIVE_FOLDERS.has(folder)) return []
const abs = path.join(root, posix.split('/').join(path.sep))
let body: string
@@ -60,5 +60,5 @@ export async function scanTasksForPath(
return []
}
const title = path.basename(posix, path.extname(posix))
- return parseTasksFromBody(body, { path: posix, title, folder: firstSeg })
+ return parseTasksFromBody(body, { path: posix, title, folder })
}
diff --git a/src/main/tikz.ts b/apps/desktop/src/main/tikz.ts
similarity index 100%
rename from src/main/tikz.ts
rename to apps/desktop/src/main/tikz.ts
diff --git a/src/main/updater.ts b/apps/desktop/src/main/updater.ts
similarity index 100%
rename from src/main/updater.ts
rename to apps/desktop/src/main/updater.ts
diff --git a/src/main/vault.test.ts b/apps/desktop/src/main/vault.test.ts
similarity index 100%
rename from src/main/vault.test.ts
rename to apps/desktop/src/main/vault.test.ts
diff --git a/src/main/vault.ts b/apps/desktop/src/main/vault.ts
similarity index 75%
rename from src/main/vault.ts
rename to apps/desktop/src/main/vault.ts
index 6bf6b084..61a72de2 100644
--- a/src/main/vault.ts
+++ b/apps/desktop/src/main/vault.ts
@@ -1,10 +1,14 @@
import { promises as fs, type Dirent } from 'node:fs'
import { execFile, spawn } from 'node:child_process'
+import { randomUUID } from 'node:crypto'
import path from 'node:path'
import { promisify } from 'node:util'
import { app } from 'electron'
-import type {
+import {
+ DEFAULT_DAILY_NOTES_DIRECTORY,
AssetMeta,
+ type PrimaryNotesLocation,
+ type VaultSettings,
FolderEntry,
ImportedAsset,
ImportedAssetKind,
@@ -24,9 +28,20 @@ import { DEMO_TOUR_ASSETS, DEMO_TOUR_NOTES } from './demo-tour-data'
const CONFIG_FILE = 'zennotes.config.json'
const FOLDERS: NoteFolder[] = ['inbox', 'quick', 'archive', 'trash']
+const SYSTEM_FOLDERS = new Set(FOLDERS)
const PRIMARY_ATTACHMENTS_DIR = 'attachements'
const LEGACY_ATTACHMENTS_DIRS = ['_assets']
const ATTACHMENTS_DIRS = [PRIMARY_ATTACHMENTS_DIR, ...LEGACY_ATTACHMENTS_DIRS]
+const INTERNAL_VAULT_DIR = '.zennotes'
+const VAULT_SETTINGS_FILE = 'vault.json'
+const RESERVED_ROOT_NAMES = new Set([...FOLDERS, ...ATTACHMENTS_DIRS, INTERNAL_VAULT_DIR])
+const HIDDEN_PRIMARY_ROOT_NAMES = new Set([
+ 'quick',
+ 'archive',
+ 'trash',
+ ...ATTACHMENTS_DIRS,
+ INTERNAL_VAULT_DIR
+])
const FENCED_CODE_BLOCK_RE = /(^|\n)```[^\n]*\n[\s\S]*?\n```[ \t]*(?=\n|$)/g
const IMAGE_EXTENSIONS = new Set([
'.apng',
@@ -50,6 +65,14 @@ const SEARCH_EXECUTABLE_NAMES = {
fzf: new Set(['fzf', 'fzf.exe'])
} as const
+const DEFAULT_VAULT_SETTINGS: VaultSettings = {
+ primaryNotesLocation: 'inbox',
+ dailyNotes: {
+ enabled: false,
+ directory: DEFAULT_DAILY_NOTES_DIRECTORY
+ }
+}
+
interface VaultTextSearchCandidate {
path: string
title: string
@@ -75,14 +98,34 @@ export interface PersistedWindowState {
isMaximized: boolean
}
+export interface PersistedRemoteWorkspaceConfig {
+ baseUrl: string
+ authToken?: string | null
+}
+
+export interface PersistedRemoteWorkspaceProfile extends PersistedRemoteWorkspaceConfig {
+ id: string
+ name: string
+ vaultPath: string | null
+ lastConnectedAt: number | null
+}
+
export interface PersistedConfig {
+ workspaceMode: 'local' | 'remote'
vaultRoot: string | null
+ remoteWorkspace: PersistedRemoteWorkspaceConfig | null
+ remoteWorkspaceProfileId: string | null
+ remoteWorkspaceProfiles: PersistedRemoteWorkspaceProfile[]
windowState: PersistedWindowState | null
zoomFactor: number
}
const DEFAULT_CONFIG: PersistedConfig = {
+ workspaceMode: 'local',
vaultRoot: null,
+ remoteWorkspace: null,
+ remoteWorkspaceProfileId: null,
+ remoteWorkspaceProfiles: [],
windowState: null,
zoomFactor: 1
}
@@ -128,8 +171,61 @@ function normalizePersistedConfig(value: unknown): PersistedConfig {
typeof candidate.zoomFactor === 'number' && Number.isFinite(candidate.zoomFactor)
? Math.min(3, Math.max(0.5, Math.round(candidate.zoomFactor * 100) / 100))
: DEFAULT_CONFIG.zoomFactor
+ const normalizeProfile = (candidate: unknown): PersistedRemoteWorkspaceProfile | null => {
+ if (!candidate || typeof candidate !== 'object') return null
+ const value = candidate as Record
+ const baseUrl = typeof value.baseUrl === 'string' ? value.baseUrl.trim() : ''
+ const name = typeof value.name === 'string' ? value.name.trim() : ''
+ if (!baseUrl || !name) return null
+ return {
+ id: typeof value.id === 'string' && value.id.trim() ? value.id : randomUUID(),
+ name,
+ baseUrl,
+ authToken: typeof value.authToken === 'string' ? value.authToken : null,
+ vaultPath: typeof value.vaultPath === 'string' && value.vaultPath.trim() ? value.vaultPath : null,
+ lastConnectedAt:
+ typeof value.lastConnectedAt === 'number' && Number.isFinite(value.lastConnectedAt)
+ ? value.lastConnectedAt
+ : null
+ }
+ }
+ const legacyRemoteWorkspace =
+ candidate.remoteWorkspace &&
+ typeof candidate.remoteWorkspace === 'object' &&
+ typeof candidate.remoteWorkspace.baseUrl === 'string'
+ ? {
+ baseUrl: candidate.remoteWorkspace.baseUrl,
+ authToken:
+ typeof candidate.remoteWorkspace.authToken === 'string'
+ ? candidate.remoteWorkspace.authToken
+ : null
+ }
+ : null
+ const remoteWorkspaceProfiles = Array.isArray(candidate.remoteWorkspaceProfiles)
+ ? candidate.remoteWorkspaceProfiles
+ .map((entry) => normalizeProfile(entry))
+ .filter((entry): entry is PersistedRemoteWorkspaceProfile => !!entry)
+ : []
+ if (legacyRemoteWorkspace && !remoteWorkspaceProfiles.some((entry) => entry.baseUrl === legacyRemoteWorkspace.baseUrl)) {
+ remoteWorkspaceProfiles.unshift({
+ id: randomUUID(),
+ name: 'ZenNotes Server',
+ baseUrl: legacyRemoteWorkspace.baseUrl,
+ authToken: legacyRemoteWorkspace.authToken,
+ vaultPath: null,
+ lastConnectedAt: null
+ })
+ }
return {
+ workspaceMode: candidate.workspaceMode === 'remote' ? 'remote' : 'local',
vaultRoot: typeof candidate.vaultRoot === 'string' ? candidate.vaultRoot : null,
+ remoteWorkspace: legacyRemoteWorkspace,
+ remoteWorkspaceProfileId:
+ typeof candidate.remoteWorkspaceProfileId === 'string' &&
+ remoteWorkspaceProfiles.some((entry) => entry.id === candidate.remoteWorkspaceProfileId)
+ ? candidate.remoteWorkspaceProfileId
+ : null,
+ remoteWorkspaceProfiles,
windowState: normalizeWindowState(candidate.windowState),
zoomFactor
}
@@ -146,8 +242,23 @@ export async function loadConfig(): Promise {
export async function saveConfig(cfg: PersistedConfig): Promise {
const normalized = normalizePersistedConfig(cfg)
+ const sanitized: PersistedConfig = {
+ ...normalized,
+ remoteWorkspace: normalized.remoteWorkspace
+ ? {
+ baseUrl: normalized.remoteWorkspace.baseUrl
+ }
+ : null,
+ remoteWorkspaceProfiles: normalized.remoteWorkspaceProfiles.map((profile) => ({
+ id: profile.id,
+ name: profile.name,
+ baseUrl: profile.baseUrl,
+ vaultPath: profile.vaultPath,
+ lastConnectedAt: profile.lastConnectedAt
+ }))
+ }
await fs.mkdir(path.dirname(configPath()), { recursive: true })
- await fs.writeFile(configPath(), JSON.stringify(normalized, null, 2), 'utf8')
+ await fs.writeFile(configPath(), JSON.stringify(sanitized, null, 2), 'utf8')
}
export async function updateConfig(
@@ -165,20 +276,161 @@ export async function updateConfig(
return nextConfig
}
+function vaultSettingsPath(root: string): string {
+ return path.join(root, INTERNAL_VAULT_DIR, VAULT_SETTINGS_FILE)
+}
+
+function cloneVaultSettings(settings: VaultSettings): VaultSettings {
+ return {
+ primaryNotesLocation: settings.primaryNotesLocation,
+ dailyNotes: {
+ enabled: settings.dailyNotes.enabled,
+ directory: settings.dailyNotes.directory
+ }
+ }
+}
+
+function normalizeDailyNotesDirectory(value: unknown): string {
+ if (typeof value !== 'string') return DEFAULT_DAILY_NOTES_DIRECTORY
+ const trimmed = value.trim().replace(/^\/+|\/+$/g, '')
+ return trimmed || DEFAULT_DAILY_NOTES_DIRECTORY
+}
+
+function normalizePrimaryNotesLocation(value: unknown): PrimaryNotesLocation {
+ return value === 'root' ? 'root' : 'inbox'
+}
+
+function normalizeVaultSettings(
+ value: unknown,
+ fallbackPrimary: PrimaryNotesLocation = DEFAULT_VAULT_SETTINGS.primaryNotesLocation
+): VaultSettings {
+ if (!value || typeof value !== 'object') {
+ return {
+ primaryNotesLocation: fallbackPrimary,
+ dailyNotes: {
+ enabled: DEFAULT_VAULT_SETTINGS.dailyNotes.enabled,
+ directory: DEFAULT_DAILY_NOTES_DIRECTORY
+ }
+ }
+ }
+ const candidate = value as {
+ primaryNotesLocation?: unknown
+ dailyNotes?: { enabled?: unknown; directory?: unknown } | null
+ }
+ return {
+ primaryNotesLocation: normalizePrimaryNotesLocation(
+ candidate.primaryNotesLocation ?? fallbackPrimary
+ ),
+ dailyNotes: {
+ enabled:
+ typeof candidate.dailyNotes?.enabled === 'boolean'
+ ? candidate.dailyNotes.enabled
+ : DEFAULT_VAULT_SETTINGS.dailyNotes.enabled,
+ directory: normalizeDailyNotesDirectory(candidate.dailyNotes?.directory)
+ }
+ }
+}
+
+async function inferPrimaryNotesLocation(root: string): Promise {
+ let entries: Dirent[]
+ try {
+ entries = await fs.readdir(root, { withFileTypes: true })
+ } catch {
+ return DEFAULT_VAULT_SETTINGS.primaryNotesLocation
+ }
+ for (const entry of entries) {
+ if (entry.name.startsWith('.')) continue
+ if (RESERVED_ROOT_NAMES.has(entry.name)) continue
+ if (entry.isDirectory()) return 'root'
+ if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) return 'root'
+ }
+ return DEFAULT_VAULT_SETTINGS.primaryNotesLocation
+}
+
+async function vaultLooksEmpty(root: string): Promise {
+ let entries: Dirent[]
+ try {
+ entries = await fs.readdir(root, { withFileTypes: true })
+ } catch {
+ return true
+ }
+ for (const entry of entries) {
+ if (entry.name.startsWith('.')) continue
+ if (entry.name === INTERNAL_VAULT_DIR) continue
+ return false
+ }
+ return true
+}
+
+export async function getVaultSettings(root: string): Promise {
+ let fallbackPrimary = DEFAULT_VAULT_SETTINGS.primaryNotesLocation
+ try {
+ fallbackPrimary = await inferPrimaryNotesLocation(root)
+ const raw = await fs.readFile(vaultSettingsPath(root), 'utf8')
+ return normalizeVaultSettings(JSON.parse(raw), fallbackPrimary)
+ } catch {
+ return normalizeVaultSettings(null, fallbackPrimary)
+ }
+}
+
+export async function setVaultSettings(
+ root: string,
+ next: VaultSettings
+): Promise {
+ const fallbackPrimary = await inferPrimaryNotesLocation(root)
+ const normalized = normalizeVaultSettings(next, fallbackPrimary)
+ await fs.mkdir(path.dirname(vaultSettingsPath(root)), { recursive: true })
+ await fs.writeFile(vaultSettingsPath(root), JSON.stringify(normalized, null, 2), 'utf8')
+ if (normalized.primaryNotesLocation === 'inbox') {
+ await fs.mkdir(path.join(root, 'inbox'), { recursive: true })
+ }
+ return cloneVaultSettings(normalized)
+}
+
+async function primaryNotesRoot(root: string): Promise {
+ const settings = await getVaultSettings(root)
+ return settings.primaryNotesLocation === 'root' ? root : path.join(root, 'inbox')
+}
+
+function shouldHidePrimaryRootEntry(name: string): boolean {
+ return HIDDEN_PRIMARY_ROOT_NAMES.has(name)
+}
+
+async function folderRoot(root: string, folder: NoteFolder): Promise {
+ if (folder === 'inbox') return await primaryNotesRoot(root)
+ return path.join(root, folder)
+}
+
+export function folderForRelativePath(rel: string): NoteFolder | null {
+ const normalized = toPosix(rel)
+ const top = normalized.split('/')[0]
+ if (SYSTEM_FOLDERS.has(top as NoteFolder)) return top as NoteFolder
+ if (!top || top.startsWith('.')) return null
+ if (RESERVED_ROOT_NAMES.has(top)) return null
+ return 'inbox'
+}
+
/**
* Ensure the expected vault folder layout exists and seed a welcome note
* the very first time a vault is opened.
*/
export async function ensureVaultLayout(root: string): Promise {
await fs.mkdir(root, { recursive: true })
+ const wasEmpty = await vaultLooksEmpty(root)
+ const settings = await getVaultSettings(root)
for (const f of FOLDERS) {
+ if (f === 'inbox' && settings.primaryNotesLocation === 'root') continue
await fs.mkdir(path.join(root, f), { recursive: true })
}
- const welcomePath = path.join(root, 'inbox', 'Welcome.md')
- try {
- await fs.access(welcomePath)
- } catch {
- await fs.writeFile(welcomePath, WELCOME_NOTE, 'utf8')
+ if (wasEmpty) {
+ const welcomeDir = await primaryNotesRoot(root)
+ await fs.mkdir(welcomeDir, { recursive: true })
+ const welcomePath = path.join(welcomeDir, 'Welcome.md')
+ try {
+ await fs.access(welcomePath)
+ } catch {
+ await fs.writeFile(welcomePath, WELCOME_NOTE, 'utf8')
+ }
}
}
@@ -195,10 +447,7 @@ function markdownDestination(p: string): string {
}
function folderOf(root: string, absPath: string): NoteFolder | null {
- const rel = toPosix(path.relative(root, absPath))
- const top = rel.split('/')[0]
- if (FOLDERS.includes(top as NoteFolder)) return top as NoteFolder
- return null
+ return folderForRelativePath(path.relative(root, absPath))
}
function stripCodeContent(body: string): string {
@@ -208,6 +457,18 @@ function stripCodeContent(body: string): string {
.replace(/`[^`\n]*`/g, ' ')
}
+function localAssetTargetKind(target: string): ImportedAssetKind | null {
+ const clean = target.split('#')[0]?.split('?')[0] ?? target
+ const lastDot = clean.lastIndexOf('.')
+ if (lastDot === -1) return null
+ const ext = clean.slice(lastDot).toLowerCase()
+ if (IMAGE_EXTENSIONS.has(ext)) return 'image'
+ if (PDF_EXTENSIONS.has(ext)) return 'pdf'
+ if (AUDIO_EXTENSIONS.has(ext)) return 'audio'
+ if (VIDEO_EXTENSIONS.has(ext)) return 'video'
+ return 'file'
+}
+
/** Pull unique `#tags` out of markdown text, ignoring fenced/inline code. */
function extractTags(body: string): string[] {
const stripped = stripCodeContent(body)
@@ -226,6 +487,7 @@ function extractTags(body: string): string[] {
function bodyHasLocalAsset(body: string): boolean {
const stripped = stripCodeContent(body)
const linkRe = /(!?)\[[^\]]*\]\((<[^>]+>|[^)\s]+)(?:\s+"[^"]*")?\)/g
+ const embedRe = /!\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]/g
let m: RegExpExecArray | null
while ((m = linkRe.exec(stripped)) !== null) {
let href = (m[2] ?? '').trim()
@@ -233,18 +495,10 @@ function bodyHasLocalAsset(body: string): boolean {
if (!href || href.startsWith('#') || href.startsWith('//')) continue
// Skip URLs (anything with a scheme like http:, mailto:, file:, …).
if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(href)) continue
- const clean = href.split('#')[0]?.split('?')[0] ?? href
- const lastDot = clean.lastIndexOf('.')
- if (lastDot === -1) continue
- const ext = clean.slice(lastDot).toLowerCase()
- if (
- IMAGE_EXTENSIONS.has(ext) ||
- PDF_EXTENSIONS.has(ext) ||
- AUDIO_EXTENSIONS.has(ext) ||
- VIDEO_EXTENSIONS.has(ext)
- ) {
- return true
- }
+ if (localAssetTargetKind(href)) return true
+ }
+ while ((m = embedRe.exec(stripped)) !== null) {
+ if (localAssetTargetKind((m[1] ?? '').trim())) return true
}
return false
}
@@ -253,11 +507,15 @@ function bodyHasLocalAsset(body: string): boolean {
* `[[target|label]]` by discarding the label. Ignores fenced/inline code. */
function extractWikilinks(body: string): string[] {
const stripped = stripCodeContent(body)
- const re = /\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]/g
+ const re = /(!?)\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]/g
const seen = new Set()
let m: RegExpExecArray | null
while ((m = re.exec(stripped)) !== null) {
- seen.add(m[1].trim())
+ const bang = m[1] ?? ''
+ const target = (m[2] ?? '').trim()
+ if (!target) continue
+ if (bang === '!' && localAssetTargetKind(target)) continue
+ seen.add(target)
}
return [...seen]
}
@@ -268,6 +526,7 @@ function buildExcerpt(body: string): string {
const text = stripCodeContent(withoutFront)
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
+ .replace(/!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_, a, b) => b || a)
.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_, a, b) => b || a)
.replace(/^#{1,6}\s+/gm, '')
.replace(/[*_~>]+/g, '')
@@ -420,14 +679,17 @@ function resolveSearchBackend(
}
function noteFolderFromRelPath(relPath: string): NoteFolder | null {
- const top = relPath.split('/')[0]
- if (top === 'inbox' || top === 'quick' || top === 'archive') return top
- return null
+ return folderForRelativePath(relPath)
}
async function collectBuiltinSearchCandidates(root: string): Promise {
const candidates: VaultTextSearchCandidate[] = []
- const walkFolder = async (folder: NoteFolder, dirAbs: string): Promise => {
+ const walkFolder = async (
+ folder: NoteFolder,
+ dirAbs: string,
+ topAbs: string,
+ isPrimaryRoot: boolean
+ ): Promise => {
let entries: Dirent[]
try {
entries = await fs.readdir(dirAbs, { withFileTypes: true })
@@ -439,7 +701,8 @@ async function collectBuiltinSearchCandidates(root: string): Promise {
+ const dir = await folderRoot(root, folder)
+ return toPosix(path.relative(root, dir)) || '.'
+ })
+ )
const result = await execFileAsync(
ripgrep,
[
@@ -496,7 +767,7 @@ async function collectRipgrepSearchCandidates(
'-g',
'*.md',
'^',
- ...SEARCHABLE_TEXT_FOLDERS
+ ...searchRoots
],
{
cwd: root,
@@ -731,7 +1002,8 @@ async function readSiblingOrder(abs: string): Promise {
export async function listFolders(root: string): Promise {
const out: FolderEntry[] = []
for (const folder of FOLDERS) {
- const topAbs = path.join(root, folder)
+ const topAbs = await folderRoot(root, folder)
+ const isPrimaryRoot = folder === 'inbox' && path.resolve(topAbs) === path.resolve(root)
const walk = async (dirAbs: string, subpath: string): Promise => {
let entries: Dirent[]
try {
@@ -742,6 +1014,7 @@ export async function listFolders(root: string): Promise {
for (const [index, e] of entries.entries()) {
if (!e.isDirectory()) continue
if (e.name.startsWith('.')) continue
+ if (isPrimaryRoot && dirAbs === topAbs && shouldHidePrimaryRootEntry(e.name)) continue
const nextSub = subpath ? `${subpath}/${e.name}` : e.name
out.push({ folder, subpath: nextSub, siblingOrder: index })
await walk(path.join(dirAbs, e.name), nextSub)
@@ -754,7 +1027,12 @@ export async function listFolders(root: string): Promise {
export async function listNotes(root: string): Promise {
const metas: NoteMeta[] = []
- const walkFolder = async (folder: NoteFolder, dirAbs: string): Promise => {
+ const walkFolder = async (
+ folder: NoteFolder,
+ dirAbs: string,
+ topAbs: string,
+ isPrimaryRoot: boolean
+ ): Promise => {
let entries: Dirent[]
try {
entries = await fs.readdir(dirAbs, { withFileTypes: true })
@@ -765,7 +1043,8 @@ export async function listNotes(root: string): Promise {
const full = path.join(dirAbs, entry.name)
if (entry.isDirectory()) {
if (entry.name.startsWith('.')) continue
- await walkFolder(folder, full)
+ if (isPrimaryRoot && dirAbs === topAbs && shouldHidePrimaryRootEntry(entry.name)) continue
+ await walkFolder(folder, full, topAbs, isPrimaryRoot)
continue
}
if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
@@ -775,7 +1054,9 @@ export async function listNotes(root: string): Promise {
}
for (const folder of FOLDERS) {
- await walkFolder(folder, path.join(root, folder))
+ const topAbs = await folderRoot(root, folder)
+ const isPrimaryRoot = folder === 'inbox' && path.resolve(topAbs) === path.resolve(root)
+ await walkFolder(folder, topAbs, topAbs, isPrimaryRoot)
}
return metas
}
@@ -900,9 +1181,8 @@ export async function createNote(
): Promise {
const base = (title && title.trim()) || 'Untitled'
const clean = subpath.replace(/^\/+|\/+$/g, '')
- const dir = clean
- ? resolveSafe(root, path.posix.join(folder, clean))
- : path.join(root, folder)
+ const topRoot = await folderRoot(root, folder)
+ const dir = clean ? resolveSafe(topRoot, clean) : topRoot
await fs.mkdir(dir, { recursive: true })
const finalTitle = await uniqueTitle(dir, base)
const abs = path.join(dir, `${finalTitle}.md`)
@@ -952,7 +1232,7 @@ async function moveBetweenFolders(
): Promise {
const abs = resolveSafe(root, rel)
const filename = path.basename(abs)
- const destDir = path.join(root, target)
+ const destDir = await folderRoot(root, target)
await fs.mkdir(destDir, { recursive: true })
const baseTitle = path.basename(filename, path.extname(filename))
const finalTitle = await uniqueTitle(destDir, baseTitle)
@@ -1008,7 +1288,7 @@ export async function createFolder(
): Promise {
const trimmed = subpath.replace(/^\/+|\/+$/g, '')
if (!trimmed) throw new Error('Folder name is required')
- const abs = resolveSafe(root, path.posix.join(topFolder, trimmed))
+ const abs = resolveSafe(await folderRoot(root, topFolder), trimmed)
await fs.mkdir(abs, { recursive: true })
}
@@ -1029,8 +1309,9 @@ export async function renameFolder(
if (!oldClean) throw new Error('Cannot rename the top-level folder')
if (!newClean) throw new Error('Target folder name is required')
- const oldAbs = resolveSafe(root, path.posix.join(topFolder, oldClean))
- const newAbs = resolveSafe(root, path.posix.join(topFolder, newClean))
+ const topRoot = await folderRoot(root, topFolder)
+ const oldAbs = resolveSafe(topRoot, oldClean)
+ const newAbs = resolveSafe(topRoot, newClean)
if (newAbs === oldAbs) return newClean
const sep = path.sep
@@ -1076,7 +1357,7 @@ export async function deleteFolder(
): Promise {
const clean = subpath.replace(/^\/+|\/+$/g, '')
if (!clean) throw new Error('Cannot delete the top-level folder')
- const abs = resolveSafe(root, path.posix.join(topFolder, clean))
+ const abs = resolveSafe(await folderRoot(root, topFolder), clean)
await fs.rm(abs, { recursive: true, force: true })
}
@@ -1092,7 +1373,8 @@ export async function duplicateFolder(
): Promise {
const clean = subpath.replace(/^\/+|\/+$/g, '')
if (!clean) throw new Error('Cannot duplicate the top-level folder')
- const oldAbs = resolveSafe(root, path.posix.join(topFolder, clean))
+ const topRoot = await folderRoot(root, topFolder)
+ const oldAbs = resolveSafe(topRoot, clean)
const parentAbs = path.dirname(oldAbs)
const baseName = path.basename(oldAbs)
let copyName = `${baseName} copy`
@@ -1108,10 +1390,7 @@ export async function duplicateFolder(
}
const newAbs = path.join(parentAbs, copyName)
await fs.cp(oldAbs, newAbs, { recursive: true })
- return path
- .relative(path.join(root, topFolder), newAbs)
- .split(path.sep)
- .join('/')
+ return path.relative(topRoot, newAbs).split(path.sep).join('/')
}
/** Build the absolute on-disk path for a vault folder / subfolder. */
@@ -1119,11 +1398,12 @@ export function folderAbsolutePath(
root: string,
topFolder: NoteFolder,
subpath: string
-): string {
+): Promise {
const clean = subpath.replace(/^\/+|\/+$/g, '')
- return clean
- ? resolveSafe(root, path.posix.join(topFolder, clean))
- : path.join(root, topFolder)
+ return (async () => {
+ const topRoot = await folderRoot(root, topFolder)
+ return clean ? resolveSafe(topRoot, clean) : topRoot
+ })()
}
export function assetsAbsolutePath(root: string): string {
@@ -1186,6 +1466,8 @@ export async function removeDemoTour(root: string): Promise
}
export async function hasAssetsDir(root: string): Promise {
+ const assets = await listAssets(root)
+ if (assets.length > 0) return true
for (const dirName of ATTACHMENTS_DIRS) {
try {
const stat = await fs.stat(path.join(root, dirName))
@@ -1199,43 +1481,42 @@ export async function hasAssetsDir(root: string): Promise {
export async function listAssets(root: string): Promise {
const out: AssetMeta[] = []
- const walk = async (dirAbs: string): Promise => {
+ const walk = async (dirAbs: string, topAbs = dirAbs): Promise => {
let entries: Dirent[]
try {
entries = await fs.readdir(dirAbs, { withFileTypes: true })
} catch {
return
}
- for (const entry of entries) {
+ for (const [index, entry] of entries.entries()) {
if (entry.name.startsWith('.')) continue
const full = path.join(dirAbs, entry.name)
if (entry.isDirectory()) {
- await walk(full)
+ if (dirAbs === root && entry.name === INTERNAL_VAULT_DIR) continue
+ await walk(full, topAbs)
continue
}
if (!entry.isFile()) continue
- const stat = await fs.stat(full)
+ if (entry.name.toLowerCase().endsWith('.md')) continue
+ let stat
+ try {
+ stat = await fs.stat(full)
+ } catch {
+ continue
+ }
const rel = toPosix(path.relative(root, full))
out.push({
path: rel,
name: path.basename(full),
kind: classifyImportedAsset(entry.name),
+ siblingOrder: index,
size: stat.size,
updatedAt: stat.mtimeMs
})
}
}
- for (const dirName of ATTACHMENTS_DIRS) {
- const attachmentsRoot = path.join(root, dirName)
- try {
- const stat = await fs.stat(attachmentsRoot)
- if (!stat.isDirectory()) continue
- } catch {
- continue
- }
- await walk(attachmentsRoot)
- }
+ await walk(root, root)
out.sort((a, b) => b.updatedAt - a.updatedAt || a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }))
return out
}
@@ -1256,9 +1537,8 @@ export async function moveNote(
const oldAbs = resolveSafe(root, oldRel)
const filename = path.basename(oldAbs)
const cleanSub = targetSubpath.replace(/^\/+|\/+$/g, '')
- const destDir = cleanSub
- ? resolveSafe(root, path.posix.join(targetFolder, cleanSub))
- : path.join(root, targetFolder)
+ const targetRoot = await folderRoot(root, targetFolder)
+ const destDir = cleanSub ? resolveSafe(targetRoot, cleanSub) : targetRoot
// No-op if the source already lives at the destination.
if (path.dirname(oldAbs) === destDir) {
@@ -1295,8 +1575,7 @@ export async function importFiles(
noteRelPath: string,
sourcePaths: string[]
): Promise {
- const assetsDir = assetsAbsolutePath(root)
- await fs.mkdir(assetsDir, { recursive: true })
+ await fs.mkdir(root, { recursive: true })
const noteDir = path.posix.dirname(toPosix(noteRelPath))
const imported: ImportedAsset[] = []
@@ -1306,8 +1585,8 @@ export async function importFiles(
const stat = await fs.stat(sourceAbs)
if (!stat.isFile()) continue
- const finalName = await uniqueFilename(assetsDir, path.basename(sourceAbs))
- const destAbs = path.join(assetsDir, finalName)
+ const finalName = await uniqueFilename(root, path.basename(sourceAbs))
+ const destAbs = path.join(root, finalName)
await fs.copyFile(sourceAbs, destAbs)
const vaultRelPath = toPosix(path.relative(root, destAbs))
diff --git a/src/main/watcher.ts b/apps/desktop/src/main/watcher.ts
similarity index 58%
rename from src/main/watcher.ts
rename to apps/desktop/src/main/watcher.ts
index 06dee977..ec4cb3fc 100644
--- a/src/main/watcher.ts
+++ b/apps/desktop/src/main/watcher.ts
@@ -1,8 +1,11 @@
import path from 'node:path'
import chokidar, { FSWatcher } from 'chokidar'
import type { NoteFolder, VaultChangeEvent, VaultChangeKind } from '@shared/ipc'
+import { folderForRelativePath } from './vault'
-const FOLDERS: NoteFolder[] = ['inbox', 'quick', 'archive', 'trash']
+const ATTACHMENTS_DIRS = new Set(['attachements', '_assets'])
+const INTERNAL_VAULT_DIR = '.zennotes'
+const VAULT_SETTINGS_RELATIVE_PATH = `${INTERNAL_VAULT_DIR}/vault.json`
function toPosix(p: string): string {
return p.split(path.sep).join('/')
@@ -10,9 +13,18 @@ function toPosix(p: string): string {
function folderOf(root: string, abs: string): NoteFolder | null {
const rel = toPosix(path.relative(root, abs))
+ const folder = folderForRelativePath(rel)
+ if (folder) return folder
const top = rel.split('/')[0]
- if (FOLDERS.includes(top as NoteFolder)) return top as NoteFolder
- return null
+ return ATTACHMENTS_DIRS.has(top) ? 'inbox' : null
+}
+
+function relativeVaultPath(root: string, abs: string): string {
+ return toPosix(path.relative(root, abs))
+}
+
+function isVaultSettingsPath(root: string, abs: string): boolean {
+ return relativeVaultPath(root, abs) === VAULT_SETTINGS_RELATIVE_PATH
}
export class VaultWatcher {
@@ -26,6 +38,8 @@ export class VaultWatcher {
ignoreInitial: true,
persistent: true,
ignored: (p: string) => {
+ if (this.root && isVaultSettingsPath(this.root, p)) return false
+ if (this.root && relativeVaultPath(this.root, p) === INTERNAL_VAULT_DIR) return false
const base = path.basename(p)
return base.startsWith('.') || base === 'node_modules'
},
@@ -36,8 +50,18 @@ export class VaultWatcher {
})
const handler = (kind: VaultChangeKind) => (absPath: string) => {
- if (!absPath.toLowerCase().endsWith('.md')) return
+ const base = path.basename(absPath)
if (!this.root) return
+ if (isVaultSettingsPath(this.root, absPath)) {
+ onEvent({
+ kind,
+ path: VAULT_SETTINGS_RELATIVE_PATH,
+ folder: 'inbox',
+ scope: 'vault-settings'
+ })
+ return
+ }
+ if (base.startsWith('.')) return
const folder = folderOf(this.root, absPath)
if (!folder) return
onEvent({
diff --git a/src/mcp/index.ts b/apps/desktop/src/mcp/index.ts
similarity index 100%
rename from src/mcp/index.ts
rename to apps/desktop/src/mcp/index.ts
diff --git a/src/mcp/instructions-store.ts b/apps/desktop/src/mcp/instructions-store.ts
similarity index 100%
rename from src/mcp/instructions-store.ts
rename to apps/desktop/src/mcp/instructions-store.ts
diff --git a/src/mcp/instructions.ts b/apps/desktop/src/mcp/instructions.ts
similarity index 100%
rename from src/mcp/instructions.ts
rename to apps/desktop/src/mcp/instructions.ts
diff --git a/src/mcp/vault-ops.ts b/apps/desktop/src/mcp/vault-ops.ts
similarity index 100%
rename from src/mcp/vault-ops.ts
rename to apps/desktop/src/mcp/vault-ops.ts
diff --git a/apps/desktop/src/preload/index.d.ts b/apps/desktop/src/preload/index.d.ts
new file mode 100644
index 00000000..863365f4
--- /dev/null
+++ b/apps/desktop/src/preload/index.d.ts
@@ -0,0 +1,9 @@
+import type { ZenBridge } from '@zennotes/bridge-contract/bridge'
+
+declare global {
+ interface Window {
+ zen: ZenBridge
+ }
+}
+
+export {}
diff --git a/src/preload/index.ts b/apps/desktop/src/preload/index.ts
similarity index 50%
rename from src/preload/index.ts
rename to apps/desktop/src/preload/index.ts
index 8127b4ad..131a31a0 100644
--- a/src/preload/index.ts
+++ b/apps/desktop/src/preload/index.ts
@@ -1,21 +1,30 @@
import { clipboard, contextBridge, ipcRenderer, webUtils } from 'electron'
import path from 'node:path'
+import appPackage from '../../package.json'
+import type { ZenAppInfo, ZenBridge, ZenCapabilities } from '@zennotes/bridge-contract/bridge'
import { IPC } from '@shared/ipc'
import type {
AppUpdateState,
AssetMeta,
- VaultDemoTourResult,
+ DirectoryBrowseResult,
FolderEntry,
ImportedAsset,
NoteContent,
NoteFolder,
NoteMeta,
+ RemoteWorkspaceInfo,
+ RemoteWorkspaceProfile,
+ RemoteWorkspaceProfileInput,
+ ServerCapabilities,
+ ServerSessionStatus,
+ VaultChangeEvent,
+ VaultDemoTourResult,
+ VaultInfo,
+ VaultSettings,
VaultTextSearchBackendPreference,
VaultTextSearchCapabilities,
- VaultTextSearchToolPaths,
VaultTextSearchMatch,
- VaultChangeEvent,
- VaultInfo
+ VaultTextSearchToolPaths
} from '@shared/ipc'
import type { VaultTask } from '@shared/tasks'
import type {
@@ -25,7 +34,85 @@ import type {
McpServerRuntime
} from '@shared/mcp-clients'
-const api = {
+const DESKTOP_CAPABILITIES: ZenCapabilities = {
+ supportsUpdater: true,
+ supportsNativeMenus: true,
+ supportsFloatingWindows: true,
+ supportsLocalFilesystemPickers: true,
+ supportsDesktopNotifications: true,
+ supportsRemoteWorkspace: true
+}
+
+const DESKTOP_APP_INFO: ZenAppInfo = {
+ name: appPackage.name,
+ productName: appPackage.productName,
+ version: appPackage.version,
+ description: appPackage.description,
+ homepage: appPackage.homepage,
+ runtime: 'desktop'
+}
+
+let remoteWorkspaceInfo: RemoteWorkspaceInfo | null = null
+
+async function refreshRemoteWorkspaceInfo(): Promise {
+ try {
+ remoteWorkspaceInfo = await ipcRenderer.invoke(IPC.WORKSPACE_GET_INFO)
+ } catch {
+ remoteWorkspaceInfo = null
+ }
+ return remoteWorkspaceInfo
+}
+
+void refreshRemoteWorkspaceInfo()
+
+function stripQueryAndHash(value: string): string {
+ const hashIdx = value.indexOf('#')
+ const queryIdx = value.indexOf('?')
+ const cutIdx =
+ hashIdx === -1
+ ? queryIdx
+ : queryIdx === -1
+ ? hashIdx
+ : Math.min(hashIdx, queryIdx)
+ return cutIdx === -1 ? value : value.slice(0, cutIdx)
+}
+
+function decodeHrefPath(value: string): string {
+ const cleaned = stripQueryAndHash(value)
+ try {
+ return decodeURIComponent(cleaned)
+ } catch {
+ return cleaned
+ }
+}
+
+function resolveVaultRelativeAssetPath(notePath: string, href: string): string | null {
+ const trimmed = href.trim()
+ if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('//')) return null
+ if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(trimmed)) return null
+
+ const normalizedNotePath = notePath.split(path.sep).join('/')
+ const noteDir = path.posix.dirname(normalizedNotePath)
+ const decodedHref = decodeHrefPath(trimmed)
+ const relativeTarget = decodedHref.startsWith('/')
+ ? decodedHref.replace(/^\/+/, '')
+ : path.posix.normalize(path.posix.join(noteDir === '.' ? '' : noteDir, decodedHref))
+ if (relativeTarget === '..' || relativeTarget.startsWith('../')) return null
+ return relativeTarget
+}
+
+function remoteAssetUrl(assetPath: string): string | null {
+ if (remoteWorkspaceInfo?.mode !== 'remote' || !remoteWorkspaceInfo.baseUrl) return null
+ const trimmed = assetPath.trim()
+ if (!trimmed) return null
+ return `zen-asset://remote?baseUrl=${encodeURIComponent(
+ remoteWorkspaceInfo.baseUrl.replace(/\/+$/, '')
+ )}&path=${encodeURIComponent(trimmed)}`
+}
+
+const api: ZenBridge = {
+ getCapabilities: (): ZenCapabilities => DESKTOP_CAPABILITIES,
+ getAppInfo: (): ZenAppInfo => DESKTOP_APP_INFO,
platform: (): Promise => ipcRenderer.invoke(IPC.APP_PLATFORM),
platformSync: (): NodeJS.Platform => process.platform,
listSystemFonts: (): Promise => ipcRenderer.invoke(IPC.APP_LIST_FONTS),
@@ -33,22 +120,89 @@ const api = {
zoomInApp: (): Promise => ipcRenderer.invoke(IPC.APP_ZOOM_IN),
zoomOutApp: (): Promise => ipcRenderer.invoke(IPC.APP_ZOOM_OUT),
resetAppZoom: (): Promise => ipcRenderer.invoke(IPC.APP_ZOOM_RESET),
- getAppUpdateState: (): Promise =>
- ipcRenderer.invoke(IPC.APP_UPDATER_GET_STATE),
- checkForAppUpdates: (): Promise =>
- ipcRenderer.invoke(IPC.APP_UPDATER_CHECK),
+ getAppUpdateState: (): Promise => ipcRenderer.invoke(IPC.APP_UPDATER_GET_STATE),
+ checkForAppUpdates: (): Promise => ipcRenderer.invoke(IPC.APP_UPDATER_CHECK),
checkForAppUpdatesWithUi: (): Promise =>
ipcRenderer.invoke(IPC.APP_UPDATER_CHECK_WITH_UI),
- downloadAppUpdate: (): Promise =>
- ipcRenderer.invoke(IPC.APP_UPDATER_DOWNLOAD),
+ downloadAppUpdate: (): Promise => ipcRenderer.invoke(IPC.APP_UPDATER_DOWNLOAD),
installAppUpdate: (): Promise => ipcRenderer.invoke(IPC.APP_UPDATER_INSTALL),
+ getServerCapabilities: async (): Promise =>
+ (await refreshRemoteWorkspaceInfo())?.capabilities ?? null,
+ getServerSession: async (): Promise => ({
+ authenticated: true,
+ authRequired: false,
+ supportsSessionLogin: false
+ }),
+ loginServerSession: async (): Promise => ({
+ authenticated: true,
+ authRequired: false,
+ supportsSessionLogin: false
+ }),
+ logoutServerSession: async (): Promise => ({
+ authenticated: true,
+ authRequired: false,
+ supportsSessionLogin: false
+ }),
+ getRemoteWorkspaceInfo: async (): Promise =>
+ await refreshRemoteWorkspaceInfo(),
+ connectRemoteWorkspace: async (
+ baseUrl: string,
+ authToken?: string | null
+ ): Promise<{ vault: VaultInfo | null; capabilities: ServerCapabilities }> => {
+ const result = await ipcRenderer.invoke(IPC.WORKSPACE_CONNECT_REMOTE, baseUrl, authToken ?? null)
+ await refreshRemoteWorkspaceInfo()
+ return result
+ },
+ disconnectRemoteWorkspace: async (): Promise => {
+ const result = await ipcRenderer.invoke(IPC.WORKSPACE_DISCONNECT_REMOTE)
+ await refreshRemoteWorkspaceInfo()
+ return result
+ },
+ listRemoteWorkspaceProfiles: async (): Promise =>
+ ipcRenderer.invoke(IPC.WORKSPACE_LIST_REMOTE_PROFILES),
+ saveRemoteWorkspaceProfile: async (
+ input: RemoteWorkspaceProfileInput
+ ): Promise => {
+ const result = await ipcRenderer.invoke(IPC.WORKSPACE_SAVE_REMOTE_PROFILE, input)
+ await refreshRemoteWorkspaceInfo()
+ return result
+ },
+ deleteRemoteWorkspaceProfile: async (id: string): Promise => {
+ await ipcRenderer.invoke(IPC.WORKSPACE_DELETE_REMOTE_PROFILE, id)
+ await refreshRemoteWorkspaceInfo()
+ },
+ connectRemoteWorkspaceProfile: async (
+ id: string
+ ): Promise<{ vault: VaultInfo | null; capabilities: ServerCapabilities }> => {
+ const result = await ipcRenderer.invoke(IPC.WORKSPACE_CONNECT_REMOTE_PROFILE, id)
+ await refreshRemoteWorkspaceInfo()
+ return result
+ },
- getCurrentVault: (): Promise => ipcRenderer.invoke(IPC.VAULT_GET_CURRENT),
- pickVault: (): Promise => ipcRenderer.invoke(IPC.VAULT_PICK),
+ getCurrentVault: async (): Promise => {
+ const vault = await ipcRenderer.invoke(IPC.VAULT_GET_CURRENT)
+ await refreshRemoteWorkspaceInfo()
+ return vault
+ },
+ pickVault: async (): Promise => {
+ const vault = await ipcRenderer.invoke(IPC.VAULT_PICK)
+ await refreshRemoteWorkspaceInfo()
+ return vault
+ },
+ selectVaultPath: async (targetPath: string): Promise => {
+ const vault = await ipcRenderer.invoke(IPC.VAULT_SELECT_PATH, targetPath)
+ await refreshRemoteWorkspaceInfo()
+ return vault
+ },
+ browseServerDirectories: async (targetPath = ''): Promise => {
+ return await ipcRenderer.invoke(IPC.VAULT_BROWSE_SERVER_DIRECTORIES, targetPath)
+ },
+ getVaultSettings: (): Promise => ipcRenderer.invoke(IPC.VAULT_GET_SETTINGS),
+ setVaultSettings: (next: VaultSettings): Promise =>
+ ipcRenderer.invoke(IPC.VAULT_SET_SETTINGS, next),
listNotes: (): Promise => ipcRenderer.invoke(IPC.VAULT_LIST_NOTES),
- listFolders: (): Promise =>
- ipcRenderer.invoke(IPC.VAULT_LIST_FOLDERS),
+ listFolders: (): Promise => ipcRenderer.invoke(IPC.VAULT_LIST_FOLDERS),
listAssets: (): Promise => ipcRenderer.invoke(IPC.VAULT_LIST_ASSETS),
hasAssetsDir: (): Promise => ipcRenderer.invoke(IPC.VAULT_HAS_ASSETS_DIR),
generateDemoTour: (): Promise =>
@@ -65,23 +219,17 @@ const api = {
paths: VaultTextSearchToolPaths = {}
): Promise =>
ipcRenderer.invoke(IPC.VAULT_SEARCH_TEXT, query, backend, paths),
- readNote: (relPath: string): Promise =>
- ipcRenderer.invoke(IPC.VAULT_READ_NOTE, relPath),
+ readNote: (relPath: string): Promise => ipcRenderer.invoke(IPC.VAULT_READ_NOTE, relPath),
scanTasks: (): Promise => ipcRenderer.invoke(IPC.VAULT_SCAN_TASKS),
scanTasksForPath: (relPath: string): Promise =>
ipcRenderer.invoke(IPC.VAULT_SCAN_TASKS_FOR, relPath),
writeNote: (relPath: string, body: string): Promise =>
ipcRenderer.invoke(IPC.VAULT_WRITE_NOTE, relPath, body),
- createNote: (
- folder: NoteFolder,
- title?: string,
- subpath?: string
- ): Promise =>
+ createNote: (folder: NoteFolder, title?: string, subpath?: string): Promise =>
ipcRenderer.invoke(IPC.VAULT_CREATE_NOTE, folder, title, subpath),
renameNote: (relPath: string, nextTitle: string): Promise =>
ipcRenderer.invoke(IPC.VAULT_RENAME_NOTE, relPath, nextTitle),
- deleteNote: (relPath: string): Promise =>
- ipcRenderer.invoke(IPC.VAULT_DELETE_NOTE, relPath),
+ deleteNote: (relPath: string): Promise => ipcRenderer.invoke(IPC.VAULT_DELETE_NOTE, relPath),
moveToTrash: (relPath: string): Promise =>
ipcRenderer.invoke(IPC.VAULT_MOVE_TO_TRASH, relPath),
restoreFromTrash: (relPath: string): Promise =>
@@ -93,8 +241,7 @@ const api = {
ipcRenderer.invoke(IPC.VAULT_UNARCHIVE_NOTE, relPath),
duplicateNote: (relPath: string): Promise =>
ipcRenderer.invoke(IPC.VAULT_DUPLICATE_NOTE, relPath),
- revealNote: (relPath: string): Promise =>
- ipcRenderer.invoke(IPC.VAULT_REVEAL_NOTE, relPath),
+ revealNote: (relPath: string): Promise => ipcRenderer.invoke(IPC.VAULT_REVEAL_NOTE, relPath),
moveNote: (
relPath: string,
targetFolder: NoteFolder,
@@ -105,11 +252,7 @@ const api = {
ipcRenderer.invoke(IPC.VAULT_IMPORT_FILES, notePath, sourcePaths),
createFolder: (folder: NoteFolder, subpath: string): Promise =>
ipcRenderer.invoke(IPC.VAULT_CREATE_FOLDER, folder, subpath),
- renameFolder: (
- folder: NoteFolder,
- oldSubpath: string,
- newSubpath: string
- ): Promise =>
+ renameFolder: (folder: NoteFolder, oldSubpath: string, newSubpath: string): Promise =>
ipcRenderer.invoke(IPC.VAULT_RENAME_FOLDER, folder, oldSubpath, newSubpath),
deleteFolder: (folder: NoteFolder, subpath: string): Promise =>
ipcRenderer.invoke(IPC.VAULT_DELETE_FOLDER, folder, subpath),
@@ -126,36 +269,17 @@ const api = {
}
},
resolveLocalAssetUrl: (vaultRoot: string, notePath: string, href: string): string | null => {
+ if (remoteWorkspaceInfo?.mode === 'remote') {
+ const resolved = resolveVaultRelativeAssetPath(notePath, href)
+ return resolved ? remoteAssetUrl(resolved) : null
+ }
+
const trimmed = href.trim()
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('//')) return null
if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(trimmed)) return null
- const stripQueryAndHash = (value: string): string => {
- const hashIdx = value.indexOf('#')
- const queryIdx = value.indexOf('?')
- const cutIdx =
- hashIdx === -1
- ? queryIdx
- : queryIdx === -1
- ? hashIdx
- : Math.min(hashIdx, queryIdx)
- return cutIdx === -1 ? value : value.slice(0, cutIdx)
- }
- const decodeHrefPath = (value: string): string => {
- const cleaned = stripQueryAndHash(value)
- try {
- return decodeURIComponent(cleaned)
- } catch {
- return cleaned
- }
- }
-
- const normalizedNotePath = notePath.split(path.sep).join('/')
- const noteDir = path.posix.dirname(normalizedNotePath)
- const decodedHref = decodeHrefPath(trimmed)
- const relativeTarget = decodedHref.startsWith('/')
- ? decodedHref.replace(/^\/+/, '')
- : path.posix.normalize(path.posix.join(noteDir === '.' ? '' : noteDir, decodedHref))
+ const relativeTarget = resolveVaultRelativeAssetPath(notePath, href)
+ if (!relativeTarget) return null
const resolved = path.resolve(vaultRoot, relativeTarget.split('/').join(path.sep))
const rootAbs = path.resolve(vaultRoot)
if (resolved !== rootAbs && !resolved.startsWith(rootAbs + path.sep)) return null
@@ -164,6 +288,9 @@ const api = {
resolveVaultAssetUrl: (vaultRoot: string, assetPath: string): string | null => {
const trimmed = assetPath.trim()
if (!trimmed) return null
+ if (remoteWorkspaceInfo?.mode === 'remote') {
+ return remoteAssetUrl(trimmed)
+ }
const resolved = path.resolve(vaultRoot, trimmed.split('/').join(path.sep))
const rootAbs = path.resolve(vaultRoot)
if (resolved !== rootAbs && !resolved.startsWith(rootAbs + path.sep)) return null
@@ -189,28 +316,23 @@ const api = {
windowMinimize: (): void => ipcRenderer.send(IPC.WINDOW_MINIMIZE),
windowToggleMaximize: (): void => ipcRenderer.send(IPC.WINDOW_TOGGLE_MAXIMIZE),
windowClose: (): void => ipcRenderer.send(IPC.WINDOW_CLOSE),
- openNoteWindow: (relPath: string): Promise =>
- ipcRenderer.invoke(IPC.WINDOW_OPEN_NOTE, relPath),
+ openNoteWindow: (relPath: string): Promise => ipcRenderer.invoke(IPC.WINDOW_OPEN_NOTE, relPath),
renderTikz: (source: string): Promise<{ ok: boolean; svg?: string; error?: string }> =>
ipcRenderer.invoke(IPC.TIKZ_RENDER, source),
mcpGetRuntime: (): Promise => ipcRenderer.invoke(IPC.MCP_RUNTIME),
mcpGetStatuses: (): Promise => ipcRenderer.invoke(IPC.MCP_STATUS),
- mcpInstall: (id: McpClientId): Promise =>
- ipcRenderer.invoke(IPC.MCP_INSTALL, id),
+ mcpInstall: (id: McpClientId): Promise => ipcRenderer.invoke(IPC.MCP_INSTALL, id),
mcpUninstall: (id: McpClientId): Promise =>
ipcRenderer.invoke(IPC.MCP_UNINSTALL, id),
mcpGetInstructions: (): Promise =>
ipcRenderer.invoke(IPC.MCP_GET_INSTRUCTIONS),
mcpSetInstructions: (next: string | null): Promise =>
ipcRenderer.invoke(IPC.MCP_SET_INSTRUCTIONS, next),
- // Native Electron clipboard — more reliable than `navigator.clipboard`
- // which can reject for focus / permission reasons in Electron contexts,
- // especially right after a React state change that unmounts a menu.
clipboardWriteText: (text: string): void => clipboard.writeText(text),
clipboardReadText: (): string => clipboard.readText()
}
-export type ZenApi = typeof api
+export type ZenApi = ZenBridge
contextBridge.exposeInMainWorld('zen', api)
diff --git a/src/renderer/index.html b/apps/desktop/src/renderer/index.html
similarity index 96%
rename from src/renderer/index.html
rename to apps/desktop/src/renderer/index.html
index 9478560f..0616beaf 100644
--- a/src/renderer/index.html
+++ b/apps/desktop/src/renderer/index.html
@@ -32,6 +32,6 @@
-
+