40 Commits

Author SHA1 Message Date
Nystik
c22ecb5fef Merge pull request #32 from Nystik-gh/v0.8.6
0.8.6: proxy rewrite, offline deploy method, realpath fix, minor fixes
2026-06-13 15:47:05 +02:00
Nystik
6394a99808 update changelog and contributing, bump version to 0.8.6 2026-06-13 15:28:22 +02:00
Nystik
1ed6a89133 answer realpath from cache instead of round-tripping to the server 2026-06-12 22:23:53 +02:00
Nystik
b36338f9f5 add multi-arch buildscript 2026-06-12 16:55:40 +02:00
Nystik
6e0878a2f4 implement offline deploy 2026-06-12 14:25:54 +02:00
Nystik
cb258e97bf don't use shell for obsidian headless 2026-06-11 15:01:16 +02:00
Nystik
ccf424af47 bump deps to clear npm audit 2026-06-10 21:16:33 +02:00
Nystik
7758f533bd implement private host allow list 2026-06-10 20:36:57 +02:00
Nystik
911ebc00af rewrite proxy for better SSRF 2026-06-09 19:53:12 +02:00
Nystik
542360c681 harden shim origin check and fs/vault endpoints 2026-06-08 18:46:32 +02:00
Nystik
62d87af7dd harden demo sessions 2026-06-08 15:43:15 +02:00
Nystik
9d01ce71bc Merge pull request #25 from Nystik-gh/v0.8.5
0.8.5: Server settings, SSRF guard, shim coverage
2026-06-07 13:56:02 +02:00
Nystik
3f47618aaf update changelog, bump version to 0.8.5 2026-06-07 13:40:11 +02:00
Nystik
5a5acb935a update docs 2026-06-07 13:33:36 +02:00
Nystik
c3a9d511b2 expand shim coverage, additional fs shims, add light stream shim 2026-06-07 12:51:27 +02:00
Nystik
35348093a6 minor settings fixes 2026-06-06 20:40:42 +02:00
Nystik
a51b2d3ffa convert bridge to ESM 2026-06-06 19:28:17 +02:00
Nystik
04be97e48c add unit tests for ssrf guard, version compare, and settings validation 2026-06-06 18:29:48 +02:00
Nystik
7688de599a fix native menu guard, fix /app/data file permission. 2026-06-06 17:25:12 +02:00
Nystik
a7824ac284 add server settings UI and enforcement 2026-06-06 17:05:26 +02:00
Nystik
b43d12f702 add server settings api 2026-06-06 13:04:34 +02:00
Nystik
938a698795 add server settings store 2026-06-06 01:16:01 +02:00
Nystik
3129ed377c use up to date basic_auth directive in caddy. 2026-06-05 23:57:38 +02:00
Nystik
44bb01f162 consolidate cross-origin proxy and add ssrf guard 2026-06-05 23:56:59 +02:00
Nystik
b88f9fdc0e Merge pull request #20 from Nystik-gh/v0.8.4
0.8.4 Minor fixes
2026-06-03 13:39:49 +02:00
Nystik
f0b7f65a36 update changelog, bump version 2026-06-03 13:32:58 +02:00
Nystik
05a3908a7a refactor utility functions 2026-06-03 01:15:27 +02:00
Nystik
b90752e0ad headless-sync minor security improvements 2026-06-02 17:42:47 +02:00
Nystik
caaf6b3144 improve url origin checking in shim 2026-06-02 17:09:54 +02:00
Nystik
3833ef2668 clipboard reccursion fix 2026-06-02 16:58:01 +02:00
Nystik
35118ca190 Merge pull request #19 from Nystik-gh/virtual-plugin
0.8.3: Ignis API, virtual plugins, version pipeline
2026-06-01 18:14:09 +02:00
Nystik
3af8687037 bump version 2026-06-01 17:52:33 +02:00
Nystik
5bf120defa update docs 2026-06-01 17:52:20 +02:00
Nystik
7d70872f7e css fix for tables on firefox 2026-06-01 16:58:06 +02:00
Nystik
d5fb9e1e1d update build process and versioning 2026-05-26 02:55:24 +02:00
Nystik
28effab1ed expose Ignis API, implement shared ws client 2026-05-24 21:51:02 +02:00
Nystik
9eeff3c1b3 load bundled plugins via virtual-plugin loader 2026-05-24 17:41:13 +02:00
Nystik
f05ee9e856 rename server plugin bundle dir 2026-05-24 02:37:44 +02:00
Nystik
956a11d0cd refactor bridge plugin into virtual module 2026-05-24 02:20:28 +02:00
Nystik
69f8320d05 rename bridge plugin package 2026-05-24 00:26:32 +02:00
117 changed files with 4185 additions and 1595 deletions

View File

@@ -11,5 +11,4 @@ demo-vaults
data data
tmp tmp
**/dist **/dist
packages/bridge-plugin/main.js apps/ignis-server/server/build-info.json
apps/ignis-server/server/plugins/*/plugin/main.js

3
.gitignore vendored
View File

@@ -3,7 +3,6 @@ dist/
investigation/ investigation/
vaults/ vaults/
packages/*/dist/ packages/*/dist/
packages/bridge-plugin/main.js apps/ignis-server/server/build-info.json
apps/ignis-server/server/plugins/*/plugin/main.js
demo-vaults/ demo-vaults/
data/ data/

View File

@@ -2,6 +2,73 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [0.8.6] - Karm (2026-06-12)
### Added
- `OBSIDIAN_PACKAGE` env var: unpack a pre-placed `.deb`, `.asar.gz`, or `.asar` on first run instead of downloading, for offline or restricted networks.
- `PROXY_ALLOW_PRIVATE_HOSTS` env var: IPs or IPv4 CIDRs the cross-origin proxy may reach despite the private-address block.
### Changed
- `fs.promises.realpath` is answered from the client-side cache; vault load no longer issues one realpath request per folder.
### Fixed
- Sync file reads serve virtual plugin files the same as async reads.
### Security
- Cross-origin proxy rewritten for better security
- Filesystem and vault error responses no longer include absolute server paths.
- Protocol-relative (`//host`) requests route through the proxy guard.
- Vault names are validated on creation; `batch-read` caps the number of paths per request.
- Demo mode: `/api/ext/*` blocked, and several security fixes
- The `ob` CLI is spawned without a shell.
- Dependency bumps clearing npm audit.
## [0.8.5] - Karm (2026-06-07)
### Added
- Server settings panel in the Ignis settings tab.
- `assert`, `constants`, and `stream` shims, plus callback-style `fs` methods and `realpath`.
### Changed
- Write coalescing is now off by default (`WRITE_COALESCE_MS=0`).
### Fixed
- Native menus now stay disabled on platforms where its default is true
- `/app/data` is now created and owned by the runtime user.
- Caddy reverse-proxy example uses the current `basic_auth` directive.
### Security
- Cross-origin proxy rejects requests that resolve to private, loopback, or link-local addresses (SSRF guard).
## [0.8.4] - Karm (2026-06-03)
### Fixed
- Codeblocks calling clipboard APIs no longer causes reccursion error.
### Security
- Hardened same-origin checks, virtual-plugin URL validation, token file permissions, and log line bounds.
## [0.8.3] - Karm (2026-06-01)
### Added
- `WS_ORIGINS` env var to restrict allowed `Origin` headers on WebSocket connections.
### Fixed
- Ignis version is now rendered correctly.
- Tables in editing mode now render correctly in Firefox.
## [0.8.2] - Karm (2026-05-23) ## [0.8.2] - Karm (2026-05-23)
### Fixed ### Fixed

View File

@@ -46,18 +46,22 @@ This kind of report makes it straightforward to add the missing shim.
If you want to contribute code: If you want to contribute code:
1. Fork the repo and create a branch for your change 1. Fork the repo and create a branch for your change
2. Run `npm run build` to verify everything builds 2. Run `npm install` once at the repo root (npm workspaces)
3. start the server with `npm run dev`. 3. Run `npm run dev` to build and start the server
4. Test your change in the browser with at least one vault open 4. Test your change in the browser with at least one vault open
5. Keep PRs focused - one fix or feature per PR 5. Run `npm test` and make sure the whole suite passes
6. Keep PRs focused - one fix or feature per PR
Changes to deliberate behavior (the fs shim's caching and write model, the proxy's request handling, anything documented as a design decision) start as an issue, not a PR. Open the issue first so the approach can be discussed; a patch against an undiscussed design change will be closed on this basis.
### Project structure ### Project structure
- `src/shims/` - Browser shims for Node.js and Electron APIs - `packages/shim/` - Browser shims for Node.js and Electron APIs
- `src/ui/` - Svelte UI components (vault manager, dialogs) - `packages/ui/` - Svelte UI components (vault manager, dialogs)
- `plugin/` - The ignis-bridge Obsidian plugin (settings, file actions) - `packages/bridge/` - The ignis-bridge Obsidian plugin (settings, file actions)
- `server/` - Express server (fs routes, WebSocket, plugin system) - `packages/server-core/` - Shared server helpers (path guards, watcher, WebSocket)
- `server/plugins/` - Server plugin packages (e.g., headless-sync) - `apps/ignis-server/` - Express server, Docker image, demo mode
- `apps/ignis-server/server/plugins/` - Server plugin packages (e.g., headless-sync)
See [ARCHITECTURE.md](docs/ARCHITECTURE.md) for more detail. See [ARCHITECTURE.md](docs/ARCHITECTURE.md) for more detail.
@@ -65,7 +69,7 @@ See [ARCHITECTURE.md](docs/ARCHITECTURE.md) for more detail.
If a plugin needs a Node.js module that isn't shimmed: If a plugin needs a Node.js module that isn't shimmed:
1. Create the shim in `src/shims/node/<module>.js` 1. Create the shim in `packages/shim/src/node/<module>.js`
2. Export the functions the plugin needs (stub what you can't implement) 2. Export the functions the plugin needs (stub what you can't implement)
3. Register it in `src/shims/require.js` (import + add to `rawRegistry`) 3. Register it in `packages/shim/src/require.js` (import + add to `rawRegistry`)
4. Build and test with the plugin that needed it 4. Build and test with the plugin that needed it

View File

@@ -66,7 +66,7 @@ Compatibility for specific community plugins is tracked in [Issue #9](https://gi
**Multi-tab and workspaces.** **Multi-tab and workspaces.**
- Live file sync between browser tabs via WebSocket: open the same vault in two tabs and edits propagate within a second. - Live file sync between browser tabs via WebSocket: open the same vault in two tabs and edits propagate within a second.
- Saved workspaces can be opened in separate browser tabs via a `?workspace=` URL parameter, so each tab can hold a different layout of the same vault. - Saved workspaces can be opened in separate browser tabs via a `?workspace=` URL parameter, so each tab can hold a different layout of the same vault.
- The bridge plugin adds an "Open workspace in tab" command to the command palette. - Ignis adds an "Open workspace in tab" command to the command palette.
**Server-side sync.** **Server-side sync.**
- Obsidian Headless is implemented as a server-side plugin that performs continuous sync without needing an active browser tab. Only one of Obsidian Sync or Obsidian Headless can run per vault. - Obsidian Headless is implemented as a server-side plugin that performs continuous sync without needing an active browser tab. Only one of Obsidian Sync or Obsidian Headless can run per vault.
@@ -74,12 +74,12 @@ Compatibility for specific community plugins is tracked in [Issue #9](https://gi
**Server-side integration.** **Server-side integration.**
- Adds a plugin system inside the server itself, separate from Obsidian's community plugin system (WIP). - Adds a plugin system inside the server itself, separate from Obsidian's community plugin system (WIP).
- Ignis-specific settings appear as their own tabs inside Obsidian's Settings modal. - Ignis-specific settings appear as their own tabs inside Obsidian's Settings modal.
- Server runtime settings (cache sizes, request body limit, etc.) are configurable from the Ignis settings panel.
- Status bar indicators surface server state and headless sync activity. - Status bar indicators surface server state and headless sync activity.
## Roadmap ## Roadmap
**Planned:** **Planned:**
- Server parameter configuration from the Ignis settings panel (LRU cache size, write coalesce window, etc.)
- Continued shim work to support more community plugins. - Continued shim work to support more community plugins.
- Server-side plugin system improvements. - Server-side plugin system improvements.
@@ -94,7 +94,7 @@ A few design decisions worth knowing about for someone evaluating Ignis against
- A pre-compressed bootstrap response delivers vault info, vault list, metadata tree, and plugin list in a single call. - A pre-compressed bootstrap response delivers vault info, vault list, metadata tree, and plugin list in a single call.
- Indexer pre-fetch warms the content cache so Obsidian's startup index hits cache instead of the network. - Indexer pre-fetch warms the content cache so Obsidian's startup index hits cache instead of the network.
- An LRU content cache (50 MB by default) keeps memory use bounded regardless of vault size, so Ignis doesn't hold the whole vault in memory. - An LRU content cache (50 MB by default) keeps memory use bounded regardless of vault size, so Ignis doesn't hold the whole vault in memory.
- Write coalescing debounces rapid writes for slow filesystems (rclone, FUSE, NFS, SMB). - Optional write coalescing debounces rapid writes for slow filesystems (rclone, FUSE, NFS, SMB); off unless `WRITE_COALESCE_MS` is set.
## Browser compatibility ## Browser compatibility

View File

@@ -9,7 +9,7 @@ COPY package.json package-lock.json ./
COPY packages/services/package.json ./packages/services/ COPY packages/services/package.json ./packages/services/
COPY packages/shim/package.json ./packages/shim/ COPY packages/shim/package.json ./packages/shim/
COPY packages/ui/package.json ./packages/ui/ COPY packages/ui/package.json ./packages/ui/
COPY packages/bridge-plugin/package.json ./packages/bridge-plugin/ COPY packages/bridge/package.json ./packages/bridge/
COPY packages/server-core/package.json ./packages/server-core/ COPY packages/server-core/package.json ./packages/server-core/
COPY apps/ignis-server/package.json ./apps/ignis-server/ COPY apps/ignis-server/package.json ./apps/ignis-server/
@@ -38,7 +38,7 @@ COPY package.json package-lock.json ./
COPY packages/services/package.json ./packages/services/ COPY packages/services/package.json ./packages/services/
COPY packages/shim/package.json ./packages/shim/ COPY packages/shim/package.json ./packages/shim/
COPY packages/ui/package.json ./packages/ui/ COPY packages/ui/package.json ./packages/ui/
COPY packages/bridge-plugin/package.json ./packages/bridge-plugin/ COPY packages/bridge/package.json ./packages/bridge/
COPY packages/server-core/package.json ./packages/server-core/ COPY packages/server-core/package.json ./packages/server-core/
COPY apps/ignis-server/package.json ./apps/ignis-server/ COPY apps/ignis-server/package.json ./apps/ignis-server/
@@ -50,15 +50,11 @@ COPY apps/ignis-server/scripts/ ./apps/ignis-server/scripts/
COPY images/ ./images/ COPY images/ ./images/
COPY packages/server-core/src/ ./packages/server-core/src/ 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. # 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/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/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/build-info.json ./apps/ignis-server/server/build-info.json
COPY --from=build /app/apps/ignis-server/server/plugins/headless-sync/plugin/main.js ./apps/ignis-server/server/plugins/headless-sync/plugin/main.js COPY --from=build /app/apps/ignis-server/server/plugins/headless-sync/obsidian/dist/ ./apps/ignis-server/server/plugins/headless-sync/obsidian/dist/
RUN chmod +x /app/apps/ignis-server/scripts/entrypoint.sh RUN chmod +x /app/apps/ignis-server/scripts/entrypoint.sh

View File

@@ -28,6 +28,8 @@ Example configurations for Basic Auth and Authelia are in [`examples/`](examples
> [!CAUTION] > [!CAUTION]
> Do not run Ignis on a public network without auth. Anyone with the URL can read and write your vault files. > Do not run Ignis on a public network without auth. Anyone with the URL can read and write your vault files.
Ignis also runs a cross-origin proxy (`/api/proxy`) that reaches any public host by default. It rejects private, loopback, and link-local addresses, and you can narrow it to an allowlist or disable it entirely from the proxy settings in the Ignis settings panel.
## Setup with Docker Compose ## Setup with Docker Compose
Example `docker-compose.yml`: Example `docker-compose.yml`:
@@ -73,13 +75,29 @@ To build from source instead of pulling the image, clone the repo and run `docke
| `DATA_ROOT` | Path to persistent data (plugin config, sync state, auth tokens) | `/app/data` | | `DATA_ROOT` | Path to persistent data (plugin config, sync state, auth tokens) | `/app/data` |
| `OBSIDIAN_VERSION` | Obsidian version to download | `1.12.7` | | `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` | | `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` |
| `OBSIDIAN_PACKAGE` | Path to a pre-placed Obsidian package to unpack on first run instead of downloading, for offline or restricted networks. Accepts `.deb` (the form obsidian.md distributes), `.asar.gz`, or `.asar`. | unset |
| `AUTO_CREATE_DEFAULT` | When `true`, creates a "My Vault" vault on startup if no vaults exist. Useful for fresh installs. | `false` | | `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` | | `PUID` | User ID for file ownership | `1000` |
| `PGID` | Group 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` | | `WRITE_COALESCE_MS` | Debounce window (ms) for rapid writes. On slow filesystems (rclone, NFS, SMB), set an appropriate duration. | `0` |
| `WS_ORIGINS` | Comma-separated allowlist of `Origin` headers accepted on the WebSocket endpoint. When unset, any origin is accepted. | unset |
| `PROXY_ALLOW_PRIVATE_HOSTS` | Comma-separated IPs or IPv4 CIDRs the cross-origin proxy may reach despite the private-address block, for LAN services. Matched against the resolved IP. Reopens SSRF to the listed targets. | unset |
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. 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.
## Offline / restricted-network install
If the container can't reach GitHub on first run (air-gapped or restricted networks), download Obsidian yourself from [obsidian.md](https://obsidian.md/download) (the `.deb`), mount it into the container, and point `OBSIDIAN_PACKAGE` at it:
```yaml
volumes:
- ./obsidian_1.12.7_amd64.deb:/packages/obsidian.deb:ro
environment:
- OBSIDIAN_PACKAGE=/packages/obsidian.deb
```
On first run the entrypoint unpacks that instead of downloading. Match the version this release pins (see the OCI label and CHANGELOG); a mismatch logs a warning and still boots. `.asar.gz` and `.asar` are also accepted.
## Migrating an existing vault ## 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. Each subdirectory of `/vaults` is treated as a separate vault, so dropping in an existing Obsidian vault directory will make it available in Ignis.

View File

@@ -1,6 +1,6 @@
# Replace with your domain, or use :443 for local access with a self-signed cert. # Replace with your domain, or use :443 for local access with a self-signed cert.
ignis.example.com { ignis.example.com {
basicauth { basic_auth {
# Username: admin # Username: admin
# Replace the hash below with your own. Generate one with: # Replace the hash below with your own. Generate one with:
# docker run --rm caddy:2 caddy hash-password --plaintext YOUR_PASSWORD # docker run --rm caddy:2 caddy hash-password --plaintext YOUR_PASSWORD

View File

@@ -0,0 +1,111 @@
// node build-image.js [--push] [--no-latest]
//
// --push build mulit-arch (amd64+arm64) and push as a manifest list, tagged with the package.json version and latest
// --no-latest don't move the latest tag
//
// Without --push, builds the host arch and loads it as <image>:dev.
const { spawnSync, execSync } = require("child_process");
const path = require("path");
const repoRoot = path.resolve(__dirname, "..", "..", "..");
const IMAGE = process.env.IGNIS_IMAGE || "nobbe/ignis";
const BUILDER = "ignis-builder";
const PLATFORMS = "linux/amd64,linux/arm64";
const args = process.argv.slice(2);
const push = args.includes("--push");
const noLatest = args.includes("--no-latest");
const unknown = args.filter((a) => a !== "--push" && a !== "--no-latest");
if (unknown.length > 0) {
console.error("[build-image] unknown arguments:", unknown.join(" "));
console.error("usage: node build-image.js [--push] [--no-latest]");
process.exit(1);
}
const version = require(path.join(repoRoot, "package.json")).version;
if (!/^\d+\.\d+\.\d+$/.test(version)) {
console.error(
`[build-image] version "${version}" is not plain X.Y.Z, refusing to tag`,
);
process.exit(1);
}
function run(cmd, cmdArgs, opts = {}) {
const result = spawnSync(cmd, cmdArgs, {
cwd: repoRoot,
stdio: "inherit",
...opts,
});
return result.status === 0;
}
let dirty = "";
try {
dirty = execSync("git status --porcelain", { cwd: repoRoot })
.toString()
.trim();
} catch {
console.warn("[build-image] could not check git status");
}
if (dirty) {
console.warn(
"[build-image] WARNING: working tree has uncommitted changes; the image will not match the committed source",
);
}
const inspect = spawnSync("docker", ["buildx", "inspect", BUILDER], {
stdio: "ignore",
});
if (inspect.status !== 0) {
console.log(`[build-image] creating buildx builder ${BUILDER}`);
const created = run("docker", [
"buildx",
"create",
"--name",
BUILDER,
"--driver",
"docker-container",
]);
if (!created) {
process.exit(1);
}
}
const buildArgs = [
"buildx",
"build",
"--builder",
BUILDER,
"-f",
"apps/ignis-server/Dockerfile",
];
if (push) {
buildArgs.push("--platform", PLATFORMS, "-t", `${IMAGE}:${version}`);
if (!noLatest) {
buildArgs.push("-t", `${IMAGE}:latest`);
}
buildArgs.push("--push");
console.log(
`[build-image] building ${PLATFORMS} and pushing ${IMAGE}:${version}${noLatest ? "" : ` + ${IMAGE}:latest`}`,
);
} else {
// Host arch only. Multi-arch builds can't be loaded into the local image store.
buildArgs.push("-t", `${IMAGE}:dev`, "--load");
console.log(`[build-image] local build, loading ${IMAGE}:dev`);
}
buildArgs.push(".");
process.exit(run("docker", buildArgs) ? 0 : 1);

View File

@@ -23,25 +23,73 @@ else
echo "[ignis] Using existing user $RUN_USER (UID $PUID)" echo "[ignis] Using existing user $RUN_USER (UID $PUID)"
fi fi
# Fix ownership of volumes
chown -R "$PUID:$PGID" /vaults /app/obsidian-app mkdir -p /app/data
chown -R "$PUID:$PGID" /vaults /app/obsidian-app /app/data
OBSIDIAN_DIR="/app/obsidian-app" OBSIDIAN_DIR="/app/obsidian-app"
OBSIDIAN_VERSION="${OBSIDIAN_VERSION:-1.12.7}" OBSIDIAN_VERSION="${OBSIDIAN_VERSION:-1.12.7}"
warn_obsidian_version() {
if [ -n "$1" ] && [ "$1" != "$OBSIDIAN_VERSION" ]; then
echo "[ignis] WARNING: package is Obsidian $1, but this build is pinned to ${OBSIDIAN_VERSION}. The shim may misbehave."
fi
}
if [ ! -f "$OBSIDIAN_DIR/index.html" ]; then if [ ! -f "$OBSIDIAN_DIR/index.html" ]; then
echo "[ignis] First run. Downloading Obsidian v${OBSIDIAN_VERSION}..." if [ -n "$OBSIDIAN_PACKAGE" ]; then
# Offline / restricted networks: unpack an operator-supplied package instead of downloading.
if [ ! -f "$OBSIDIAN_PACKAGE" ]; then
echo "[ignis] ERROR: OBSIDIAN_PACKAGE='$OBSIDIAN_PACKAGE' but that file does not exist."
exit 1
fi
curl -fSL "https://github.com/obsidianmd/obsidian-releases/releases/download/v${OBSIDIAN_VERSION}/obsidian-${OBSIDIAN_VERSION}.asar.gz" \ echo "[ignis] First run. Unpacking local Obsidian package: $OBSIDIAN_PACKAGE"
-o /tmp/obsidian.asar.gz
echo "[ignis] Unpacking asar..." case "$OBSIDIAN_PACKAGE" in
gunzip /tmp/obsidian.asar.gz *.deb)
npx --yes @electron/asar extract /tmp/obsidian.asar "$OBSIDIAN_DIR" warn_obsidian_version "$(dpkg-deb -f "$OBSIDIAN_PACKAGE" Version 2>/dev/null)"
rm -rf /tmp/ob-deb
dpkg-deb -x "$OBSIDIAN_PACKAGE" /tmp/ob-deb
npx --yes @electron/asar extract \
/tmp/ob-deb/opt/Obsidian/resources/obsidian.asar "$OBSIDIAN_DIR"
rm -rf /tmp/ob-deb
;;
*.asar.gz)
warn_obsidian_version "$(basename "$OBSIDIAN_PACKAGE" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)"
cp "$OBSIDIAN_PACKAGE" /tmp/obsidian.asar.gz
gunzip -f /tmp/obsidian.asar.gz
npx --yes @electron/asar extract /tmp/obsidian.asar "$OBSIDIAN_DIR"
rm -f /tmp/obsidian.asar
;;
*.asar)
warn_obsidian_version "$(basename "$OBSIDIAN_PACKAGE" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)"
npx --yes @electron/asar extract "$OBSIDIAN_PACKAGE" "$OBSIDIAN_DIR"
;;
*)
echo "[ignis] ERROR: unsupported OBSIDIAN_PACKAGE format. Supported: .deb, .asar.gz, .asar"
exit 1
;;
esac
else
echo "[ignis] First run. Downloading Obsidian v${OBSIDIAN_VERSION}..."
rm -f /tmp/obsidian.asar curl -fSL "https://github.com/obsidianmd/obsidian-releases/releases/download/v${OBSIDIAN_VERSION}/obsidian-${OBSIDIAN_VERSION}.asar.gz" \
-o /tmp/obsidian.asar.gz
echo "[ignis] Obsidian v${OBSIDIAN_VERSION} ready." echo "[ignis] Unpacking asar..."
gunzip /tmp/obsidian.asar.gz
npx --yes @electron/asar extract /tmp/obsidian.asar "$OBSIDIAN_DIR"
rm -f /tmp/obsidian.asar
fi
if [ ! -f "$OBSIDIAN_DIR/index.html" ]; then
echo "[ignis] ERROR: setup did not produce $OBSIDIAN_DIR/index.html; the Obsidian package may be invalid."
exit 1
fi
echo "[ignis] Obsidian ready (v${OBSIDIAN_VERSION})."
else else
echo "[ignis] Obsidian already set up." echo "[ignis] Obsidian already set up."
fi fi

View File

@@ -8,3 +8,9 @@
.is-hidden-frameless:not(.is-fullscreen):not(.mod-macos) .workspace-tabs.mod-top-right-space .workspace-tab-header-container:after { .is-hidden-frameless:not(.is-fullscreen):not(.mod-macos) .workspace-tabs.mod-top-right-space .workspace-tab-header-container:after {
display: none !important; display: none !important;
} }
/* fix table cell height in firefox in edit mode with live preview */
.markdown-source-view.mod-cm6 .cm-table-widget th,
.markdown-source-view.mod-cm6 .cm-table-widget td {
height: auto !important;
}

View File

@@ -1,46 +1,42 @@
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const {
installObsidianPlugin,
isObsidianPluginInstalled,
} = require("./plugin-system/obsidian-plugin");
const BRIDGE_PLUGIN_ID = "ignis-bridge"; const BRIDGE_PLUGIN_ID = "ignis-bridge";
const BRIDGE_PLUGIN_DIR = path.join(__dirname, "..", "..", "..", "packages", "bridge-plugin");
// .ignis metadata helpers // Old vaults still have bridge in .obsidian/plugins from before it became virtual.
async function migratePluginFromVault(vaultPath, vaultName, pluginId) {
let didWork = false;
async function getIgnisMeta(vaultPath) { const pluginDir = path.join(vaultPath, ".obsidian", "plugins", pluginId);
const metaFile = path.join(vaultPath, ".ignis", "meta.json");
if (await fs.promises.stat(pluginDir).catch(() => null)) {
await fs.promises.rm(pluginDir, { recursive: true, force: true });
didWork = true;
}
const cpFile = path.join(vaultPath, ".obsidian", "community-plugins.json");
try { try {
const content = await fs.promises.readFile(metaFile, "utf-8"); const list = JSON.parse(await fs.promises.readFile(cpFile, "utf-8"));
return JSON.parse(content);
} catch { if (Array.isArray(list)) {
return {}; const filtered = list.filter((id) => id !== pluginId);
if (filtered.length !== list.length) {
await fs.promises.writeFile(cpFile, JSON.stringify(filtered));
didWork = true;
}
}
} catch {}
if (didWork) {
console.log(`[ignis] Migrated ${pluginId} out of vault: ${vaultName}`);
} }
return didWork;
} }
async function setIgnisMeta(vaultPath, data) { async function migratePluginsFromAllVaults(vaultRoot, pluginIds) {
const ignisDir = path.join(vaultPath, ".ignis");
const metaFile = path.join(ignisDir, "meta.json");
await fs.promises.mkdir(ignisDir, { recursive: true });
await fs.promises.writeFile(metaFile, JSON.stringify(data, null, 2));
}
// Bridge plugin install/check
async function isBridgePluginInstalled(vaultPath) {
return isObsidianPluginInstalled(BRIDGE_PLUGIN_ID, vaultPath);
}
async function installBridgePlugin(vaultPath) {
const result = await installObsidianPlugin(BRIDGE_PLUGIN_DIR, vaultPath);
return result.installed;
}
async function updateBridgePluginInAllVaults(vaultRoot) {
if (!(await fs.promises.stat(vaultRoot).catch(() => null))) { if (!(await fs.promises.stat(vaultRoot).catch(() => null))) {
return; return;
} }
@@ -53,18 +49,14 @@ async function updateBridgePluginInAllVaults(vaultRoot) {
} }
const vaultPath = path.join(vaultRoot, entry.name); const vaultPath = path.join(vaultRoot, entry.name);
const installed = await installBridgePlugin(vaultPath);
if (installed) { for (const pluginId of pluginIds) {
console.log(`[ignis] Installed bridge plugin in vault: ${entry.name}`); await migratePluginFromVault(vaultPath, entry.name, pluginId);
} }
} }
} }
module.exports = { module.exports = {
installBridgePlugin, BRIDGE_PLUGIN_ID,
updateBridgePluginInAllVaults, migratePluginsFromAllVaults,
isBridgePluginInstalled,
getIgnisMeta,
setIgnisMeta,
}; };

