20 Commits

Author SHA1 Message Date
Nystik
ec3c38228c release: 0.8.2 2026-05-23 18:37:03 +02:00
Nystik
e6975d631c fix path validation bug 2026-05-23 18:01:23 +02:00
Nystik
4fff803cbd prevent native menus in browser 2026-05-23 18:01:23 +02:00
Nystik-gh
10c6782652 Merge pull request #15 from Nystik-gh/monorepo-refactor
Monorepo refactor
2026-05-22 20:43:44 +02:00
Nystik-gh
840af89feb Update IREADME, variant clarification
Clarified the current status of Ignis variants and their hosting options.
2026-05-22 20:36:12 +02:00
Nystik
f7fd3d9fba update docs 2026-05-22 15:17:05 +02:00
Nystik
85b61a09c4 update docker files 2026-05-22 15:15:07 +02:00
Nystik
8672fa11a3 move server into apps/ignis-server 2026-05-21 17:26:08 +02:00
Nystik
a6807fe850 break out code into server-core 2026-05-21 01:59:30 +02:00
Nystik
4a65f142bc move bridge plugin to package 2026-05-20 22:26:58 +02:00
Nystik
fe11f30c01 move shim to new package 2026-05-20 20:49:28 +02:00
Nystik
a0b44bde58 move UI code into new package 2026-05-20 17:05:29 +02:00
Nystik
0433f1f8ca move vault service 2026-05-19 03:21:34 +02:00
Nystik
4da91d017b implement ui-registry for dynamic UI handler registration 2026-05-19 01:39:29 +02:00
Nystik
64073968d4 scaffold new structure and packages 2026-05-18 22:40:34 +02:00
Nystik
23306ff68e improve token management for headless sync cli 2026-05-17 22:11:17 +02:00
Nystik
43778d7bca solve bug in demo vault query 2026-05-17 22:03:36 +02:00
Nystik
32f21445d4 release 0.8.1 2026-05-17 15:50:28 +02:00
Nystik
6a719aca7c clean up architecture doc. add roadmap. 2026-05-17 15:49:32 +02:00
Nystik
56776e7f13 fix version check, link to release page 2026-05-17 15:48:24 +02:00
180 changed files with 971 additions and 377 deletions

15
.dockerignore Normal file
View File

@@ -0,0 +1,15 @@
node_modules
**/node_modules
.git
.gitignore
.gitattributes
.vscode
.prettierrc
investigation
vaults
demo-vaults
data
tmp
**/dist
packages/bridge-plugin/main.js
apps/ignis-server/server/plugins/*/plugin/main.js

6
.gitignore vendored
View File

@@ -2,6 +2,8 @@ node_modules/
dist/
investigation/
vaults/
plugin/main.js
server/plugins/*/plugin/main.js
packages/*/dist/
packages/bridge-plugin/main.js
apps/ignis-server/server/plugins/*/plugin/main.js
demo-vaults/
data/

View File

@@ -2,6 +2,23 @@
All notable changes to this project will be documented in this file.
## [0.8.2] - Karm (2026-05-23)
### Fixed
- Various app menus crash when "Use native menus" enabled. Solved by forcing the setting off internally; the on-disk value is preserved across toggles.
- `/api/fs/rename` and `/api/fs/copyFile` reject missing path fields with 400 instead of silently resolving to the vault root.
## [0.8.1] - Karm (2026-05-17)
### Added
- "Available version" indicator in Ignis settings now links to the release page on GitHub.
### Fixed
- Update check no longer reports a new version available when only the SemVer build metadata differs.
## [0.8.0] - Karm (2026-05-16)
### Added

View File

@@ -1,53 +0,0 @@
# Build shim-loader.js
FROM node:22-slim AS build
WORKDIR /build
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
COPY build.js ./
COPY src/ ./src/
COPY plugin/src/ ./plugin/src/
COPY server/plugins/ ./server/plugins/
RUN npm run build
# Production image. No Obsidian code included.
# On first run, the entrypoint downloads Obsidian.
FROM node:22-slim
LABEL com.thiefling.ignis.obsidian-version="1.12.7"
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl gosu \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts
COPY server/ ./server/
COPY scripts/ ./scripts/
COPY images/ ./images/
COPY plugin/ ./plugin/
COPY --from=build /build/dist ./dist
COPY --from=build /build/plugin/main.js ./plugin/main.js
COPY --from=build /build/server/plugins/headless-sync/plugin/main.js ./server/plugins/headless-sync/plugin/main.js
RUN chmod +x /app/scripts/entrypoint.sh
ENV PORT=8080
ENV VAULT_ROOT=/vaults
ENV OBSIDIAN_VERSION=1.12.7
ENV OBSIDIAN_ASSETS_PATH=/app/obsidian-app
ENV PUID=1000
ENV PGID=1000
EXPOSE 8080
VOLUME /vaults
VOLUME /app/obsidian-app
ENTRYPOINT ["/app/scripts/entrypoint.sh"]

101
README.md
View File

@@ -28,6 +28,10 @@ What started as an experiment turned out to be more viable than expected, and th
Plugin compatibility depends on what APIs a plugin uses; most plugins built on Obsidian's plugin API work, anything requiring Node native modules or `child_process` doesn't. See [What doesn't work](#what-doesnt-work) for the full list of known limitations.
## Variants
Ignis ships currently ships as a self-hosted server but I have plans for a desktop plugin. The server variant code and Readme with details and setup instructions lives here: [`apps/ignis-server/`](apps/ignis-server/)
## What works
- All core editor features: markdown, canvas, bases, and the command palette.
@@ -72,6 +76,17 @@ Compatibility for specific community plugins is tracked in [Issue #9](https://gi
- Ignis-specific settings appear as their own tabs inside Obsidian's Settings modal.
- Status bar indicators surface server state and headless sync activity.
## Roadmap
**Planned:**
- Server parameter configuration from the Ignis settings panel (LRU cache size, write coalesce window, etc.)
- Continued shim work to support more community plugins.
- Server-side plugin system improvements.
**Eventually:**
- Multi-user support with OIDC for self-hosted shared deployments.
- Built-in auth, so a reverse proxy isn't required for basic protected use.
## Performance
A few design decisions worth knowing about for someone evaluating Ignis against large vaults or slow storage:
@@ -85,90 +100,6 @@ A few design decisions worth knowing about for someone evaluating Ignis against
Tested in Chrome, Brave, and Firefox, with limited testing in Safari.
## Authentication
Ignis has **no built-in authentication** and serves plain HTTP by default. Both authentication and TLS termination are expected to be handled by whatever you put in front of it.
If you are exposing Ignis to the internet, **you should really** put an authentication layer in front of it. Options include:
- A reverse proxy with Basic Auth (nginx, Caddy, Traefik)
- An SSO proxy like Authelia, Authentik, or OAuth2 Proxy
- A VPN (Tailscale, WireGuard)
- Cloudflare Application Tunnel
Example for Basic Auth, and Authelia can be found [here](examples).
> [!CAUTION]
> Do not run Ignis on a public network without auth. Anyone with the url can read and write your vault files.
## Setup with Docker Compose
Example `docker-compose.yml`:
```yaml
services:
ignis:
image: nobbe/ignis:latest
ports:
- "8080:8080"
environment:
- OBSIDIAN_VERSION=1.12.7
- PUID=1000
- PGID=1000
volumes:
- ./vaults:/vaults
- ./data:/app/data
- obsidian-app:/app/obsidian-app
restart: unless-stopped
volumes:
obsidian-app:
```
Then `docker compose up -d`. On first start the container downloads Obsidian from the official source and installs Obsidian Headless CLI. This takes a minute or two.
To build from source instead of pulling the image, clone the repo and replace `image: nobbe/ignis:latest` with `build: .`.
### Volumes
| Mount | Description |
| ----- | ----------- |
| `/vaults` | Vault storage. Each subdirectory is a vault. |
| `/data` | state persistence for various ignis specific functionality, plugin management, headless sync config, etc |
| `/app/obsidian-app` | Cached Obsidian assets. Persisting this avoids re-downloading on container recreate. |
### Environment Variables
| Variable | Description | Default |
| -------- | ----------- | ------- |
| `PORT` | Server listen port | `8080` |
| `VAULT_ROOT` | Path to vault storage inside the container | `/vaults` |
| `DATA_ROOT` | Path to persistent data (plugin config, sync state, auth tokens) | `/app/data` |
| `OBSIDIAN_VERSION` | Obsidian version to download | `1.12.7` |
| `OBSIDIAN_ASSETS_PATH` | Where the extracted Obsidian app files live. Override if you're pointing at a pre-extracted directory instead of letting the entrypoint download. | `/app/obsidian-app` |
| `AUTO_CREATE_DEFAULT` | When `true`, creates a "My Vault" vault on startup if no vaults exist. Useful for fresh installs. | `false` |
| `PUID` | User ID for file ownership | `1000` |
| `PGID` | Group ID for file ownership | `1000` |
| `WRITE_COALESCE_MS` | Debounce window (ms) for rapid writes. Useful for slow filesystems (rclone, NFS, SMB). Set to `0` to disable. | `5000` |
Demo mode adds its own set of env vars (per-session vaults, auto-cleanup, proxy allowlist, login blocking). See [examples/demo/](examples/demo/) if you want to run a public demo deployment.
### Migrating an existing vault
Each subdirectory of `/vaults` is treated as a separate vault, so dropping in an existing Obsidian vault directory will make it available in Ignis.
### Upgrading Obsidian
Obsidian releases can include changes that break the compatibility shim. Each Ignis release pins a known-working Obsidian version through the `OBSIDIAN_VERSION` env var, so the recommended path is to wait for an Ignis release that bumps the version, pull the new image, and restart.
If you want to try a newer Obsidian version before Ignis updates, set `OBSIDIAN_VERSION` in your compose file. The entrypoint will download that version on next start, but there's no guarantee it'll work cleanly with the current shim.
### Backups
Vault data lives as ordinary files in `/vaults`. Back it up however you back up other server-side data; Ignis doesn't provide a built in backup mechanism.
## Contributing
Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, especially on how to report plugin compatibility issues. Check the [open issues](https://github.com/Nystik-gh/ignis/issues) for things to work on.
@@ -187,4 +118,4 @@ Ignis is not affiliated with, endorsed by, or associated with Dynalist Inc. or O
This work falls under the interoperability provisions of [Directive 2009/24/EC](https://eur-lex.europa.eu/eli/dir/2009/24/oj/eng) (the EU Software Directive), Article 6. See [LEGAL.md](LEGAL.md) for the full rationale.
This project exists because its author uses Obsidian daily and wants to access it from a browser. There is no intent to harm Obsidian, Dynalist Inc., or their business. If you are a representative of Dynalist Inc. and wish to discuss this project, please reach out: ignis@thiefling.com
This project exists because its author uses Obsidian daily and wants to access it from a browser. There is no intent to harm Obsidian, Dynalist Inc., or their business. If you are a representative of Dynalist Inc. and wish to discuss this project, please reach out: ignis@thiefling.com

View File

@@ -0,0 +1,77 @@
# Build stage.
# Runs from the repo root as build context so workspace symlinks resolve.
# Copies workspace package.jsons first for cache-friendly npm ci.
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
COPY packages/services/package.json ./packages/services/
COPY packages/shim/package.json ./packages/shim/
COPY packages/ui/package.json ./packages/ui/
COPY packages/bridge-plugin/package.json ./packages/bridge-plugin/
COPY packages/server-core/package.json ./packages/server-core/
COPY apps/ignis-server/package.json ./apps/ignis-server/
RUN npm ci --ignore-scripts
COPY build.js ./
COPY packages/ ./packages/
COPY apps/ignis-server/ ./apps/ignis-server/
RUN npm run build
# Production image. No Obsidian code included.
# On first run, the entrypoint downloads Obsidian.
FROM node:22-slim
LABEL com.thiefling.ignis.obsidian-version="1.12.7"
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl gosu \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Workspace package.jsons + lockfile so npm ci sets up the @ignis/* symlinks.
COPY package.json package-lock.json ./
COPY packages/services/package.json ./packages/services/
COPY packages/shim/package.json ./packages/shim/
COPY packages/ui/package.json ./packages/ui/
COPY packages/bridge-plugin/package.json ./packages/bridge-plugin/
COPY packages/server-core/package.json ./packages/server-core/
COPY apps/ignis-server/package.json ./apps/ignis-server/
RUN npm ci --omit=dev --ignore-scripts
# Runtime sources: the server, entrypoint script, repo-rooted images, and the server-core JS that gets required from the server at runtime.
COPY apps/ignis-server/server/ ./apps/ignis-server/server/
COPY apps/ignis-server/scripts/ ./apps/ignis-server/scripts/
COPY images/ ./images/
COPY packages/server-core/src/ ./packages/server-core/src/
# Bridge plugin: manifest + styles for auto-install into vaults; built main.js comes from the build stage.
COPY packages/bridge-plugin/manifest.json ./packages/bridge-plugin/
COPY packages/bridge-plugin/styles.css ./packages/bridge-plugin/
# Built artifacts from the build stage.
COPY --from=build /app/packages/shim/dist/shim-loader.js ./packages/shim/dist/shim-loader.js
COPY --from=build /app/packages/ui/dist/ignis-ui.js ./packages/ui/dist/ignis-ui.js
COPY --from=build /app/packages/bridge-plugin/main.js ./packages/bridge-plugin/main.js
COPY --from=build /app/apps/ignis-server/server/plugins/headless-sync/plugin/main.js ./apps/ignis-server/server/plugins/headless-sync/plugin/main.js
RUN chmod +x /app/apps/ignis-server/scripts/entrypoint.sh
ENV PORT=8080
ENV VAULT_ROOT=/vaults
ENV OBSIDIAN_VERSION=1.12.7
ENV OBSIDIAN_ASSETS_PATH=/app/obsidian-app
ENV PUID=1000
ENV PGID=1000
EXPOSE 8080
VOLUME /vaults
VOLUME /app/obsidian-app
ENTRYPOINT ["/app/apps/ignis-server/scripts/entrypoint.sh"]

View File

@@ -0,0 +1,95 @@
# Ignis Server
The self-hosted Docker variant of Ignis. For the project overview, feature list, and what works / what doesn't, see the [root README](../../README.md).
## Contents
- [Authentication](#authentication)
- [Setup with Docker Compose](#setup-with-docker-compose)
- [Volumes](#volumes)
- [Environment Variables](#environment-variables)
- [Migrating an existing vault](#migrating-an-existing-vault)
- [Upgrading Obsidian](#upgrading-obsidian)
- [Backups](#backups)
## Authentication
Ignis has **no built-in authentication** and serves plain HTTP by default. Both authentication and TLS termination are expected to be handled by whatever you put in front of it.
If you are exposing Ignis to the internet, **you should really** put an authentication layer in front of it. Options include:
- A reverse proxy with Basic Auth (nginx, Caddy, Traefik)
- An SSO proxy like Authelia, Authentik, or OAuth2 Proxy
- A VPN (Tailscale, WireGuard)
- Cloudflare Application Tunnel
Example configurations for Basic Auth and Authelia are in [`examples/`](examples).
> [!CAUTION]
> Do not run Ignis on a public network without auth. Anyone with the URL can read and write your vault files.
## Setup with Docker Compose
Example `docker-compose.yml`:
```yaml
services:
ignis:
image: nobbe/ignis:latest
ports:
- "8080:8080"
environment:
- OBSIDIAN_VERSION=1.12.7
- PUID=1000
- PGID=1000
volumes:
- ./vaults:/vaults
- ./data:/app/data
- obsidian-app:/app/obsidian-app
restart: unless-stopped
volumes:
obsidian-app:
```
Then `docker compose up -d`. On first start the container downloads Obsidian from the official source and installs the Obsidian Headless CLI. This takes a minute or two.
To build from source instead of pulling the image, clone the repo and run `docker compose up` against the [`docker-compose.yml`](docker-compose.yml) in this directory.
## Volumes
| Mount | Description |
| ----- | ----------- |
| `/vaults` | Vault storage. Each subdirectory is a vault. |
| `/app/data` | State persistence for various Ignis-specific functionality: plugin management, headless sync config, etc. |
| `/app/obsidian-app` | Cached Obsidian assets. Persisting this avoids re-downloading on container recreate. |
## Environment Variables
| Variable | Description | Default |
| -------- | ----------- | ------- |
| `PORT` | Server listen port | `8080` |
| `VAULT_ROOT` | Path to vault storage inside the container | `/vaults` |
| `DATA_ROOT` | Path to persistent data (plugin config, sync state, auth tokens) | `/app/data` |
| `OBSIDIAN_VERSION` | Obsidian version to download | `1.12.7` |
| `OBSIDIAN_ASSETS_PATH` | Where the extracted Obsidian app files live. Override if you're pointing at a pre-extracted directory instead of letting the entrypoint download. | `/app/obsidian-app` |
| `AUTO_CREATE_DEFAULT` | When `true`, creates a "My Vault" vault on startup if no vaults exist. Useful for fresh installs. | `false` |
| `PUID` | User ID for file ownership | `1000` |
| `PGID` | Group ID for file ownership | `1000` |
| `WRITE_COALESCE_MS` | Debounce window (ms) for rapid writes. Useful for slow filesystems (rclone, NFS, SMB). Set to `0` to disable. | `5000` |
Demo mode adds its own set of env vars (per-session vaults, auto-cleanup, proxy allowlist, login blocking). See [`examples/demo/`](examples/demo/) if you want to run a public demo deployment.
## Migrating an existing vault
Each subdirectory of `/vaults` is treated as a separate vault, so dropping in an existing Obsidian vault directory will make it available in Ignis.
## Upgrading Obsidian
Obsidian releases can include changes that break the compatibility shim. Each Ignis release pins a known-working Obsidian version through the `OBSIDIAN_VERSION` env var, so the recommended path is to wait for an Ignis release that bumps the version, pull the new image, and restart.
If you want to try a newer Obsidian version before Ignis updates, set `OBSIDIAN_VERSION` in your compose file. The entrypoint will download that version on next start, but there is no guarantee it will work cleanly with the current shim.
## Backups
Vault data lives as ordinary files in `/vaults`. Back it up however you back up other server-side data; Ignis does not provide a built-in backup mechanism.

View File

@@ -1,6 +1,8 @@
services:
ignis:
build: .
build:
context: ../..
dockerfile: apps/ignis-server/Dockerfile
ports:
- "8082:8080"
environment:

View File

@@ -9,7 +9,8 @@
services:
ignis-demo:
build:
context: ../..
context: ../../../..
dockerfile: apps/ignis-server/Dockerfile
ports:
- "8080:8080"
environment:

View File

@@ -0,0 +1,5 @@
{
"name": "@ignis/app",
"version": "0.0.0-internal",
"private": true
}

View File

@@ -68,4 +68,4 @@ else
fi
# Run as the determined user
exec gosu "$RUN_USER" node /app/server/index.js
exec gosu "$RUN_USER" node /app/apps/ignis-server/server/index.js

View File

Before

Width:  |  Height:  |  Size: 984 B

After

Width:  |  Height:  |  Size: 984 B

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -41,8 +41,8 @@
<div id="ignis-status-label">Loading Obsidian...</div>
</div>
<!-- Ignis shims: must run before any Obsidian code. -->
<script type="text/javascript" src="__IGNIS_UI_SRC__"></script>
<script type="text/javascript" src="__SHIM_LOADER_SRC__"></script>
<script type="text/javascript" src="__IGNIS_UI_SRC__"></script>
<!-- Obsidian scripts injected dynamically to avoid touching their files. -->
<script>
(function () {

View File

@@ -6,7 +6,7 @@ const {
} = require("./plugin-system/obsidian-plugin");
const BRIDGE_PLUGIN_ID = "ignis-bridge";
const BRIDGE_PLUGIN_DIR = path.join(__dirname, "..", "plugin");
const BRIDGE_PLUGIN_DIR = path.join(__dirname, "..", "..", "..", "packages", "bridge-plugin");
// .ignis metadata helpers

View File

@@ -1,12 +1,14 @@
const path = require("path");
const fs = require("fs");
const REPO_ROOT = path.join(__dirname, "..", "..", "..");
// VAULT_ROOT: a directory that contains vault folders.
// Each subdirectory is a vault. New vaults are created as new subdirs.
const vaultRoot =
process.env.VAULT_ROOT || path.join(__dirname, "..", "vaults");
process.env.VAULT_ROOT || path.join(REPO_ROOT, "vaults");
const dataRoot = process.env.DATA_ROOT || path.join(__dirname, "..", "data");
const dataRoot = process.env.DATA_ROOT || path.join(REPO_ROOT, "data");
// Ensure required directories exist
try {
@@ -91,7 +93,7 @@ module.exports = {
obsidianAssetsPath:
process.env.OBSIDIAN_ASSETS_PATH ||
path.join(__dirname, "..", "investigation", "obsidian_1.12.7_unpacked"),
path.join(REPO_ROOT, "investigation", "obsidian_1.12.7_unpacked"),
get obsidianVersion() {
const assetsPath =

View File

@@ -5,7 +5,7 @@ const fsp = fs.promises;
const path = require("path");
const config = require("../config");
const watcher = require("../watcher");
const { watcher } = require("@ignis/server-core");
const bootstrapRoutes = require("../routes/bootstrap");
const {

View File

@@ -272,16 +272,36 @@ function trackVaultLifecycle(req, res, next) {
if (s) {
if (req.path === "/create" && body.id) {
s.vaults.add(body.id);
// body.id is storage-prefixed at this point (outboundTranslator runs after us).
// Translate to the user-visible name so it matches what pageLoadHandler queries with.
const userName = tryParseUserVaultName(sessionId, body.id);
if (userName !== null) {
s.vaults.add(userName);
} else {
console.warn(
"[demo] trackVaultLifecycle: could not parse user name from create response id:",
body.id,
);
}
} else if (req.path === "/rename") {
const oldName = req.body && req.body._origVault;
const oldName = req._demoOriginalVault;
if (oldName) {
s.vaults.delete(oldName);
}
if (body.id) {
s.vaults.add(body.id);
const userName = tryParseUserVaultName(sessionId, body.id);
if (userName !== null) {
s.vaults.add(userName);
} else {
console.warn(
"[demo] trackVaultLifecycle: could not parse user name from rename response id:",
body.id,
);
}
}
} else if (req.method === "DELETE" && req.path === "/remove") {
const removed = req._demoOriginalVault;

View File

@@ -4,14 +4,20 @@ const path = require("path");
const compression = require("compression");
const config = require("./config");
const { getVersion } = require("./version");
const { setupWebSocket } = require("./ws");
const watcher = require("./watcher");
const {
setupWebSocket,
watcher,
writeCoalescer,
} = require("@ignis/server-core");
const { updateBridgePluginInAllVaults } = require("./bridge-plugin");
const { initPlugins, shutdownPlugins } = require("./plugin-system/manager");
const pluginRoutes = require("./routes/plugins");
const { flushAll } = require("./write-coalescer");
writeCoalescer.configure({ writeCoalesceMs: config.writeCoalesceMs });
const { flushAll } = writeCoalescer;
const { setupDemo, wireDemoWebSocket } = require("./demo");
const REPO_ROOT = path.join(__dirname, "..", "..", "..");
const ANSI_RED = "\x1b[31m";
const ANSI_YELLOW = "\x1b[33m";
const ANSI_GREEN = "\x1b[32m";
@@ -90,9 +96,7 @@ app.use("/vault-files", (req, res, next) => {
express.static(vaultPath)(req, res, next);
});
// Serve our own index.html. Obsidian's scripts are discovered at startup
// and injected dynamically by the client -- no Obsidian files are read or
// transformed in the response.
// Serve our own index.html. Obsidian's scripts are discovered at startup and injected dynamically by the client.
let cachedHtml = null;
function buildIndexHtml() {
@@ -139,7 +143,7 @@ app.get(["/", "/index.html"], (req, res) => {
});
app.get("/favicon.png", (req, res) => {
res.sendFile(path.join(__dirname, "..", "images", "favicon.png"));
res.sendFile(path.join(REPO_ROOT, "images", "favicon.png"));
});
// Serve dist files with cache headers based on version param
@@ -156,7 +160,8 @@ app.use((req, res, next) => {
next();
});
app.use(express.static(path.join(__dirname, "..", "dist")));
app.use(express.static(path.join(REPO_ROOT, "packages", "ui", "dist")));
app.use(express.static(path.join(REPO_ROOT, "packages", "shim", "dist")));
app.use(express.static(config.obsidianAssetsPath));
@@ -172,7 +177,7 @@ const server = app.listen(config.port, async () => {
.catch((e) => console.warn("[bootstrap] warm-up error:", e.message));
});
const wss = setupWebSocket(server);
const wss = setupWebSocket(server, { getVaultPath: config.getVaultPath });
wireDemoWebSocket(server);
async function gracefulShutdown(signal) {

View File

@@ -1,10 +1,10 @@
const fs = require("fs");
const path = require("path");
const os = require("os");
const { getObHome } = require("./ob-cli");
function getObAuthFile() {
function getObAuthFile(dataDir) {
return path.join(
os.homedir(),
getObHome(dataDir),
".config",
"obsidian-headless",
"auth_token",
@@ -23,14 +23,14 @@ function loadToken(dataDir) {
const data = JSON.parse(fs.readFileSync(internalFile, "utf-8"));
if (data && data.token) {
syncToObCli(data.token);
syncToObCli(dataDir, data.token);
return data;
}
}
} catch {}
// Fall back to ob CLI's own auth file
const obAuthFile = getObAuthFile();
const obAuthFile = getObAuthFile(dataDir);
try {
if (fs.existsSync(obAuthFile)) {
@@ -49,7 +49,7 @@ function loadToken(dataDir) {
function saveToken(dataDir, tokenData) {
saveInternal(dataDir, tokenData);
syncToObCli(tokenData.token);
syncToObCli(dataDir, tokenData.token);
}
function clearToken(dataDir) {
@@ -61,7 +61,7 @@ function clearToken(dataDir) {
}
} catch {}
const obAuthFile = getObAuthFile();
const obAuthFile = getObAuthFile(dataDir);
try {
if (fs.existsSync(obAuthFile)) {
@@ -94,8 +94,8 @@ function saveInternal(dataDir, tokenData) {
fs.writeFileSync(internalFile, JSON.stringify(tokenData, null, 2), "utf-8");
}
function syncToObCli(token) {
const obAuthFile = getObAuthFile();
function syncToObCli(dataDir, token) {
const obAuthFile = getObAuthFile(dataDir);
try {
const dir = path.dirname(obAuthFile);
@@ -124,4 +124,10 @@ function getTokenInfo(dataDir) {
return null;
}
module.exports = { loadToken, saveToken, clearToken, isAuthenticated, getTokenInfo };
module.exports = {
loadToken,
saveToken,
clearToken,
isAuthenticated,
getTokenInfo,
};

View File

@@ -29,6 +29,10 @@ module.exports = {
ctx.log("ob CLI not found. Install obsidian-headless to enable sync.");
}
// Redirect ob's HOME under the plugin's data dir so its config (per-vault sync setups, etc.)
// survives container recreates. Must happen before auth.loadToken since loadToken pushes the token into ob's config location via syncToObCli.
obCli.configure({ dataDir: ctx.dataDir });
const token = auth.loadToken(ctx.dataDir);
if (token) {

View File

@@ -1,8 +1,28 @@
const { spawn, execSync } = require("child_process");
const fs = require("fs");
const os = require("os");
const path = require("path");
const isWindows = process.platform === "win32";
// When set via configure(), HOME for the spawned ob points under the plugin's data dir so
// ob's config dir (~/.config/obsidian-headless/) survives container recreates.
let configuredDataDir = null;
function getObHome(dataDir) {
return path.join(dataDir, "ob-home");
}
function configure(opts) {
configuredDataDir = opts && opts.dataDir ? opts.dataDir : null;
if (configuredDataDir) {
try {
fs.mkdirSync(getObHome(configuredDataDir), { recursive: true });
} catch {}
}
}
function checkInstalled() {
try {
const output = execSync("ob --version", {
@@ -19,8 +39,12 @@ function checkInstalled() {
}
function spawnOb(args, opts = {}) {
const home = configuredDataDir
? getObHome(configuredDataDir)
: os.homedir();
return spawn("ob", args, {
env: { ...process.env, HOME: os.homedir() },
env: { ...process.env, HOME: home },
shell: isWindows,
windowsHide: true,
...opts,
@@ -58,4 +82,10 @@ function runCommand(args, opts = {}) {
});
}
module.exports = { checkInstalled, spawnOb, runCommand };
module.exports = {
checkInstalled,
spawnOb,
runCommand,
configure,
getObHome,
};

View File

@@ -3,59 +3,16 @@ const fs = require("fs");
const path = require("path");
const archiver = require("archiver");
const config = require("../config");
const { writeCoalesced, getPending } = require("../write-coalescer");
const {
writeCoalescer,
encodeContentDispositionFilename,
resolveVaultPath,
} = require("@ignis/server-core");
const { writeCoalesced, getPending } = writeCoalescer;
const bootstrapRoutes = require("./bootstrap");
const router = express.Router();
/**
* Encode a filename for use in Content-Disposition header.
* Handles non-ASCII characters and special characters to prevent header injection.
* Uses RFC 5987 encoding for filename* parameter when needed.
*
* @param {string} filename - The filename to encode
* @returns {string} - Properly formatted Content-Disposition value
*/
function encodeContentDispositionFilename(filename) {
// Check if filename contains non-ASCII characters
const hasNonASCII = /[^\x00-\x7F]/.test(filename);
// Escape quotes and backslashes in ASCII filename by prefixing with backslash
const escapedFilename = filename.replace(/["\\ ]/g, function (match) {
if (match === '"') return '\\"';
if (match === "\\") return "\\\\";
return match;
});
// Remove any control characters that could cause header injection
const sanitizedFilename = escapedFilename.replace(/[\x00-\x1F\x7F]/g, "");
if (!hasNonASCII) {
// Simple ASCII filename - use standard format
return `attachment; filename="${sanitizedFilename}"`;
}
// Non-ASCII filename - use RFC 5987 encoding
// Encode using percent-encoding for UTF-8
const encodedFilename = encodeURIComponent(filename)
.replace(/['()]/g, function (c) {
return "%" + c.charCodeAt(0).toString(16).toUpperCase();
})
.replace(/\*/g, "%2A");
// Provide both filename (ASCII fallback) and filename* (UTF-8 encoded)
// For fallback, replace non-ASCII with underscores
const asciiFallback = filename
.replace(/[^\x00-\x7F]/g, "_")
.replace(/["\\ ]/g, function (match) {
if (match === '"') return '\\"';
if (match === "\\") return "\\\\";
return match;
});
return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodedFilename}`;
}
// Resolve the vault root for a request. Reads vault ID from query or body.
function getVaultRoot(req, res) {
const vaultId = req.query.vault || req.body?.vault || config.defaultVaultId;
@@ -76,20 +33,6 @@ function invalidateBootstrap(req) {
}
}
// Resolve a client-provided path to an absolute path within a vault.
// Strips leading slashes so paths from the client are always treated as relative to the vault root.
function resolveVaultPath(vaultRoot, relativePath) {
const cleaned = (relativePath || "").replace(/^\/+/, "");
const resolved = path.resolve(vaultRoot, cleaned);
const resolvedRoot = path.resolve(vaultRoot);
if (resolved !== resolvedRoot && !resolved.startsWith(resolvedRoot + path.sep)) {
return null;
}
return resolved;
}
function guardPath(req, res, source = "query") {
const vaultRoot = getVaultRoot(req, res);
@@ -320,8 +263,12 @@ router.post("/rename", async (req, res) => {
return;
}
const oldResolved = resolveVaultPath(vaultRoot, req.body?.oldPath);
const newResolved = resolveVaultPath(vaultRoot, req.body?.newPath);
if (!req.body?.oldPath || !req.body?.newPath) {
return res.status(400).json({ error: "Missing oldPath or newPath" });
}
const oldResolved = resolveVaultPath(vaultRoot, req.body.oldPath);
const newResolved = resolveVaultPath(vaultRoot, req.body.newPath);
if (!oldResolved || !newResolved) {
return res.status(403).json({ error: "Invalid path" });
@@ -345,8 +292,12 @@ router.post("/copyFile", async (req, res) => {
return;
}
const srcResolved = resolveVaultPath(vaultRoot, req.body?.src);
const destResolved = resolveVaultPath(vaultRoot, req.body?.dest);
if (!req.body?.src || !req.body?.dest) {
return res.status(400).json({ error: "Missing src or dest" });
}
const srcResolved = resolveVaultPath(vaultRoot, req.body.src);
const destResolved = resolveVaultPath(vaultRoot, req.body.dest);
if (!srcResolved || !destResolved) {
return res.status(403).json({ error: "Invalid path" });
@@ -653,5 +604,3 @@ router.get("/download-zip", async (req, res) => {
});
module.exports = router;
module.exports.resolveVaultPath = resolveVaultPath;
module.exports.encodeContentDispositionFilename = encodeContentDispositionFilename;

View File

@@ -6,7 +6,7 @@ const require = createRequire(import.meta.url);
const {
resolveVaultPath,
encodeContentDispositionFilename,
} = require("./fs.js");
} = require("@ignis/server-core");
// -- encodeContentDispositionFilename --------------------------------
@@ -77,12 +77,12 @@ describe("resolveVaultPath", () => {
expect(resolveVaultPath(root, "")).toBe(path.resolve(root));
});
it("treats null input as vault root", () => {
expect(resolveVaultPath(root, null)).toBe(path.resolve(root));
it("returns null for null input", () => {
expect(resolveVaultPath(root, null)).toBe(null);
});
it("treats undefined input as vault root", () => {
expect(resolveVaultPath(root, undefined)).toBe(path.resolve(root));
it("returns null for undefined input", () => {
expect(resolveVaultPath(root, undefined)).toBe(null);
});
it("strips leading slashes", () => {

View File

@@ -1,59 +1,23 @@
const esbuild = require("esbuild");
const sveltePlugin = require("esbuild-svelte");
const path = require("path");
const { version: ignisVersion } = require("./package.json");
Promise.all([
// Build shim-loader.js
esbuild.build({
entryPoints: [path.join(__dirname, "src", "shims", "loader.js")],
bundle: true,
outfile: path.join(__dirname, "dist", "shim-loader.js"),
format: "iife",
platform: "browser",
target: ["chrome90"],
alias: {
path: "path-browserify",
},
define: {
__IGNIS_VERSION__: JSON.stringify(ignisVersion),
},
logLevel: "info",
}),
// Build shim-loader.js (delegated to packages/shim)
require("./packages/shim/build.js"),
// Build ignis-ui.js
esbuild.build({
entryPoints: [path.join(__dirname, "src", "ui", "index.js")],
bundle: true,
outfile: path.join(__dirname, "dist", "ignis-ui.js"),
format: "iife",
globalName: "IgnisUI",
platform: "browser",
target: ["chrome90"],
mainFields: ["svelte", "browser", "module", "main"],
conditions: ["svelte", "browser"],
plugins: [sveltePlugin({ compilerOptions: { css: "injected" } })],
logLevel: "info",
}),
// Build ignis-ui.js (delegated to packages/ui)
require("./packages/ui/build.js"),
// Build ignis-bridge plugin
esbuild.build({
entryPoints: [path.join(__dirname, "plugin", "src", "main.js")],
bundle: true,
outfile: path.join(__dirname, "plugin", "main.js"),
format: "cjs",
platform: "browser",
target: ["chrome90"],
external: ["obsidian", "fs"],
logLevel: "info",
}),
// Build ignis-bridge plugin (delegated to packages/bridge-plugin)
require("./packages/bridge-plugin/build.js"),
// Build headless-sync bundled plugin
esbuild.build({
entryPoints: [
path.join(
__dirname,
"apps",
"ignis-server",
"server",
"plugins",
"headless-sync",
@@ -65,6 +29,8 @@ Promise.all([
bundle: true,
outfile: path.join(
__dirname,
"apps",
"ignis-server",
"server",
"plugins",
"headless-sync",

View File

@@ -2,6 +2,25 @@
Ignis runs Obsidian in a browser by replacing its Electron backend with a shim layer that routes Node.js and Electron API calls to an Express server over HTTP and WebSocket.
## Contents
- [Overview](#overview)
- [Shim Layer](#shim-layer)
- [Loading](#loading)
- [Modules](#modules)
- [Filesystem](#filesystem)
- [Transforms](#transforms)
- [IPC](#ipc)
- [Cross-origin requests](#cross-origin-requests)
- [Workspaces in browser tabs](#workspaces-in-browser-tabs)
- [Vaults](#vaults)
- [Server](#server)
- [Plugins](#plugins)
- [Obsidian Plugins](#obsidian-plugins)
- [Bridge Plugin (ignis-bridge)](#bridge-plugin-ignis-bridge)
- [Ignis Plugins](#ignis-plugins)
- [Demo mode](#demo-mode)
## Overview
```
@@ -24,7 +43,7 @@ The shim layer makes Obsidian think it's running in Electron. The bridge plugin
### Loading
The server serves its own `index.html` (in `server/assets/`) rather than Obsidian's. At startup it reads Obsidian's `index.html` once to discover which scripts Obsidian expects, then embeds that list in our HTML as a JSON array. The client-side HTML loads the shim loader and UI bundle first (non-deferred), then a small inline script dynamically injects Obsidian's scripts in order. Obsidian's files are never modified on disk, or transformed in transit.
The server serves its own `index.html` (in `apps/ignis-server/server/assets/`) rather than Obsidian's. At startup it reads Obsidian's `index.html` once to discover which scripts Obsidian expects, then embeds that list in our HTML as a JSON array. The client-side HTML loads the shim loader and UI bundle first (non-deferred), then a small inline script dynamically injects Obsidian's scripts in order. Obsidian's files are never modified on disk, or transformed in transit.
Before injecting Obsidian's scripts, the shim loader sets `localStorage.EmulateMobile` based on viewport width (< 600px) so Obsidian boots into its mobile UI on phones and narrow windows. The loader replaces the module system, then issues a single blocking bootstrap request that returns the vault info, vault list, metadata tree, and Ignis plugin list in one pre-compressed response. The request has to be blocking because Obsidian makes synchronous filesystem calls during page load, before the event loop is running, so the cache has to already be populated.
@@ -58,17 +77,17 @@ Two caches on the client side. The **MetadataCache** holds `{ type, size, mtime,
Reads not satisfied by ContentCache go through the transport layer to `/api/fs/readFile`. Sync calls use synchronous XHR to keep Obsidian's pre-boot module code working. Async calls use fetch. The transport handles vault id injection, base64 encoding for binary files, and mapping HTTP error codes back to Node errno values (`ENOENT`, `EEXIST`, `ENOTDIR`).
Writes go through a server-side write coalescer (`server/write-coalescer.js`) designed for slow filesystems like rclone FUSE mounts. The first write to a path goes to disk immediately. Subsequent writes within a configurable window (default 5 seconds, `WRITE_COALESCE_MS`) are buffered and flushed when the debounce timer fires; the timer resets on each write. Buffered writes return to the HTTP client immediately with synthetic metadata so connection-pool starvation on rapid-fire writes (e.g. `workspace.json` autosaves) doesn't stall unrelated reads. Reads for pending paths serve the buffered content so clients never see stale data. All pending writes are flushed on graceful shutdown.
Writes go through a server-side write coalescer (`packages/server-core/src/write-coalescer.js`) designed for slow filesystems like rclone FUSE mounts. The first write to a path goes to disk immediately. Subsequent writes within a configurable window (default 5 seconds, `WRITE_COALESCE_MS`) are buffered and flushed when the debounce timer fires; the timer resets on each write. Buffered writes return to the HTTP client immediately with synthetic metadata so connection-pool starvation on rapid-fire writes (e.g. `workspace.json` autosaves) doesn't stall unrelated reads. Reads for pending paths serve the buffered content so clients never see stale data. All pending writes are flushed on graceful shutdown.
### Translation registry
### Transforms
The shim has a registry (`src/shims/fs/transforms.js`) for hooks applied at the public shim surface, before caches or transport see the path. Three hook types:
The shim has a transforms registry (`packages/shim/src/fs/transforms.js`) for hooks applied at the public shim surface, before caches or transport see the path. Three hook types:
- **Path resolvers** map a logical path to a physical path. Used by the workspaces shim to redirect reads and writes of `.obsidian/workspace.json` to `.obsidian/workspace.<name>.json` based on the `?workspace=` URL parameter, so each browser tab can hold a separate layout.
- **Read transforms** post-process bytes returned by a read (cache hit or transport miss). Used to mask the Obsidian Sync setting in `core-plugins.json` when headless-sync is active for the vault, and to override the `active` field on reads of `workspaces.json` so each tab sees its own workspace as selected.
- **Write transforms** pre-process bytes before a write hits the cache or transport. Used to override the `active` field on writes to `workspaces.json` so cross-tab disk state stays canonical.
All hooks are synchronous and registered at module load. Translation happens once at the shim entry; downstream layers (content cache, metadata cache, transport) operate only on resolved physical paths and as-stored bytes. This keeps cache keys coherent with what transport actually reads and writes, so prefetch and on-demand fetches share the same cache slot.
All hooks are synchronous and registered at module load. They fire once at the shim entry; downstream layers (content cache, metadata cache, transport) operate only on resolved physical paths and as-stored bytes. This keeps cache keys coherent with what transport actually reads and writes, so prefetch and on-demand fetches share the same cache slot.
### IPC
@@ -86,15 +105,11 @@ The proxy itself is intentionally generic. It forwards method, headers, and body
### Workspaces in browser tabs
Obsidian's Workspaces core plugin lets you save a window layout under a name. Ignis adds a `?workspace=<name>` URL parameter that binds a tab to a specific layout. The bridge plugin's "Open workspace in new tab" command opens the picked workspace at `?workspace=<name>` in a fresh tab.
Obsidian's Workspaces core plugin lets you save a window layout under a name. Ignis adds a `?workspace=<name>` URL parameter that binds a tab to a specific layout. The bridge plugin's "Open workspace in new tab" command opens the picked workspace in a fresh tab.
The fs shim redirects reads and writes of `.obsidian/workspace.json` to a per-workspace file (`.obsidian/workspace.<name>.json`), giving each tab its own layout. It also rewrites the active field on reads of `workspaces.json` so each tab's menu shows its own workspace as active.
The implementation uses all three transforms (above): a path resolver redirects `.obsidian/workspace.json` to `.obsidian/workspace.<name>.json` so each tab has its own state file; a read transform overrides the `active` field on `workspaces.json` so the current tab's menu shows its own workspace as selected; a write transform keeps the canonical `active` value stable on disk so concurrent tabs don't clobber each other.
Two tabs sharing a vault stay in sync through the file watcher.
### Obsidian Plugin Compatibility
Obsidian evals plugin code with its own require that checks its internal module map first, then falls back to the window-level require, which is the shim. Plugins that use the filesystem, path utilities, or crypto get shim implementations without any changes. Plugins that need child processes, raw sockets, or native addons will load but throw on use; the error message names the missing API.
Two tabs in the same workspace share the same state file and stay in sync through the file watcher. Two tabs in different workspaces hold independent layout state.
## Vaults
@@ -124,11 +139,11 @@ Three things are called "plugin" in this project.
### Obsidian Plugins
Standard community and core Obsidian plugins. They work through the shim layer with no Ignis involvement beyond providing fs, path, and crypto.
Standard community and core Obsidian plugins. Obsidian evals plugin code with its own require that checks its internal module map first, then falls back to the window-level require, which Ignis replaces with the shim. Plugins that use the filesystem, path utilities, or crypto get shim implementations transparently. Plugins that need child processes, raw sockets, or native addons load but throw on first use; the error message names the missing API.
### Bridge Plugin (ignis-bridge)
An Obsidian plugin auto-installed into every vault by the server. Source lives in `plugin/`, built to `plugin/main.js`.
An Obsidian plugin auto-installed into every vault by the server. Source lives in `packages/bridge-plugin/`, built to `packages/bridge-plugin/main.js`.
It contributes:
- **File actions**: a ribbon icon for uploading files into the current folder, and right-click menu items: Download (single file), Download as ZIP (folder), and Upload file (folder).
@@ -143,11 +158,11 @@ Not user-installable through Obsidian's plugin browser. Managed entirely by the
A basic plugin system for extending the server. Still early, the core lifecycle works but the API surface is minimal and likely to change.
An Ignis plugin is a Node.js package under `server/plugins/<name>/` that exports an id, name, and a `register` function. On load it receives a context object with access to config, the WebSocket server, a file watcher, an Express router, a logger, and a persistent data directory. Plugins are enabled and disabled per vault, with state persisted in `data/plugin-config.json`.
An Ignis plugin is a Node.js package under `apps/ignis-server/server/plugins/<name>/` that exports an id, name, and a `register` function. On load it receives a context object with access to config, the WebSocket server, a file watcher, an Express router, a logger, and a persistent data directory. Plugins are enabled and disabled per vault, with state persisted in `data/plugin-config.json`.
When enabled, a plugin's Express router is mounted at `/api/ext/<pluginId>/`. A plugin can also optionally bundle an Obsidian plugin, a directory containing a standard Obsidian plugin (manifest.json, main.js) that gets auto-installed into the vault on enable and removed on disable. This bridges the server and client sides: the Ignis plugin handles server logic and routes, while the bundled Obsidian plugin provides the in-app UI or behavior.
The one Ignis plugin currently in the repo is **headless-sync** (`server/plugins/headless-sync/`). It wraps the [obsidian-headless](https://github.com/Yuri-Khomyakov/obsidian-headless) CLI (`ob`) and runs `ob sync --continuous` as a per-vault child process, optionally with `--pull-only` or `--mirror-remote`. Process state (running/stopped/error, pid, last activity, recent log lines) is broadcast over the WebSocket via a small per-vault subscription protocol. The bundled Obsidian plugin (`ignis-headless-sync`) adds a status bar item, a settings tab with start/stop/unlink controls, and a core-sync guard that hides Obsidian's own Sync setting from `core-plugins.json` reads while headless sync is active for that vault, so a different device syncing the "Active core plugins list" can't accidentally re-enable it.
The one Ignis plugin currently in the repo is **headless-sync** (`apps/ignis-server/server/plugins/headless-sync/`). It wraps the [obsidian-headless](https://github.com/Yuri-Khomyakov/obsidian-headless) CLI (`ob`) and runs `ob sync --continuous` as a per-vault child process, optionally with `--pull-only` or `--mirror-remote`. Process state (running/stopped/error, pid, last activity, recent log lines) is broadcast over the WebSocket via a small per-vault subscription protocol. The bundled Obsidian plugin (`ignis-headless-sync`) adds a status bar item, a settings tab with start/stop/unlink controls, and a core-sync guard that hides Obsidian's own Sync setting from `core-plugins.json` reads while headless sync is active for that vault, so a different device syncing the "Active core plugins list" can't accidentally re-enable it.
## Demo mode
@@ -164,4 +179,4 @@ Other demo behaviors:
- Server-side plugins (e.g. headless-sync) hidden from the client; enable/disable returns 403.
- The bridge plugin disables any `<input type="email">` or `<input type="password">` it sees anywhere in the document, with a placeholder telling users not to enter credentials.
All server-side demo code lives in `server/demo/`. The client-side hooks live in `src/shims/demo.js`. The deployment example is in `examples/demo/` (tmpfs-mounted vaults, restricted proxy, all the env vars).
All server-side demo code lives in `apps/ignis-server/server/demo/`. The client-side hooks live in `packages/shim/src/demo.js`. The deployment example is in `apps/ignis-server/examples/demo/` (tmpfs-mounted vaults, restricted proxy, all the env vars).

112
package-lock.json generated
View File

@@ -1,12 +1,16 @@
{
"name": "ignis",
"version": "0.8.0",
"name": "ignis-monorepo",
"version": "0.8.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ignis",
"version": "0.8.0",
"name": "ignis-monorepo",
"version": "0.8.1",
"workspaces": [
"packages/*",
"apps/*"
],
"dependencies": {
"archiver": "^7.0.1",
"chokidar": "^3.6.0",
@@ -26,11 +30,19 @@
"vitest": "^3.2.4"
}
},
"apps/ignis": {
"name": "@ignis/app",
"version": "0.8.1",
"extraneous": true
},
"apps/ignis-server": {
"name": "@ignis/app",
"version": "0.0.0-internal"
},
"node_modules/@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
@@ -482,6 +494,30 @@
"node": ">=12"
}
},
"node_modules/@ignis/app": {
"resolved": "apps/ignis-server",
"link": true
},
"node_modules/@ignis/bridge-plugin": {
"resolved": "packages/bridge-plugin",
"link": true
},
"node_modules/@ignis/server-core": {
"resolved": "packages/server-core",
"link": true
},
"node_modules/@ignis/services": {
"resolved": "packages/services",
"link": true
},
"node_modules/@ignis/shim": {
"resolved": "packages/shim",
"link": true
},
"node_modules/@ignis/ui": {
"resolved": "packages/ui",
"link": true
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -503,7 +539,6 @@
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -514,7 +549,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -524,14 +558,12 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -542,7 +574,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
@@ -972,7 +1003,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitest/expect": {
@@ -1119,7 +1149,6 @@
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@@ -1205,7 +1234,6 @@
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@@ -1237,7 +1265,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@@ -1567,7 +1594,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz",
"integrity": "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15",
@@ -1752,7 +1778,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",
"integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.0.30",
@@ -1944,7 +1969,6 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
@@ -2369,7 +2393,6 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.6"
@@ -2467,7 +2490,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash": {
@@ -2493,7 +2515,6 @@
"version": "0.577.0",
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.577.0.tgz",
"integrity": "sha512-0i88o57KsaHWnc80J57fY99CWzlZsSdtH5kKjLUJa7z8dum/9/AbINNLzJ7NiRFUdOgMnfAmJt8jFbW2zeC5qQ==",
"dev": true,
"license": "ISC",
"peerDependencies": {
"svelte": "^3 || ^4 || ^5.0.0-next.42"
@@ -2503,7 +2524,6 @@
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
@@ -2522,7 +2542,6 @@
"version": "2.0.30",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
"integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/media-typer": {
@@ -2719,7 +2738,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
"dev": true,
"license": "MIT"
},
"node_modules/path-key": {
@@ -2774,7 +2792,6 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz",
"integrity": "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
@@ -3184,7 +3201,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -3346,7 +3362,6 @@
"version": "4.2.20",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.20.tgz",
"integrity": "sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.2.1",
@@ -4412,6 +4427,51 @@
"engines": {
"node": ">= 14"
}
},
"packages/bridge-plugin": {
"name": "@ignis/bridge-plugin",
"version": "0.0.0-internal",
"devDependencies": {
"esbuild": "^0.20.0"
}
},
"packages/server-core": {
"name": "@ignis/server-core",
"version": "0.0.0-internal",
"dependencies": {
"chokidar": "^3.6.0",
"ws": "^8.16.0"
}
},
"packages/services": {
"name": "@ignis/services",
"version": "0.0.0-internal"
},
"packages/shim": {
"name": "@ignis/shim",
"version": "0.0.0-internal",
"dependencies": {
"@ignis/services": "*",
"@noble/hashes": "^2.2.0",
"pako": "^2.1.0",
"path-browserify": "^1.0.1"
},
"devDependencies": {
"esbuild": "^0.20.0"
}
},
"packages/ui": {
"name": "@ignis/ui",
"version": "0.0.0-internal",
"dependencies": {
"@ignis/services": "*",
"lucide-svelte": "^0.577.0",
"svelte": "^4.2.20"
},
"devDependencies": {
"esbuild": "^0.20.0",
"esbuild-svelte": "^0.9.4"
}
}
}
}

View File

@@ -1,11 +1,15 @@
{
"name": "ignis",
"version": "0.8.0",
"name": "ignis-monorepo",
"version": "0.8.2",
"private": true,
"description": "An Electron shim and server bridge for running Obsidian in a browser.",
"description": "Monorepo for Ignis: a browser-based Obsidian client. Self-hosted server in apps/ignis-server; shim, UI, and shared libraries in packages/.",
"workspaces": [
"packages/*",
"apps/*"
],
"scripts": {
"build": "node build.js",
"dev:server": "node server/index.js",
"dev:server": "node apps/ignis-server/server/index.js",
"dev": "npm run build && npm run dev:server",
"test": "vitest run",
"test:watch": "vitest"

View File

@@ -0,0 +1,13 @@
const esbuild = require("esbuild");
const path = require("path");
module.exports = esbuild.build({
entryPoints: [path.join(__dirname, "src", "main.js")],
bundle: true,
outfile: path.join(__dirname, "main.js"),
format: "cjs",
platform: "browser",
target: ["chrome90"],
external: ["obsidian", "fs"],
logLevel: "info",
});

View File

@@ -1,7 +1,7 @@
{
"id": "ignis-bridge",
"name": "Ignis Bridge",
"version": "0.8.0",
"version": "0.8.1",
"minAppVersion": "1.12.4",
"description": "Additional Ignis specific functionality and ignis plugin management.",
"author": "Nystik",

View File

@@ -0,0 +1,11 @@
{
"name": "@ignis/bridge-plugin",
"version": "0.0.0-internal",
"private": true,
"scripts": {
"build": "node build.js"
},
"devDependencies": {
"esbuild": "^0.20.0"
}
}

View File

@@ -13,6 +13,11 @@ function getVersion(app) {
}
}
// SemVer build metadata (`+xyz`) is informational and ignored for precedence.
function stripBuildMetadata(version) {
return (version || "").split("+")[0];
}
async function checkForUpdate(currentVersion) {
try {
const res = await fetch(GITHUB_API_LATEST);
@@ -22,10 +27,11 @@ async function checkForUpdate(currentVersion) {
}
const data = await res.json();
const latest = data.tag_name?.replace(/^v/, "");
const latest = stripBuildMetadata(data.tag_name?.replace(/^v/, ""));
const current = stripBuildMetadata(currentVersion);
if (latest && latest !== currentVersion) {
return latest;
if (latest && latest !== current) {
return { version: latest, url: data.html_url };
}
return null;
@@ -59,9 +65,10 @@ function display(containerEl, app) {
cls: "ignis-header-version",
});
const updateIndicator = versionCol.createEl("span", {
const updateIndicator = versionCol.createEl("a", {
text: "Checking...",
cls: "ignis-update-indicator",
attr: { target: "_blank", rel: "noopener noreferrer" },
});
const githubLink = right.createEl("a", {
@@ -75,10 +82,11 @@ function display(containerEl, app) {
attr: { src: "/assets/github.svg", alt: "GitHub" },
});
checkForUpdate(version).then((latestVersion) => {
if (latestVersion) {
updateIndicator.textContent = `v${latestVersion} available`;
checkForUpdate(version).then((latest) => {
if (latest) {
updateIndicator.textContent = `v${latest.version} available`;
updateIndicator.addClass("ignis-update-available");
updateIndicator.href = latest.url;
} else {
updateIndicator.textContent = "Up to date";
}

View File

@@ -54,12 +54,17 @@
.ignis-update-indicator {
font-size: var(--font-ui-smaller);
color: var(--text-faint);
text-decoration: none;
}
.ignis-update-indicator.ignis-update-available {
color: var(--text-accent);
}
.ignis-update-indicator.ignis-update-available:hover {
text-decoration: underline;
}
.ignis-github-link {
color: var(--text-muted);
display: flex;

View File

@@ -0,0 +1,10 @@
{
"name": "@ignis/server-core",
"version": "0.0.0-internal",
"private": true,
"main": "src/index.js",
"dependencies": {
"chokidar": "^3.6.0",
"ws": "^8.16.0"
}
}

View File

@@ -0,0 +1,15 @@
const writeCoalescer = require("./write-coalescer");
const watcher = require("./watcher");
const { setupWebSocket } = require("./ws");
const {
encodeContentDispositionFilename,
resolveVaultPath,
} = require("./path-utils");
module.exports = {
writeCoalescer,
watcher,
setupWebSocket,
encodeContentDispositionFilename,
resolveVaultPath,
};

View File

@@ -0,0 +1,69 @@
const path = require("path");
/**
* Encode a filename for use in Content-Disposition header.
* Handles non-ASCII characters and special characters to prevent header injection.
* Uses RFC 5987 encoding for filename* parameter when needed.
*/
function encodeContentDispositionFilename(filename) {
const hasNonASCII = /[^\x00-\x7F]/.test(filename);
// Escape quotes and backslashes in ASCII filename
const escapedFilename = filename.replace(/["\\ ]/g, function (match) {
if (match === '"') return '\\"';
if (match === "\\") return "\\\\";
return match;
});
// Remove any control characters that could cause header injection
const sanitizedFilename = escapedFilename.replace(/[\x00-\x1F\x7F]/g, "");
if (!hasNonASCII) {
// Simple ASCII filename - use standard format
return `attachment; filename="${sanitizedFilename}"`;
}
// Non-ASCII filename - use RFC 5987 encoding
// Encode using percent-encoding for UTF-8
const encodedFilename = encodeURIComponent(filename)
.replace(/['()]/g, function (c) {
return "%" + c.charCodeAt(0).toString(16).toUpperCase();
})
.replace(/\*/g, "%2A");
// Provide both filename (ASCII fallback) and filename* (UTF-8 encoded)
// For fallback, replace non-ASCII with underscores
const asciiFallback = filename
.replace(/[^\x00-\x7F]/g, "_")
.replace(/["\\ ]/g, function (match) {
if (match === '"') return '\\"';
if (match === "\\") return "\\\\";
return match;
});
return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodedFilename}`;
}
// Resolve a client-provided path to an absolute path within a vault.
// Strips leading slashes so paths from the client are always treated as relative to the vault root.
// Rejects nullish input so missing-field bugs in callers don't silently target the vault root.
function resolveVaultPath(vaultRoot, relativePath) {
if (relativePath === null || relativePath === undefined) {
return null;
}
const cleaned = relativePath.replace(/^\/+/, "");
const resolved = path.resolve(vaultRoot, cleaned);
const resolvedRoot = path.resolve(vaultRoot);
if (
resolved !== resolvedRoot &&
!resolved.startsWith(resolvedRoot + path.sep)
) {
return null;
}
return resolved;
}
module.exports = { encodeContentDispositionFilename, resolveVaultPath };

View File

@@ -5,10 +5,18 @@
// Buffered writes respond to the HTTP client right away with synthetic mtime/size. Otherwise the browser's per-host connection cap blocks unrelated reads while writes sit in the buffer.
const fs = require("fs");
const config = require("./config");
const FLUSH_TIMEOUT_MS = 10000;
// Coalesce window in ms. 0 disables coalescing. Set via configure({ writeCoalesceMs }).
let writeCoalesceMs = 0;
function configure(opts) {
if (typeof opts?.writeCoalesceMs === "number") {
writeCoalesceMs = opts.writeCoalesceMs;
}
}
// absPath -> timestamp of last completed (or scheduled) write
const lastWriteTime = new Map();
@@ -51,7 +59,7 @@ function scheduleFlush(absPath) {
}
clearTimeout(entry.timer);
entry.timer = setTimeout(() => flushEntry(absPath), config.writeCoalesceMs);
entry.timer = setTimeout(() => flushEntry(absPath), writeCoalesceMs);
}
function estimateSize(data, encoding) {
@@ -67,7 +75,7 @@ function estimateSize(data, encoding) {
* Fresh writes resolve with real mtime/size once data is on disk. Buffered writes resolve immediately with synthetic values; the disk flush happens later when the debounce timer fires.
*/
async function writeCoalesced(absPath, data, encoding) {
const windowMs = config.writeCoalesceMs;
const windowMs = writeCoalesceMs;
const last = lastWriteTime.get(absPath);
// Fast path: coalescing disabled or far enough from the last write.
@@ -140,9 +148,7 @@ async function flushAll() {
const timeout = new Promise((resolve) => {
setTimeout(() => {
console.warn(
"[write-coalesce] Flush timeout -- some writes may be lost",
);
console.warn("[write-coalesce] Flush timeout. Some writes may be lost");
resolve();
}, FLUSH_TIMEOUT_MS);
});
@@ -159,4 +165,4 @@ function _reset() {
lastWriteTime.clear();
}
module.exports = { writeCoalesced, getPending, flushAll, _reset };
module.exports = { writeCoalesced, getPending, flushAll, configure, _reset };

View File

@@ -6,15 +6,13 @@ import os from "os";
const require = createRequire(import.meta.url);
const coalescer = require("./write-coalescer.js");
const config = require("./config.js");
const SHORT_WINDOW_MS = 50;
const originalWindow = config.writeCoalesceMs;
let tmpDir;
beforeEach(async () => {
config.writeCoalesceMs = SHORT_WINDOW_MS;
coalescer.configure({ writeCoalesceMs: SHORT_WINDOW_MS });
coalescer._reset();
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "coalesce-test-"));
});
@@ -22,7 +20,7 @@ beforeEach(async () => {
afterEach(async () => {
coalescer._reset();
vi.restoreAllMocks();
config.writeCoalesceMs = originalWindow;
coalescer.configure({ writeCoalesceMs: 0 });
await fs.promises.rm(tmpDir, { recursive: true, force: true });
});

View File

@@ -1,9 +1,14 @@
const { WebSocketServer } = require("ws");
const url = require("url");
const config = require("./config");
const watcher = require("./watcher");
function setupWebSocket(server) {
function setupWebSocket(server, opts = {}) {
const { getVaultPath } = opts;
if (typeof getVaultPath !== "function") {
throw new Error("setupWebSocket: opts.getVaultPath is required");
}
const wss = new WebSocketServer({ server, path: "/ws" });
// Plugin-registered message handlers: type -> handler(msg, ws)
@@ -13,12 +18,12 @@ function setupWebSocket(server) {
const params = new url.URL(req.url, "http://localhost").searchParams;
const vaultId = params.get("vault");
if (!vaultId || !config.getVaultPath(vaultId)) {
if (!vaultId || !getVaultPath(vaultId)) {
ws.close(4001, "Invalid or missing vault ID");
return;
}
const vaultPath = config.getVaultPath(vaultId);
const vaultPath = getVaultPath(vaultId);
console.log(`[ws] Client connected to vault: ${vaultId}`);
// Start watching this vault (no-op if already watching)

View File

@@ -0,0 +1,6 @@
{
"name": "@ignis/services",
"version": "0.0.0-internal",
"private": true,
"main": "src/index.js"
}

View File

@@ -0,0 +1 @@
export { vaultService } from "./vault-service.js";

20
packages/shim/build.js Normal file
View File

@@ -0,0 +1,20 @@
const esbuild = require("esbuild");
const path = require("path");
const { version: ignisVersion } = require("../../package.json");
module.exports = esbuild.build({
entryPoints: [path.join(__dirname, "src", "loader.js")],
bundle: true,
outfile: path.join(__dirname, "dist", "shim-loader.js"),
format: "iife",
platform: "browser",
target: ["chrome90"],
alias: {
path: "path-browserify",
},
define: {
__IGNIS_VERSION__: JSON.stringify(ignisVersion),
},
logLevel: "info",
});

View File

@@ -0,0 +1,18 @@
{
"name": "@ignis/shim",
"version": "0.0.0-internal",
"private": true,
"main": "src/loader.js",
"scripts": {
"build": "node build.js"
},
"dependencies": {
"@ignis/services": "*",
"@noble/hashes": "^2.2.0",
"pako": "^2.1.0",
"path-browserify": "^1.0.1"
},
"devDependencies": {
"esbuild": "^0.20.0"
}
}

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