View File

@@ -5,8 +5,7 @@ const REPO_ROOT = path.join(__dirname, "..", "..", "..");
// VAULT_ROOT: a directory that contains vault folders. // VAULT_ROOT: a directory that contains vault folders.
// Each subdirectory is a vault. New vaults are created as new subdirs. // Each subdirectory is a vault. New vaults are created as new subdirs.
const vaultRoot = const vaultRoot = process.env.VAULT_ROOT || path.join(REPO_ROOT, "vaults");
process.env.VAULT_ROOT || path.join(REPO_ROOT, "vaults");
const dataRoot = process.env.DATA_ROOT || path.join(REPO_ROOT, "data"); const dataRoot = process.env.DATA_ROOT || path.join(REPO_ROOT, "data");
@@ -76,10 +75,6 @@ module.exports = {
vaults = discoverVaults(); vaults = discoverVaults();
return vaults; return vaults;
}, },
writeCoalesceMs:
process.env.WRITE_COALESCE_MS !== undefined
? parseInt(process.env.WRITE_COALESCE_MS)
: 5000,
demoMode: process.env.DEMO_MODE === "true", demoMode: process.env.DEMO_MODE === "true",
demoMaxSessions: parseInt(process.env.DEMO_MAX_SESSIONS) || 20, demoMaxSessions: parseInt(process.env.DEMO_MAX_SESSIONS) || 20,
@@ -88,8 +83,7 @@ module.exports = {
parseInt(process.env.DEMO_SESSION_QUOTA_BYTES) || 700 * 1024, parseInt(process.env.DEMO_SESSION_QUOTA_BYTES) || 700 * 1024,
demoTimeoutMs: parseInt(process.env.DEMO_TIMEOUT_MS) || 30 * 60 * 1000, demoTimeoutMs: parseInt(process.env.DEMO_TIMEOUT_MS) || 30 * 60 * 1000,
demoTemplateDir: demoTemplateDir:
process.env.DEMO_TEMPLATE_DIR || process.env.DEMO_TEMPLATE_DIR || path.join(__dirname, "demo-template"),
path.join(__dirname, "demo-template"),
obsidianAssetsPath: obsidianAssetsPath:
process.env.OBSIDIAN_ASSETS_PATH || process.env.OBSIDIAN_ASSETS_PATH ||

View File

@@ -1,6 +1,6 @@
// Vault provisioning for demo sessions. // Vault provisioning for demo sessions.
// //
// Copies the template into a session-prefixed dir, installs the bridge plugin, and registers the vault on the session. // Copies the template into a session-prefixed dir and registers the vault on the session.
// Re-provisions if disk was wiped under an existing session. // Re-provisions if disk was wiped under an existing session.
const fs = require("fs"); const fs = require("fs");
@@ -8,7 +8,6 @@ const fsp = fs.promises;
const path = require("path"); const path = require("path");
const config = require("../config"); const config = require("../config");
const { installBridgePlugin } = require("../bridge-plugin");
const bootstrapRoutes = require("../routes/bootstrap"); const bootstrapRoutes = require("../routes/bootstrap");
const { sessions, makeStorageName } = require("./demo-sessions"); const { sessions, makeStorageName } = require("./demo-sessions");
@@ -81,6 +80,14 @@ async function provisionVault(sessionId, userVaultName) {
const storageName = makeStorageName(sessionId, userVaultName); const storageName = makeStorageName(sessionId, userVaultName);
const vaultPath = path.join(config.vaultRoot, storageName); const vaultPath = path.join(config.vaultRoot, storageName);
// keep the resolved path inside the vault root.
const root = path.resolve(config.vaultRoot);
const resolved = path.resolve(vaultPath);
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
return { error: "invalid-vault-name" };
}
await fsp.mkdir(config.vaultRoot, { recursive: true }); await fsp.mkdir(config.vaultRoot, { recursive: true });
try { try {
@@ -96,9 +103,6 @@ async function provisionVault(sessionId, userVaultName) {
// Copy template (default: Welcome.md, Getting Started.md, .obsidian/*). // Copy template (default: Welcome.md, Getting Started.md, .obsidian/*).
await fsp.cp(config.demoTemplateDir, vaultPath, { recursive: true }); await fsp.cp(config.demoTemplateDir, vaultPath, { recursive: true });
// Install bridge plugin
await installBridgePlugin(vaultPath);
config.refreshVaults(); config.refreshVaults();
bootstrapRoutes.invalidateVault(storageName); bootstrapRoutes.invalidateVault(storageName);

View File

@@ -16,6 +16,13 @@ function newSessionId() {
return crypto.randomBytes(12).toString("hex"); return crypto.randomBytes(12).toString("hex");
} }
// accept only the format we issue.
const SESSION_ID_RE = /^[a-f0-9]{24}$/;
function isValidSessionId(id) {
return typeof id === "string" && SESSION_ID_RE.test(id);
}
function prefixFor(sessionId) { function prefixFor(sessionId) {
return "demo-" + sessionId + PREFIX_SEPARATOR; return "demo-" + sessionId + PREFIX_SEPARATOR;
} }
@@ -61,20 +68,25 @@ function setSessionCookie(res, sessionId) {
res.setHeader( res.setHeader(
"Set-Cookie", "Set-Cookie",
`${COOKIE_NAME}=${sessionId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSeconds}`, `${COOKIE_NAME}=${sessionId}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAgeSeconds}`,
); );
} }
// Resolve the session for a request. If none exists, create one (unless options.peek is true). // Resolve the session for a request. If none exists, create one (unless options.peek is true).
function getOrCreateSession(req, res, options = {}) { function getOrCreateSession(req, res, options = {}) {
const cookies = parseCookies(req); const cookies = parseCookies(req);
const existing = cookies[COOKIE_NAME]; const raw = cookies[COOKIE_NAME];
const existing = isValidSessionId(raw) ? raw : null;
if (existing && sessions.has(existing)) { if (existing && sessions.has(existing)) {
return existing; return existing;
} }
if (existing && !sessions.has(existing)) { if (existing && !sessions.has(existing)) {
if (sessions.size >= config.demoMaxSessions) {
return null;
}
// Cookie outlived in-memory session. reuse the id to keep the prefix. // Cookie outlived in-memory session. reuse the id to keep the prefix.
sessions.set(existing, { sessions.set(existing, {
lastActivity: Date.now(), lastActivity: Date.now(),

View File

@@ -10,6 +10,7 @@ const {
sessions, sessions,
parseCookies, parseCookies,
makeStorageName, makeStorageName,
tryParseUserVaultName,
touchSession, touchSession,
} = require("./demo-sessions"); } = require("./demo-sessions");
@@ -28,6 +29,20 @@ function wireWebSocket(server) {
if (userVault && !userVault.startsWith("demo-")) { if (userVault && !userVault.startsWith("demo-")) {
u.searchParams.set("vault", makeStorageName(sessionId, userVault)); u.searchParams.set("vault", makeStorageName(sessionId, userVault));
req.url = u.pathname + u.search; req.url = u.pathname + u.search;
} else if (
userVault &&
userVault.startsWith("demo-") &&
tryParseUserVaultName(sessionId, userVault) === null
) {
// An already-prefixed vault that isn't this session's: refuse the upgrade.
const socket = rest[0];
if (socket && socket.writable) {
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
socket.destroy();
}
return;
} }
touchSession(sessionId); touchSession(sessionId);

View File

@@ -70,6 +70,16 @@ function setupDemo(app) {
// Hide server-side plugins (headless-sync) from the demo UI // Hide server-side plugins (headless-sync) from the demo UI
app.use("/api/plugins", pluginsBlocker); app.use("/api/plugins", pluginsBlocker);
// Plugin routes are not exposed in demo mode.
app.use("/api/ext", (req, res) => {
res.status(403).json({ error: "Plugin routes are disabled in demo mode" });
});
// Server settings are-fixed in demo mode.
app.use("/api/settings", (req, res) => {
res.status(403).json({ error: "Settings are disabled in demo mode" });
});
// Cleanup timer // Cleanup timer
const interval = setInterval(() => { const interval = setInterval(() => {
cleanupExpired().catch((e) => cleanupExpired().catch((e) =>

View File

@@ -3,16 +3,24 @@ const fs = require("fs");
const path = require("path"); const path = require("path");
const compression = require("compression"); const compression = require("compression");
const config = require("./config"); const config = require("./config");
const settings = require("./settings");
const { getVersion } = require("./version"); const { getVersion } = require("./version");
const { const {
setupWebSocket, setupWebSocket,
watcher, watcher,
writeCoalescer, writeCoalescer,
} = require("@ignis/server-core"); } = require("@ignis/server-core");
const { updateBridgePluginInAllVaults } = require("./bridge-plugin"); const {
const { initPlugins, shutdownPlugins } = require("./plugin-system/manager"); BRIDGE_PLUGIN_ID,
migratePluginsFromAllVaults,
} = require("./bridge-plugin");
const {
initPlugins,
shutdownPlugins,
getBundledPluginDirs,
} = require("./plugin-system/manager");
const pluginRoutes = require("./routes/plugins"); const pluginRoutes = require("./routes/plugins");
writeCoalescer.configure({ writeCoalesceMs: config.writeCoalesceMs }); writeCoalescer.configure({ writeCoalesceMs: settings.get("writeCoalesceMs") });
const { flushAll } = writeCoalescer; const { flushAll } = writeCoalescer;
const { setupDemo, wireDemoWebSocket } = require("./demo"); const { setupDemo, wireDemoWebSocket } = require("./demo");
@@ -25,7 +33,18 @@ const ANSI_RESET = "\x1b[0m";
const app = express(); const app = express();
app.use(express.json({ limit: "50mb" })); // Reject oversized requests by Content-Length before parsing.
app.use((req, res, next) => {
const declared = Number(req.headers["content-length"]);
if (Number.isFinite(declared) && declared > settings.get("maxBodyBytes")) {
return res.status(413).json({ error: "Request body too large" });
}
next();
});
app.use(express.json({ limit: settings.MAX_BODY_BACKSTOP }));
app.use(compression()); app.use(compression());
// logger middleware // logger middleware
@@ -59,6 +78,7 @@ const fsRoutes = require("./routes/fs");
const vaultRoutes = require("./routes/vault"); const vaultRoutes = require("./routes/vault");
const proxyRoutes = require("./routes/proxy"); const proxyRoutes = require("./routes/proxy");
const versionRoutes = require("./routes/version"); const versionRoutes = require("./routes/version");
const settingsRoutes = require("./routes/settings");
const bootstrapRoutes = require("./routes/bootstrap"); const bootstrapRoutes = require("./routes/bootstrap");
app.use("/assets", express.static(path.join(__dirname, "assets"))); app.use("/assets", express.static(path.join(__dirname, "assets")));
@@ -71,6 +91,7 @@ app.use("/api/fs", fsRoutes);
app.use("/api/vault", vaultRoutes); app.use("/api/vault", vaultRoutes);
app.use("/api/proxy", proxyRoutes); app.use("/api/proxy", proxyRoutes);
app.use("/api/version", versionRoutes); app.use("/api/version", versionRoutes);
app.use("/api/settings", settingsRoutes);
app.use("/api/plugins", pluginRoutes); app.use("/api/plugins", pluginRoutes);
app.use("/api/bootstrap", bootstrapRoutes); app.use("/api/bootstrap", bootstrapRoutes);
@@ -170,14 +191,28 @@ const server = app.listen(config.port, async () => {
console.log(`[ignis] Vault root: ${config.vaultRoot}`); console.log(`[ignis] Vault root: ${config.vaultRoot}`);
console.log(`[ignis] Vaults: ${Object.keys(config.vaults).join(", ")}`); console.log(`[ignis] Vaults: ${Object.keys(config.vaults).join(", ")}`);
await updateBridgePluginInAllVaults(config.vaultRoot);
await initPlugins({ app, config, wss, watcher }); await initPlugins({ app, config, wss, watcher });
const bundledPluginDirs = getBundledPluginDirs();
for (const { distDir } of bundledPluginDirs) {
app.use(express.static(distDir));
}
await migratePluginsFromAllVaults(config.vaultRoot, [
BRIDGE_PLUGIN_ID,
...bundledPluginDirs.map((d) => d.bundledPluginId),
]);
bootstrapRoutes bootstrapRoutes
.warmUp() .warmUp()
.catch((e) => console.warn("[bootstrap] warm-up error:", e.message)); .catch((e) => console.warn("[bootstrap] warm-up error:", e.message));
}); });
const wss = setupWebSocket(server, { getVaultPath: config.getVaultPath }); const wss = setupWebSocket(server, {
getVaultPath: config.getVaultPath,
originAllowlist: settings.get("wsOrigins"),
});
wireDemoWebSocket(server); wireDemoWebSocket(server);
async function gracefulShutdown(signal) { async function gracefulShutdown(signal) {

View File

@@ -40,17 +40,16 @@ function discoverPlugins(pluginsDir) {
continue; continue;
} }
let bundledPluginId = null; let bundledManifest = null;
if (plugin.obsidianPlugin) { if (plugin.obsidianPlugin) {
try { try {
const manifest = JSON.parse( bundledManifest = JSON.parse(
fs.readFileSync( fs.readFileSync(
path.join(plugin.obsidianPlugin, "manifest.json"), path.join(plugin.obsidianPlugin, "manifest.json"),
"utf-8", "utf-8",
), ),
); );
bundledPluginId = manifest.id;
} catch { } catch {
// No valid bundled plugin manifest // No valid bundled plugin manifest
} }
@@ -61,7 +60,8 @@ function discoverPlugins(pluginsDir) {
name: plugin.name, name: plugin.name,
description: plugin.description || "", description: plugin.description || "",
obsidianPlugin: plugin.obsidianPlugin || null, obsidianPlugin: plugin.obsidianPlugin || null,
bundledPluginId, bundledPluginId: bundledManifest ? bundledManifest.id : null,
bundledManifest,
module: plugin, module: plugin,
}); });

View File

@@ -3,10 +3,7 @@ const path = require("path");
const express = require("express"); const express = require("express");
const { discoverPlugins } = require("./discovery"); const { discoverPlugins } = require("./discovery");
const configStore = require("./config-store"); const configStore = require("./config-store");
const { const { getVersion } = require("../version");
installObsidianPlugin,
removeObsidianPlugin,
} = require("./obsidian-plugin");
let discoveredPlugins = new Map(); let discoveredPlugins = new Map();
const loadedPlugins = new Map(); const loadedPlugins = new Map();
@@ -50,18 +47,6 @@ async function initPlugins(ctx) {
continue; continue;
} }
const discovered = discoveredPlugins.get(pluginId);
if (discovered.obsidianPlugin) {
try {
await installObsidianPlugin(discovered.obsidianPlugin, vaultPath);
} catch (e) {
console.error(
`[plugins] Failed to verify bundled plugin for ${pluginId} in ${vaultId}: ${e.message}`,
);
}
}
const loaded = loadedPlugins.get(pluginId); const loaded = loadedPlugins.get(pluginId);
if (loaded?.module?.onVaultEnabled) { if (loaded?.module?.onVaultEnabled) {
@@ -182,30 +167,28 @@ async function enablePluginForVault(pluginId, vaultId) {
await loadPlugin(pluginId); await loadPlugin(pluginId);
} }
if (discovered.obsidianPlugin) {
try {
const result = await installObsidianPlugin(
discovered.obsidianPlugin,
vaultPath,
);
if (result.installed) {
console.log(
`[plugins] Installed bundled Obsidian plugin for ${pluginId} in vault: ${vaultId}`,
);
}
} catch (e) {
console.error(
`[plugins] Failed to install bundled plugin for ${pluginId}: ${e.message}`,
);
}
}
const loaded = loadedPlugins.get(pluginId); const loaded = loadedPlugins.get(pluginId);
if (loaded?.module?.onVaultEnabled) { if (loaded?.module?.onVaultEnabled) {
await loaded.module.onVaultEnabled(vaultId, vaultPath); await loaded.module.onVaultEnabled(vaultId, vaultPath);
} }
// Broadcast to any open tabs on this vault so they load the plugin properly.
if (discovered.obsidianPlugin && discovered.bundledPluginId) {
const v = `?v=${getVersion()}`;
const entry = {
id: discovered.bundledPluginId,
scriptUrl: `/${discovered.bundledPluginId}.js${v}`,
cssUrl: `/${discovered.bundledPluginId}.css${v}`,
manifest: discovered.bundledManifest,
};
serverCtx.wss?.broadcastToVault?.(vaultId, {
type: "virtual-plugin-enable",
vault: vaultId,
entry,
});
}
} }
async function disablePluginForVault(pluginId, vaultId) { async function disablePluginForVault(pluginId, vaultId) {
@@ -227,25 +210,6 @@ async function disablePluginForVault(pluginId, vaultId) {
await loaded.module.onVaultDisabled(vaultId, vaultPath); await loaded.module.onVaultDisabled(vaultId, vaultPath);
} }
if (discovered.obsidianPlugin) {
try {
const result = await removeObsidianPlugin(
discovered.obsidianPlugin,
vaultPath,
);
if (result.removed) {
console.log(
`[plugins] Removed bundled Obsidian plugin for ${pluginId} from vault: ${vaultId}`,
);
}
} catch (e) {
console.error(
`[plugins] Failed to remove bundled plugin for ${pluginId}: ${e.message}`,
);
}
}
const enabledVaults = configStore.getEnabledVaults(pluginConfig, pluginId); const enabledVaults = configStore.getEnabledVaults(pluginConfig, pluginId);
const updated = enabledVaults.filter((id) => id !== vaultId); const updated = enabledVaults.filter((id) => id !== vaultId);
configStore.setEnabledVaults(pluginConfig, pluginId, updated); configStore.setEnabledVaults(pluginConfig, pluginId, updated);
@@ -254,6 +218,55 @@ async function disablePluginForVault(pluginId, vaultId) {
if (updated.length === 0) { if (updated.length === 0) {
await unloadPlugin(pluginId); await unloadPlugin(pluginId);
} }
if (discovered.bundledPluginId) {
serverCtx.wss?.broadcastToVault?.(vaultId, {
type: "virtual-plugin-disable",
vault: vaultId,
id: discovered.bundledPluginId,
});
}
}
function getBundledPluginDirs() {
const dirs = [];
for (const [, discovered] of discoveredPlugins) {
if (discovered.obsidianPlugin && discovered.bundledPluginId) {
dirs.push({
bundledPluginId: discovered.bundledPluginId,
distDir: path.join(discovered.obsidianPlugin, "dist"),
});
}
}
return dirs;
}
function getVirtualPluginsForVault(vaultId, version) {
const v = version ? `?v=${version}` : "";
const result = [];
for (const [pluginId, discovered] of discoveredPlugins) {
if (!discovered.obsidianPlugin || !discovered.bundledPluginId) {
continue;
}
const enabledVaults = configStore.getEnabledVaults(pluginConfig, pluginId);
if (!enabledVaults.includes(vaultId)) {
continue;
}
result.push({
id: discovered.bundledPluginId,
scriptUrl: `/${discovered.bundledPluginId}.js${v}`,
cssUrl: `/${discovered.bundledPluginId}.css${v}`,
manifest: discovered.bundledManifest,
});
}
return result;
} }
function getDiscoveredPlugins() { function getDiscoveredPlugins() {
@@ -280,4 +293,6 @@ module.exports = {
enablePluginForVault, enablePluginForVault,
disablePluginForVault, disablePluginForVault,
getDiscoveredPlugins, getDiscoveredPlugins,
getBundledPluginDirs,
getVirtualPluginsForVault,
}; };

View File

@@ -83,15 +83,23 @@ function isAuthenticated(dataDir) {
return false; return false;
} }
function writeSecret(file, contents) {
fs.writeFileSync(file, contents, { encoding: "utf-8", mode: 0o600 });
try {
fs.chmodSync(file, 0o600);
} catch {}
}
function saveInternal(dataDir, tokenData) { function saveInternal(dataDir, tokenData) {
const internalFile = getInternalTokenFile(dataDir); const internalFile = getInternalTokenFile(dataDir);
const dir = path.dirname(internalFile); const dir = path.dirname(internalFile);
if (!fs.existsSync(dir)) { if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
} }
fs.writeFileSync(internalFile, JSON.stringify(tokenData, null, 2), "utf-8"); writeSecret(internalFile, JSON.stringify(tokenData, null, 2));
} }
function syncToObCli(dataDir, token) { function syncToObCli(dataDir, token) {
@@ -101,10 +109,10 @@ function syncToObCli(dataDir, token) {
const dir = path.dirname(obAuthFile); const dir = path.dirname(obAuthFile);
if (!fs.existsSync(dir)) { if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
} }
fs.writeFileSync(obAuthFile, token, "utf-8"); writeSecret(obAuthFile, token);
} catch {} } catch {}
} }

View File

@@ -2,57 +2,26 @@ const CHANNEL = "plugin:headless-sync";
class SyncBroadcaster { class SyncBroadcaster {
constructor(wss) { constructor(wss) {
this._wss = wss; this._channel = wss.channel(CHANNEL);
this._logSubscriptions = new Map();
}
subscribeToLogs(vaultId) {
this._logSubscriptions.set(vaultId, { expires: Date.now() + 10000 });
} }
broadcastLog(vaultId, line) { broadcastLog(vaultId, line) {
if (!this._wss?.clients) { this._channel.broadcastToVault(vaultId, {
return;
}
const sub = this._logSubscriptions.get(vaultId);
if (!sub || Date.now() > sub.expires) {
return;
}
this._send({
channel: CHANNEL,
type: "sync-log", type: "sync-log",
payload: { vaultId, line }, payload: { vaultId, line },
}); });
} }
broadcastStatus(state) { broadcastStatus(state) {
if (!state) { if (!state || !state.vaultId) {
return; return;
} }
this._send({ this._channel.broadcastToVault(state.vaultId, {
channel: CHANNEL,
type: "sync-status", type: "sync-status",
payload: state, payload: state,
}); });
} }
_send(msg) {
if (!this._wss?.clients) {
return;
}
const data = JSON.stringify(msg);
for (const client of this._wss.clients) {
if (client.readyState === 1) {
client.send(data);
}
}
}
} }
module.exports = { SyncBroadcaster }; module.exports = { SyncBroadcaster };

View File

@@ -11,7 +11,7 @@ module.exports = {
version: "0.3.0", version: "0.3.0",
//TODO: add server plugin manifest //TODO: add server plugin manifest
obsidianPlugin: path.join(__dirname, "plugin"), obsidianPlugin: path.join(__dirname, "obsidian"),
_ctx: null, _ctx: null,
_obStatus: null, _obStatus: null,
@@ -63,22 +63,9 @@ module.exports = {
const { mountRoutes } = require("./routes"); const { mountRoutes } = require("./routes");
mountRoutes(ctx.router, this); mountRoutes(ctx.router, this);
// Register WebSocket message handler for log subscriptions
if (ctx.wss && ctx.wss.messageHandlers) {
ctx.wss.messageHandlers.set("subscribe-logs", (msg) => {
if (msg.vaultId && this._broadcaster) {
this._broadcaster.subscribeToLogs(msg.vaultId);
}
});
}
}, },
async shutdown() { async shutdown() {
if (this._ctx?.wss?.messageHandlers) {
this._ctx.wss.messageHandlers.delete("subscribe-logs");
}
if (this._syncManager) { if (this._syncManager) {
await this._syncManager.shutdown(); await this._syncManager.shutdown();
this._syncManager = null; this._syncManager = null;

View File

@@ -3,8 +3,6 @@ const fs = require("fs");
const os = require("os"); const os = require("os");
const path = require("path"); 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 // 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. // ob's config dir (~/.config/obsidian-headless/) survives container recreates.
let configuredDataDir = null; let configuredDataDir = null;
@@ -39,13 +37,11 @@ function checkInstalled() {
} }
function spawnOb(args, opts = {}) { function spawnOb(args, opts = {}) {
const home = configuredDataDir const home = configuredDataDir ? getObHome(configuredDataDir) : os.homedir();
? getObHome(configuredDataDir)
: os.homedir();
return spawn("ob", args, { return spawn("ob", args, {
env: { ...process.env, HOME: home }, env: { ...process.env, HOME: home },
shell: isWindows, shell: false,
windowsHide: true, windowsHide: true,
...opts, ...opts,
}); });

View File

@@ -1,6 +1,6 @@
{ {
"id": "ignis-headless-sync", "id": "ignis-headless-sync",
"name": "Ignis Headless Sync", "name": "Headless Sync",
"version": "0.3.0", "version": "0.3.0",
"minAppVersion": "1.12.4", "minAppVersion": "1.12.4",
"description": "Client-side companion for server-side Obsidian Sync", "description": "Client-side companion for server-side Obsidian Sync",

View File

@@ -32,13 +32,12 @@ function showConflictWarning(title, message) {
}); });
} }
function startCoreSyncGuard(plugin, api, wsListener) { function startCoreSyncGuard(plugin, api) {
const app = plugin.app; const app = plugin.app;
const vaultId = app.vault.getName(); const vaultId = app.vault.getName();
// Monkey-patch syncPlugin.enable() to clear the shim flag before // Monkey-patch syncPlugin.enable() to clear the shim flag before Obsidian writes core-plugins.json.
// Obsidian writes core-plugins.json. This ensures the read transform // This ensures the read transform doesn't block a user-initiated core sync enable.
// doesn't block a user-initiated core sync enable.
const syncPlugin = app.internalPlugins.getPluginById("sync"); const syncPlugin = app.internalPlugins.getPluginById("sync");
let origEnable = null; let origEnable = null;
@@ -52,16 +51,13 @@ function startCoreSyncGuard(plugin, api, wsListener) {
}; };
} }
// Watch for core-plugins.json changes via WebSocket.
let wasEnabled = isCoreSyncEnabled(); let wasEnabled = isCoreSyncEnabled();
const rawHandler = (msg) => { const unsubModified = window.__ignis.ws.subscribe("modified", (msg) => {
if (msg.type === "modified" && msg.path === CORE_PLUGINS_PATH) { if (msg.path === CORE_PLUGINS_PATH) {
handleCoreSyncChange(); handleCoreSyncChange();
} }
}; });
wsListener.onRaw(rawHandler);
function handleCoreSyncChange() { function handleCoreSyncChange() {
const enabled = isCoreSyncEnabled(); const enabled = isCoreSyncEnabled();
@@ -80,7 +76,7 @@ function startCoreSyncGuard(plugin, api, wsListener) {
return { return {
cleanup() { cleanup() {
wsListener.offRaw(); unsubModified();
if (syncPlugin && origEnable) { if (syncPlugin && origEnable) {
syncPlugin.enable = origEnable; syncPlugin.enable = origEnable;

View File

@@ -1,6 +1,8 @@
const api = require("./api"); const api = require("./api");
async function renderLogViewer(containerEl, vaultId, wsListener) { const CHANNEL = "plugin:headless-sync";
async function renderLogViewer(containerEl, vaultId) {
const details = containerEl.createEl("details", { const details = containerEl.createEl("details", {
cls: "ignis-log-details", cls: "ignis-log-details",
}); });
@@ -32,19 +34,12 @@ async function renderLogViewer(containerEl, vaultId, wsListener) {
logBox.scrollTop = logBox.scrollHeight; logBox.scrollTop = logBox.scrollHeight;
if (!wsListener) { const channel = window.__ignis.ws.channel(CHANNEL);
return () => {}; let unsubLog = null;
}
details.addEventListener("toggle", () => { const onLog = (msg) => {
if (details.open) { const payload = msg.payload || {};
wsListener.subscribeLogs(vaultId);
} else {
wsListener.unsubscribeLogs();
}
});
const onLog = (payload) => {
if (payload.vaultId !== vaultId) { if (payload.vaultId !== vaultId) {
return; return;
} }
@@ -66,11 +61,22 @@ async function renderLogViewer(containerEl, vaultId, wsListener) {
} }
}; };
wsListener.on("sync-log", onLog); details.addEventListener("toggle", () => {
if (details.open) {
if (!unsubLog) {
unsubLog = channel.subscribe("sync-log", onLog);
}
} else if (unsubLog) {
unsubLog();
unsubLog = null;
}
});
return () => { return () => {
wsListener.off("sync-log", onLog); if (unsubLog) {
wsListener.unsubscribeLogs(); unsubLog();
unsubLog = null;
}
}; };
} }

View File

@@ -1,6 +1,5 @@
const { Plugin } = require("obsidian"); const { Plugin } = require("obsidian");
const { HeadlessSyncSettingTab } = require("./settings-tab"); const { HeadlessSyncSettingTab } = require("./settings-tab");
const { WsListener } = require("./ws-listener");
const { initSyncStatusBar } = require("./sync-status-bar"); const { initSyncStatusBar } = require("./sync-status-bar");
const { startCoreSyncGuard } = require("./core-sync-guard"); const { startCoreSyncGuard } = require("./core-sync-guard");
const api = require("./api"); const api = require("./api");
@@ -14,14 +13,11 @@ class IgnisHeadlessSyncPlugin extends Plugin {
return; return;
} }
this.wsListener = new WsListener(); this._syncStatusBarCleanup = initSyncStatusBar(this);
this.wsListener.start();
this._syncStatusBarCleanup = initSyncStatusBar(this, this.wsListener);
this.addSettingTab(new HeadlessSyncSettingTab(this.app, this)); this.addSettingTab(new HeadlessSyncSettingTab(this.app, this));
this._coreSyncGuard = startCoreSyncGuard(this, api, this.wsListener); this._coreSyncGuard = startCoreSyncGuard(this, api);
this.addCommand({ this.addCommand({
id: "start-sync", id: "start-sync",
@@ -75,11 +71,6 @@ class IgnisHeadlessSyncPlugin extends Plugin {
this._syncStatusBarCleanup(); this._syncStatusBarCleanup();
this._syncStatusBarCleanup = null; this._syncStatusBarCleanup = null;
} }
if (this.wsListener) {
this.wsListener.stop();
this.wsListener = null;
}
} }
} }

View File

@@ -316,11 +316,7 @@ class HeadlessSyncSettingTab extends PluginSettingTab {
} }
async renderLogs(containerEl, vaultId) { async renderLogs(containerEl, vaultId) {
this._logCleanup = await renderLogViewer( this._logCleanup = await renderLogViewer(containerEl, vaultId);
containerEl,
vaultId,
this.plugin.wsListener,
);
} }
hide() { hide() {

View File

@@ -1,6 +1,8 @@
const { setIcon } = require("obsidian"); const { setIcon } = require("obsidian");
const api = require("./api"); const api = require("./api");
const CHANNEL = "plugin:headless-sync";
const TOOLTIP_MAP = { const TOOLTIP_MAP = {
running: "Syncing...", running: "Syncing...",
synced: "Synced", synced: "Synced",
@@ -8,8 +10,11 @@ const TOOLTIP_MAP = {
error: "Sync error", error: "Sync error",
}; };
function initSyncStatusBar(plugin, wsListener) { function initSyncStatusBar(plugin) {
const vaultId = plugin.app.vault.getName(); const vaultId = plugin.app.vault.getName();
const ws = window.__ignis.ws;
const channel = ws.channel(CHANNEL);
const item = plugin.addStatusBarItem(); const item = plugin.addStatusBarItem();
item.addClass("ignis-sync-statusbar"); item.addClass("ignis-sync-statusbar");
item.style.display = "none"; item.style.display = "none";
@@ -21,6 +26,7 @@ function initSyncStatusBar(plugin, wsListener) {
let popoverOpen = false; let popoverOpen = false;
let currentStatus = "stopped"; let currentStatus = "stopped";
let outsideClickHandler = null; let outsideClickHandler = null;
let unsubLog = null;
function updateState(status, error) { function updateState(status, error) {
currentStatus = status; currentStatus = status;
@@ -62,7 +68,7 @@ function initSyncStatusBar(plugin, wsListener) {
popoverOpen = true; popoverOpen = true;
wsListener.subscribeLogs(vaultId); unsubLog = channel.subscribe("sync-log", onLog);
outsideClickHandler = (e) => { outsideClickHandler = (e) => {
if (!item.contains(e.target)) { if (!item.contains(e.target)) {
@@ -86,7 +92,11 @@ function initSyncStatusBar(plugin, wsListener) {
outsideClickHandler = null; outsideClickHandler = null;
} }
wsListener.unsubscribeLogs(); if (unsubLog) {
unsubLog();
unsubLog = null;
}
popoverOpen = false; popoverOpen = false;
} }
@@ -95,7 +105,7 @@ function initSyncStatusBar(plugin, wsListener) {
return path; return path;
} }
return "\u2026" + path.slice(-(maxLen - 1)); return "" + path.slice(-(maxLen - 1));
} }
function formatPopoverText(prefix, path) { function formatPopoverText(prefix, path) {
@@ -115,35 +125,30 @@ function initSyncStatusBar(plugin, wsListener) {
} }
function extractFileActivity(line) { function extractFileActivity(line) {
// Downloading/Downloaded path
let match = line.match(/^(?:Downloading|Downloaded)\s+(.+)$/); let match = line.match(/^(?:Downloading|Downloaded)\s+(.+)$/);
if (match) { if (match) {
return { prefix: "Syncing", path: match[1].trim() }; return { prefix: "Syncing", path: match[1].trim() };
} }
// Uploading file / Upload complete path
match = line.match(/^(?:Uploading file|Upload complete|New file)\s+(.+)$/); match = line.match(/^(?:Uploading file|Upload complete|New file)\s+(.+)$/);
if (match) { if (match) {
return { prefix: "Syncing", path: match[1].trim() }; return { prefix: "Syncing", path: match[1].trim() };
} }
// Deleting path
match = line.match(/^Deleting\s+(.+)$/); match = line.match(/^Deleting\s+(.+)$/);
if (match) { if (match) {
return { prefix: "Deleting", path: match[1].trim() }; return { prefix: "Deleting", path: match[1].trim() };
} }
// Push: path (updated)
match = line.match(/^Push:\s+(.+?)\s+\(updated\)$/); match = line.match(/^Push:\s+(.+?)\s+\(updated\)$/);
if (match) { if (match) {
return { prefix: "Syncing", path: match[1].trim() }; return { prefix: "Syncing", path: match[1].trim() };
} }
// Push: path (deleted)
match = line.match(/^Push:\s+(.+?)\s+\(deleted\)$/); match = line.match(/^Push:\s+(.+?)\s+\(deleted\)$/);
if (match) { if (match) {
@@ -157,7 +162,6 @@ function initSyncStatusBar(plugin, wsListener) {
return /Fully synced/i.test(line); return /Fully synced/i.test(line);
} }
// Click toggles popover
item.addEventListener("click", () => { item.addEventListener("click", () => {
if (popoverOpen) { if (popoverOpen) {
hidePopover(); hidePopover();
@@ -166,16 +170,15 @@ function initSyncStatusBar(plugin, wsListener) {
} }
}); });
// Listen for status updates const onStatus = (msg) => {
const onStatus = (payload) => { const payload = msg.payload || {};
if (payload.vaultId !== vaultId) { if (payload.vaultId !== vaultId) {
return; return;
} }
item.style.display = ""; item.style.display = "";
// "running" from server means the process is alive, but we refine
// the visual state based on log activity.
if (payload.status === "running") { if (payload.status === "running") {
updateState("synced"); updateState("synced");
} else { } else {
@@ -183,10 +186,8 @@ function initSyncStatusBar(plugin, wsListener) {
} }
}; };
wsListener.on("sync-status", onStatus); const unsubStatus = channel.subscribe("sync-status", onStatus);
// Debounce the transition to "synced" state to avoid flickering
// during rapid delete cycles (Fully synced -> Deleting -> Fully synced).
let syncedTimer = null; let syncedTimer = null;
function deferSynced() { function deferSynced() {
@@ -208,8 +209,9 @@ function initSyncStatusBar(plugin, wsListener) {
} }
} }
// Listen for log lines function onLog(msg) {
const onLog = (payload) => { const payload = msg.payload || {};
if (payload.vaultId !== vaultId) { if (payload.vaultId !== vaultId) {
return; return;
} }
@@ -226,11 +228,8 @@ function initSyncStatusBar(plugin, wsListener) {
updateState("running"); updateState("running");
updatePopoverText(formatPopoverText(activity.prefix, activity.path)); updatePopoverText(formatPopoverText(activity.prefix, activity.path));
} }
}; }
wsListener.on("sync-log", onLog);
// Fetch initial state
api api
.getVaults() .getVaults()
.then((data) => { .then((data) => {
@@ -244,16 +243,16 @@ function initSyncStatusBar(plugin, wsListener) {
}) })
.catch(() => {}); .catch(() => {});
// Poll WebSocket state to detect server disconnect/reconnect // Reflect WebSocket disconnect/reconnect in the indicator.
let wasDisconnected = false; let wasDisconnected = false;
const wsCheckInterval = setInterval(() => { const unsubState = ws.onStateChange((state) => {
const disconnected = !wsListener.isConnected(); const open = state === "open";
if (disconnected && currentStatus === "running") { if (!open && currentStatus === "running") {
updateState("error", "Server connection lost"); updateState("error", "Server connection lost");
wasDisconnected = true; wasDisconnected = true;
} else if (!disconnected && wasDisconnected) { } else if (open && wasDisconnected) {
wasDisconnected = false; wasDisconnected = false;
api api
@@ -268,14 +267,12 @@ function initSyncStatusBar(plugin, wsListener) {
}) })
.catch(() => {}); .catch(() => {});
} }
}, 3000); });
// Return cleanup function
return () => { return () => {
clearInterval(wsCheckInterval);
cancelDeferredSynced(); cancelDeferredSynced();
wsListener.off("sync-status", onStatus); unsubStatus();
wsListener.off("sync-log", onLog); unsubState();
hidePopover(); hidePopover();
}; };
} }

View File

@@ -1,153 +0,0 @@
const CHANNEL = "plugin:headless-sync";
const POLL_INTERVAL = 3000;
const LOG_KEEPALIVE_INTERVAL = 7000;
class WsListener {
constructor() {
this._callbacks = new Map();
this._handler = null;
this._rawHandler = null;
this._pollTimer = null;
this._currentWs = null;
this._logSubInterval = null;
this._logSubVaultId = null;
}
start() {
this._attachToWs();
this._pollTimer = setInterval(() => {
this._attachToWs();
}, POLL_INTERVAL);
}
stop() {
if (this._pollTimer) {
clearInterval(this._pollTimer);
this._pollTimer = null;
}
this.unsubscribeLogs();
this._detachFromWs();
}
isConnected() {
const ws = window.__ignisWs;
return ws && ws.readyState === WebSocket.OPEN;
}
on(type, callback) {
if (!this._callbacks.has(type)) {
this._callbacks.set(type, []);
}
this._callbacks.get(type).push(callback);
}
off(type, callback) {
const list = this._callbacks.get(type);
if (!list) {
return;
}
const idx = list.indexOf(callback);
if (idx !== -1) {
list.splice(idx, 1);
}
}
// Listen for raw WebSocket messages (not channel-filtered).
// Used by core-sync-guard to watch for file changes.
onRaw(callback) {
this._rawHandler = callback;
}
offRaw() {
this._rawHandler = null;
}
send(type, payload) {
const ws = window.__ignisWs;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type, ...payload }));
}
}
// Subscribe to server log broadcasts for a vault.
// Sends the initial subscribe message and keeps the subscription alive.
subscribeLogs(vaultId) {
// If already subscribed to this vault, no-op.
if (this._logSubVaultId === vaultId && this._logSubInterval) {
return;
}
this.unsubscribeLogs();
this._logSubVaultId = vaultId;
this.send("subscribe-logs", { vaultId });
this._logSubInterval = setInterval(() => {
this.send("subscribe-logs", { vaultId });
}, LOG_KEEPALIVE_INTERVAL);
}
// Stop the log subscription keepalive.
unsubscribeLogs() {
if (this._logSubInterval) {
clearInterval(this._logSubInterval);
this._logSubInterval = null;
}
this._logSubVaultId = null;
}
_attachToWs() {
const ws = window.__ignisWs;
if (!ws || ws === this._currentWs) {
return;
}
this._detachFromWs();
this._currentWs = ws;
this._handler = (event) => {
try {
const msg = JSON.parse(event.data);
// Dispatch raw messages (for non-channel listeners like file watchers)
if (this._rawHandler) {
this._rawHandler(msg);
}
if (msg.channel !== CHANNEL) {
return;
}
const listeners = this._callbacks.get(msg.type);
if (listeners) {
for (const cb of listeners) {
cb(msg.payload);
}
}
} catch {}
};
ws.addEventListener("message", this._handler);
}
_detachFromWs() {
if (this._currentWs && this._handler) {
this._currentWs.removeEventListener("message", this._handler);
}
this._currentWs = null;
this._handler = null;
}
}
module.exports = { WsListener };

View File

@@ -4,6 +4,7 @@ const { spawn } = require("child_process");
const { spawnOb, runCommand } = require("./ob-cli"); const { spawnOb, runCommand } = require("./ob-cli");
const MAX_LOG_ENTRIES = 200; const MAX_LOG_ENTRIES = 200;
const MAX_LOG_LINE = 4096;
function killProcess(proc) { function killProcess(proc) {
if (!proc) { if (!proc) {
@@ -151,10 +152,13 @@ class SyncManager {
const lines = data.toString().split("\n"); const lines = data.toString().split("\n");
for (const line of lines) { for (const line of lines) {
if (line.trim()) { const trimmed = line.trim();
this.addLog(state, line.trim());
if (trimmed) {
const capped = trimmed.slice(0, MAX_LOG_LINE);
this.addLog(state, capped);
state.lastActivity = new Date().toISOString(); state.lastActivity = new Date().toISOString();
this.broadcaster.broadcastLog(vaultId, line.trim()); this.broadcaster.broadcastLog(vaultId, capped);
} }
} }
}); });
@@ -302,7 +306,7 @@ class SyncManager {
addLog(state, line) { addLog(state, line) {
state.logs.push({ state.logs.push({
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
line, line: line.slice(0, MAX_LOG_LINE),
}); });
if (state.logs.length > MAX_LOG_ENTRIES) { if (state.logs.length > MAX_LOG_ENTRIES) {

View File

@@ -9,8 +9,12 @@ const fsp = fs.promises;
const path = require("path"); const path = require("path");
const zlib = require("zlib"); const zlib = require("zlib");
const config = require("../config"); const config = require("../config");
const { isBridgePluginInstalled, getIgnisMeta } = require("../bridge-plugin"); const {
const { getDiscoveredPlugins } = require("../plugin-system/manager"); getDiscoveredPlugins,
getVirtualPluginsForVault,
} = require("../plugin-system/manager");
const { getVersion } = require("../version");
const settings = require("../settings");
const router = express.Router(); const router = express.Router();
@@ -76,20 +80,13 @@ async function walkTree(rootPath) {
return { tree, dirMtimes }; return { tree, dirMtimes };
} }
async function buildVaultInfo(vaultId, vaultPath) { function buildVaultInfo(vaultId, vaultPath) {
const pluginInstalled = await isBridgePluginInstalled(vaultPath);
const ignisMeta = await getIgnisMeta(vaultPath);
return { return {
id: vaultId, id: vaultId,
name: vaultId, name: vaultId,
path: vaultPath, path: vaultPath,
platform: process.platform, platform: process.platform,
version: config.obsidianVersion, version: config.obsidianVersion,
ignisPlugin: {
installed: pluginInstalled,
prompted: ignisMeta.pluginPrompted || false,
},
}; };
} }
@@ -134,10 +131,8 @@ async function buildEntry(vaultId) {
} }
const t0 = Date.now(); const t0 = Date.now();
const [vault, { tree, dirMtimes }] = await Promise.all([ const vault = buildVaultInfo(vaultId, vaultPath);
buildVaultInfo(vaultId, vaultPath), const { tree, dirMtimes } = await walkTree(vaultPath);
walkTree(vaultPath),
]);
const response = { const response = {
vault, vault,
@@ -145,6 +140,12 @@ async function buildEntry(vaultId) {
tree, tree,
// In demo mode, hide server-side plugins from the client. // In demo mode, hide server-side plugins from the client.
plugins: config.demoMode ? [] : getDiscoveredPlugins(), plugins: config.demoMode ? [] : getDiscoveredPlugins(),
virtualPlugins: getVirtualPluginsForVault(vaultId, getVersion()),
settings: {
contentCacheBytes: settings.get("contentCacheBytes"),
inputCacheBytes: settings.get("inputCacheBytes"),
inputCacheTtlMs: settings.get("inputCacheTtlMs"),
},
}; };
const jsonBuf = Buffer.from(JSON.stringify(response)); const jsonBuf = Buffer.from(JSON.stringify(response));
@@ -190,6 +191,10 @@ function invalidateVault(vaultId) {
cache.delete(vaultId); cache.delete(vaultId);
} }
function invalidateAll() {
cache.clear();
}
async function warmUp() { async function warmUp() {
const ids = Object.keys(config.vaults); const ids = Object.keys(config.vaults);
@@ -256,4 +261,5 @@ router.get("/", async (req, res) => {
module.exports = router; module.exports = router;
module.exports.invalidateVault = invalidateVault; module.exports.invalidateVault = invalidateVault;
module.exports.invalidateAll = invalidateAll;
module.exports.warmUp = warmUp; module.exports.warmUp = warmUp;

View File

@@ -98,7 +98,7 @@ router.get("/stat", async (req, res) => {
} catch (e) { } catch (e) {
res res
.status(e.code === "ENOENT" ? 404 : 500) .status(e.code === "ENOENT" ? 404 : 500)
.json({ error: e.message, code: e.code }); .json({ error: e.code || "internal", code: e.code });
} }
}); });
@@ -133,7 +133,7 @@ router.get("/readdir", async (req, res) => {
} catch (e) { } catch (e) {
res res
.status(e.code === "ENOENT" ? 404 : 500) .status(e.code === "ENOENT" ? 404 : 500)
.json({ error: e.message, code: e.code }); .json({ error: e.code || "internal", code: e.code });
} }
}); });
@@ -184,7 +184,7 @@ router.get("/readFile", async (req, res) => {
} catch (e) { } catch (e) {
res res
.status(e.code === "ENOENT" ? 404 : 500) .status(e.code === "ENOENT" ? 404 : 500)
.json({ error: e.message, code: e.code }); .json({ error: e.code || "internal", code: e.code });
} }
}); });
@@ -213,7 +213,7 @@ router.post("/writeFile", async (req, res) => {
invalidateBootstrap(req); invalidateBootstrap(req);
res.json({ ok: true, mtime: result.mtime, size: result.size }); res.json({ ok: true, mtime: result.mtime, size: result.size });
} catch (e) { } catch (e) {
res.status(500).json({ error: e.message, code: e.code }); res.status(500).json({ error: e.code || "internal", code: e.code });
} }
}); });
@@ -231,7 +231,7 @@ router.post("/appendFile", async (req, res) => {
invalidateBootstrap(req); invalidateBootstrap(req);
res.json({ ok: true }); res.json({ ok: true });
} catch (e) { } catch (e) {
res.status(500).json({ error: e.message, code: e.code }); res.status(500).json({ error: e.code || "internal", code: e.code });
} }
}); });
@@ -251,7 +251,7 @@ router.post("/mkdir", async (req, res) => {
invalidateBootstrap(req); invalidateBootstrap(req);
res.json({ ok: true }); res.json({ ok: true });
} catch (e) { } catch (e) {
res.status(500).json({ error: e.message, code: e.code }); res.status(500).json({ error: e.code || "internal", code: e.code });
} }
}); });
@@ -280,7 +280,7 @@ router.post("/rename", async (req, res) => {
invalidateBootstrap(req); invalidateBootstrap(req);
res.json({ ok: true }); res.json({ ok: true });
} catch (e) { } catch (e) {
res.status(500).json({ error: e.message, code: e.code }); res.status(500).json({ error: e.code || "internal", code: e.code });
} }
}); });
@@ -309,7 +309,7 @@ router.post("/copyFile", async (req, res) => {
invalidateBootstrap(req); invalidateBootstrap(req);
res.json({ ok: true }); res.json({ ok: true });
} catch (e) { } catch (e) {
res.status(500).json({ error: e.message, code: e.code }); res.status(500).json({ error: e.code || "internal", code: e.code });
} }
}); });
@@ -331,7 +331,7 @@ router.delete("/unlink", async (req, res) => {
// File already gone - desired outcome achieved // File already gone - desired outcome achieved
res.json({ ok: true }); res.json({ ok: true });
} else { } else {
res.status(500).json({ error: e.message, code: e.code }); res.status(500).json({ error: e.code || "internal", code: e.code });
} }
} }
}); });
@@ -350,7 +350,7 @@ router.delete("/rmdir", async (req, res) => {
invalidateBootstrap(req); invalidateBootstrap(req);
res.json({ ok: true }); res.json({ ok: true });
} catch (e) { } catch (e) {
res.status(500).json({ error: e.message, code: e.code }); res.status(500).json({ error: e.code || "internal", code: e.code });
} }
}); });
@@ -370,7 +370,7 @@ router.delete("/rm", async (req, res) => {
invalidateBootstrap(req); invalidateBootstrap(req);
res.json({ ok: true }); res.json({ ok: true });
} catch (e) { } catch (e) {
res.status(500).json({ error: e.message, code: e.code }); res.status(500).json({ error: e.code || "internal", code: e.code });
} }
}); });
@@ -388,23 +388,7 @@ router.get("/access", async (req, res) => {
} catch (e) { } catch (e) {
res res
.status(e.code === "ENOENT" ? 404 : 500) .status(e.code === "ENOENT" ? 404 : 500)
.json({ error: e.message, code: e.code }); .json({ error: e.code || "internal", code: e.code });
}
});
router.get("/realpath", async (req, res) => {
const resolved = guardPath(req, res);
if (!resolved) {
return;
}
try {
const real = await fs.promises.realpath(resolved);
res.json({ path: path.relative(req._vaultRoot, real) });
} catch (e) {
res.status(500).json({ error: e.message, code: e.code });
} }
}); });
@@ -426,7 +410,7 @@ router.post("/utimes", async (req, res) => {
invalidateBootstrap(req); invalidateBootstrap(req);
res.json({ ok: true }); res.json({ ok: true });
} catch (e) { } catch (e) {
res.status(500).json({ error: e.message, code: e.code }); res.status(500).json({ error: e.code || "internal", code: e.code });
} }
}); });
@@ -441,6 +425,11 @@ router.post("/batch-read", async (req, res) => {
const paths = Array.isArray(req.body?.paths) ? req.body.paths : []; const paths = Array.isArray(req.body?.paths) ? req.body.paths : [];
// The indexer prefetcher (the only caller) batches at 50, so a much larger list is not legitimate.
if (paths.length > 1000) {
return res.status(400).json({ error: "too many paths in batch-read" });
}
if (paths.length === 0) { if (paths.length === 0) {
return res.json({ files: {} }); return res.json({ files: {} });
} }
@@ -531,7 +520,7 @@ router.get("/tree", async (req, res) => {
res.json(tree); res.json(tree);
} catch (e) { } catch (e) {
res.status(500).json({ error: e.message, code: e.code }); res.status(500).json({ error: e.code || "internal", code: e.code });
} }
}); });
@@ -561,7 +550,7 @@ router.get("/download", async (req, res) => {
} catch (e) { } catch (e) {
res res
.status(e.code === "ENOENT" ? 404 : 500) .status(e.code === "ENOENT" ? 404 : 500)
.json({ error: e.message, code: e.code }); .json({ error: e.code || "internal", code: e.code });
} }
}); });
@@ -599,7 +588,7 @@ router.get("/download-zip", async (req, res) => {
} catch (e) { } catch (e) {
res res
.status(e.code === "ENOENT" ? 404 : 500) .status(e.code === "ENOENT" ? 404 : 500)
.json({ error: e.message, code: e.code }); .json({ error: e.code || "internal", code: e.code });
} }
}); });

View File

@@ -1,9 +1,349 @@
const express = require("express"); const express = require("express");
const dns = require("dns");
const net = require("net");
const http = require("http");
const https = require("https");
const zlib = require("zlib");
const settings = require("../settings");
const router = express.Router(); const router = express.Router();
// POST /api/proxy - forward a request to an external URL to bypass CORS const MAX_RESPONSE_BYTES = 50 * 1024 * 1024;
// Used by the requestUrl shim for plugin installation, etc. const MAX_REDIRECTS = 5;
const REDIRECT_CODES = new Set([301, 302, 303, 307, 308]);
function isPrivateIp(ip) {
const type = net.isIP(ip);
if (type === 4) {
const o = ip.split(".").map(Number);
return (
o[0] === 0 ||
o[0] === 10 ||
o[0] === 127 ||
(o[0] === 169 && o[1] === 254) ||
(o[0] === 172 && o[1] >= 16 && o[1] <= 31) ||
(o[0] === 192 && o[1] === 168) ||
(o[0] === 100 && o[1] >= 64 && o[1] <= 127)
);
}
if (type === 6) {
const a = ip.toLowerCase();
if (a === "::1" || a === "::") {
return true;
}
if (/^fe[89ab]/.test(a) || a.startsWith("fc") || a.startsWith("fd")) {
return true;
}
const mapped = a.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
if (mapped) {
return isPrivateIp(mapped[1]);
}
return false;
}
return false;
}
function ipv4ToInt(ip) {
return ip
.split(".")
.reduce((acc, oct) => ((acc << 8) + Number(oct)) >>> 0, 0);
}
// Parse PROXY_ALLOW_PRIVATE_HOSTS into matchers.
// Exact IPs (v4 and v6) and IPv4 CIDRs are supported; IPv6 CIDR and malformed entries are ignored.
function buildAllowList(entries) {
const exact = new Set();
const cidrV4 = [];
for (const entry of entries) {
const slash = entry.indexOf("/");
if (slash === -1) {
if (net.isIP(entry)) {
exact.add(entry);
} else {
console.warn(
"[proxy] ignoring invalid PROXY_ALLOW_PRIVATE_HOSTS entry:",
entry,
);
}
continue;
}
const base = entry.slice(0, slash);
const prefix = Number(entry.slice(slash + 1));
if (
net.isIP(base) === 4 &&
Number.isInteger(prefix) &&
prefix >= 0 &&
prefix <= 32
) {
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
cidrV4.push({ network: (ipv4ToInt(base) & mask) >>> 0, mask });
} else {
console.warn(
"[proxy] ignoring unsupported PROXY_ALLOW_PRIVATE_HOSTS entry:",
entry,
);
}
}
return { exact, cidrV4 };
}
function allowsAddress(allow, ip) {
if (allow.exact.has(ip)) {
return true;
}
if (net.isIP(ip) === 4) {
const value = ipv4ToInt(ip);
for (const { network, mask } of allow.cidrV4) {
if ((value & mask) >>> 0 === network) {
return true;
}
}
}
return false;
}
const privateAllowList = buildAllowList(settings.get("proxyAllowPrivate"));
// A public address always passes; a private one passes only when listed it in PROXY_ALLOW_PRIVATE_HOSTS.
function addressAllowed(ip) {
return !isPrivateIp(ip) || allowsAddress(privateAllowList, ip);
}
function httpError(status, message) {
const e = new Error(message);
e.statusCode = status;
return e;
}
function safeLookup(hostname, options, callback) {
dns.lookup(hostname, { ...options, all: true }, (err, addresses) => {
if (err) {
callback(err);
return;
}
if (!addresses.length) {
callback(httpError(502, "DNS resolution failed"));
return;
}
for (const a of addresses) {
if (!addressAllowed(a.address)) {
callback(httpError(403, "Host resolves to a private address"));
return;
}
}
if (options && options.all) {
callback(null, addresses);
return;
}
callback(null, addresses[0].address, addresses[0].family);
});
}
// Reject non-http(s) schemes and hosts that resolve to a disallowed address.
async function assertPublicUrl(urlStr) {
let parsed;
try {
parsed = new URL(urlStr);
} catch {
throw httpError(400, "Invalid URL");
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw httpError(400, "Only http and https URLs are allowed");
}
const host = parsed.hostname;
if (net.isIP(host)) {
if (!addressAllowed(host)) {
throw httpError(403, "Host not allowed");
}
return;
}
let addrs;
try {
addrs = await dns.promises.lookup(host, { all: true });
} catch {
throw httpError(502, "DNS resolution failed");
}
for (const a of addrs) {
if (!addressAllowed(a.address)) {
throw httpError(403, "Host resolves to a private address");
}
}
}
function sameOrigin(a, b) {
return a.protocol === b.protocol && a.host === b.host;
}
function requestOnce(targetUrl, method, headers, body) {
return new Promise((resolve, reject) => {
const mod = targetUrl.protocol === "https:" ? https : http;
const req = mod.request(
targetUrl,
{ method, headers, lookup: safeLookup },
resolve,
);
req.on("error", reject);
if (body && method !== "GET" && method !== "HEAD") {
req.write(body);
}
req.end();
});
}
// Follow redirects manually so every hop runs through safeLookup and is re-checked.
async function proxyRequest({ url, method, headers, body }) {
let current = new URL(url);
let currentMethod = method;
let currentHeaders = headers;
let currentBody = body;
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
if (current.protocol !== "http:" && current.protocol !== "https:") {
throw httpError(400, "Only http and https URLs are allowed");
}
// An IP-literal host skips DNS, so safeLookup never runs for it; check it here.
if (net.isIP(current.hostname) && !addressAllowed(current.hostname)) {
throw httpError(403, "Host not allowed");
}
const res = await requestOnce(
current,
currentMethod,
currentHeaders,
currentBody,
);
if (!REDIRECT_CODES.has(res.statusCode) || !res.headers.location) {
return res;
}
res.resume();
const next = new URL(res.headers.location, current);
// The caller did not choose the redirect target, so credentials do not cross origins.
if (!sameOrigin(current, next)) {
currentHeaders = { ...currentHeaders };
for (const key of Object.keys(currentHeaders)) {
const lower = key.toLowerCase();
if (lower === "authorization" || lower === "cookie") {
delete currentHeaders[key];
}
}
}
// 301/302/303 turn a non-GET follow-up into a GET; 307/308 preserve method and body.
if (res.statusCode !== 307 && res.statusCode !== 308) {
if (currentMethod !== "GET" && currentMethod !== "HEAD") {
currentMethod = "GET";
currentBody = null;
}
}
current = next;
}
throw httpError(508, "Too many redirects");
}
function readBody(res, maxBytes) {
return new Promise((resolve, reject) => {
const encoding = (res.headers["content-encoding"] || "").toLowerCase();
let stream = res;
let decompressor = null;
if (encoding === "gzip" || encoding === "x-gzip") {
decompressor = zlib.createGunzip();
} else if (encoding === "deflate") {
decompressor = zlib.createInflate();
} else if (encoding === "br") {
decompressor = zlib.createBrotliDecompress();
}
if (decompressor) {
stream = res.pipe(decompressor);
}
const chunks = [];
let total = 0;
let settled = false;
function fail(err) {
if (settled) {
return;
}
settled = true;
res.destroy();
if (decompressor) {
decompressor.destroy();
}
reject(err);
}
stream.on("data", (chunk) => {
total += chunk.length;
if (total > maxBytes) {
fail(httpError(413, "Upstream response too large"));
return;
}
chunks.push(chunk);
});
stream.on("end", () => {
if (settled) {
return;
}
settled = true;
resolve(Buffer.concat(chunks));
});
stream.on("error", (e) => fail(httpError(502, e.message)));
res.on("error", (e) => fail(httpError(502, e.message)));
});
}
// POST /api/proxy - forward a request to an external URL to bypass CORS.
router.post("/", async (req, res) => { router.post("/", async (req, res) => {
const { url, method, headers, body, binary } = req.body; const { url, method, headers, body, binary } = req.body;
@@ -11,25 +351,53 @@ router.post("/", async (req, res) => {
return res.status(400).json({ error: "Missing url" }); return res.status(400).json({ error: "Missing url" });
} }
const proxyMode = settings.get("proxyMode");
if (proxyMode === "disabled") {
return res.status(403).json({ error: "Proxy is disabled" });
}
try { try {
const fetchOpts = { await assertPublicUrl(url);
} catch (e) {
return res.status(e.statusCode || 400).json({ error: e.message });
}
if (proxyMode === "allowlist") {
const allowlist = settings.get("proxyAllowlist");
const host = new URL(url).hostname;
if (!allowlist.includes(host)) {
return res
.status(403)
.json({ error: `Host not in proxy allowlist: ${host}` });
}
}
try {
const reqBody =
binary && typeof body === "string" ? Buffer.from(body, "base64") : body;
const upstream = await proxyRequest({
url,
method: method || "GET", method: method || "GET",
headers: headers || {}, headers: headers || {},
}; body: reqBody,
});
if (body && method !== "GET" && method !== "HEAD") { const declaredLength = Number(upstream.headers["content-length"]);
if (binary && typeof body === "string") {
fetchOpts.body = Buffer.from(body, "base64"); if (
} else { Number.isFinite(declaredLength) &&
fetchOpts.body = body; declaredLength > MAX_RESPONSE_BYTES
} ) {
upstream.destroy();
return res.status(413).json({ error: "Upstream response too large" });
} }
const upstream = await fetch(url, fetchOpts); const respBody = await readBody(upstream, MAX_RESPONSE_BYTES);
const respBody = Buffer.from(await upstream.arrayBuffer());
// Forward response headers, stripping hop-by-hop / encoding headers // Strip hop-by-hop and encoding headers; the body is already decompressed.
// since the body is already decompressed by Node's fetch
const skipHeaders = new Set([ const skipHeaders = new Set([
"content-encoding", "content-encoding",
"transfer-encoding", "transfer-encoding",
@@ -37,20 +405,25 @@ router.post("/", async (req, res) => {
"connection", "connection",
]); ]);
const respHeaders = {}; const respHeaders = {};
upstream.headers.forEach((val, key) => {
if (!skipHeaders.has(key)) { for (const [key, val] of Object.entries(upstream.headers)) {
if (!skipHeaders.has(key.toLowerCase())) {
respHeaders[key] = val; respHeaders[key] = val;
} }
}); }
res.json({ res.json({
status: upstream.status, status: upstream.statusCode,
headers: respHeaders, headers: respHeaders,
body: respBody.toString("base64"), body: respBody.toString("base64"),
}); });
} catch (e) { } catch (e) {
res.status(502).json({ error: e.message }); res.status(e.statusCode || 502).json({ error: e.message });
} }
}); });
module.exports = router; module.exports = router;
module.exports.isPrivateIp = isPrivateIp;
module.exports.proxyRequest = proxyRequest;
module.exports.buildAllowList = buildAllowList;
module.exports.allowsAddress = allowsAddress;

View File

@@ -0,0 +1,98 @@
import { describe, it, expect } from "vitest";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const { isPrivateIp, proxyRequest, buildAllowList, allowsAddress } =
require("./proxy.js");
describe("isPrivateIp", () => {
it("flags private and link-local IPv4", () => {
for (const ip of [
"0.0.0.0",
"10.0.0.1",
"127.0.0.1",
"169.254.1.1",
"172.16.0.1",
"172.31.255.255",
"192.168.1.1",
"100.64.0.1",
"100.127.255.255",
]) {
expect(isPrivateIp(ip), ip).toBe(true);
}
});
it("allows public IPv4, including range boundaries", () => {
for (const ip of [
"8.8.8.8",
"1.1.1.1",
"172.15.255.255",
"172.32.0.0",
"100.63.255.255",
"100.128.0.0",
"169.253.0.0",
"169.255.0.0",
"11.0.0.1",
"192.169.0.1",
]) {
expect(isPrivateIp(ip), ip).toBe(false);
}
});
it("flags private and link-local IPv6", () => {
for (const ip of ["::1", "::", "fc00::1", "fd12::1", "fe80::1", "feaf::1"]) {
expect(isPrivateIp(ip), ip).toBe(true);
}
});
it("allows public IPv6", () => {
for (const ip of ["2606:4700:4700::1111", "2001:4860:4860::8888"]) {
expect(isPrivateIp(ip), ip).toBe(false);
}
});
it("classifies IPv4-mapped IPv6 by the embedded address", () => {
expect(isPrivateIp("::ffff:127.0.0.1")).toBe(true);
expect(isPrivateIp("::ffff:8.8.8.8")).toBe(false);
});
it("returns false for non-IP input", () => {
expect(isPrivateIp("not-an-ip")).toBe(false);
expect(isPrivateIp("")).toBe(false);
});
});
describe("proxyRequest guard", () => {
it("rejects a hostname that resolves to a private address", async () => {
await expect(
proxyRequest({ url: "http://localhost/", method: "GET", headers: {} }),
).rejects.toMatchObject({ statusCode: 403 });
});
it("rejects a private IP literal (no DNS lookup runs for literals)", async () => {
await expect(
proxyRequest({ url: "http://127.0.0.1/", method: "GET", headers: {} }),
).rejects.toMatchObject({ statusCode: 403 });
});
});
describe("proxy private-host allow list", () => {
it("allows exact IPs and IPv4 CIDRs, rejects everything else", () => {
const allow = buildAllowList(["192.168.0.0/16", "10.1.2.3", "::1"]);
expect(allowsAddress(allow, "192.168.1.5")).toBe(true);
expect(allowsAddress(allow, "192.169.0.1")).toBe(false);
expect(allowsAddress(allow, "10.1.2.3")).toBe(true);
expect(allowsAddress(allow, "10.1.2.4")).toBe(false);
expect(allowsAddress(allow, "::1")).toBe(true);
expect(allowsAddress(allow, "8.8.8.8")).toBe(false);
});
it("ignores IPv6 CIDR and malformed entries", () => {
const allow = buildAllowList(["fd00::/8", "garbage", "192.168.0.0/33"]);
expect(allow.exact.size).toBe(0);
expect(allow.cidrV4.length).toBe(0);
expect(allowsAddress(allow, "fd00::1")).toBe(false);
});
});

View File

@@ -0,0 +1,97 @@
const express = require("express");
const { writeCoalescer } = require("@ignis/server-core");
const settings = require("../settings");
const bootstrapRoutes = require("./bootstrap");
const router = express.Router();
const NUMBER_KEYS = [
"contentCacheBytes",
"inputCacheBytes",
"inputCacheTtlMs",
"writeCoalesceMs",
"maxBodyBytes",
];
const LIST_KEYS = ["proxyAllowlist"];
function validate(body) {
const clean = {};
if (body.proxyMode !== undefined) {
if (!settings.PROXY_MODES.includes(body.proxyMode)) {
throw new Error(
`proxyMode must be one of: ${settings.PROXY_MODES.join(", ")}`,
);
}
clean.proxyMode = body.proxyMode;
}
for (const key of NUMBER_KEYS) {
if (body[key] === undefined) {
continue;
}
const n = body[key];
if (!Number.isInteger(n) || n < 0) {
throw new Error(`${key} must be a non-negative integer`);
}
if (key === "maxBodyBytes" && (n < 1 || n > settings.MAX_BODY_BACKSTOP)) {
throw new Error(
`maxBodyBytes must be between 1 and ${settings.MAX_BODY_BACKSTOP}`,
);
}
clean[key] = n;
}
for (const key of LIST_KEYS) {
if (body[key] === undefined) {
continue;
}
const list = body[key];
if (
!Array.isArray(list) ||
list.some((v) => typeof v !== "string" || !v.trim())
) {
throw new Error(`${key} must be an array of non-empty strings`);
}
clean[key] = list.map((v) => v.trim());
}
return clean;
}
function applySettings(effective) {
writeCoalescer.configure({ writeCoalesceMs: effective.writeCoalesceMs });
}
router.get("/", (req, res) => {
res.json(settings.getAll());
});
router.post("/", (req, res) => {
let clean;
try {
clean = validate(req.body || {});
} catch (e) {
return res.status(400).json({ error: e.message });
}
const effective = settings.update(clean);
applySettings(effective);
// Cache sizes ride in the bootstrap response; clear it so the next page load picks up new values.
bootstrapRoutes.invalidateAll();
res.json(effective);
});
module.exports = router;
module.exports.validate = validate;

View File

@@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const { validate } = require("./settings.js");
const settings = require("../settings.js");
describe("settings validate", () => {
it("rejects an unknown proxy mode", () => {
expect(() => validate({ proxyMode: "bogus" })).toThrow();
});
it("rejects negative or non-integer numbers", () => {
expect(() => validate({ contentCacheBytes: -1 })).toThrow();
expect(() => validate({ contentCacheBytes: 1.5 })).toThrow();
expect(() => validate({ contentCacheBytes: "5" })).toThrow();
});
it("enforces maxBodyBytes bounds", () => {
expect(() => validate({ maxBodyBytes: 0 })).toThrow();
expect(() =>
validate({ maxBodyBytes: settings.MAX_BODY_BACKSTOP + 1 }),
).toThrow();
expect(validate({ maxBodyBytes: 1048576 })).toEqual({
maxBodyBytes: 1048576,
});
});
it("trims a valid proxy allowlist", () => {
expect(
validate({ proxyAllowlist: [" api.example.com ", "github.com"] }),
).toEqual({ proxyAllowlist: ["api.example.com", "github.com"] });
});
it("rejects a non-array allowlist or an empty entry", () => {
expect(() => validate({ proxyAllowlist: "x" })).toThrow();
expect(() => validate({ proxyAllowlist: ["ok", " "] })).toThrow();
});
it("ignores wsOrigins, which is env-only", () => {
expect(validate({ wsOrigins: ["https://evil.example.com"] })).toEqual({});
});
it("ignores unknown keys", () => {
expect(validate({ bogusKey: 1 })).toEqual({});
});
});

View File

@@ -2,16 +2,29 @@ const express = require("express");
const fs = require("fs"); const fs = require("fs");
const config = require("../config"); const config = require("../config");
const path = require("path"); const path = require("path");
const {
isBridgePluginInstalled,
getIgnisMeta,
setIgnisMeta,
installBridgePlugin,
} = require("../bridge-plugin");
const bootstrapRoutes = require("./bootstrap"); const bootstrapRoutes = require("./bootstrap");
const router = express.Router(); const router = express.Router();
// Vault names become directories under VAULT_ROOT; reject traversal, hidden, and reserved-device names.
const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
function isValidVaultName(name) {
if (typeof name !== "string" || name.length === 0 || name.length > 255) {
return false;
}
if (/[\/\\:*?"<>|]/.test(name)) {
return false;
}
if (name.startsWith(".")) {
return false;
}
return !WINDOWS_RESERVED.test(name);
}
// GET /api/vault/list - returns all discovered vaults (re-scans on each call) // GET /api/vault/list - returns all discovered vaults (re-scans on each call)
router.get("/list", (req, res) => { router.get("/list", (req, res) => {
config.refreshVaults(); config.refreshVaults();
@@ -34,19 +47,12 @@ router.get("/info", async (req, res) => {
return res.status(404).json({ error: "Vault not found", id: vaultId }); return res.status(404).json({ error: "Vault not found", id: vaultId });
} }
const pluginInstalled = await isBridgePluginInstalled(vaultPath);
const ignisMeta = await getIgnisMeta(vaultPath);
res.json({ res.json({
id: vaultId, id: vaultId,
name: vaultId, name: vaultId,
path: vaultPath, path: vaultPath,
platform: process.platform, platform: process.platform,
version: config.obsidianVersion, version: config.obsidianVersion,
ignisPlugin: {
installed: pluginInstalled,
prompted: ignisMeta.pluginPrompted || false,
},
}); });
}); });
@@ -54,7 +60,7 @@ router.get("/info", async (req, res) => {
router.post("/create", async (req, res) => { router.post("/create", async (req, res) => {
const name = req.body?.name; const name = req.body?.name;
if (!name || /[\/\\:*?"<>|]/.test(name)) { if (!isValidVaultName(name)) {
return res.status(400).json({ error: "Invalid vault name" }); return res.status(400).json({ error: "Invalid vault name" });
} }
@@ -66,8 +72,6 @@ router.post("/create", async (req, res) => {
recursive: false, recursive: false,
}); });
await installBridgePlugin(vaultPath);
config.refreshVaults(); config.refreshVaults();
bootstrapRoutes.invalidateVault(name); bootstrapRoutes.invalidateVault(name);
@@ -77,7 +81,7 @@ router.post("/create", async (req, res) => {
return res.status(409).json({ error: "Vault already exists" }); return res.status(409).json({ error: "Vault already exists" });
} }
res.status(500).json({ error: e.message, code: e.code }); res.status(500).json({ error: e.code || "internal", code: e.code });
} }
}); });
@@ -86,7 +90,7 @@ router.post("/rename", async (req, res) => {
const vaultId = req.body?.vault; const vaultId = req.body?.vault;
const newName = req.body?.name; const newName = req.body?.name;
if (!newName || /[\/\\:*?"<>|]/.test(newName)) { if (!isValidVaultName(newName)) {
return res.status(400).json({ error: "Invalid vault name" }); return res.status(400).json({ error: "Invalid vault name" });
} }
@@ -113,7 +117,7 @@ router.post("/rename", async (req, res) => {
.json({ error: "A vault with that name already exists" }); .json({ error: "A vault with that name already exists" });
} }
res.status(500).json({ error: e.message, code: e.code }); res.status(500).json({ error: e.code || "internal", code: e.code });
} }
}); });
@@ -134,45 +138,7 @@ router.delete("/remove", async (req, res) => {
res.json({ ok: true }); res.json({ ok: true });
} catch (e) { } catch (e) {
res.status(500).json({ error: e.message, code: e.code }); res.status(500).json({ error: e.code || "internal", code: e.code });
}
});
// POST /api/vault/install-plugin { vault, dismiss } - install plugin or mark as prompted
router.post("/install-plugin", async (req, res) => {
const vaultId = req.body?.vault;
const dismiss = req.body?.dismiss || false;
if (!vaultId) {
return res.status(400).json({ error: "Missing vault ID" });
}
const vaultPath = config.getVaultPath(vaultId);
if (!vaultPath) {
return res.status(404).json({ error: "Vault not found" });
}
try {
const meta = await getIgnisMeta(vaultPath);
if (dismiss) {
// User clicked "Don't Ask Again" or "Not Now"
meta.pluginPrompted = true;
await setIgnisMeta(vaultPath, meta);
return res.json({ ok: true, prompted: true });
} else {
// User wants to install the plugin
const installed = await installBridgePlugin(vaultPath);
meta.pluginPrompted = true;
await setIgnisMeta(vaultPath, meta);
return res.json({ ok: true, installed, prompted: true });
}
} catch (e) {
res.status(500).json({ error: e.message, code: e.code });
} }
}); });

View File

@@ -1,15 +1,14 @@
const express = require("express"); const express = require("express");
const { getVersion } = require("../version"); const { getSemver, getBuild } = require("../version");
const config = require("../config"); const config = require("../config");
const router = express.Router(); const router = express.Router();
// `version` is the display-friendly SemVer. `build` is the per-build stamp for cache-bust.
router.get("/", (req, res) => { router.get("/", (req, res) => {
const pkg = require("../../package.json");
res.json({ res.json({
version: getVersion(), version: getSemver(),
semver: pkg.version, build: getBuild(),
obsidianVersion: config.obsidianVersion, obsidianVersion: config.obsidianVersion,
}); });
}); });

View File

@@ -0,0 +1,120 @@
const fs = require("fs");
const path = require("path");
const config = require("./config");
// Runtime server settings set through UI.
const SETTINGS_FILE = path.join(config.dataRoot, "server-settings.json");
const DEFAULTS = {
contentCacheBytes: 50 * 1024 * 1024,
inputCacheBytes: 200 * 1024 * 1024,
inputCacheTtlMs: 5 * 60 * 1000,
writeCoalesceMs: 0,
maxBodyBytes: 50 * 1024 * 1024,
// "any" reaches any public host, "allowlist" restricts to proxyAllowlist, "disabled" blocks all proxying.
proxyMode: "any",
// Empty allows any public host.
proxyAllowlist: [],
wsOrigins: [],
// Private IPs/CIDRs the proxy may reach despite the SSRF guard.
proxyAllowPrivate: [],
};
const PROXY_MODES = ["any", "allowlist", "disabled"];
const KEYS = Object.keys(DEFAULTS);
// Env vars only; never persisted to the settings file.
const ENV_ONLY_KEYS = ["wsOrigins", "proxyAllowPrivate"];
// Hard ceiling for request bodies.
const MAX_BODY_BACKSTOP = 500 * 1024 * 1024;
function parseList(raw) {
return raw
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
function fromEnv() {
const env = {};
if (process.env.WRITE_COALESCE_MS !== undefined) {
const n = parseInt(process.env.WRITE_COALESCE_MS, 10);
if (Number.isFinite(n)) {
env.writeCoalesceMs = n;
}
}
if (process.env.WS_ORIGINS) {
env.wsOrigins = parseList(process.env.WS_ORIGINS);
}
if (process.env.PROXY_ALLOW_PRIVATE_HOSTS) {
env.proxyAllowPrivate = parseList(process.env.PROXY_ALLOW_PRIVATE_HOSTS);
}
return env;
}
const envOverrides = fromEnv();
function loadFile() {
try {
const parsed = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8"));
// Keep only known keys so a stale or hand-edited file can't inject junk.
const clean = {};
for (const key of KEYS) {
if (ENV_ONLY_KEYS.includes(key)) {
continue;
}
if (parsed[key] !== undefined) {
clean[key] = parsed[key];
}
}
return clean;
} catch {
return {};
}
}
let fileOverrides = loadFile();
function getAll() {
return { ...DEFAULTS, ...envOverrides, ...fileOverrides };
}
function get(key) {
return getAll()[key];
}
// Merge validated changes into the persisted file and return the new effective settings.
function update(partial) {
for (const [key, value] of Object.entries(partial)) {
if (KEYS.includes(key) && !ENV_ONLY_KEYS.includes(key)) {
fileOverrides[key] = value;
}
}
fs.mkdirSync(path.dirname(SETTINGS_FILE), { recursive: true });
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(fileOverrides, null, 2));
return getAll();
}
module.exports = {
DEFAULTS,
KEYS,
ENV_ONLY_KEYS,
PROXY_MODES,
MAX_BODY_BACKSTOP,
getAll,
get,
update,
};

View File

@@ -1,23 +1,51 @@
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const { execSync } = require("child_process");
function getVersion() { let cached = null;
const pkg = JSON.parse(
fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf-8"),
);
const semver = pkg.version;
let hash; function load() {
try { if (cached) {
hash = execSync("git rev-parse --short=7 HEAD", { return cached;
encoding: "utf-8",
}).trim();
} catch (e) {
hash = Date.now().toString(36).slice(-7);
} }
return `${semver}-${hash}`; // Production: root build.js writes this next to us.
try {
cached = JSON.parse(
fs.readFileSync(path.join(__dirname, "build-info.json"), "utf-8"),
);
return cached;
} catch {}
// Local dev fallback. Read root package.json.
try {
const pkg = JSON.parse(
fs.readFileSync(
path.join(__dirname, "..", "..", "..", "package.json"),
"utf-8",
),
);
cached = {
semver: pkg.version,
build: "dev",
version: `${pkg.version}-dev`,
};
return cached;
} catch {}
cached = { semver: "0.0.0", build: "unknown", version: "0.0.0-unknown" };
return cached;
} }
module.exports = { getVersion }; function getVersion() {
return load().version;
}
function getSemver() {
return load().semver;
}
function getBuild() {
return load().build;
}
module.exports = { getVersion, getSemver, getBuild };

View File

@@ -1,6 +1,38 @@
const esbuild = require("esbuild"); const esbuild = require("esbuild");
const fs = require("fs");
const path = require("path"); const path = require("path");
const headlessSyncDir = path.join(
__dirname,
"apps",
"ignis-server",
"server",
"plugins",
"headless-sync",
"obsidian",
);
// Compute version info once and share across per-package builds.
const { version: semver } = require("./package.json");
const build = process.env.IGNIS_BUILD || Date.now().toString(36).slice(-7);
const version = `${semver}+${build}`;
const buildInfoPath = path.join(
__dirname,
"apps",
"ignis-server",
"server",
"build-info.json",
);
fs.writeFileSync(
buildInfoPath,
JSON.stringify({ semver, build, version }, null, 2),
);
// Used by packages.
process.env.IGNIS_BUILD_RESOLVED = build;
Promise.all([ Promise.all([
// Build shim-loader.js (delegated to packages/shim) // Build shim-loader.js (delegated to packages/shim)
require("./packages/shim/build.js"), require("./packages/shim/build.js"),
@@ -8,39 +40,22 @@ Promise.all([
// Build ignis-ui.js (delegated to packages/ui) // Build ignis-ui.js (delegated to packages/ui)
require("./packages/ui/build.js"), require("./packages/ui/build.js"),
// Build ignis-bridge plugin (delegated to packages/bridge-plugin)
require("./packages/bridge-plugin/build.js"),
// Build headless-sync bundled plugin // Build headless-sync bundled plugin
esbuild.build({ esbuild
entryPoints: [ .build({
path.join( entryPoints: [path.join(headlessSyncDir, "src", "main.js")],
__dirname, bundle: true,
"apps", outfile: path.join(headlessSyncDir, "dist", "ignis-headless-sync.js"),
"ignis-server", format: "cjs",
"server", platform: "browser",
"plugins", target: ["chrome90"],
"headless-sync", external: ["obsidian", "fs"],
"plugin", logLevel: "info",
"src", })
"main.js", .then(() => {
), fs.copyFileSync(
], path.join(headlessSyncDir, "styles.css"),
bundle: true, path.join(headlessSyncDir, "dist", "ignis-headless-sync.css"),
outfile: path.join( );
__dirname, }),
"apps",
"ignis-server",
"server",
"plugins",
"headless-sync",
"plugin",
"main.js",
),
format: "cjs",
platform: "browser",
target: ["chrome90"],
external: ["obsidian", "fs"], //using fs shim
logLevel: "info",
}),
]).catch(() => process.exit(1)); ]).catch(() => process.exit(1));

View File

@@ -13,12 +13,13 @@ Ignis runs Obsidian in a browser by replacing its Electron backend with a shim l
- [IPC](#ipc) - [IPC](#ipc)
- [Cross-origin requests](#cross-origin-requests) - [Cross-origin requests](#cross-origin-requests)
- [Workspaces in browser tabs](#workspaces-in-browser-tabs) - [Workspaces in browser tabs](#workspaces-in-browser-tabs)
- [Bridge](#bridge)
- [Vaults](#vaults) - [Vaults](#vaults)
- [Server](#server) - [Server](#server)
- [Plugins](#plugins) - [Plugins](#plugins)
- [Obsidian Plugins](#obsidian-plugins) - [Obsidian Plugins](#obsidian-plugins)
- [Bridge Plugin (ignis-bridge)](#bridge-plugin-ignis-bridge)
- [Ignis Plugins](#ignis-plugins) - [Ignis Plugins](#ignis-plugins)
- [Virtual Plugins](#virtual-plugins)
- [Demo mode](#demo-mode) - [Demo mode](#demo-mode)
## Overview ## Overview
@@ -31,13 +32,13 @@ Browser Server
│ Shim layer │ <────> │ /api/vault/* │ │ Shim layer │ <────> │ /api/vault/* │
│ fs, electron, etc. │ WS │ /api/plugins/* │ │ fs, electron, etc. │ WS │ /api/plugins/* │
│ ↕ │ <────> │ /api/ext/:plugin/* │ │ ↕ │ <────> │ /api/ext/:plugin/* │
│ Bridge plugin │ │ Ignis plugins │ │ Bridge │ │ Ignis plugins │
└──────────────────────┘ └──────────────────────┘ └──────────────────────┘ └──────────────────────┘
Filesystem (vaults/) Filesystem (vaults/)
``` ```
The shim layer makes Obsidian think it's running in Electron. The bridge plugin adds Ignis-specific features inside Obsidian. The shim layer makes Obsidian think it's running in Electron. The bridge adds Ignis-specific features inside Obsidian.
## Shim Layer ## Shim Layer
@@ -68,6 +69,9 @@ Immediately after the bootstrap response is applied, the client kicks off a batc
| `net` | All classes/functions throw. | | `net` | All classes/functions throw. |
| `http` / `https` | Module is importable but `request()`/`get()` emit an `error` event; `createServer` throws. Plugins should use `requestUrl` or `fetch` (the shim routes cross-origin `fetch` through the server proxy). | | `http` / `https` | Module is importable but `request()`/`get()` emit an `error` event; `createServer` throws. Plugins should use `requestUrl` or `fetch` (the shim routes cross-origin `fetch` through the server proxy). |
| `buffer` | Aliased to the browser `Buffer` polyfill set up by the loader. | | `buffer` | Aliased to the browser `Buffer` polyfill set up by the loader. |
| `assert` | Standard assertions: `assert`, `equal`, `strictEqual`, `deepEqual`, `throws`. |
| `constants` | File access and mode constants (`F_OK`, `O_RDONLY`, `S_IFMT`, etc.) for the reported Linux platform. |
| `stream` | Base classes (`Stream`, `Readable`, `Writable`, `Duplex`, `Transform`, `PassThrough`) extending EventEmitter. Data-flow methods warn and do nothing. |
Unknown modules return an empty proxy and log a warning. The `node:` prefix is stripped. The shim exposes two console helpers, `window.__shimLog()` (everything that has been accessed) and `window.__shimMisses()` (accessed-but-missing properties). Unknown modules return an empty proxy and log a warning. The `node:` prefix is stripped. The shim exposes two console helpers, `window.__shimLog()` (everything that has been accessed) and `window.__shimMisses()` (accessed-but-missing properties).
@@ -77,7 +81,7 @@ 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`). 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 (`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. 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 (`WRITE_COALESCE_MS`, default `0` which disables coalescing) 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.
### Transforms ### Transforms
@@ -101,7 +105,7 @@ Obsidian on the desktop can make arbitrary cross-origin HTTP requests because it
The shim handles this transparently. `window.fetch` and `window.requestUrl` are intercepted. Same-origin requests pass through unchanged. Cross-origin requests are POSTed to `/api/proxy`, which performs the outbound call from the server with headers that mimic Obsidian's desktop runtime: `Origin: app://obsidian.md` and the browser's own User-Agent. The response body is returned base64-encoded so binary content survives the JSON round-trip; the shim decodes it and hands the caller a normal `Response` or `requestUrl` result. The shim handles this transparently. `window.fetch` and `window.requestUrl` are intercepted. Same-origin requests pass through unchanged. Cross-origin requests are POSTed to `/api/proxy`, which performs the outbound call from the server with headers that mimic Obsidian's desktop runtime: `Origin: app://obsidian.md` and the browser's own User-Agent. The response body is returned base64-encoded so binary content survives the JSON round-trip; the shim decodes it and hands the caller a normal `Response` or `requestUrl` result.
The proxy itself is intentionally generic. It forwards method, headers, and body verbatim and returns whatever the upstream sent. In demo mode, an allowlist restricts the hostname to a known-safe set; in normal self-hosted mode there's no restriction, which is one of the reasons the server needs to be behind authentication when exposed to the internet. The proxy itself is intentionally generic. It forwards method, headers, and body verbatim and returns whatever the upstream sent. It always rejects requests whose hostname resolves to a private, loopback, or link-local address (SSRF guard). Outbound access is governed by `proxyMode`: `any` (the default) reaches any public host, `allowlist` restricts to a configured host list, and `disabled` blocks all proxying; demo mode pins it to `allowlist`. Under the default `any`, the proxy is an open relay to public hosts, which is one of the reasons the server needs to be behind authentication when exposed to the internet.
### Workspaces in browser tabs ### Workspaces in browser tabs
@@ -111,6 +115,18 @@ The implementation uses all three transforms (above): a path resolver redirects
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. 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.
## Bridge
Ignis's built-in integration with the Obsidian UI. It subclasses Obsidian's `Plugin` to get convenient hooks (commands, ribbon icons, status bar items, settings tabs, workspace events), but it is not a plugin in the managed sense: it isn't discovered, toggled, enabled per vault, or installed into `.obsidian/plugins/`. It's bundled into `shim-loader.js` (source in `packages/bridge/`), instantiated directly by the shim loader after Obsidian boots, and always on.
The bridge 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).
- **Commands**: `Open workspace in new tab`.
- **Status bar item**: a dot showing the WebSocket connection state to the Ignis server.
- **Settings injection**: monkey-patches `app.setting.onOpen` to add two tabs in their own "Ignis" sidebar group. Each enabled Ignis plugin's companion is pulled into a separate "Ignis Core Plugins" sidebar group.
- **Demo guards**: in demo mode, a MutationObserver disables every email/password input that appears anywhere in the document.
## Vaults ## Vaults
Any subdirectory under the vault root is treated as a vault. The active vault is selected via a `?vault=` URL parameter. Without the queryparam, the last active vault is loaded (from `localStorage.last-vault`), or the first discovered. Any subdirectory under the vault root is treated as a vault. The active vault is selected via a `?vault=` URL parameter. Without the queryparam, the last active vault is loaded (from `localStorage.last-vault`), or the first discovered.
@@ -124,45 +140,39 @@ An Express server that handles filesystem operations, vault management, static f
- `/api/vault/*` - vault CRUD and config. - `/api/vault/*` - vault CRUD and config.
- `/api/bootstrap` - one-shot cold-start endpoint; returns vault info + list + metadata tree + plugin list as a single pre-compressed response, cached per vault with mtime-based invalidation. - `/api/bootstrap` - one-shot cold-start endpoint; returns vault info + list + metadata tree + plugin list as a single pre-compressed response, cached per vault with mtime-based invalidation.
- `/api/proxy` - cross-origin HTTP proxy used by the fetch and requestUrl shims. - `/api/proxy` - cross-origin HTTP proxy used by the fetch and requestUrl shims.
- `/api/version` - server version and git hash. - `/api/version` - Ignis version (SemVer), per-build identifier, and pinned Obsidian version.
- `/api/settings/*` - read and update runtime server settings (cache sizes, request body limit, write-coalesce window, proxy mode and allowlist).
- `/api/plugins/*` - Ignis plugin management (list, enable, disable). __WIP__ - `/api/plugins/*` - Ignis plugin management (list, enable, disable). __WIP__
- `/api/ext/:pluginId/*` - routes registered by individual Ignis plugins. - `/api/ext/:pluginId/*` - routes registered by individual Ignis plugins.
- `/vault-files/<vaultId>/<path>` - static file serving rooted at a vault, used by Obsidian for image/attachment resource URLs. - `/vault-files/<vaultId>/<path>` - static file serving rooted at a vault, used by Obsidian for image/attachment resource URLs.
**WebSocket:** A file watcher monitors vault directories and pushes change events to connected clients, keeping the client-side metadata and content caches in sync. An echo guard suppresses events caused by the same client's recent writes so they don't bounce back. The watcher also carries plugin-defined message types (e.g. headless-sync status broadcasts). **WebSocket:** A file watcher monitors vault directories and pushes change events to connected clients, keeping the client-side metadata and content caches in sync. An echo guard suppresses events caused by the same client's recent writes so they don't bounce back. The watcher also carries plugin-defined message types (e.g. headless-sync status broadcasts).
**Bridge plugin auto-install:** On server startup and on vault creation, the server copies the ignis-bridge plugin into each vault's `.obsidian/plugins/` directory. **Legacy bridge cleanup:** Earlier versions installed the bridge into each vault's `.obsidian/plugins/`. The bridge is now bundled into the shim and loaded client-side, so on startup the server removes any leftover on-disk `ignis-bridge` install from each vault (and strips it from `community-plugins.json`).
## Plugins ## Plugins
Three things are called "plugin" in this project. Aside from the built-in [Bridge](#bridge), three kinds of plugin exist in Ignis, distinguished by who loads them and where they run.
### Obsidian Plugins ### Obsidian Plugins
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. 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 `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).
- **Commands**: `Open workspace in new tab` (with a FuzzySuggestModal listing saved workspaces).
- **Status bar item**: a dot showing the WebSocket connection state to the Ignis server.
- **Settings injection**: monkey-patches `app.setting.onOpen` to add two tabs in their own "Ignis" sidebar group. General (server status, version, GitHub link, update check against the GitHub releases API) and Core plugins (toggle the bundled Obsidian plugins of enabled Ignis plugins on/off per vault). Each enabled Ignis plugin's bundled Obsidian plugin also gets pulled into a separate "Ignis Core Plugins" sidebar group.
- **Demo guards**: in demo mode, a MutationObserver disables every email/password input that appears anywhere in the document and rewrites its placeholder.
Not user-installable through Obsidian's plugin browser. Managed entirely by the server.
### Ignis Plugins ### Ignis Plugins
A basic plugin system for extending the server. Still early, the core lifecycle works but the API surface is minimal and likely to change. A 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 `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`. 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>/`.
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. An Ignis plugin can optionally ship a **virtual plugin** (see below): an Obsidian-side companion that provides the in-app UI. The Ignis plugin handles server logic and routes; the virtual plugin runs in the browser.
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. 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/obsidianmd/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 to subscribed clients over a WebSocket channel.
### Virtual Plugins
The client-side companion of an Ignis plugin: a standard Obsidian plugin (a `manifest.json` plus a bundled script) that Ignis loads in the browser rather than installing to disk. The virtual-plugin-loader (`packages/shim/src/virtual-plugin-loader.js`) fetches the bundle from the server, evals it, instantiates the plugin class against the live `app`. Loaded instances are tracked in `window.__ignis.plugins` and can be toggled per vault. Nothing is ever written to `.obsidian/plugins/`.
headless-sync's companion (`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 ## Demo mode

175
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "ignis-monorepo", "name": "ignis-monorepo",
"version": "0.8.1", "version": "0.8.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ignis-monorepo", "name": "ignis-monorepo",
"version": "0.8.1", "version": "0.8.5",
"workspaces": [ "workspaces": [
"packages/*", "packages/*",
"apps/*" "apps/*"
@@ -498,8 +498,8 @@
"resolved": "apps/ignis-server", "resolved": "apps/ignis-server",
"link": true "link": true
}, },
"node_modules/@ignis/bridge-plugin": { "node_modules/@ignis/bridge": {
"resolved": "packages/bridge-plugin", "resolved": "packages/bridge",
"link": true "link": true
}, },
"node_modules/@ignis/server-core": { "node_modules/@ignis/server-core": {
@@ -1006,15 +1006,15 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "3.2.4", "version": "3.2.6",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz",
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/chai": "^5.2.2", "@types/chai": "^5.2.2",
"@vitest/spy": "3.2.4", "@vitest/spy": "3.2.6",
"@vitest/utils": "3.2.4", "@vitest/utils": "3.2.6",
"chai": "^5.2.0", "chai": "^5.2.0",
"tinyrainbow": "^2.0.0" "tinyrainbow": "^2.0.0"
}, },
@@ -1023,13 +1023,13 @@
} }
}, },
"node_modules/@vitest/mocker": { "node_modules/@vitest/mocker": {
"version": "3.2.4", "version": "3.2.6",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz",
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/spy": "3.2.4", "@vitest/spy": "3.2.6",
"estree-walker": "^3.0.3", "estree-walker": "^3.0.3",
"magic-string": "^0.30.17" "magic-string": "^0.30.17"
}, },
@@ -1050,9 +1050,9 @@
} }
}, },
"node_modules/@vitest/pretty-format": { "node_modules/@vitest/pretty-format": {
"version": "3.2.4", "version": "3.2.6",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz",
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1063,13 +1063,13 @@
} }
}, },
"node_modules/@vitest/runner": { "node_modules/@vitest/runner": {
"version": "3.2.4", "version": "3.2.6",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz",
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/utils": "3.2.4", "@vitest/utils": "3.2.6",
"pathe": "^2.0.3", "pathe": "^2.0.3",
"strip-literal": "^3.0.0" "strip-literal": "^3.0.0"
}, },
@@ -1078,13 +1078,13 @@
} }
}, },
"node_modules/@vitest/snapshot": { "node_modules/@vitest/snapshot": {
"version": "3.2.4", "version": "3.2.6",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz",
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/pretty-format": "3.2.4", "@vitest/pretty-format": "3.2.6",
"magic-string": "^0.30.17", "magic-string": "^0.30.17",
"pathe": "^2.0.3" "pathe": "^2.0.3"
}, },
@@ -1093,9 +1093,9 @@
} }
}, },
"node_modules/@vitest/spy": { "node_modules/@vitest/spy": {
"version": "3.2.4", "version": "3.2.6",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz",
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1106,13 +1106,13 @@
} }
}, },
"node_modules/@vitest/utils": { "node_modules/@vitest/utils": {
"version": "3.2.4", "version": "3.2.6",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz",
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/pretty-format": "3.2.4", "@vitest/pretty-format": "3.2.6",
"loupe": "^3.1.4", "loupe": "^3.1.4",
"tinyrainbow": "^2.0.0" "tinyrainbow": "^2.0.0"
}, },
@@ -1414,9 +1414,9 @@
} }
}, },
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "1.20.4", "version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bytes": "~3.1.2", "bytes": "~3.1.2",
@@ -1427,7 +1427,7 @@
"http-errors": "~2.0.1", "http-errors": "~2.0.1",
"iconv-lite": "~0.4.24", "iconv-lite": "~0.4.24",
"on-finished": "~2.4.1", "on-finished": "~2.4.1",
"qs": "~6.14.0", "qs": "~6.15.1",
"raw-body": "~2.5.3", "raw-body": "~2.5.3",
"type-is": "~1.6.18", "type-is": "~1.6.18",
"unpipe": "~1.0.0" "unpipe": "~1.0.0"
@@ -1438,9 +1438,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "2.0.2", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
@@ -1892,9 +1892,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/es-object-atoms": { "node_modules/es-object-atoms": {
"version": "1.1.1", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0" "es-errors": "^1.3.0"
@@ -2021,14 +2021,14 @@
} }
}, },
"node_modules/express": { "node_modules/express": {
"version": "4.22.1", "version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"accepts": "~1.3.8", "accepts": "~1.3.8",
"array-flatten": "1.1.1", "array-flatten": "1.1.1",
"body-parser": "~1.20.3", "body-parser": "~1.20.5",
"content-disposition": "~0.5.4", "content-disposition": "~0.5.4",
"content-type": "~1.0.4", "content-type": "~1.0.4",
"cookie": "~0.7.1", "cookie": "~0.7.1",
@@ -2047,7 +2047,7 @@
"parseurl": "~1.3.3", "parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12", "path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7", "proxy-addr": "~2.0.7",
"qs": "~6.14.0", "qs": "~6.15.1",
"range-parser": "~1.2.1", "range-parser": "~1.2.1",
"safe-buffer": "5.2.1", "safe-buffer": "5.2.1",
"send": "~0.19.0", "send": "~0.19.0",
@@ -2260,9 +2260,9 @@
} }
}, },
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
@@ -2493,9 +2493,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.23", "version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/loupe": { "node_modules/loupe": {
@@ -2766,9 +2766,9 @@
} }
}, },
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {
"version": "0.1.12", "version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/pathe": { "node_modules/pathe": {
@@ -2807,9 +2807,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.1", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8.6" "node": ">=8.6"
@@ -2876,9 +2876,9 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.14.2", "version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"side-channel": "^1.1.0" "side-channel": "^1.1.0"
@@ -3107,14 +3107,14 @@
} }
}, },
"node_modules/side-channel": { "node_modules/side-channel": {
"version": "1.1.0", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
"object-inspect": "^1.13.3", "object-inspect": "^1.13.4",
"side-channel-list": "^1.0.0", "side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1", "side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2" "side-channel-weakmap": "^1.0.2"
}, },
@@ -3126,13 +3126,13 @@
} }
}, },
"node_modules/side-channel-list": { "node_modules/side-channel-list": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
"object-inspect": "^1.13.3" "object-inspect": "^1.13.4"
}, },
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -4160,20 +4160,20 @@
} }
}, },
"node_modules/vitest": { "node_modules/vitest": {
"version": "3.2.4", "version": "3.2.6",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/chai": "^5.2.2", "@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4", "@vitest/expect": "3.2.6",
"@vitest/mocker": "3.2.4", "@vitest/mocker": "3.2.6",
"@vitest/pretty-format": "^3.2.4", "@vitest/pretty-format": "^3.2.6",
"@vitest/runner": "3.2.4", "@vitest/runner": "3.2.6",
"@vitest/snapshot": "3.2.4", "@vitest/snapshot": "3.2.6",
"@vitest/spy": "3.2.4", "@vitest/spy": "3.2.6",
"@vitest/utils": "3.2.4", "@vitest/utils": "3.2.6",
"chai": "^5.2.0", "chai": "^5.2.0",
"debug": "^4.4.1", "debug": "^4.4.1",
"expect-type": "^1.2.1", "expect-type": "^1.2.1",
@@ -4203,8 +4203,8 @@
"@edge-runtime/vm": "*", "@edge-runtime/vm": "*",
"@types/debug": "^4.1.12", "@types/debug": "^4.1.12",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@vitest/browser": "3.2.4", "@vitest/browser": "3.2.6",
"@vitest/ui": "3.2.4", "@vitest/ui": "3.2.6",
"happy-dom": "*", "happy-dom": "*",
"jsdom": "*" "jsdom": "*"
}, },
@@ -4394,9 +4394,9 @@
} }
}, },
"node_modules/ws": { "node_modules/ws": {
"version": "8.19.0", "version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"
@@ -4428,9 +4428,14 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"packages/bridge": {
"name": "@ignis/bridge",
"version": "0.0.0-internal"
},
"packages/bridge-plugin": { "packages/bridge-plugin": {
"name": "@ignis/bridge-plugin", "name": "@ignis/bridge-plugin",
"version": "0.0.0-internal", "version": "0.0.0-internal",
"extraneous": true,
"devDependencies": { "devDependencies": {
"esbuild": "^0.20.0" "esbuild": "^0.20.0"
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "ignis-monorepo", "name": "ignis-monorepo",
"version": "0.8.2", "version": "0.8.6",
"private": true, "private": true,
"description": "Monorepo for Ignis: a browser-based Obsidian client. Self-hosted server in apps/ignis-server; shim, UI, and shared libraries in packages/.", "description": "Monorepo for Ignis: a browser-based Obsidian client. Self-hosted server in apps/ignis-server; shim, UI, and shared libraries in packages/.",
"workspaces": [ "workspaces": [
@@ -11,6 +11,7 @@
"build": "node build.js", "build": "node build.js",
"dev:server": "node apps/ignis-server/server/index.js", "dev:server": "node apps/ignis-server/server/index.js",
"dev": "npm run build && npm run dev:server", "dev": "npm run build && npm run dev:server",
"docker:build": "node apps/ignis-server/scripts/build-image.js",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest" "test:watch": "vitest"
}, },

View File

@@ -1,13 +0,0 @@
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,10 +0,0 @@
{
"id": "ignis-bridge",
"name": "Ignis Bridge",
"version": "0.8.1",
"minAppVersion": "1.12.4",
"description": "Additional Ignis specific functionality and ignis plugin management.",
"author": "Nystik",
"authorUrl": "https://github.com/Nystik-gh/ignis",
"isDesktopOnly": false
}

View File

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

View File

@@ -1,166 +0,0 @@
const { Setting } = require("obsidian");
const GITHUB_URL = "https://github.com/Nystik-gh/ignis";
const GITHUB_API_LATEST =
"https://api.github.com/repos/Nystik-gh/ignis/releases/latest";
function getVersion(app) {
try {
const manifest = app.plugins.getPlugin("ignis-bridge")?.manifest;
return manifest?.version || "unknown";
} catch {
return "unknown";
}
}
// 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);
if (!res.ok) {
return null;
}
const data = await res.json();
const latest = stripBuildMetadata(data.tag_name?.replace(/^v/, ""));
const current = stripBuildMetadata(currentVersion);
if (latest && latest !== current) {
return { version: latest, url: data.html_url };
}
return null;
} catch {
return null;
}
}
function display(containerEl, app) {
const version = getVersion(app);
const header = containerEl.createDiv("ignis-header");
const logo = header.createEl("img", {
cls: "ignis-header-logo",
attr: { src: "/assets/ignis.webp", alt: "Ignis" },
});
const info = header.createDiv("ignis-header-info");
info.createEl("div", { text: "Ignis", cls: "ignis-header-title" });
info.createEl("div", {
text: "Obsidian server bridge",
cls: "ignis-header-subtitle",
});
const right = header.createDiv("ignis-header-right");
const versionCol = right.createDiv("ignis-header-version-col");
versionCol.createEl("span", {
text: `Version ${version}`,
cls: "ignis-header-version",
});
const updateIndicator = versionCol.createEl("a", {
text: "Checking...",
cls: "ignis-update-indicator",
attr: { target: "_blank", rel: "noopener noreferrer" },
});
const githubLink = right.createEl("a", {
cls: "ignis-github-link",
href: GITHUB_URL,
attr: { target: "_blank", "aria-label": "GitHub" },
});
const githubIcon = githubLink.createEl("img", {
cls: "ignis-github-icon",
attr: { src: "/assets/github.svg", alt: "GitHub" },
});
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";
}
});
addServerStatus(containerEl);
}
function getWsStatus() {
const ws = window.__ignisWs;
if (!ws) {
return "disconnected";
}
switch (ws.readyState) {
case WebSocket.CONNECTING:
return "connecting";
case WebSocket.OPEN:
return "connected";
case WebSocket.CLOSING:
case WebSocket.CLOSED:
return "disconnected";
default:
return "disconnected";
}
}
function statusLabel(status) {
switch (status) {
case "connected":
return "Connected";
case "connecting":
return "Connecting...";
case "disconnected":
return "Disconnected";
default:
return "Unknown";
}
}
function addServerStatus(containerEl) {
const status = getWsStatus();
const setting = new Setting(containerEl).setName("Server status");
const dotEl = setting.controlEl.createEl("span", {
cls: `ignis-status-dot ignis-status-${status}`,
});
const labelEl = setting.controlEl.createEl("span", {
text: statusLabel(status),
cls: "ignis-status-label",
});
const update = () => {
const s = getWsStatus();
dotEl.className = `ignis-status-dot ignis-status-${s}`;
labelEl.textContent = statusLabel(s);
};
const pollInterval = setInterval(update, 3000);
const observer = new MutationObserver(() => {
if (!containerEl.isConnected) {
clearInterval(pollInterval);
observer.disconnect();
}
});
observer.observe(containerEl.parentElement || document.body, {
childList: true,
subtree: true,
});
}
module.exports = { display };

View File

@@ -1,48 +0,0 @@
function getWsStatus() {
const ws = window.__ignisWs;
if (!ws) {
return "disconnected";
}
switch (ws.readyState) {
case WebSocket.CONNECTING:
return "connecting";
case WebSocket.OPEN:
return "connected";
default:
return "disconnected";
}
}
const STATUS_LABELS = {
connected: "Ignis server: Connected",
connecting: "Ignis server: Connecting...",
disconnected: "Ignis server: Disconnected",
};
function initStatusBar(plugin) {
const item = plugin.addStatusBarItem();
item.addClass("ignis-statusbar-item");
const dot = item.createEl("span", {
cls: "ignis-statusbar-dot",
});
item.setAttribute("aria-label", "Ignis: Checking...");
item.setAttribute("data-tooltip-position", "top");
const update = () => {
const status = getWsStatus();
dot.className = `ignis-statusbar-dot ignis-statusbar-${status}`;
item.setAttribute("aria-label", STATUS_LABELS[status] || "Ignis: Unknown");
};
update();
const interval = setInterval(update, 3000);
return interval;
}
module.exports = { initStatusBar };

View File

@@ -0,0 +1,7 @@
{
"name": "@ignis/bridge",
"version": "0.0.0-internal",
"private": true,
"type": "module",
"main": "src/main.js"
}

View File

@@ -51,4 +51,4 @@ function stopDemoGuards() {
} }
} }
module.exports = { startDemoGuards, stopDemoGuards }; export { startDemoGuards, stopDemoGuards, isDemoMode };

View File

@@ -1,4 +1,4 @@
const { Notice, TFile, TFolder } = require("obsidian"); import { Notice, TFile, TFolder } from "obsidian";
function getVaultId() { function getVaultId() {
return window.__currentVaultId || ""; return window.__currentVaultId || "";
@@ -92,4 +92,4 @@ function addFolderMenuItems(menu, folder, app) {
}); });
} }
module.exports = { showFilePicker, addFileMenuItems, addFolderMenuItems }; export { showFilePicker, addFileMenuItems, addFolderMenuItems };

View File

@@ -1,19 +1,17 @@
const { Plugin, TFile, TFolder } = require("obsidian"); import { Plugin, TFile, TFolder } from "obsidian";
const { import {
showFilePicker, showFilePicker,
addFileMenuItems, addFileMenuItems,
addFolderMenuItems, addFolderMenuItems,
} = require("./file-actions"); } from "./file-actions.js";
const { import {
patchSettingsModal, patchSettingsModal,
unpatchSettingsModal, unpatchSettingsModal,
} = require("./settings/inject"); } from "./settings/inject.js";
const pluginRegistry = require("./plugin-registry"); import * as pluginRegistry from "./plugin-registry.js";
const { initStatusBar } = require("./status-bar"); import { initStatusBar } from "./status-bar.js";
const { WorkspacePickerModal } = require("./workspace-picker"); import { WorkspacePickerModal } from "./workspace-picker.js";
const { startDemoGuards, stopDemoGuards } = require("./demo-guards"); import { startDemoGuards, stopDemoGuards } from "./demo-guards.js";
window.__obsidianAPI = require("obsidian");
class IgnisBridgePlugin extends Plugin { class IgnisBridgePlugin extends Plugin {
async onload() { async onload() {
@@ -67,4 +65,4 @@ class IgnisBridgePlugin extends Plugin {
} }
} }
module.exports = IgnisBridgePlugin; export default IgnisBridgePlugin;

View File

@@ -34,4 +34,4 @@ function getKnownIds() {
return knownIds; return knownIds;
} }
module.exports = { refresh, isIgnisPlugin, addId, getKnownIds }; export { refresh, isIgnisPlugin, addId, getKnownIds };

View File

@@ -0,0 +1,362 @@
import { Setting, Notice } from "obsidian";
import { isDemoMode } from "../demo-guards.js";
import { stripBuildMetadata, isNewer } from "../util/version.js";
import { ListEditorModal } from "./list-editor-modal.js";
const GITHUB_URL = "https://github.com/Nystik-gh/ignis";
const GITHUB_API_LATEST =
"https://api.github.com/repos/Nystik-gh/ignis/releases/latest";
function getVersion() {
return window.__ignis?.version || "unknown";
}
async function checkForUpdate(currentVersion) {
try {
const res = await fetch(GITHUB_API_LATEST);
if (!res.ok) {
return null;
}
const data = await res.json();
const latest = stripBuildMetadata(data.tag_name?.replace(/^v/, ""));
const current = stripBuildMetadata(currentVersion);
if (isNewer(latest, current)) {
return { version: latest, url: data.html_url };
}
return null;
} catch {
return null;
}
}
function display(containerEl, app) {
const version = getVersion();
const header = containerEl.createDiv("ignis-header");
const logo = header.createEl("img", {
cls: "ignis-header-logo",
attr: { src: "/assets/ignis.webp", alt: "Ignis" },
});
const info = header.createDiv("ignis-header-info");
info.createEl("div", { text: "Ignis", cls: "ignis-header-title" });
info.createEl("div", {
text: "Obsidian server bridge",
cls: "ignis-header-subtitle",
});
const right = header.createDiv("ignis-header-right");
const versionCol = right.createDiv("ignis-header-version-col");
versionCol.createEl("span", {
text: `Version ${version}`,
cls: "ignis-header-version",
});
const updateIndicator = versionCol.createEl("a", {
text: "Checking...",
cls: "ignis-update-indicator",
attr: { target: "_blank", rel: "noopener noreferrer" },
});
const githubLink = right.createEl("a", {
cls: "ignis-github-link",
href: GITHUB_URL,
attr: { target: "_blank", "aria-label": "GitHub" },
});
const githubIcon = githubLink.createEl("img", {
cls: "ignis-github-icon",
attr: { src: "/assets/github.svg", alt: "GitHub" },
});
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";
}
});
addServerStatus(containerEl);
addServerSettings(containerEl, app);
}
const STATUS_LABELS = {
open: "Connected",
connecting: "Connecting...",
closed: "Disconnected",
};
const STATUS_DOT_CLASSES = {
open: "ignis-status-connected",
connecting: "ignis-status-connecting",
closed: "ignis-status-disconnected",
};
function createSettingGroup(containerEl, heading) {
const group = containerEl.createDiv("setting-group");
if (heading) {
new Setting(group).setName(heading).setHeading();
}
return group.createDiv("setting-items");
}
function addServerStatus(containerEl) {
const ws = window.__ignis.ws;
const items = createSettingGroup(containerEl);
const setting = new Setting(items).setName("Server status");
const dotEl = setting.controlEl.createEl("span", {
cls: "ignis-status-dot",
});
const labelEl = setting.controlEl.createEl("span", {
cls: "ignis-status-label",
});
function render(state) {
dotEl.className = `ignis-status-dot ${STATUS_DOT_CLASSES[state] || STATUS_DOT_CLASSES.closed}`;
labelEl.textContent = STATUS_LABELS[state] || STATUS_LABELS.closed;
}
render(ws.isOpen() ? "open" : "closed");
const unsub = ws.onStateChange(render);
// Detach when the settings tab DOM goes away.
const observer = new MutationObserver(() => {
if (!containerEl.isConnected) {
unsub();
observer.disconnect();
}
});
observer.observe(containerEl.parentElement || document.body, {
childList: true,
subtree: true,
});
}
const MB = 1024 * 1024;
const MINUTE = 60 * 1000;
function addServerSettings(containerEl, app) {
if (isDemoMode()) {
const items = createSettingGroup(containerEl);
new Setting(items)
.setName("Server settings")
.setDesc("Server settings are disabled in demo mode.");
return;
}
const loading = containerEl.createEl("p", {
text: "Loading server settings...",
cls: "setting-item-description",
});
fetch("/api/settings")
.then((res) => (res.ok ? res.json() : Promise.reject(res)))
.then((current) => {
loading.remove();
renderServerSettings(containerEl, current, app);
})
.catch(() => {
loading.setText("Failed to load server settings.");
});
}
function renderServerSettings(containerEl, current, app) {
const caching = createSettingGroup(containerEl, "Caching");
numberField(caching, {
name: "Content cache (MB)",
desc: "Browser cache of file content. Applies after reload.",
value: Math.round(current.contentCacheBytes / MB),
key: "contentCacheBytes",
toStored: (n) => n * MB,
});
numberField(caching, {
name: "Input cache (MB)",
desc: "Cache for files picked for import. Applies after reload.",
value: Math.round(current.inputCacheBytes / MB),
key: "inputCacheBytes",
toStored: (n) => n * MB,
});
numberField(caching, {
name: "Input cache TTL (minutes)",
desc: "How long picked files stay cached. Applies after reload.",
value: Math.round(current.inputCacheTtlMs / MINUTE),
key: "inputCacheTtlMs",
toStored: (n) => n * MINUTE,
});
const security = createSettingGroup(containerEl, "Security");
numberField(security, {
name: "Max request body (MB)",
desc: "Largest request the server accepts.",
value: Math.round(current.maxBodyBytes / MB),
key: "maxBodyBytes",
toStored: (n) => n * MB,
});
proxyAccessField(security, current, app);
const advanced = createSettingGroup(containerEl, "Advanced");
numberField(advanced, {
name: "Write coalesce window (ms)",
desc: "Debounce window for rapid writes on slow filesystems. 0 disables.",
value: current.writeCoalesceMs,
key: "writeCoalesceMs",
toStored: (n) => n,
});
}
// Persist a single setting. The server validates, applies the live ones, and saves.
async function saveSetting(partial) {
try {
const res = await fetch("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(partial),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "Save failed");
}
} catch (e) {
new Notice(`Failed to save setting: ${e.message}`);
}
}
function numberField(containerEl, { name, desc, value, key, toStored }) {
let committed = value;
new Setting(containerEl)
.setName(name)
.setDesc(desc)
.addText((text) => {
text.setValue(String(value));
// Commit only on change.
const commit = () => {
const n = parseInt(text.getValue(), 10);
if (!Number.isInteger(n) || n < 0 || n === committed) {
return;
}
committed = n;
saveSetting({ [key]: toStored(n) });
};
text.inputEl.addEventListener("blur", commit);
text.inputEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
commit();
}
});
});
}
// Proxy access mode plus the allowlist row, which only shows in "allowlist" mode.
function proxyAccessField(parent, current, app) {
let mode = current.proxyMode || "any";
const setting = new Setting(parent)
.setName("Proxy access")
.setDesc(
"Which external hosts Obsidian may reach through the server's CORS proxy.",
);
const allowlistSetting = listField(parent, {
name: "Proxy host allowlist",
desc: "Hostnames the proxy may reach, matched exactly.",
value: current.proxyAllowlist,
key: "proxyAllowlist",
app,
modal: {
placeholder: "api.example.com",
emptyNote: "No hosts yet.",
recommended: {
note: "Restricting the proxy stops Obsidian's plugin and theme browser and updates from working unless their hosts are allowed.",
hosts: [
"releases.obsidian.md",
"github.com",
"api.github.com",
"raw.githubusercontent.com",
],
buttonText: "Add recommended hosts",
},
},
});
const applyVisibility = () => {
allowlistSetting.settingEl.style.display =
mode === "allowlist" ? "" : "none";
};
setting.addDropdown((dd) => {
dd.addOption("any", "Any public host");
dd.addOption("allowlist", "Allowlist only");
dd.addOption("disabled", "Disabled");
dd.setValue(mode);
dd.onChange((value) => {
mode = value;
saveSetting({ proxyMode: value });
applyVisibility();
});
});
applyVisibility();
}
function listField(containerEl, { name, desc, value, key, app, modal }) {
let current = [...(value || [])];
const setting = new Setting(containerEl).setName(name).setDesc(desc);
const setLabel = (btn) =>
btn.setButtonText(current.length ? `Edit (${current.length})` : "Edit");
setting.addButton((btn) => {
setLabel(btn);
btn.onClick(() => {
new ListEditorModal(app, {
title: name,
placeholder: modal.placeholder,
emptyNote: modal.emptyNote,
recommended: modal.recommended,
values: current,
onChange: (next) => {
current = next;
saveSetting({ [key]: current });
setLabel(btn);
},
}).open();
});
});
return setting;
}
export { display };

View File

@@ -1,13 +1,14 @@
const generalTab = require("./general-tab"); import * as generalTab from "./general-tab.js";
const serverPluginsTab = require("./server-plugins-tab"); import * as serverPluginsTab from "./server-plugins-tab.js";
const { createNavEl, createTab, createGroup } = require("./settings-ui"); import { createNavEl, createTab, createGroup } from "./settings-ui.js";
const { import {
allIgnisNavEls,
setupPluginTabs, setupPluginTabs,
reconcilePluginTabs, reconcilePluginTabs,
hideIgnisFromCommunityPlugins, hideIgnisFromCommunityPlugins,
restoreCommunityPlugins, restoreCommunityPlugins,
clearOwnedPluginIds, clearOwnedPluginIds,
} = require("./plugin-tabs"); } from "./plugin-tabs.js";
function removeExistingIgnisGroups(tabHeadersEl) { function removeExistingIgnisGroups(tabHeadersEl) {
const groups = tabHeadersEl.querySelectorAll(".vertical-tab-header-group"); const groups = tabHeadersEl.querySelectorAll(".vertical-tab-header-group");
@@ -24,10 +25,6 @@ function removeExistingIgnisGroups(tabHeadersEl) {
} }
} }
// All ignis-managed nav elements (both Ignis group and Ignis Core Plugins group).
// Collected here so the openTab patch can manage is-active across all of them.
const allIgnisNavEls = new Map(); // tab id -> nav element
function replaceInstallerVersionRow(setting, ignisVersion) { function replaceInstallerVersionRow(setting, ignisVersion) {
const container = setting.tabContentContainer || setting.contentEl; const container = setting.tabContentContainer || setting.contentEl;
@@ -117,7 +114,7 @@ function injectIgnisSettings(setting, app, plugin) {
setting.tabHeadersEl.appendChild(corePlugins.group); setting.tabHeadersEl.appendChild(corePlugins.group);
hideIgnisFromCommunityPlugins(setting); hideIgnisFromCommunityPlugins(setting);
setupPluginTabs(setting, corePlugins.items, allIgnisNavEls); setupPluginTabs(setting, corePlugins.items);
} }
function patchSettingsModal(plugin) { function patchSettingsModal(plugin) {
@@ -142,7 +139,4 @@ function unpatchSettingsModal(plugin) {
clearOwnedPluginIds(); clearOwnedPluginIds();
} }
window.__ignisReconcilePluginTabs = (setting) => export { patchSettingsModal, unpatchSettingsModal, reconcilePluginTabs };
reconcilePluginTabs(setting, allIgnisNavEls);
module.exports = { patchSettingsModal, unpatchSettingsModal, reconcilePluginTabs };

View File

@@ -0,0 +1,134 @@
import { Modal, Setting, Notice } from "obsidian";
// Modal editor for a list of string entries (the proxy host allowlist).
class ListEditorModal extends Modal {
constructor(app, opts) {
super(app);
this.opts = opts;
this.values = [...(opts.values || [])];
}
onOpen() {
this.titleEl.setText(this.opts.title);
if (this.opts.recommended) {
new Setting(this.contentEl)
.setDesc(this.opts.recommended.note)
.addButton((btn) =>
btn
.setButtonText(
this.opts.recommended.buttonText || "Add recommended",
)
.onClick(() => this.addRecommended()),
);
}
this.listEl = this.contentEl.createDiv("ignis-list-editor");
this.renderList();
new Setting(this.contentEl)
.setName("Add entry")
.addText((text) => {
this.input = text;
text.setPlaceholder(this.opts.placeholder || "");
text.inputEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
this.addCurrent();
}
});
})
.addButton((btn) =>
btn
.setButtonText("Add")
.setCta()
.onClick(() => this.addCurrent()),
);
}
addEntry(entry) {
if (this.values.includes(entry)) {
return false;
}
this.values.push(entry);
return true;
}
addCurrent() {
const entry = this.input.getValue().trim();
if (!entry) {
return;
}
if (!this.addEntry(entry)) {
new Notice("That entry is already in the list.");
return;
}
this.input.setValue("");
this.input.inputEl.focus();
this.commit();
this.renderList();
}
addRecommended() {
let added = 0;
for (const host of this.opts.recommended.hosts) {
if (this.addEntry(host)) {
added++;
}
}
if (added > 0) {
this.commit();
this.renderList();
}
new Notice(
added > 0
? `Added ${added} host${added === 1 ? "" : "s"}.`
: "All recommended hosts are already in the list.",
);
}
remove(entry) {
this.values = this.values.filter((v) => v !== entry);
this.commit();
this.renderList();
}
renderList() {
this.listEl.empty();
if (this.values.length === 0) {
this.listEl.createDiv({
text: this.opts.emptyNote,
cls: "ignis-list-empty",
});
return;
}
for (const entry of this.values) {
new Setting(this.listEl).setName(entry).addExtraButton((btn) =>
btn
.setIcon("trash-2")
.setTooltip("Remove")
.onClick(() => this.remove(entry)),
);
}
}
commit() {
this.opts.onChange([...this.values]);
}
onClose() {
this.contentEl.empty();
}
}
export { ListEditorModal };

View File

@@ -1,11 +1,15 @@
const { setIcon } = require("obsidian"); import { setIcon } from "obsidian";
const { findGroupByTitle } = require("./settings-ui"); import { findGroupByTitle } from "./settings-ui.js";
const { isIgnisPlugin } = require("../plugin-registry"); import { isIgnisPlugin } from "../plugin-registry.js";
// All ignis-managed nav elements (both Ignis group and Ignis Core Plugins group).
// Shared with inject.js so the openTab patch can manage is-active across all of them.
const allIgnisNavEls = new Map(); // tab id -> nav element
// Tracks which plugin IDs have nav items we created. // Tracks which plugin IDs have nav items we created.
const ownedPluginIds = new Set(); const ownedPluginIds = new Set();
function addPluginNavItem(pluginId, setting, corePluginsItems, ignisNavEls) { function addPluginNavItem(pluginId, setting, corePluginsItems) {
const tab = setting.pluginTabs.find((t) => t.id === pluginId); const tab = setting.pluginTabs.find((t) => t.id === pluginId);
if (!tab) { if (!tab) {
@@ -41,16 +45,16 @@ function addPluginNavItem(pluginId, setting, corePluginsItems, ignisNavEls) {
corePluginsItems.appendChild(nav); corePluginsItems.appendChild(nav);
ownedPluginIds.add(pluginId); ownedPluginIds.add(pluginId);
ignisNavEls.set(pluginId, nav); allIgnisNavEls.set(pluginId, nav);
} }
function removePluginNavItem(pluginId, ignisNavEls) { function removePluginNavItem(pluginId) {
const nav = ignisNavEls.get(pluginId); const nav = allIgnisNavEls.get(pluginId);
if (nav && ownedPluginIds.has(pluginId)) { if (nav && ownedPluginIds.has(pluginId)) {
nav.remove(); nav.remove();
ownedPluginIds.delete(pluginId); ownedPluginIds.delete(pluginId);
ignisNavEls.delete(pluginId); allIgnisNavEls.delete(pluginId);
} }
} }
@@ -116,11 +120,11 @@ function hideIgnisNavFromCommunityGroup(setting) {
communityGroup.style.display = hasVisible ? "" : "none"; communityGroup.style.display = hasVisible ? "" : "none";
} }
function hideCorePluginsGroupIfEmpty(ignisNavEls) { function hideCorePluginsGroupIfEmpty() {
let hasConnected = false; let hasConnected = false;
for (const id of ownedPluginIds) { for (const id of ownedPluginIds) {
const nav = ignisNavEls.get(id); const nav = allIgnisNavEls.get(id);
if (nav?.isConnected) { if (nav?.isConnected) {
hasConnected = true; hasConnected = true;
@@ -140,15 +144,15 @@ function hideCorePluginsGroupIfEmpty(ignisNavEls) {
} }
} }
function setupPluginTabs(setting, corePluginsItems, ignisNavEls) { function setupPluginTabs(setting, corePluginsItems) {
for (const tab of setting.pluginTabs) { for (const tab of setting.pluginTabs) {
if (isIgnisPlugin(tab.id) && tab.id !== "ignis-bridge") { if (isIgnisPlugin(tab.id) && tab.id !== "ignis-bridge") {
addPluginNavItem(tab.id, setting, corePluginsItems, ignisNavEls); addPluginNavItem(tab.id, setting, corePluginsItems);
} }
} }
hideIgnisNavFromCommunityGroup(setting); hideIgnisNavFromCommunityGroup(setting);
hideCorePluginsGroupIfEmpty(ignisNavEls); hideCorePluginsGroupIfEmpty();
const communityGroup = findGroupByTitle( const communityGroup = findGroupByTitle(
setting.tabHeadersEl, setting.tabHeadersEl,
@@ -159,12 +163,12 @@ function setupPluginTabs(setting, corePluginsItems, ignisNavEls) {
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => {
for (const tab of setting.pluginTabs) { for (const tab of setting.pluginTabs) {
if (isIgnisPlugin(tab.id) && tab.id !== "ignis-bridge") { if (isIgnisPlugin(tab.id) && tab.id !== "ignis-bridge") {
addPluginNavItem(tab.id, setting, corePluginsItems, ignisNavEls); addPluginNavItem(tab.id, setting, corePluginsItems);
} }
} }
hideIgnisNavFromCommunityGroup(setting); hideIgnisNavFromCommunityGroup(setting);
hideCorePluginsGroupIfEmpty(ignisNavEls); hideCorePluginsGroupIfEmpty();
}); });
observer.observe(communityGroup, { childList: true, subtree: true }); observer.observe(communityGroup, { childList: true, subtree: true });
@@ -186,7 +190,7 @@ function setupPluginTabs(setting, corePluginsItems, ignisNavEls) {
} }
} }
function reconcilePluginTabs(setting, ignisNavEls) { function reconcilePluginTabs(setting) {
const corePluginsGroup = findGroupByTitle( const corePluginsGroup = findGroupByTitle(
setting.tabHeadersEl, setting.tabHeadersEl,
"Ignis Core Plugins", "Ignis Core Plugins",
@@ -212,23 +216,24 @@ function reconcilePluginTabs(setting, ignisNavEls) {
for (const id of ownedPluginIds) { for (const id of ownedPluginIds) {
if (!activeIds.has(id)) { if (!activeIds.has(id)) {
removePluginNavItem(id, ignisNavEls); removePluginNavItem(id);
} }
} }
for (const id of activeIds) { for (const id of activeIds) {
addPluginNavItem(id, setting, corePluginsItems, ignisNavEls); addPluginNavItem(id, setting, corePluginsItems);
} }
hideIgnisNavFromCommunityGroup(setting); hideIgnisNavFromCommunityGroup(setting);
hideCorePluginsGroupIfEmpty(ignisNavEls); hideCorePluginsGroupIfEmpty();
} }
function clearOwnedPluginIds() { function clearOwnedPluginIds() {
ownedPluginIds.clear(); ownedPluginIds.clear();
} }
module.exports = { export {
allIgnisNavEls,
setupPluginTabs, setupPluginTabs,
reconcilePluginTabs, reconcilePluginTabs,
hideIgnisFromCommunityPlugins, hideIgnisFromCommunityPlugins,

View File

@@ -1,18 +1,10 @@
const { Setting, Notice } = require("obsidian"); import { Setting, Notice } from "obsidian";
import { reconcilePluginTabs } from "./plugin-tabs.js";
function getVaultId() { function getVaultId() {
return window.__currentVaultId || ""; return window.__currentVaultId || "";
} }
async function refreshPluginCache(bundledPluginId) {
const pluginPath = `.obsidian/plugins/${bundledPluginId}`;
const fs = require("fs");
if (fs._refreshSubtree) {
await fs._refreshSubtree(pluginPath);
}
}
async function fetchPlugins() { async function fetchPlugins() {
const res = await fetch("/api/plugins"); const res = await fetch("/api/plugins");
@@ -23,7 +15,7 @@ async function fetchPlugins() {
return res.json(); return res.json();
} }
async function togglePlugin(pluginId, enable, app) { async function togglePlugin(pluginId, enable) {
const action = enable ? "enable" : "disable"; const action = enable ? "enable" : "disable";
const vaultId = getVaultId(); const vaultId = getVaultId();
@@ -41,25 +33,10 @@ async function togglePlugin(pluginId, enable, app) {
return res.json(); return res.json();
} }
async function activateBundledPlugin(bundledPluginId, enable, app) {
if (!bundledPluginId) {
return;
}
const plugins = app.plugins;
if (enable) {
await plugins.loadManifests();
await plugins.enablePluginAndSave(bundledPluginId);
} else {
await plugins.disablePluginAndSave(bundledPluginId);
}
}
function display(containerEl, app) { function display(containerEl, app) {
containerEl.createEl("h2", { text: "Ignis Core Plugins" }); containerEl.createEl("h2", { text: "Ignis Core Plugins" });
const descEl = containerEl.createEl("p", { containerEl.createEl("p", {
text: text:
"Ignis plugins extend server functionality and run alongside your vaults. " + "Ignis plugins extend server functionality and run alongside your vaults. " +
"They are separate from Obsidian's built-in plugins.", "They are separate from Obsidian's built-in plugins.",
@@ -92,28 +69,16 @@ function display(containerEl, app) {
toggle.setValue(enabled); toggle.setValue(enabled);
toggle.onChange(async (value) => { toggle.onChange(async (value) => {
try { try {
await togglePlugin(plugin.id, value, app); await togglePlugin(plugin.id, value);
if (value && plugin.bundledPluginId) {
await refreshPluginCache(plugin.bundledPluginId);
}
await activateBundledPlugin(
plugin.bundledPluginId,
value,
app,
);
new Notice( new Notice(
`${plugin.name} ${value ? "enabled" : "disabled"} for this vault.`, `${plugin.name} ${value ? "enabled" : "disabled"} for this vault.`,
); );
// Give Obsidian a moment to update its plugin tabs, // The server's WS broadcast drives the actual load/unload via virtual-plugin-loader.
// then reconcile our sidebar groups. // Reconcile the settings sidebar so the new plugin's settings tab gets grouped correctly.
setTimeout(() => { setTimeout(() => {
if (typeof window.__ignisReconcilePluginTabs === "function") { reconcilePluginTabs(app.setting);
window.__ignisReconcilePluginTabs(app.setting);
}
}, 100); }, 100);
} catch (e) { } catch (e) {
new Notice(`Failed: ${e.message}`); new Notice(`Failed: ${e.message}`);
@@ -129,4 +94,4 @@ function display(containerEl, app) {
}); });
} }
module.exports = { display }; export { display };

View File

@@ -1,4 +1,4 @@
const { setIcon } = require("obsidian"); import { setIcon } from "obsidian";
function createNavEl(tab, setting) { function createNavEl(tab, setting) {
const nav = document.createElement("div"); const nav = document.createElement("div");
@@ -86,4 +86,4 @@ function findGroupByTitle(tabHeadersEl, title) {
return null; return null;
} }
module.exports = { createNavEl, createTab, createGroup, findGroupByTitle }; export { createNavEl, createTab, createGroup, findGroupByTitle };

View File

@@ -0,0 +1,35 @@
const STATUS_LABELS = {
open: "Ignis server: Connected",
connecting: "Ignis server: Connecting...",
closed: "Ignis server: Disconnected",
};
const STATUS_DOT_CLASSES = {
open: "ignis-statusbar-connected",
connecting: "ignis-statusbar-connecting",
closed: "ignis-statusbar-disconnected",
};
function initStatusBar(plugin) {
const ws = window.__ignis.ws;
const item = plugin.addStatusBarItem();
item.addClass("ignis-statusbar-item");
const dot = item.createEl("span", {
cls: "ignis-statusbar-dot",
});
item.setAttribute("data-tooltip-position", "top");
function render(state) {
dot.className = `ignis-statusbar-dot ${STATUS_DOT_CLASSES[state] || STATUS_DOT_CLASSES.closed}`;
item.setAttribute("aria-label", STATUS_LABELS[state] || STATUS_LABELS.closed);
}
render(ws.isOpen() ? "open" : "closed");
return ws.onStateChange(render);
}
export { initStatusBar };

View File

@@ -0,0 +1,39 @@
// Version comparison helpers for the update check.
// SemVer build metadata (`+xyz`) is informational and ignored for precedence.
function stripBuildMetadata(version) {
return (version || "").split("+")[0];
}
// Parse X.Y.Z to [major, minor, patch], or null when it isn't three integers.
function parseSemver(version) {
const parts = (version || "").split(".");
if (parts.length < 3) {
return null;
}
const nums = parts.slice(0, 3).map((p) => parseInt(p, 10));
return nums.some((n) => !Number.isInteger(n)) ? null : nums;
}
// True only when latest is strictly newer than current.
function isNewer(latest, current) {
const a = parseSemver(latest);
const b = parseSemver(current);
if (!a || !b) {
return false;
}
for (let i = 0; i < 3; i++) {
if (a[i] !== b[i]) {
return a[i] > b[i];
}
}
return false;
}
export { stripBuildMetadata, parseSemver, isNewer };

View File

@@ -0,0 +1,28 @@
import { describe, it, expect } from "vitest";
import { stripBuildMetadata, isNewer } from "./version.js";
describe("isNewer", () => {
it("is true when latest is strictly newer", () => {
expect(isNewer("0.8.4", "0.8.3")).toBe(true);
expect(isNewer("1.0.0", "0.9.9")).toBe(true);
expect(isNewer("0.9.0", "0.8.9")).toBe(true);
});
it("is false for older or equal, so no downgrade is prompted", () => {
expect(isNewer("0.8.3", "0.8.4")).toBe(false);
expect(isNewer("0.8.4", "0.8.4")).toBe(false);
expect(isNewer("0.9.9", "1.0.0")).toBe(false);
});
it("is false for malformed versions", () => {
expect(isNewer("x", "0.8.4")).toBe(false);
expect(isNewer("0.8", "0.8.4")).toBe(false);
expect(isNewer("1.x.0", "0.8.4")).toBe(false);
});
it("ignores build metadata, so an equal version with a build tag is not newer", () => {
expect(
isNewer(stripBuildMetadata("0.8.4"), stripBuildMetadata("0.8.4+q2fmfox")),
).toBe(false);
});
});

View File

@@ -1,4 +1,4 @@
const { FuzzySuggestModal } = require("obsidian"); import { FuzzySuggestModal } from "obsidian";
class WorkspacePickerModal extends FuzzySuggestModal { class WorkspacePickerModal extends FuzzySuggestModal {
constructor(app) { constructor(app) {
@@ -29,4 +29,4 @@ class WorkspacePickerModal extends FuzzySuggestModal {
} }
} }
module.exports = { WorkspacePickerModal }; export { WorkspacePickerModal };

View File

@@ -141,3 +141,18 @@
font-size: var(--font-ui-small); font-size: var(--font-ui-small);
margin-bottom: 16px; margin-bottom: 16px;
} }
.ignis-list-editor {
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
max-height: 220px;
overflow-y: auto;
padding: 0 var(--size-4-3);
margin-bottom: var(--size-4-4);
}
.ignis-list-empty {
color: var(--text-muted);
font-size: var(--font-ui-smaller);
padding: var(--size-4-3) 0;
}

View File

@@ -2,19 +2,119 @@ const { WebSocketServer } = require("ws");
const url = require("url"); const url = require("url");
const watcher = require("./watcher"); const watcher = require("./watcher");
// Null / undefined / empty array means no Origin check.
function toOriginSet(list) {
return Array.isArray(list) && list.length > 0 ? new Set(list) : null;
}
function setupWebSocket(server, opts = {}) { function setupWebSocket(server, opts = {}) {
const { getVaultPath } = opts; const { getVaultPath, originAllowlist } = opts;
if (typeof getVaultPath !== "function") { if (typeof getVaultPath !== "function") {
throw new Error("setupWebSocket: opts.getVaultPath is required"); throw new Error("setupWebSocket: opts.getVaultPath is required");
} }
const originSet = toOriginSet(originAllowlist);
const wss = new WebSocketServer({ server, path: "/ws" }); const wss = new WebSocketServer({ server, path: "/ws" });
// Plugin-registered message handlers: type -> handler(msg, ws) // Global message handlers: type -> handler(msg, ws).
wss.messageHandlers = new Map(); wss.messageHandlers = new Map();
// Channel-scoped message handlers: channel -> Map<type, handler>.
const channelHandlers = new Map();
// Connected clients per vault, for outbound broadcasts.
const clientsByVault = new Map();
// Per-client channel subscriptions, populated by inbound subscribe-channel / unsubscribe-channel messages.
// The broadcast layer uses this to gate channel-scoped broadcasts to only the clients that asked for them.
const channelSubsByClient = new WeakMap();
function clientHasChannel(ws, channelName) {
return channelSubsByClient.get(ws)?.has(channelName) === true;
}
function addClientChannel(ws, channelName) {
let set = channelSubsByClient.get(ws);
if (!set) {
set = new Set();
channelSubsByClient.set(ws, set);
}
set.add(channelName);
}
function removeClientChannel(ws, channelName) {
channelSubsByClient.get(ws)?.delete(channelName);
}
wss.broadcastToVault = function (vaultId, message) {
const clients = clientsByVault.get(vaultId);
if (!clients) {
return;
}
const payload = JSON.stringify(message);
for (const ws of clients) {
if (ws.readyState === ws.OPEN) {
ws.send(payload);
}
}
};
wss.channel = function (name) {
return {
on(type, handler) {
if (!channelHandlers.has(name)) {
channelHandlers.set(name, new Map());
}
channelHandlers.get(name).set(type, handler);
},
off(type) {
channelHandlers.get(name)?.delete(type);
},
// Sends a channel-scoped message only to clients that subscribed to this channel via subscribe-channel.
broadcastToVault(vaultId, message) {
const clients = clientsByVault.get(vaultId);
if (!clients) {
return;
}
const payload = JSON.stringify({ channel: name, ...message });
for (const ws of clients) {
if (ws.readyState !== ws.OPEN) {
continue;
}
if (!clientHasChannel(ws, name)) {
continue;
}
ws.send(payload);
}
},
};
};
wss.on("connection", (ws, req) => { wss.on("connection", (ws, req) => {
if (originSet) {
const origin = req.headers.origin;
if (!origin || !originSet.has(origin)) {
ws.close(4003, "Origin not allowed");
return;
}
}
const params = new url.URL(req.url, "http://localhost").searchParams; const params = new url.URL(req.url, "http://localhost").searchParams;
const vaultId = params.get("vault"); const vaultId = params.get("vault");
@@ -26,10 +126,16 @@ function setupWebSocket(server, opts = {}) {
const vaultPath = getVaultPath(vaultId); const vaultPath = getVaultPath(vaultId);
console.log(`[ws] Client connected to vault: ${vaultId}`); console.log(`[ws] Client connected to vault: ${vaultId}`);
if (!clientsByVault.has(vaultId)) {
clientsByVault.set(vaultId, new Set());
}
clientsByVault.get(vaultId).add(ws);
// Start watching this vault (no-op if already watching) // Start watching this vault (no-op if already watching)
watcher.startWatching(vaultId, vaultPath); watcher.startWatching(vaultId, vaultPath);
// Per-client listener that forwards events over WebSocket // Per-client listener that forwards file events over WebSocket
const listener = (event) => { const listener = (event) => {
if (ws.readyState === ws.OPEN) { if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify(event)); ws.send(JSON.stringify(event));
@@ -38,21 +144,68 @@ function setupWebSocket(server, opts = {}) {
watcher.addListener(vaultId, listener); watcher.addListener(vaultId, listener);
// Dispatch incoming messages to registered handlers // Dispatch incoming messages to registered handlers.
ws.on("message", (data) => { ws.on("message", (data) => {
try { let msg;
const msg = JSON.parse(data);
const handler = wss.messageHandlers.get(msg.type);
if (handler) { try {
handler(msg, ws); msg = JSON.parse(data);
} catch (e) {
console.warn("[ws] failed to parse incoming message:", e.message);
return;
}
// Built-in channel-subscription tracking. Plugins don't register handlers for these types.
if (msg.type === "subscribe-channel" && typeof msg.channel === "string") {
addClientChannel(ws, msg.channel);
return;
}
if (
msg.type === "unsubscribe-channel" &&
typeof msg.channel === "string"
) {
removeClientChannel(ws, msg.channel);
return;
}
try {
if (msg.channel) {
const handler = channelHandlers.get(msg.channel)?.get(msg.type);
if (handler) {
handler(msg, ws);
}
} else {
const handler = wss.messageHandlers.get(msg.type);
if (handler) {
handler(msg, ws);
}
} }
} catch {} } catch (e) {
console.warn(
`[ws] handler for ${msg.channel ? msg.channel + ":" : ""}${msg.type} threw:`,
e.message,
);
}
}); });
ws.on("close", () => { ws.on("close", () => {
console.log(`[ws] Client disconnected from vault: ${vaultId}`); console.log(`[ws] Client disconnected from vault: ${vaultId}`);
watcher.removeListener(vaultId, listener); watcher.removeListener(vaultId, listener);
const set = clientsByVault.get(vaultId);
if (set) {
set.delete(ws);
if (set.size === 0) {
clientsByVault.delete(vaultId);
}
}
channelSubsByClient.delete(ws);
}); });
}); });

View File

@@ -1,7 +1,10 @@
const esbuild = require("esbuild"); const esbuild = require("esbuild");
const path = require("path"); const path = require("path");
const { version: ignisVersion } = require("../../package.json"); const { version: semver } = require("../../package.json");
// Root build.js sets IGNIS_BUILD_RESOLVED when it runs first; standalone invocation falls back to a dev stamp.
const build = process.env.IGNIS_BUILD_RESOLVED || "dev";
module.exports = esbuild.build({ module.exports = esbuild.build({
entryPoints: [path.join(__dirname, "src", "loader.js")], entryPoints: [path.join(__dirname, "src", "loader.js")],
@@ -13,8 +16,13 @@ module.exports = esbuild.build({
alias: { alias: {
path: "path-browserify", path: "path-browserify",
}, },
loader: {
".css": "text",
},
external: ["obsidian", "fs"],
define: { define: {
__IGNIS_VERSION__: JSON.stringify(ignisVersion), __IGNIS_VERSION__: JSON.stringify(semver),
__IGNIS_BUILD__: JSON.stringify(build),
}, },
logLevel: "info", logLevel: "info",
}); });

View File

@@ -1,4 +1,4 @@
// Injects a link to the CSS overrides stylesheet served from /assets/overrides.css. import bridgeCss from "@ignis/bridge/styles.css";
export function installCssOverrides() { export function installCssOverrides() {
const link = document.createElement("link"); const link = document.createElement("link");
@@ -6,4 +6,9 @@ export function installCssOverrides() {
link.href = "/assets/overrides.css"; link.href = "/assets/overrides.css";
link.setAttribute("data-ignis", "css-overrides"); link.setAttribute("data-ignis", "css-overrides");
document.head.appendChild(link); document.head.appendChild(link);
const bridgeStyle = document.createElement("style");
bridgeStyle.textContent = bridgeCss;
bridgeStyle.setAttribute("data-ignis", "bridge-css");
document.head.appendChild(bridgeStyle);
} }

View File

@@ -1,5 +1,6 @@
import { showVaultManager } from "../ui-registry.js"; import { showVaultManager } from "../ui-registry.js";
import { vaultService } from "@ignis/services"; import { vaultService } from "@ignis/services";
import { proxyFetch } from "../util/proxy.js";
const listeners = new Map(); const listeners = new Map();
@@ -85,66 +86,21 @@ const syncHandlers = {
resources: () => "", resources: () => "",
}; };
function arrayBufferToBase64(buf) {
const bytes = new Uint8Array(buf);
let binary = "";
const chunk = 8192;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
}
return btoa(binary);
}
function base64ToArrayBuffer(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
async function handleRequestUrl(requestId, request) { async function handleRequestUrl(requestId, request) {
try { try {
let body = request.body; const result = await proxyFetch({
let binary = false; url: request.url,
method: request.method,
if (body instanceof ArrayBuffer) { headers: request.headers,
body = arrayBufferToBase64(body); body: request.body,
binary = true; contentType: request.contentType,
}
const res = await fetch("/api/proxy", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: request.url,
method: request.method || "GET",
headers: request.headers || {},
contentType: request.contentType,
body,
binary,
}),
}); });
const proxyResult = await res.json();
if (!res.ok) {
ipcRenderer._emit(requestId, {
error: proxyResult.error || "Proxy request failed",
});
return;
}
// Electron's e.reply(requestId, data) sends on the requestId channel // Electron's e.reply(requestId, data) sends on the requestId channel
ipcRenderer._emit(requestId, { ipcRenderer._emit(requestId, {
status: proxyResult.status, status: result.status,
headers: proxyResult.headers, headers: result.headers,
body: base64ToArrayBuffer(proxyResult.body), body: result.body,
}); });
} catch (e) { } catch (e) {
ipcRenderer._emit(requestId, { ipcRenderer._emit(requestId, {

View File

@@ -1,10 +1,18 @@
import { getClipboard } from "./native-clipboard.js";
export const clipboardShim = { export const clipboardShim = {
readText() { readText() {
return ""; return "";
}, },
writeText(text) { writeText(text) {
navigator.clipboard.writeText(text).catch((e) => { const clip = getClipboard();
if (!clip) {
return;
}
clip.writeText(text).catch((e) => {
console.warn("[shim:clipboard] writeText failed:", e); console.warn("[shim:clipboard] writeText failed:", e);
}); });
}, },
@@ -14,7 +22,13 @@ export const clipboardShim = {
}, },
writeHTML(html) { writeHTML(html) {
navigator.clipboard const clip = getClipboard();
if (!clip) {
return;
}
clip
.write([ .write([
new ClipboardItem({ new ClipboardItem({
"text/html": new Blob([html], { type: "text/html" }), "text/html": new Blob([html], { type: "text/html" }),
@@ -35,6 +49,12 @@ export const clipboardShim = {
return; return;
} }
const clip = getClipboard();
if (!clip) {
return;
}
const pngData = image.toPNG(); const pngData = image.toPNG();
if (!pngData || pngData.length === 0) { if (!pngData || pngData.length === 0) {
@@ -43,11 +63,9 @@ export const clipboardShim = {
const blob = new Blob([pngData], { type: "image/png" }); const blob = new Blob([pngData], { type: "image/png" });
navigator.clipboard clip.write([new ClipboardItem({ "image/png": blob })]).catch((e) => {
.write([new ClipboardItem({ "image/png": blob })]) console.warn("[shim:clipboard] writeImage failed:", e);
.catch((e) => { });
console.warn("[shim:clipboard] writeImage failed:", e);
});
}, },
has(format) { has(format) {
@@ -59,6 +77,12 @@ export const clipboardShim = {
}, },
clear() { clear() {
navigator.clipboard.writeText("").catch(() => {}); const clip = getClipboard();
if (!clip) {
return;
}
clip.writeText("").catch(() => {});
}, },
}; };

View File

@@ -0,0 +1,22 @@
// Obsidian points navigator.clipboard.writeText at electron.clipboard, which already points at this shim.
// To avoid recursion, use the untouched native prototype methods.
const proto = typeof Clipboard !== "undefined" ? Clipboard.prototype : null;
// Returns a native-backed clipboard facade, or null in insecure (non-localhost http) contexts.
export function getClipboard() {
const clip =
typeof navigator !== "undefined" ? navigator.clipboard : undefined;
if (!proto || !clip) {
console.warn(
"[shim:clipboard] clipboard API unavailable (insecure context?)",
);
return null;
}
return {
writeText: (text) => proto.writeText.call(clip, text),
write: (items) => proto.write.call(clip, items),
read: () => proto.read.call(clip),
};
}

View File

@@ -1,3 +1,5 @@
import { getClipboard } from "./native-clipboard.js";
const currentWindowState = { const currentWindowState = {
title: "Obsidian", title: "Obsidian",
isMaximized: false, isMaximized: false,
@@ -196,7 +198,13 @@ const currentWebContents = {
document.execCommand("copy"); document.execCommand("copy");
}, },
paste() { paste() {
navigator.clipboard const clip = getClipboard();
if (!clip) {
return;
}
clip
.read() .read()
.then(async (items) => { .then(async (items) => {
const dt = new DataTransfer(); const dt = new DataTransfer();
@@ -233,7 +241,13 @@ const currentWebContents = {
}); });
}, },
pasteAndMatchStyle() { pasteAndMatchStyle() {
navigator.clipboard const clip = getClipboard();
if (!clip) {
return;
}
clip
.read() .read()
.then(async (items) => { .then(async (items) => {
for (const item of items) { for (const item of items) {

View File

@@ -0,0 +1,34 @@
const CALLBACK_METHODS = [
"stat",
"lstat",
"readdir",
"readFile",
"writeFile",
"appendFile",
"unlink",
"rename",
"mkdir",
"rmdir",
"rm",
"copyFile",
"access",
"utimes",
"chmod",
];
export function createFsCallbacks(fsPromises) {
const callbacks = {};
for (const name of CALLBACK_METHODS) {
callbacks[name] = function (...args) {
const callback = args.pop();
fsPromises[name](...args).then(
(result) => callback(null, result),
(err) => callback(err),
);
};
}
return callbacks;
}

View File

@@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
import { createFsCallbacks } from "./callback.js";
describe("fs callbacks", () => {
it("resolves the promise result through the callback", async () => {
const fakePromises = { readFile: async (p) => `data:${p}` };
const cb = createFsCallbacks(fakePromises);
const result = await new Promise((resolve) =>
cb.readFile("/x", (err, data) => resolve([err, data])),
);
expect(result).toEqual([null, "data:/x"]);
});
it("passes a rejection to the callback as the error argument", async () => {
const boom = new Error("nope");
const fakePromises = {
stat: async () => {
throw boom;
},
};
const cb = createFsCallbacks(fakePromises);
const result = await new Promise((resolve) =>
cb.stat("/x", (err) => resolve(err)),
);
expect(result).toBe(boom);
});
it("forwards the arguments that precede the callback", async () => {
let received = null;
const fakePromises = {
mkdir: async (p, opts) => {
received = [p, opts];
},
};
const cb = createFsCallbacks(fakePromises);
await new Promise((resolve) =>
cb.mkdir("/d", { recursive: true }, () => resolve()),
);
expect(received).toEqual(["/d", { recursive: true }]);
});
});

View File

@@ -10,6 +10,14 @@ export class ContentCache {
this._maxSize = maxSize; this._maxSize = maxSize;
} }
setMaxSize(maxSize) {
this._maxSize = maxSize;
while (this._currentSize > this._maxSize && this._cache.size > 0) {
this._evictOne();
}
}
has(path) { has(path) {
return this._cache.has(this._normalize(path)); return this._cache.has(this._normalize(path));
} }

View File

@@ -1,17 +1,10 @@
// Shared echo suppression for file watcher. // Shared echo suppression for file watcher.
// fs operations mark paths as "locally modified" so the watcher client // fs operations mark paths as "locally modified" so the watcher client can skip events that originated from this client.
// can skip events that originated from this client. import { normalize } from "../util/path.js";
const ECHO_SUPPRESS_MS = 1500; const ECHO_SUPPRESS_MS = 1500;
const recentOps = new Map(); // normalized path -> timestamp const recentOps = new Map(); // normalized path -> timestamp
function normalize(p) {
return (p || "")
.replace(/\\/g, "/")
.replace(/^\/+/, "")
.replace(/\/+$/, "");
}
export function markLocalOp(path) { export function markLocalOp(path) {
recentOps.set(normalize(path), Date.now()); recentOps.set(normalize(path), Date.now());
} }

View File

@@ -1,9 +1,8 @@
// File descriptor shim - maps fake integer fds to in-memory file buffers. // File descriptor shim. Maps fake integer fds to in-memory file buffers.
// Enables libraries like yauzl that use fs.open/fs.read/fs.close to seek
// around files without loading them via readFileSync upfront.
import { isInputCachePath, inputCacheGet } from "./input-cache.js"; import { isInputCachePath, inputCacheGet } from "./input-cache.js";
import { resolvePath } from "./transforms.js"; import { resolvePath } from "./transforms.js";
import { hasVirtualFile, getVirtualFile } from "./virtual-files.js";
let nextFd = 100; let nextFd = 100;
const openFiles = new Map(); const openFiles = new Map();
@@ -24,6 +23,15 @@ export function createFdOps(metadataCache, contentCache, transport) {
} }
const resolved = resolvePath(path); const resolved = resolvePath(path);
if (hasVirtualFile(resolved)) {
const content = getVirtualFile(resolved);
return typeof content === "string"
? new TextEncoder().encode(content)
: content;
}
const cached = contentCache.get(resolved); const cached = contentCache.get(resolved);
if (cached !== null) { if (cached !== null) {
@@ -60,7 +68,11 @@ export function createFdOps(metadataCache, contentCache, transport) {
const hasInCache = isInputCachePath(path) && inputCacheGet(path) !== null; const hasInCache = isInputCachePath(path) && inputCacheGet(path) !== null;
const resolved = resolvePath(path); const resolved = resolvePath(path);
if (!hasInCache && !metadataCache.has(resolved)) { if (
!hasInCache &&
!hasVirtualFile(resolved) &&
!metadataCache.has(resolved)
) {
const err = new Error( const err = new Error(
`ENOENT: no such file or directory, open '${path}'`, `ENOENT: no such file or directory, open '${path}'`,
); );

View File

@@ -6,8 +6,11 @@ import { createFsSync } from "./sync.js";
import { createFsWatch } from "./watch.js"; import { createFsWatch } from "./watch.js";
import { createWatcherClient } from "./watcher-client.js"; import { createWatcherClient } from "./watcher-client.js";
import { createFdOps } from "./fd.js"; import { createFdOps } from "./fd.js";
import { createFsCallbacks } from "./callback.js";
import { realpath, realpathSync } from "./realpath.js";
import { constants } from "./constants.js"; import { constants } from "./constants.js";
import { registerReadTransform, removeReadTransform, resolvePath } from "./transforms.js"; import { registerReadTransform, removeReadTransform, resolvePath } from "./transforms.js";
import { wsClient } from "../ws-client.js";
const metadataCache = new MetadataCache(); const metadataCache = new MetadataCache();
const contentCache = new ContentCache(); const contentCache = new ContentCache();
@@ -15,12 +18,15 @@ const contentCache = new ContentCache();
const fsPromises = createFsPromises(metadataCache, contentCache, transport); const fsPromises = createFsPromises(metadataCache, contentCache, transport);
const fsSync = createFsSync(metadataCache, contentCache, transport); const fsSync = createFsSync(metadataCache, contentCache, transport);
const fsWatch = createFsWatch(transport); const fsWatch = createFsWatch(transport);
const watcherClient = createWatcherClient(metadataCache, contentCache, fsWatch); const watcherClient = createWatcherClient(metadataCache, contentCache, fsWatch, wsClient);
const fdOps = createFdOps(metadataCache, contentCache, transport); const fdOps = createFdOps(metadataCache, contentCache, transport);
const fsCallbacks = createFsCallbacks(fsPromises);
export const fsShim = { export const fsShim = {
promises: fsPromises, promises: fsPromises,
...fsCallbacks,
existsSync: fsSync.existsSync, existsSync: fsSync.existsSync,
readFileSync: fsSync.readFileSync, readFileSync: fsSync.readFileSync,
writeFileSync: fsSync.writeFileSync, writeFileSync: fsSync.writeFileSync,
@@ -28,6 +34,18 @@ export const fsShim = {
accessSync: fsSync.accessSync, accessSync: fsSync.accessSync,
statSync: fsSync.statSync, statSync: fsSync.statSync,
readdirSync: fsSync.readdirSync, readdirSync: fsSync.readdirSync,
lstatSync: fsSync.lstatSync,
mkdirSync: fsSync.mkdirSync,
rmdirSync: fsSync.rmdirSync,
rmSync: fsSync.rmSync,
renameSync: fsSync.renameSync,
copyFileSync: fsSync.copyFileSync,
appendFileSync: fsSync.appendFileSync,
utimesSync: fsSync.utimesSync,
chmodSync: fsSync.chmodSync,
realpath,
realpathSync,
open: fdOps.open, open: fdOps.open,
openSync: fdOps.openSync, openSync: fdOps.openSync,

View File

@@ -5,19 +5,14 @@
// - 5-minute TTL per entry // - 5-minute TTL per entry
// - Entries kept until TTL expires (plugins may read the same file multiple times) // - Entries kept until TTL expires (plugins may read the same file multiple times)
const MAX_SIZE = 200 * 1024 * 1024; import { normalize } from "../util/path.js";
const TTL_MS = 5 * 60 * 1000;
let MAX_SIZE = 200 * 1024 * 1024;
let TTL_MS = 5 * 60 * 1000;
const cache = new Map(); // path -> { data, size, createdAt } const cache = new Map(); // path -> { data, size, createdAt }
let currentSize = 0; let currentSize = 0;
function normalize(p) {
return (p || "")
.replace(/\\/g, "/")
.replace(/^\/+/, "")
.replace(/\/+$/, "");
}
function evictExpired() { function evictExpired() {
const now = Date.now(); const now = Date.now();
@@ -117,6 +112,20 @@ export function inputCacheClear() {
currentSize = 0; currentSize = 0;
} }
export function setInputCacheLimits({ maxSize, ttlMs }) {
if (Number.isFinite(maxSize)) {
MAX_SIZE = maxSize;
while (currentSize > MAX_SIZE && cache.size > 0) {
evictOldest();
}
}
if (Number.isFinite(ttlMs)) {
TTL_MS = ttlMs;
}
}
export function isInputCachePath(path) { export function isInputCachePath(path) {
const norm = normalize(path); const norm = normalize(path);
return norm.startsWith(".obsidian/imports/"); return norm.startsWith(".obsidian/imports/");

View File

@@ -1,6 +1,12 @@
import { markLocalOp } from "./echo-guard.js"; import { markLocalOp } from "./echo-guard.js";
import { isInputCachePath, inputCacheGet } from "./input-cache.js"; import { isInputCachePath, inputCacheGet } from "./input-cache.js";
import { applyReadTransform, applyWriteTransform, resolvePath } from "./transforms.js"; import {
applyReadTransform,
applyWriteTransform,
resolvePath,
} from "./transforms.js";
import { hasVirtualFile, getVirtualFile } from "./virtual-files.js";
import { realpathSync } from "./realpath.js";
export function createFsPromises(metadataCache, contentCache, transport) { export function createFsPromises(metadataCache, contentCache, transport) {
return { return {
@@ -49,6 +55,21 @@ export function createFsPromises(metadataCache, contentCache, transport) {
const wantText = encoding === "utf8" || encoding === "utf-8"; const wantText = encoding === "utf8" || encoding === "utf-8";
const resolved = resolvePath(path); const resolved = resolvePath(path);
// Virtual plugin source overrides any cache/transport version.
if (hasVirtualFile(resolved)) {
const content = getVirtualFile(resolved);
if (wantText) {
return typeof content === "string"
? content
: new TextDecoder().decode(content);
}
return typeof content === "string"
? new TextEncoder().encode(content)
: content;
}
let result = null; let result = null;
// Check input cache for files picked via browser file dialogs. // Check input cache for files picked via browser file dialogs.
@@ -240,7 +261,8 @@ export function createFsPromises(metadataCache, contentCache, transport) {
return "/"; return "/";
} }
return transport.realpath(path); // No symlinks in the vault FS, so realpath is the identity.
return realpathSync(path);
}, },
async utimes(path, atime, mtime) { async utimes(path, atime, mtime) {
@@ -254,6 +276,10 @@ export function createFsPromises(metadataCache, contentCache, transport) {
} }
}, },
async chmod() {
// No permission bits in the vault FS. No-op.
},
async open(path, flags) { async open(path, flags) {
const hasInCache = isInputCachePath(path) && inputCacheGet(path) !== null; const hasInCache = isInputCachePath(path) && inputCacheGet(path) !== null;
const resolved = resolvePath(path); const resolved = resolvePath(path);

View File

@@ -0,0 +1,12 @@
export function realpathSync(path) {
return typeof path === "string" ? path : String(path);
}
export function realpath(path, options, callback) {
const cb = typeof options === "function" ? options : callback;
queueMicrotask(() => cb(null, realpathSync(path)));
}
realpath.native = realpath;
realpathSync.native = realpathSync;

View File

@@ -0,0 +1,36 @@
import { describe, it, expect } from "vitest";
import { realpath } from "./realpath.js";
import { createFsPromises } from "./promises.js";
describe("fs realpath shim", () => {
it("realpath invokes the callback with the path", async () => {
const result = await new Promise((resolve) =>
realpath("/a/b.md", (err, p) => resolve(p)),
);
expect(result).toBe("/a/b.md");
});
it("realpath accepts an options argument before the callback", async () => {
const result = await new Promise((resolve) =>
realpath("/a/b.md", "utf8", (err, p) => resolve(p)),
);
expect(result).toBe("/a/b.md");
});
});
describe("fs.promises realpath", () => {
it("answers locally without touching the transport", async () => {
const fs = createFsPromises({}, {}, {});
expect(await fs.realpath("/a/b.md")).toBe("/a/b.md");
});
it("maps empty and root paths to /", async () => {
const fs = createFsPromises({}, {}, {});
expect(await fs.realpath("")).toBe("/");
expect(await fs.realpath(".")).toBe("/");
});
});

View File

@@ -0,0 +1,128 @@
import { describe, it, expect, vi } from "vitest";
import { createFsSync } from "./sync.js";
import { resolvePath } from "./transforms.js";
function makeDeps() {
const store = new Map();
const metadataCache = {
has: (p) => store.has(p),
get: (p) => (store.has(p) ? store.get(p) : null),
set: (p, m) => store.set(p, m),
delete: (p) => store.delete(p),
rename: (a, b) => {
if (store.has(a)) {
store.set(b, store.get(a));
store.delete(a);
}
},
toStat: (p) =>
store.has(p)
? {
type: store.get(p).type,
isDirectory: () => store.get(p).type === "directory",
isFile: () => store.get(p).type === "file",
}
: null,
readdir: () => [],
};
const contentCache = {
get: () => null,
set: vi.fn(),
delete: vi.fn(),
invalidate: vi.fn(),
};
const transport = {
mkdir: vi.fn(async () => {}),
rmdir: vi.fn(async () => {}),
rm: vi.fn(async () => {}),
rename: vi.fn(async () => {}),
copyFile: vi.fn(async () => {}),
appendFile: vi.fn(async () => {}),
utimes: vi.fn(async () => {}),
stat: vi.fn(async () => ({ type: "file", size: 1 })),
};
return { metadataCache, contentCache, transport, store };
}
describe("sync fs mutations", () => {
it("lstatSync mirrors statSync", () => {
const deps = makeDeps();
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
deps.store.set(resolvePath("dir"), { type: "directory" });
expect(fs.lstatSync("dir").isDirectory()).toBe(true);
});
it("mkdirSync updates the cache and fires the transport", () => {
const deps = makeDeps();
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
fs.mkdirSync("newdir", { recursive: true });
expect(deps.store.get("newdir")).toEqual({ type: "directory" });
expect(deps.transport.mkdir).toHaveBeenCalledWith("newdir", true);
});
it("rmSync deletes from the cache and fires the transport", () => {
const deps = makeDeps();
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
const key = resolvePath("gone.md");
deps.store.set(key, { type: "file" });
fs.rmSync("gone.md", { recursive: true });
expect(deps.store.has(key)).toBe(false);
expect(deps.transport.rm).toHaveBeenCalled();
});
it("renameSync moves cache metadata and fires the transport", () => {
const deps = makeDeps();
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
const from = resolvePath("a.md");
const to = resolvePath("b.md");
deps.store.set(from, { type: "file", size: 2 });
fs.renameSync("a.md", "b.md");
expect(deps.store.has(from)).toBe(false);
expect(deps.store.get(to)).toEqual({ type: "file", size: 2 });
expect(deps.transport.rename).toHaveBeenCalled();
});
it("copyFileSync optimistically mirrors source metadata and fires the transport", () => {
const deps = makeDeps();
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
const srcKey = resolvePath("src.md");
const destKey = resolvePath("dest.md");
deps.store.set(srcKey, { type: "file", size: 9 });
fs.copyFileSync("src.md", "dest.md");
expect(deps.store.get(destKey)).toEqual({ type: "file", size: 9 });
expect(deps.transport.copyFile).toHaveBeenCalled();
});
it("utimesSync sets mtime and fires the transport", () => {
const deps = makeDeps();
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
const key = resolvePath("note.md");
deps.store.set(key, { type: "file", mtime: 0 });
fs.utimesSync("note.md", 111, 222);
expect(deps.store.get(key).mtime).toBe(222);
expect(deps.transport.utimes).toHaveBeenCalled();
});
it("chmodSync is a no-op that does not throw", () => {
const deps = makeDeps();
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
expect(() => fs.chmodSync("note.md", 0o644)).not.toThrow();
expect(fs.chmodSync("note.md", 0o644)).toBeUndefined();
});
});

View File

@@ -5,6 +5,7 @@ import {
applyWriteTransform, applyWriteTransform,
resolvePath, resolvePath,
} from "./transforms.js"; } from "./transforms.js";
import { hasVirtualFile, getVirtualFile } from "./virtual-files.js";
export function createFsSync(metadataCache, contentCache, transport) { export function createFsSync(metadataCache, contentCache, transport) {
return { return {
@@ -70,6 +71,21 @@ export function createFsSync(metadataCache, contentCache, transport) {
const wantText = encoding === "utf8" || encoding === "utf-8"; const wantText = encoding === "utf8" || encoding === "utf-8";
const resolved = resolvePath(path); const resolved = resolvePath(path);
// Virtual plugin source overrides any cache or transport version.
if (hasVirtualFile(resolved)) {
const content = getVirtualFile(resolved);
if (wantText) {
return typeof content === "string"
? content
: new TextDecoder().decode(content);
}
return typeof content === "string"
? new TextEncoder().encode(content)
: content;
}
const meta = metadataCache.get(resolved); const meta = metadataCache.get(resolved);
if (meta && meta.type === "directory") { if (meta && meta.type === "directory") {
const e = new Error("EISDIR: illegal operation on a directory, read"); const e = new Error("EISDIR: illegal operation on a directory, read");
@@ -180,5 +196,147 @@ export function createFsSync(metadataCache, contentCache, transport) {
const entries = metadataCache.readdir(path); const entries = metadataCache.readdir(path);
return entries.map((e) => e.name); return entries.map((e) => e.name);
}, },
lstatSync(path) {
// No symlinks in our context.
return this.statSync(path);
},
mkdirSync(path, options) {
const recursive =
typeof options === "object" ? !!options.recursive : !!options;
markLocalOp(path);
metadataCache.set(path, { type: "directory" });
transport.mkdir(path, recursive).catch((e) => {
console.error("[shim:fs] mkdirSync background create failed:", path, e);
});
},
rmdirSync(path) {
markLocalOp(path);
metadataCache.delete(path);
transport.rmdir(path).catch((e) => {
console.error("[shim:fs] rmdirSync background remove failed:", path, e);
});
},
rmSync(path, options) {
const recursive =
typeof options === "object" ? !!options.recursive : false;
const resolved = resolvePath(path);
markLocalOp(resolved);
metadataCache.delete(resolved);
contentCache.delete(resolved);
transport.rm(resolved, recursive).catch((e) => {
console.error(
"[shim:fs] rmSync background remove failed:",
resolved,
e,
);
});
},
renameSync(oldPath, newPath) {
const resolvedOld = resolvePath(oldPath);
const resolvedNew = resolvePath(newPath);
markLocalOp(resolvedOld);
markLocalOp(resolvedNew);
const content = contentCache.get(resolvedOld);
if (content !== null) {
contentCache.set(resolvedNew, content);
contentCache.delete(resolvedOld);
}
metadataCache.rename(resolvedOld, resolvedNew);
transport.rename(resolvedOld, resolvedNew).catch((e) => {
console.error(
"[shim:fs] renameSync background rename failed:",
resolvedOld,
e,
);
});
},
copyFileSync(src, dest) {
const resolvedSrc = resolvePath(src);
const resolvedDest = resolvePath(dest);
markLocalOp(resolvedDest);
// Optimistically mirror the source so a sync read right after sees it.
const content = contentCache.get(resolvedSrc);
if (content !== null) {
contentCache.set(resolvedDest, content);
}
const srcMeta = metadataCache.get(resolvedSrc);
if (srcMeta) {
metadataCache.set(resolvedDest, { ...srcMeta });
}
transport
.copyFile(src, resolvedDest)
.then(() => transport.stat(resolvedDest))
.then((meta) => metadataCache.set(resolvedDest, meta))
.catch((e) => {
console.error(
"[shim:fs] copyFileSync background copy failed:",
resolvedDest,
e,
);
});
},
appendFileSync(path, data) {
const resolved = resolvePath(path);
markLocalOp(resolved);
contentCache.invalidate(resolved);
transport
.appendFile(resolved, data)
.then(() => transport.stat(resolved))
.then((meta) => metadataCache.set(resolved, meta))
.catch((e) => {
console.error(
"[shim:fs] appendFileSync background append failed:",
resolved,
e,
);
});
},
utimesSync(path, atime, mtime) {
const resolved = resolvePath(path);
const meta = metadataCache.get(resolved);
if (meta) {
meta.mtime = typeof mtime === "number" ? mtime : mtime.getTime();
metadataCache.set(resolved, meta);
}
transport.utimes(resolved, atime, mtime).catch((e) => {
console.error(
"[shim:fs] utimesSync background utimes failed:",
resolved,
e,
);
});
},
chmodSync() {
// The vault FS does not model permission bits. No-op.
},
}; };
} }

View File

@@ -2,9 +2,7 @@
// Path resolvers map logical paths to physical paths; read transforms post-process bytes after a read; write transforms pre-process bytes before a write. // Path resolvers map logical paths to physical paths; read transforms post-process bytes after a read; write transforms pre-process bytes before a write.
// All hooks run at the shim's public surface, so caches and transport see only physical paths and as-stored bytes. // All hooks run at the shim's public surface, so caches and transport see only physical paths and as-stored bytes.
function normalize(p) { import { normalize } from "../util/path.js";
return (p || "").replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, "");
}
// --- Path resolvers --- // --- Path resolvers ---

View File

@@ -177,13 +177,6 @@ export const transport = {
return requestJson("GET", "/access", { path: normPath(path) }); return requestJson("GET", "/access", { path: normPath(path) });
}, },
async realpath(path) {
const result = await requestJson("GET", "/realpath", {
path: normPath(path),
});
return result.path;
},
async utimes(path, atime, mtime) { async utimes(path, atime, mtime) {
return requestJson("POST", "/utimes", { return requestJson("POST", "/utimes", {
path: normPath(path), path: normPath(path),

View File

@@ -0,0 +1,27 @@
// Virtual plugin source served from memory; the fs shim's read path checks here before disk.
import { normalize } from "../util/path.js";
const virtualFiles = new Map();
export function setVirtualFile(path, content) {
const normalized = normalize(path);
if (normalized.split("/").includes("..")) {
throw new Error(`virtual file path may not contain '..': ${path}`);
}
virtualFiles.set(normalized, content);
}
export function removeVirtualFile(path) {
virtualFiles.delete(normalize(path));
}
export function getVirtualFile(path) {
return virtualFiles.get(normalize(path));
}
export function hasVirtualFile(path) {
return virtualFiles.has(normalize(path));
}

View File

@@ -1,143 +1,83 @@
// Client-side WebSocket file watcher. // Bridges WebSocket file events to the fs shim's metadata/content caches and fs.watch listeners.
// Connects to the server's /ws endpoint, receives file change events, // The WebSocket itself is owned by ws-client.js; this module is a consumer.
// updates the metadata/content caches, and dispatches to fs.watch listeners
// so Obsidian's vault picks them up automatically.
import { isRecentLocalOp } from "./echo-guard.js"; import { isRecentLocalOp } from "./echo-guard.js";
const RECONNECT_DELAY = 2000; export function createWatcherClient(metadataCache, contentCache, fsWatch, wsClient) {
function handleCreated(msg) {
const { path, stat } = msg;
export function createWatcherClient(metadataCache, contentCache, fsWatch) { if (!path || isRecentLocalOp(path)) {
let ws = null;
let vaultId = null;
let reconnectTimer = null;
function connect(vault) {
vaultId = vault;
if (!vaultId) {
console.warn("[watcher] No vault ID, skipping WebSocket connection");
return; return;
} }
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; if (stat) {
const url = `${protocol}//${window.location.host}/ws?vault=${encodeURIComponent(vaultId)}`; metadataCache.set(path, {
type: "file",
try { size: stat.size,
ws = new WebSocket(url); mtime: stat.mtime,
window.__ignisWs = ws; ctime: stat.ctime,
} catch (e) { });
console.error("[watcher] Failed to create WebSocket:", e);
scheduleReconnect();
return;
} }
ws.onopen = () => { contentCache.invalidate(path);
console.log("[watcher] Connected to file watcher"); fsWatch._dispatch("created", path);
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
handleEvent(msg);
} catch (e) {
console.error("[watcher] Failed to parse message:", e);
}
};
ws.onclose = () => {
console.log("[watcher] Disconnected");
ws = null;
scheduleReconnect();
};
ws.onerror = (e) => {
console.error("[watcher] WebSocket error:", e);
};
} }
function scheduleReconnect() { function handleFolderCreated(msg) {
if (reconnectTimer) return; const { path } = msg;
reconnectTimer = setTimeout(() => { if (!path || isRecentLocalOp(path)) {
reconnectTimer = null; return;
}
if (vaultId) { metadataCache.set(path, { type: "directory" });
console.log("[watcher] Reconnecting..."); fsWatch._dispatch("folder-created", path);
connect(vaultId);
}
}, RECONNECT_DELAY);
} }
function handleEvent(msg) { function handleModified(msg) {
// Skip channel-based plugin messages, those are for other listeners const { path, stat } = msg;
if (msg.channel) {
if (!path || isRecentLocalOp(path)) {
return; return;
} }
const { type, path, stat } = msg; if (stat) {
metadataCache.set(path, {
type: "file",
size: stat.size,
mtime: stat.mtime,
ctime: stat.ctime,
});
}
if (!type || !path) return; contentCache.invalidate(path);
fsWatch._dispatch("modified", path);
}
// Suppress echo from our own operations function handleDeleted(msg) {
if (isRecentLocalOp(path)) { const { path } = msg;
if (!path || isRecentLocalOp(path)) {
return; return;
} }
switch (type) { metadataCache.delete(path);
case "created": contentCache.invalidate(path);
if (stat) { fsWatch._dispatch("deleted", path);
metadataCache.set(path, { }
type: "file",
size: stat.size,
mtime: stat.mtime,
ctime: stat.ctime,
});
}
contentCache.invalidate(path);
fsWatch._dispatch("created", path);
break;
case "folder-created": wsClient.subscribe("created", handleCreated);
metadataCache.set(path, { type: "directory" }); wsClient.subscribe("folder-created", handleFolderCreated);
fsWatch._dispatch("folder-created", path); wsClient.subscribe("modified", handleModified);
break; wsClient.subscribe("deleted", handleDeleted);
case "modified": function connect(vaultId) {
if (stat) { wsClient.connect(vaultId);
metadataCache.set(path, {
type: "file",
size: stat.size,
mtime: stat.mtime,
ctime: stat.ctime,
});
}
contentCache.invalidate(path);
fsWatch._dispatch("modified", path);
break;
case "deleted":
metadataCache.delete(path);
contentCache.invalidate(path);
fsWatch._dispatch("deleted", path);
break;
default:
console.warn("[watcher] Unknown event type:", type);
}
} }
function disconnect() { function disconnect() {
if (reconnectTimer) { wsClient.disconnect();
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (ws) {
ws.onclose = null; // prevent reconnect
ws.close();
ws = null;
}
} }
return { return {

View File

@@ -4,6 +4,8 @@ import {
unregisterPopupWindow, unregisterPopupWindow,
} from "./electron/remote/window.js"; } from "./electron/remote/window.js";
import { showVaultManager } from "./ui-registry.js"; import { showVaultManager } from "./ui-registry.js";
import { isSameOrigin } from "./util/url.js";
import { proxyFetch } from "./util/proxy.js";
function installProcess() { function installProcess() {
window.process = processShim; window.process = processShim;
@@ -115,51 +117,6 @@ function installWindowOpen() {
}; };
} }
function arrayBufferToBase64(buf) {
const bytes = new Uint8Array(buf);
let binary = "";
const chunk = 8192;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
}
return btoa(binary);
}
function base64ToArrayBuffer(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
function isSameOrigin(url) {
if (
!url ||
url.startsWith("/") ||
url.startsWith("./") ||
url.startsWith("../")
) {
return true;
}
if (url.startsWith("data:") || url.startsWith("blob:")) {
return true;
}
try {
const parsed = new URL(url, window.location.origin);
return parsed.origin === window.location.origin;
} catch {
return true;
}
}
function installFetchShim() { function installFetchShim() {
const originalFetch = window.fetch.bind(window); const originalFetch = window.fetch.bind(window);
window.__originalFetch = originalFetch; window.__originalFetch = originalFetch;
@@ -210,17 +167,15 @@ function installFetchShim() {
} }
let body = null; let body = null;
let binary = false;
if (init?.body && method !== "GET" && method !== "HEAD") { if (init?.body && method !== "GET" && method !== "HEAD") {
if (typeof init.body === "string") { if (typeof init.body === "string") {
body = init.body; body = init.body;
} else if (init.body instanceof ArrayBuffer) { } else if (
body = arrayBufferToBase64(init.body); init.body instanceof ArrayBuffer ||
binary = true; init.body instanceof Uint8Array
} else if (init.body instanceof Uint8Array) { ) {
body = arrayBufferToBase64(init.body.buffer); body = init.body;
binary = true;
} else if (typeof init.body === "object") { } else if (typeof init.body === "object") {
body = JSON.stringify(init.body); body = JSON.stringify(init.body);
} else { } else {
@@ -230,23 +185,15 @@ function installFetchShim() {
console.log("[shim:fetch] Proxying cross-origin:", method, url); console.log("[shim:fetch] Proxying cross-origin:", method, url);
const proxyRes = await originalFetch("/api/proxy", { let result;
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url, method, headers, body, binary }),
});
if (!proxyRes.ok) { try {
const err = await proxyRes result = await proxyFetch({ url, method, headers, body });
.json() } catch (e) {
.catch(() => ({ error: "Proxy request failed" })); throw new TypeError(e.message || "Failed to fetch");
throw new TypeError(err.error || "Failed to fetch");
} }
const result = await proxyRes.json(); return new Response(result.body, {
const respBody = base64ToArrayBuffer(result.body);
return new Response(respBody, {
status: result.status, status: result.status,
headers: result.headers, headers: result.headers,
}); });
@@ -281,7 +228,12 @@ function installContextMenuFix() {
); );
} }
function installGlobalAlias() {
window.global = window;
}
export function installGlobals() { export function installGlobals() {
installGlobalAlias();
installProcess(); installProcess();
installBuffer(); installBuffer();
installFetchShim(); installFetchShim();

View File

@@ -0,0 +1,28 @@
// Public Ignis API surface. The documented way for plugins (and Ignis-internal code) to reach shim services.
// WIP, may expand to cover more shared functionality.
export function installIgnisApi(wsClient) {
window.__ignis = window.__ignis || {};
// Live getters so vault info reflects whatever init.js / vault-switch code has set.
Object.defineProperty(window.__ignis, "vault", {
get() {
return {
id: window.__currentVaultId || null,
path: window.__vaultConfig?.path || null,
};
},
enumerable: true,
configurable: true,
});
window.__ignis.ws = {
subscribe: wsClient.subscribe,
send: wsClient.send,
channel: wsClient.channel,
isOpen: wsClient.isOpen,
onStateChange: wsClient.onStateChange,
};
window.__ignis.plugins = window.__ignis.plugins || {};
}

View File

@@ -1,7 +1,6 @@
import { fsShim } from "./fs/index.js"; import { fsShim } from "./fs/index.js";
import { installRequestUrlShim } from "./request-url.js"; import { installRequestUrlShim } from "./request-url.js";
import { vaultService } from "@ignis/services"; import { vaultService } from "@ignis/services";
import { showPluginInstallDialog } from "./ui-registry.js";
import { registerReadTransform } from "./fs/transforms.js"; import { registerReadTransform } from "./fs/transforms.js";
import { import {
resolveWorkspaceName, resolveWorkspaceName,
@@ -9,9 +8,30 @@ import {
initWorkspacePatch, initWorkspacePatch,
} from "./workspace.js"; } from "./workspace.js";
import { prefetchVaultContent } from "./fs/indexer-prefetch.js"; import { prefetchVaultContent } from "./fs/indexer-prefetch.js";
import { setInputCacheLimits } from "./fs/input-cache.js";
import { autoTrustDemoVaults, maybeProvisionDemoVault } from "./demo.js"; import { autoTrustDemoVaults, maybeProvisionDemoVault } from "./demo.js";
import { initNativeMenuGuard } from "./native-menu-guard.js"; import { initNativeMenuGuard } from "./native-menu-guard.js";
let bootstrapVirtualPlugins = [];
// Cache sizes come from the bootstrap response and are applied at page load.
// The server owns the rest of the settings and applies them on its side.
function applyServerSettings(s) {
if (!s) {
return;
}
if (Number.isFinite(s.contentCacheBytes)) {
fsShim._contentCache.setMaxSize(s.contentCacheBytes);
}
setInputCacheLimits({ maxSize: s.inputCacheBytes, ttlMs: s.inputCacheTtlMs });
}
export function getBootstrapVirtualPlugins() {
return bootstrapVirtualPlugins;
}
function resolveVaultId() { function resolveVaultId() {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
window.__currentVaultId = window.__currentVaultId =
@@ -56,8 +76,6 @@ function applyVaultInfo(info) {
path: "/", path: "/",
}; };
window.__ignisPlugin = info.ignisPlugin || null;
console.log("[ignis] Vault:", window.__vaultConfig); console.log("[ignis] Vault:", window.__vaultConfig);
console.log("[ignis] Obsidian version:", window.__obsidianVersion); console.log("[ignis] Obsidian version:", window.__obsidianVersion);
} }
@@ -124,30 +142,6 @@ function initMetadataCacheFallback() {
} }
} }
function initPluginPrompt() {
if (
!window.__ignisPlugin ||
window.__ignisPlugin.installed ||
window.__ignisPlugin.prompted
) {
return;
}
const vaultId = window.__currentVaultId;
const observer = new MutationObserver(() => {
if (document.querySelector(".workspace")) {
observer.disconnect();
showPluginInstallDialog(vaultId);
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
}
// if headless sync is active, we transform reads of core-plugins.json to hide the sync setting from Obsidian. // if headless sync is active, we transform reads of core-plugins.json to hide the sync setting from Obsidian.
// this prevents headless sync from being disabled as a result of a different device syncing "Active core plugins list". // this prevents headless sync from being disabled as a result of a different device syncing "Active core plugins list".
// i.e ensure Ignis always has sync: false if headless sync is active. // i.e ensure Ignis always has sync: false if headless sync is active.
@@ -232,6 +226,8 @@ export function initialize() {
autoTrustDemoVaults(bootstrap.vaultList); autoTrustDemoVaults(bootstrap.vaultList);
applyTree(bootstrap.tree); applyTree(bootstrap.tree);
applyCoreSyncGuard(bootstrap.plugins); applyCoreSyncGuard(bootstrap.plugins);
bootstrapVirtualPlugins = bootstrap.virtualPlugins || [];
applyServerSettings(bootstrap.settings);
// Race the indexer: batch-fetch text content into ContentCache so // Race the indexer: batch-fetch text content into ContentCache so
// Obsidian's startup indexing reads hit the cache instead of the network. // Obsidian's startup indexing reads hit the cache instead of the network.
@@ -249,5 +245,4 @@ export function initialize() {
installRequestUrlShim(); installRequestUrlShim();
initWorkspacePatch(); initWorkspacePatch();
initPluginPrompt();
} }

View File

@@ -1,14 +1,36 @@
import { installRequire } from "./require.js"; import { installRequire } from "./require.js";
import { installGlobals } from "./globals.js"; import { installGlobals } from "./globals.js";
import { installCssOverrides } from "./css-overrides.js"; import { installCssOverrides } from "./css-overrides.js";
import { initialize } from "./init.js"; import { initialize, getBootstrapVirtualPlugins } from "./init.js";
import { fsShim } from "./fs/index.js"; import { fsShim } from "./fs/index.js";
import { registerUI } from "./ui-registry.js"; import { registerUI } from "./ui-registry.js";
import {
extractObsidianModule,
loadVirtualPlugin,
reportLoadFailure,
watchPluginToggles,
} from "./virtual-plugin-loader.js";
import { wsClient } from "./ws-client.js";
import { installIgnisApi } from "./ignis-api.js";
// __IGNIS_VERSION__ is replaced at build time from package.json. // __IGNIS_VERSION__ (semver) and __IGNIS_BUILD__ are replaced at build time.
window.__ignis = { version: __IGNIS_VERSION__ }; window.__ignis = { version: __IGNIS_VERSION__, build: __IGNIS_BUILD__ };
window.__ignis_registerUI = registerUI; window.__ignis_registerUI = registerUI;
installIgnisApi(wsClient);
const BRIDGE_MANIFEST = {
id: "ignis-bridge",
name: "Ignis Bridge",
version: __IGNIS_VERSION__,
minAppVersion: "1.12.4",
description:
"Additional Ignis specific functionality and ignis plugin management.",
author: "Nystik",
authorUrl: "https://github.com/Nystik-gh/ignis",
isDesktopOnly: false,
};
installGlobals(); // process, Buffer, window overrides (before require so Buffer is available) installGlobals(); // process, Buffer, window overrides (before require so Buffer is available)
installRequire(); // shim registry, window.require installRequire(); // shim registry, window.require
installCssOverrides(); // browser-specific CSS fixes installCssOverrides(); // browser-specific CSS fixes
@@ -22,9 +44,30 @@ if (window.innerWidth < 600) {
initialize(); // vault config, metadata cache, plugin prompt initialize(); // vault config, metadata cache, plugin prompt
// Connect file watcher WebSocket after everything is initialized // Connect the shared WebSocket after everything is initialized; watcher and live-toggle subscribers attach to the same client.
if (window.__currentVaultId) { if (window.__currentVaultId) {
fsShim._watcherClient.connect(window.__currentVaultId); fsShim._watcherClient.connect(window.__currentVaultId);
watchPluginToggles(wsClient);
} }
extractObsidianModule()
.then(async () => {
// Dynamic import so the bridge's top-level obsidian import resolves after installRequire + extractObsidianModule.
const mod = await import("@ignis/bridge");
const IgnisBridgePlugin = mod.default || mod;
const bridge = new IgnisBridgePlugin(window.app, BRIDGE_MANIFEST);
await bridge.onload();
console.log("[ignis] bridge loaded");
for (const vp of getBootstrapVirtualPlugins()) {
try {
await loadVirtualPlugin(vp);
console.log(`[ignis] virtual plugin loaded: ${vp.id}`);
} catch (e) {
reportLoadFailure(vp.id, e);
}
}
})
.catch((e) => console.error("[ignis] bridge load failed:", e));
console.log("[ignis] Shim loader initialized"); console.log("[ignis] Shim loader initialized");

View File

@@ -49,7 +49,8 @@ function readTransform(data) {
try { try {
const obj = JSON.parse(text); const obj = JSON.parse(text);
if (obj.nativeMenus) { // force native menus to false since its never appropriate in a browser context.
if (obj.nativeMenus !== false) {
obj.nativeMenus = false; obj.nativeMenus = false;
return JSON.stringify(obj); return JSON.stringify(obj);
} }
@@ -100,6 +101,9 @@ function patchSetConfig() {
}; };
vault.__ignisNativeMenuGuarded = true; vault.__ignisNativeMenuGuarded = true;
// set to false to override any platform default (like macOS).
vault.setConfig("nativeMenus", false);
return true; return true;
}; };

View File

@@ -0,0 +1,82 @@
class AssertionError extends Error {
constructor(message) {
super(message || "Assertion failed");
this.name = "AssertionError";
}
}
function assert(value, message) {
if (!value) {
throw new AssertionError(message);
}
}
assert.AssertionError = AssertionError;
assert.ok = assert;
assert.strict = assert;
assert.fail = function (message) {
throw new AssertionError(message || "Failed");
};
assert.equal = function (actual, expected, message) {
if (actual != expected) {
throw new AssertionError(message || `${actual} == ${expected}`);
}
};
assert.notEqual = function (actual, expected, message) {
if (actual == expected) {
throw new AssertionError(message || `${actual} != ${expected}`);
}
};
assert.strictEqual = function (actual, expected, message) {
if (actual !== expected) {
throw new AssertionError(message || `${actual} === ${expected}`);
}
};
assert.notStrictEqual = function (actual, expected, message) {
if (actual === expected) {
throw new AssertionError(message || `${actual} !== ${expected}`);
}
};
assert.deepEqual = function (actual, expected, message) {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new AssertionError(message || "deepEqual");
}
};
assert.deepStrictEqual = assert.deepEqual;
assert.throws = function (fn, message) {
let threw = false;
try {
fn();
} catch {
threw = true;
}
if (!threw) {
throw new AssertionError(message || "Missing expected exception");
}
};
assert.doesNotThrow = function (fn, message) {
try {
fn();
} catch (e) {
throw new AssertionError(message || `Got unwanted exception: ${e.message}`);
}
};
assert.ifError = function (value) {
if (value) {
throw new AssertionError(`ifError got unwanted exception: ${value}`);
}
};
export const assertShim = assert;

View File

@@ -0,0 +1,30 @@
import { describe, it, expect } from "vitest";
import { assertShim as assert } from "./assert.js";
describe("assert shim", () => {
it("is callable and throws on a falsy value", () => {
expect(() => assert(false)).toThrow();
expect(() => assert(true)).not.toThrow();
});
it("equal throws on mismatch and passes on loose match", () => {
expect(() => assert.equal(1, 2)).toThrow();
expect(() => assert.equal(1, 1)).not.toThrow();
expect(() => assert.equal(1, "1")).not.toThrow();
});
it("strictEqual distinguishes type", () => {
expect(() => assert.strictEqual(1, "1")).toThrow();
expect(() => assert.strictEqual(1, 1)).not.toThrow();
});
it("throws() verifies that a function threw", () => {
expect(() =>
assert.throws(() => {
throw new Error("x");
}),
).not.toThrow();
expect(() => assert.throws(() => {})).toThrow();
});
});

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