Compare commits
No commits in common. "main" and "v0.8.0+obsidian.1.12.7" have entirely different histories.
main
...
v0.8.0+obs
195 changed files with 1645 additions and 2651 deletions
|
|
@ -1,14 +0,0 @@
|
|||
node_modules
|
||||
**/node_modules
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
.vscode
|
||||
.prettierrc
|
||||
investigation
|
||||
vaults
|
||||
demo-vaults
|
||||
data
|
||||
tmp
|
||||
**/dist
|
||||
apps/ignis-server/server/build-info.json
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -2,7 +2,6 @@ node_modules/
|
|||
dist/
|
||||
investigation/
|
||||
vaults/
|
||||
packages/*/dist/
|
||||
apps/ignis-server/server/build-info.json
|
||||
plugin/main.js
|
||||
server/plugins/*/plugin/main.js
|
||||
demo-vaults/
|
||||
data/
|
||||
|
|
|
|||
38
CHANGELOG.md
38
CHANGELOG.md
|
|
@ -2,44 +2,6 @@
|
|||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [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)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Various app menus crash when "Use native menus" enabled. Solved by forcing the setting off internally; the on-disk value is preserved across toggles.
|
||||
- `/api/fs/rename` and `/api/fs/copyFile` reject missing path fields with 400 instead of silently resolving to the vault root.
|
||||
|
||||
## [0.8.1] - Karm (2026-05-17)
|
||||
|
||||
### Added
|
||||
|
||||
- "Available version" indicator in Ignis settings now links to the release page on GitHub.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Update check no longer reports a new version available when only the SemVer build metadata differs.
|
||||
|
||||
## [0.8.0] - Karm (2026-05-16)
|
||||
|
||||
### Added
|
||||
|
|
|
|||
53
Dockerfile
Normal file
53
Dockerfile
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# Build shim-loader.js
|
||||
FROM node:22-slim AS build
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --ignore-scripts
|
||||
|
||||
COPY build.js ./
|
||||
COPY src/ ./src/
|
||||
COPY plugin/src/ ./plugin/src/
|
||||
COPY server/plugins/ ./server/plugins/
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Production image. No Obsidian code included.
|
||||
# On first run, the entrypoint downloads Obsidian.
|
||||
FROM node:22-slim
|
||||
|
||||
LABEL com.thiefling.ignis.obsidian-version="1.12.7"
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl gosu \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev --ignore-scripts
|
||||
|
||||
COPY server/ ./server/
|
||||
COPY scripts/ ./scripts/
|
||||
COPY images/ ./images/
|
||||
COPY plugin/ ./plugin/
|
||||
COPY --from=build /build/dist ./dist
|
||||
COPY --from=build /build/plugin/main.js ./plugin/main.js
|
||||
COPY --from=build /build/server/plugins/headless-sync/plugin/main.js ./server/plugins/headless-sync/plugin/main.js
|
||||
|
||||
RUN chmod +x /app/scripts/entrypoint.sh
|
||||
|
||||
ENV PORT=8080
|
||||
ENV VAULT_ROOT=/vaults
|
||||
ENV OBSIDIAN_VERSION=1.12.7
|
||||
ENV OBSIDIAN_ASSETS_PATH=/app/obsidian-app
|
||||
ENV PUID=1000
|
||||
ENV PGID=1000
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
VOLUME /vaults
|
||||
VOLUME /app/obsidian-app
|
||||
|
||||
ENTRYPOINT ["/app/scripts/entrypoint.sh"]
|
||||
103
README.md
103
README.md
|
|
@ -28,10 +28,6 @@ What started as an experiment turned out to be more viable than expected, and th
|
|||
|
||||
Plugin compatibility depends on what APIs a plugin uses; most plugins built on Obsidian's plugin API work, anything requiring Node native modules or `child_process` doesn't. See [What doesn't work](#what-doesnt-work) for the full list of known limitations.
|
||||
|
||||
## Variants
|
||||
|
||||
Ignis ships currently ships as a self-hosted server but I have plans for a desktop plugin. The server variant code and Readme with details and setup instructions lives here: [`apps/ignis-server/`](apps/ignis-server/)
|
||||
|
||||
## What works
|
||||
|
||||
- All core editor features: markdown, canvas, bases, and the command palette.
|
||||
|
|
@ -66,7 +62,7 @@ Compatibility for specific community plugins is tracked in [Issue #9](https://gi
|
|||
**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.
|
||||
- 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.
|
||||
- Ignis adds an "Open workspace in tab" command to the command palette.
|
||||
- The bridge plugin adds an "Open workspace in tab" command to the command palette.
|
||||
|
||||
**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.
|
||||
|
|
@ -76,17 +72,6 @@ Compatibility for specific community plugins is tracked in [Issue #9](https://gi
|
|||
- Ignis-specific settings appear as their own tabs inside Obsidian's Settings modal.
|
||||
- Status bar indicators surface server state and headless sync activity.
|
||||
|
||||
## Roadmap
|
||||
|
||||
**Planned:**
|
||||
- Server parameter configuration from the Ignis settings panel (LRU cache size, write coalesce window, etc.)
|
||||
- Continued shim work to support more community plugins.
|
||||
- Server-side plugin system improvements.
|
||||
|
||||
**Eventually:**
|
||||
- Multi-user support with OIDC for self-hosted shared deployments.
|
||||
- Built-in auth, so a reverse proxy isn't required for basic protected use.
|
||||
|
||||
## Performance
|
||||
|
||||
A few design decisions worth knowing about for someone evaluating Ignis against large vaults or slow storage:
|
||||
|
|
@ -100,6 +85,90 @@ A few design decisions worth knowing about for someone evaluating Ignis against
|
|||
|
||||
Tested in Chrome, Brave, and Firefox, with limited testing in Safari.
|
||||
|
||||
## Authentication
|
||||
|
||||
Ignis has **no built-in authentication** and serves plain HTTP by default. Both authentication and TLS termination are expected to be handled by whatever you put in front of it.
|
||||
|
||||
If you are exposing Ignis to the internet, **you should really** put an authentication layer in front of it. Options include:
|
||||
|
||||
- A reverse proxy with Basic Auth (nginx, Caddy, Traefik)
|
||||
- An SSO proxy like Authelia, Authentik, or OAuth2 Proxy
|
||||
- A VPN (Tailscale, WireGuard)
|
||||
- Cloudflare Application Tunnel
|
||||
|
||||
Example for Basic Auth, and Authelia can be found [here](examples).
|
||||
|
||||
> [!CAUTION]
|
||||
> Do not run Ignis on a public network without auth. Anyone with the url can read and write your vault files.
|
||||
|
||||
|
||||
|
||||
## Setup with Docker Compose
|
||||
|
||||
Example `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
ignis:
|
||||
image: nobbe/ignis:latest
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
- OBSIDIAN_VERSION=1.12.7
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
volumes:
|
||||
- ./vaults:/vaults
|
||||
- ./data:/app/data
|
||||
- obsidian-app:/app/obsidian-app
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
obsidian-app:
|
||||
```
|
||||
|
||||
Then `docker compose up -d`. On first start the container downloads Obsidian from the official source and installs Obsidian Headless CLI. This takes a minute or two.
|
||||
|
||||
To build from source instead of pulling the image, clone the repo and replace `image: nobbe/ignis:latest` with `build: .`.
|
||||
|
||||
### Volumes
|
||||
|
||||
| Mount | Description |
|
||||
| ----- | ----------- |
|
||||
| `/vaults` | Vault storage. Each subdirectory is a vault. |
|
||||
| `/data` | state persistence for various ignis specific functionality, plugin management, headless sync config, etc |
|
||||
| `/app/obsidian-app` | Cached Obsidian assets. Persisting this avoids re-downloading on container recreate. |
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
| -------- | ----------- | ------- |
|
||||
| `PORT` | Server listen port | `8080` |
|
||||
| `VAULT_ROOT` | Path to vault storage inside the container | `/vaults` |
|
||||
| `DATA_ROOT` | Path to persistent data (plugin config, sync state, auth tokens) | `/app/data` |
|
||||
| `OBSIDIAN_VERSION` | Obsidian version to download | `1.12.7` |
|
||||
| `OBSIDIAN_ASSETS_PATH` | Where the extracted Obsidian app files live. Override if you're pointing at a pre-extracted directory instead of letting the entrypoint download. | `/app/obsidian-app` |
|
||||
| `AUTO_CREATE_DEFAULT` | When `true`, creates a "My Vault" vault on startup if no vaults exist. Useful for fresh installs. | `false` |
|
||||
| `PUID` | User ID for file ownership | `1000` |
|
||||
| `PGID` | Group ID for file ownership | `1000` |
|
||||
| `WRITE_COALESCE_MS` | Debounce window (ms) for rapid writes. Useful for slow filesystems (rclone, NFS, SMB). Set to `0` to disable. | `5000` |
|
||||
|
||||
Demo mode adds its own set of env vars (per-session vaults, auto-cleanup, proxy allowlist, login blocking). See [examples/demo/](examples/demo/) if you want to run a public demo deployment.
|
||||
|
||||
### Migrating an existing vault
|
||||
|
||||
Each subdirectory of `/vaults` is treated as a separate vault, so dropping in an existing Obsidian vault directory will make it available in Ignis.
|
||||
|
||||
### Upgrading Obsidian
|
||||
|
||||
Obsidian releases can include changes that break the compatibility shim. Each Ignis release pins a known-working Obsidian version through the `OBSIDIAN_VERSION` env var, so the recommended path is to wait for an Ignis release that bumps the version, pull the new image, and restart.
|
||||
|
||||
If you want to try a newer Obsidian version before Ignis updates, set `OBSIDIAN_VERSION` in your compose file. The entrypoint will download that version on next start, but there's no guarantee it'll work cleanly with the current shim.
|
||||
|
||||
### Backups
|
||||
|
||||
Vault data lives as ordinary files in `/vaults`. Back it up however you back up other server-side data; Ignis doesn't provide a built in backup mechanism.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, especially on how to report plugin compatibility issues. Check the [open issues](https://github.com/Nystik-gh/ignis/issues) for things to work on.
|
||||
|
|
@ -118,4 +187,4 @@ Ignis is not affiliated with, endorsed by, or associated with Dynalist Inc. or O
|
|||
|
||||
This work falls under the interoperability provisions of [Directive 2009/24/EC](https://eur-lex.europa.eu/eli/dir/2009/24/oj/eng) (the EU Software Directive), Article 6. See [LEGAL.md](LEGAL.md) for the full rationale.
|
||||
|
||||
This project exists because its author uses Obsidian daily and wants to access it from a browser. There is no intent to harm Obsidian, Dynalist Inc., or their business. If you are a representative of Dynalist Inc. and wish to discuss this project, please reach out: ignis@thiefling.com
|
||||
This project exists because its author uses Obsidian daily and wants to access it from a browser. There is no intent to harm Obsidian, Dynalist Inc., or their business. If you are a representative of Dynalist Inc. and wish to discuss this project, please reach out: ignis@thiefling.com
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
# Build stage.
|
||||
# Runs from the repo root as build context so workspace symlinks resolve.
|
||||
# Copies workspace package.jsons first for cache-friendly npm ci.
|
||||
FROM node:22-slim AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY packages/services/package.json ./packages/services/
|
||||
COPY packages/shim/package.json ./packages/shim/
|
||||
COPY packages/ui/package.json ./packages/ui/
|
||||
COPY packages/bridge/package.json ./packages/bridge/
|
||||
COPY packages/server-core/package.json ./packages/server-core/
|
||||
COPY apps/ignis-server/package.json ./apps/ignis-server/
|
||||
|
||||
RUN npm ci --ignore-scripts
|
||||
|
||||
COPY build.js ./
|
||||
COPY packages/ ./packages/
|
||||
COPY apps/ignis-server/ ./apps/ignis-server/
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Production image. No Obsidian code included.
|
||||
# On first run, the entrypoint downloads Obsidian.
|
||||
FROM node:22-slim
|
||||
|
||||
LABEL com.thiefling.ignis.obsidian-version="1.12.7"
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl gosu \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Workspace package.jsons + lockfile so npm ci sets up the @ignis/* symlinks.
|
||||
COPY package.json package-lock.json ./
|
||||
COPY packages/services/package.json ./packages/services/
|
||||
COPY packages/shim/package.json ./packages/shim/
|
||||
COPY packages/ui/package.json ./packages/ui/
|
||||
COPY packages/bridge/package.json ./packages/bridge/
|
||||
COPY packages/server-core/package.json ./packages/server-core/
|
||||
COPY apps/ignis-server/package.json ./apps/ignis-server/
|
||||
|
||||
RUN npm ci --omit=dev --ignore-scripts
|
||||
|
||||
# Runtime sources: the server, entrypoint script, repo-rooted images, and the server-core JS that gets required from the server at runtime.
|
||||
COPY apps/ignis-server/server/ ./apps/ignis-server/server/
|
||||
COPY apps/ignis-server/scripts/ ./apps/ignis-server/scripts/
|
||||
COPY images/ ./images/
|
||||
COPY packages/server-core/src/ ./packages/server-core/src/
|
||||
|
||||
# Built artifacts from the build stage.
|
||||
COPY --from=build /app/packages/shim/dist/shim-loader.js ./packages/shim/dist/shim-loader.js
|
||||
COPY --from=build /app/packages/ui/dist/ignis-ui.js ./packages/ui/dist/ignis-ui.js
|
||||
COPY --from=build /app/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/obsidian/dist/ ./apps/ignis-server/server/plugins/headless-sync/obsidian/dist/
|
||||
|
||||
RUN chmod +x /app/apps/ignis-server/scripts/entrypoint.sh
|
||||
|
||||
ENV PORT=8080
|
||||
ENV VAULT_ROOT=/vaults
|
||||
ENV OBSIDIAN_VERSION=1.12.7
|
||||
ENV OBSIDIAN_ASSETS_PATH=/app/obsidian-app
|
||||
ENV PUID=1000
|
||||
ENV PGID=1000
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
VOLUME /vaults
|
||||
VOLUME /app/obsidian-app
|
||||
|
||||
ENTRYPOINT ["/app/apps/ignis-server/scripts/entrypoint.sh"]
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
# Ignis Server
|
||||
|
||||
The self-hosted Docker variant of Ignis. For the project overview, feature list, and what works / what doesn't, see the [root README](../../README.md).
|
||||
|
||||
## Contents
|
||||
|
||||
- [Authentication](#authentication)
|
||||
- [Setup with Docker Compose](#setup-with-docker-compose)
|
||||
- [Volumes](#volumes)
|
||||
- [Environment Variables](#environment-variables)
|
||||
- [Migrating an existing vault](#migrating-an-existing-vault)
|
||||
- [Upgrading Obsidian](#upgrading-obsidian)
|
||||
- [Backups](#backups)
|
||||
|
||||
## Authentication
|
||||
|
||||
Ignis has **no built-in authentication** and serves plain HTTP by default. Both authentication and TLS termination are expected to be handled by whatever you put in front of it.
|
||||
|
||||
If you are exposing Ignis to the internet, **you should really** put an authentication layer in front of it. Options include:
|
||||
|
||||
- A reverse proxy with Basic Auth (nginx, Caddy, Traefik)
|
||||
- An SSO proxy like Authelia, Authentik, or OAuth2 Proxy
|
||||
- A VPN (Tailscale, WireGuard)
|
||||
- Cloudflare Application Tunnel
|
||||
|
||||
Example configurations for Basic Auth and Authelia are in [`examples/`](examples).
|
||||
|
||||
> [!CAUTION]
|
||||
> Do not run Ignis on a public network without auth. Anyone with the URL can read and write your vault files.
|
||||
|
||||
## Setup with Docker Compose
|
||||
|
||||
Example `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
ignis:
|
||||
image: nobbe/ignis:latest
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
- OBSIDIAN_VERSION=1.12.7
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
volumes:
|
||||
- ./vaults:/vaults
|
||||
- ./data:/app/data
|
||||
- obsidian-app:/app/obsidian-app
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
obsidian-app:
|
||||
```
|
||||
|
||||
Then `docker compose up -d`. On first start the container downloads Obsidian from the official source and installs the Obsidian Headless CLI. This takes a minute or two.
|
||||
|
||||
To build from source instead of pulling the image, clone the repo and run `docker compose up` against the [`docker-compose.yml`](docker-compose.yml) in this directory.
|
||||
|
||||
## Volumes
|
||||
|
||||
| Mount | Description |
|
||||
| ----- | ----------- |
|
||||
| `/vaults` | Vault storage. Each subdirectory is a vault. |
|
||||
| `/app/data` | State persistence for various Ignis-specific functionality: plugin management, headless sync config, etc. |
|
||||
| `/app/obsidian-app` | Cached Obsidian assets. Persisting this avoids re-downloading on container recreate. |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
| -------- | ----------- | ------- |
|
||||
| `PORT` | Server listen port | `8080` |
|
||||
| `VAULT_ROOT` | Path to vault storage inside the container | `/vaults` |
|
||||
| `DATA_ROOT` | Path to persistent data (plugin config, sync state, auth tokens) | `/app/data` |
|
||||
| `OBSIDIAN_VERSION` | Obsidian version to download | `1.12.7` |
|
||||
| `OBSIDIAN_ASSETS_PATH` | Where the extracted Obsidian app files live. Override if you're pointing at a pre-extracted directory instead of letting the entrypoint download. | `/app/obsidian-app` |
|
||||
| `AUTO_CREATE_DEFAULT` | When `true`, creates a "My Vault" vault on startup if no vaults exist. Useful for fresh installs. | `false` |
|
||||
| `PUID` | User ID for file ownership | `1000` |
|
||||
| `PGID` | Group ID for file ownership | `1000` |
|
||||
| `WRITE_COALESCE_MS` | Debounce window (ms) for rapid writes. Useful for slow filesystems (rclone, NFS, SMB). Set to `0` to disable. | `5000` |
|
||||
| `WS_ORIGINS` | Comma-separated allowlist of `Origin` headers accepted on the WebSocket endpoint. When unset, any origin is accepted. | 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.
|
||||
|
||||
## Migrating an existing vault
|
||||
|
||||
Each subdirectory of `/vaults` is treated as a separate vault, so dropping in an existing Obsidian vault directory will make it available in Ignis.
|
||||
|
||||
## Upgrading Obsidian
|
||||
|
||||
Obsidian releases can include changes that break the compatibility shim. Each Ignis release pins a known-working Obsidian version through the `OBSIDIAN_VERSION` env var, so the recommended path is to wait for an Ignis release that bumps the version, pull the new image, and restart.
|
||||
|
||||
If you want to try a newer Obsidian version before Ignis updates, set `OBSIDIAN_VERSION` in your compose file. The entrypoint will download that version on next start, but there is no guarantee it will work cleanly with the current shim.
|
||||
|
||||
## Backups
|
||||
|
||||
Vault data lives as ordinary files in `/vaults`. Back it up however you back up other server-side data; Ignis does not provide a built-in backup mechanism.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"name": "@ignis/app",
|
||||
"version": "0.0.0-internal",
|
||||
"private": true
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const BRIDGE_PLUGIN_ID = "ignis-bridge";
|
||||
|
||||
// Old vaults still have bridge in .obsidian/plugins from before it became virtual.
|
||||
async function migratePluginFromVault(vaultPath, vaultName, pluginId) {
|
||||
let didWork = false;
|
||||
|
||||
const pluginDir = path.join(vaultPath, ".obsidian", "plugins", pluginId);
|
||||
|
||||
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 {
|
||||
const list = JSON.parse(await fs.promises.readFile(cpFile, "utf-8"));
|
||||
|
||||
if (Array.isArray(list)) {
|
||||
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 migratePluginsFromAllVaults(vaultRoot, pluginIds) {
|
||||
if (!(await fs.promises.stat(vaultRoot).catch(() => null))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = await fs.promises.readdir(vaultRoot, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const vaultPath = path.join(vaultRoot, entry.name);
|
||||
|
||||
for (const pluginId of pluginIds) {
|
||||
await migratePluginFromVault(vaultPath, entry.name, pluginId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BRIDGE_PLUGIN_ID,
|
||||
migratePluginsFromAllVaults,
|
||||
};
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
const CHANNEL = "plugin:headless-sync";
|
||||
|
||||
class SyncBroadcaster {
|
||||
constructor(wss) {
|
||||
this._channel = wss.channel(CHANNEL);
|
||||
}
|
||||
|
||||
broadcastLog(vaultId, line) {
|
||||
this._channel.broadcastToVault(vaultId, {
|
||||
type: "sync-log",
|
||||
payload: { vaultId, line },
|
||||
});
|
||||
}
|
||||
|
||||
broadcastStatus(state) {
|
||||
if (!state || !state.vaultId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._channel.broadcastToVault(state.vaultId, {
|
||||
type: "sync-status",
|
||||
payload: state,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { SyncBroadcaster };
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
let cached = null;
|
||||
|
||||
function load() {
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
function getVersion() {
|
||||
return load().version;
|
||||
}
|
||||
|
||||
function getSemver() {
|
||||
return load().semver;
|
||||
}
|
||||
|
||||
function getBuild() {
|
||||
return load().build;
|
||||
}
|
||||
|
||||
module.exports = { getVersion, getSemver, getBuild };
|
||||
123
build.js
123
build.js
|
|
@ -1,61 +1,80 @@
|
|||
const esbuild = require("esbuild");
|
||||
const fs = require("fs");
|
||||
const sveltePlugin = require("esbuild-svelte");
|
||||
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;
|
||||
const { version: ignisVersion } = require("./package.json");
|
||||
|
||||
Promise.all([
|
||||
// Build shim-loader.js (delegated to packages/shim)
|
||||
require("./packages/shim/build.js"),
|
||||
// Build shim-loader.js
|
||||
esbuild.build({
|
||||
entryPoints: [path.join(__dirname, "src", "shims", "loader.js")],
|
||||
bundle: true,
|
||||
outfile: path.join(__dirname, "dist", "shim-loader.js"),
|
||||
format: "iife",
|
||||
platform: "browser",
|
||||
target: ["chrome90"],
|
||||
alias: {
|
||||
path: "path-browserify",
|
||||
},
|
||||
define: {
|
||||
__IGNIS_VERSION__: JSON.stringify(ignisVersion),
|
||||
},
|
||||
logLevel: "info",
|
||||
}),
|
||||
|
||||
// Build ignis-ui.js (delegated to packages/ui)
|
||||
require("./packages/ui/build.js"),
|
||||
// Build ignis-ui.js
|
||||
esbuild.build({
|
||||
entryPoints: [path.join(__dirname, "src", "ui", "index.js")],
|
||||
bundle: true,
|
||||
outfile: path.join(__dirname, "dist", "ignis-ui.js"),
|
||||
format: "iife",
|
||||
globalName: "IgnisUI",
|
||||
platform: "browser",
|
||||
target: ["chrome90"],
|
||||
mainFields: ["svelte", "browser", "module", "main"],
|
||||
conditions: ["svelte", "browser"],
|
||||
plugins: [sveltePlugin({ compilerOptions: { css: "injected" } })],
|
||||
logLevel: "info",
|
||||
}),
|
||||
|
||||
// Build ignis-bridge plugin
|
||||
esbuild.build({
|
||||
entryPoints: [path.join(__dirname, "plugin", "src", "main.js")],
|
||||
bundle: true,
|
||||
outfile: path.join(__dirname, "plugin", "main.js"),
|
||||
format: "cjs",
|
||||
platform: "browser",
|
||||
target: ["chrome90"],
|
||||
external: ["obsidian", "fs"],
|
||||
logLevel: "info",
|
||||
}),
|
||||
|
||||
// Build headless-sync bundled plugin
|
||||
esbuild
|
||||
.build({
|
||||
entryPoints: [path.join(headlessSyncDir, "src", "main.js")],
|
||||
bundle: true,
|
||||
outfile: path.join(headlessSyncDir, "dist", "ignis-headless-sync.js"),
|
||||
format: "cjs",
|
||||
platform: "browser",
|
||||
target: ["chrome90"],
|
||||
external: ["obsidian", "fs"],
|
||||
logLevel: "info",
|
||||
})
|
||||
.then(() => {
|
||||
fs.copyFileSync(
|
||||
path.join(headlessSyncDir, "styles.css"),
|
||||
path.join(headlessSyncDir, "dist", "ignis-headless-sync.css"),
|
||||
);
|
||||
}),
|
||||
esbuild.build({
|
||||
entryPoints: [
|
||||
path.join(
|
||||
__dirname,
|
||||
"server",
|
||||
"plugins",
|
||||
"headless-sync",
|
||||
"plugin",
|
||||
"src",
|
||||
"main.js",
|
||||
),
|
||||
],
|
||||
bundle: true,
|
||||
outfile: path.join(
|
||||
__dirname,
|
||||
"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));
|
||||
|
|
|
|||
|
|
@ -1,71 +0,0 @@
|
|||
# =============================================================================
|
||||
# obsidian-notes : Ignis (real Obsidian, in the browser)
|
||||
# Lives at the ROOT of your manohar/ignis Forgejo mirror as a SEPARATE compose
|
||||
# file, so upstream's own docker-compose.yml stays untouched and pull-able.
|
||||
# In Dokploy: Compose app -> repo manohar/ignis -> Compose Path: docker-compose.dokploy.yml
|
||||
# Public endpoint: https://notes.manohargupta.com (Traefik basicAuth gate)
|
||||
# =============================================================================
|
||||
services:
|
||||
ignis:
|
||||
# Official image from Docker Hub. Dockerfile lives at apps/ignis-server/ in
|
||||
# the source repo but there's no reason to build — nobbe/ignis:latest is the
|
||||
# canonical published image and avoids a long source build on every deploy.
|
||||
image: nobbe/ignis:latest
|
||||
container_name: obsidian-ignis
|
||||
restart: unless-stopped
|
||||
|
||||
environment:
|
||||
- PORT=8080
|
||||
# Pin the Obsidian version Ignis downloads. Bump deliberately, since a new
|
||||
# Obsidian can outrun the shim. Keep in sync with what you run on the Mac.
|
||||
- OBSIDIAN_VERSION=1.12.4
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
|
||||
volumes:
|
||||
# BIND mount (not a named volume) so the host backup cron can git-commit
|
||||
# the plain-markdown vault directly. Each subdir here = one Obsidian vault.
|
||||
- /opt/obsidian/vaults:/vaults
|
||||
# Ignis internal state (plugin mgmt, sync state, auth tokens).
|
||||
- /opt/obsidian/data:/app/data
|
||||
# Cached Obsidian assets — persisted so redeploys don't re-download.
|
||||
- obsidian-app:/app/obsidian-app
|
||||
|
||||
# Rendering happens in YOUR browser, so the server side is light. Cap anyway.
|
||||
mem_limit: 512m
|
||||
|
||||
networks:
|
||||
- dokploy-network
|
||||
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
# --- HTTP router on :80. Gated by the SAME basicAuth as belt-and-suspenders
|
||||
# so port 80 never serves the vault unauthenticated (in case there's no
|
||||
# global 80->443 redirect). Also serves the ACME challenge. ---
|
||||
- traefik.http.routers.obsidian-notes-http.rule=Host(`notes.manohargupta.com`)
|
||||
- traefik.http.routers.obsidian-notes-http.entrypoints=web
|
||||
- traefik.http.routers.obsidian-notes-http.middlewares=obsidian-auth
|
||||
# --- HTTPS router on :443, WITH basicAuth ---
|
||||
- traefik.http.routers.obsidian-notes.rule=Host(`notes.manohargupta.com`)
|
||||
- traefik.http.routers.obsidian-notes.entrypoints=websecure
|
||||
- traefik.http.routers.obsidian-notes.tls=true
|
||||
- traefik.http.routers.obsidian-notes.tls.certresolver=letsencrypt
|
||||
- traefik.http.routers.obsidian-notes.middlewares=obsidian-auth
|
||||
# --- basicAuth middleware. The user:bcrypt hash is injected from Dokploy's
|
||||
# Environment tab (key BASIC_AUTH_USERS) so it never lands in git.
|
||||
# IMPORTANT: bcrypt hashes contain $ signs. In the Dokploy env tab you
|
||||
# MUST double every $ so compose doesn't try to expand them as variables:
|
||||
# htpasswd -nbB manohar 'YOUR_PASSWORD'
|
||||
# Take the output e.g. manohar:$2y$05$abc... and replace every $ with $$:
|
||||
# BASIC_AUTH_USERS=manohar:$$2y$$05$$abc...
|
||||
- traefik.http.middlewares.obsidian-auth.basicauth.users=${BASIC_AUTH_USERS}
|
||||
# --- Service: Ignis listens on 8080 ---
|
||||
- traefik.http.services.obsidian-notes.loadbalancer.server.port=8080
|
||||
- traefik.docker.network=dokploy-network
|
||||
|
||||
networks:
|
||||
dokploy-network:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
obsidian-app:
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
services:
|
||||
ignis:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: apps/ignis-server/Dockerfile
|
||||
build: .
|
||||
ports:
|
||||
- "8082:8080"
|
||||
environment:
|
||||
|
|
@ -2,26 +2,6 @@
|
|||
|
||||
Ignis runs Obsidian in a browser by replacing its Electron backend with a shim layer that routes Node.js and Electron API calls to an Express server over HTTP and WebSocket.
|
||||
|
||||
## Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Shim Layer](#shim-layer)
|
||||
- [Loading](#loading)
|
||||
- [Modules](#modules)
|
||||
- [Filesystem](#filesystem)
|
||||
- [Transforms](#transforms)
|
||||
- [IPC](#ipc)
|
||||
- [Cross-origin requests](#cross-origin-requests)
|
||||
- [Workspaces in browser tabs](#workspaces-in-browser-tabs)
|
||||
- [Bridge](#bridge)
|
||||
- [Vaults](#vaults)
|
||||
- [Server](#server)
|
||||
- [Plugins](#plugins)
|
||||
- [Obsidian Plugins](#obsidian-plugins)
|
||||
- [Ignis Plugins](#ignis-plugins)
|
||||
- [Virtual Plugins](#virtual-plugins)
|
||||
- [Demo mode](#demo-mode)
|
||||
|
||||
## Overview
|
||||
|
||||
```
|
||||
|
|
@ -32,19 +12,19 @@ Browser Server
|
|||
│ Shim layer │ <────> │ /api/vault/* │
|
||||
│ fs, electron, etc. │ WS │ /api/plugins/* │
|
||||
│ ↕ │ <────> │ /api/ext/:plugin/* │
|
||||
│ Bridge │ │ Ignis plugins │
|
||||
│ Bridge plugin │ │ Ignis plugins │
|
||||
└──────────────────────┘ └──────────────────────┘
|
||||
↕
|
||||
Filesystem (vaults/)
|
||||
```
|
||||
|
||||
The shim layer makes Obsidian think it's running in Electron. The bridge adds Ignis-specific features inside Obsidian.
|
||||
The shim layer makes Obsidian think it's running in Electron. The bridge plugin adds Ignis-specific features inside Obsidian.
|
||||
|
||||
## Shim Layer
|
||||
|
||||
### Loading
|
||||
|
||||
The server serves its own `index.html` (in `apps/ignis-server/server/assets/`) rather than Obsidian's. At startup it reads Obsidian's `index.html` once to discover which scripts Obsidian expects, then embeds that list in our HTML as a JSON array. The client-side HTML loads the shim loader and UI bundle first (non-deferred), then a small inline script dynamically injects Obsidian's scripts in order. Obsidian's files are never modified on disk, or transformed in transit.
|
||||
The server serves its own `index.html` (in `server/assets/`) rather than Obsidian's. At startup it reads Obsidian's `index.html` once to discover which scripts Obsidian expects, then embeds that list in our HTML as a JSON array. The client-side HTML loads the shim loader and UI bundle first (non-deferred), then a small inline script dynamically injects Obsidian's scripts in order. Obsidian's files are never modified on disk, or transformed in transit.
|
||||
|
||||
Before injecting Obsidian's scripts, the shim loader sets `localStorage.EmulateMobile` based on viewport width (< 600px) so Obsidian boots into its mobile UI on phones and narrow windows. The loader replaces the module system, then issues a single blocking bootstrap request that returns the vault info, vault list, metadata tree, and Ignis plugin list in one pre-compressed response. The request has to be blocking because Obsidian makes synchronous filesystem calls during page load, before the event loop is running, so the cache has to already be populated.
|
||||
|
||||
|
|
@ -78,17 +58,17 @@ Two caches on the client side. The **MetadataCache** holds `{ type, size, mtime,
|
|||
|
||||
Reads not satisfied by ContentCache go through the transport layer to `/api/fs/readFile`. Sync calls use synchronous XHR to keep Obsidian's pre-boot module code working. Async calls use fetch. The transport handles vault id injection, base64 encoding for binary files, and mapping HTTP error codes back to Node errno values (`ENOENT`, `EEXIST`, `ENOTDIR`).
|
||||
|
||||
Writes go through a server-side write coalescer (`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 (`server/write-coalescer.js`) designed for slow filesystems like rclone FUSE mounts. The first write to a path goes to disk immediately. Subsequent writes within a configurable window (default 5 seconds, `WRITE_COALESCE_MS`) are buffered and flushed when the debounce timer fires; the timer resets on each write. Buffered writes return to the HTTP client immediately with synthetic metadata so connection-pool starvation on rapid-fire writes (e.g. `workspace.json` autosaves) doesn't stall unrelated reads. Reads for pending paths serve the buffered content so clients never see stale data. All pending writes are flushed on graceful shutdown.
|
||||
|
||||
### Transforms
|
||||
### Translation registry
|
||||
|
||||
The shim has a transforms registry (`packages/shim/src/fs/transforms.js`) for hooks applied at the public shim surface, before caches or transport see the path. Three hook types:
|
||||
The shim has a registry (`src/shims/fs/transforms.js`) for hooks applied at the public shim surface, before caches or transport see the path. Three hook types:
|
||||
|
||||
- **Path resolvers** map a logical path to a physical path. Used by the workspaces shim to redirect reads and writes of `.obsidian/workspace.json` to `.obsidian/workspace.<name>.json` based on the `?workspace=` URL parameter, so each browser tab can hold a separate layout.
|
||||
- **Read transforms** post-process bytes returned by a read (cache hit or transport miss). Used to mask the Obsidian Sync setting in `core-plugins.json` when headless-sync is active for the vault, and to override the `active` field on reads of `workspaces.json` so each tab sees its own workspace as selected.
|
||||
- **Write transforms** pre-process bytes before a write hits the cache or transport. Used to override the `active` field on writes to `workspaces.json` so cross-tab disk state stays canonical.
|
||||
|
||||
All hooks are synchronous and registered at module load. They fire once at the shim entry; downstream layers (content cache, metadata cache, transport) operate only on resolved physical paths and as-stored bytes. This keeps cache keys coherent with what transport actually reads and writes, so prefetch and on-demand fetches share the same cache slot.
|
||||
All hooks are synchronous and registered at module load. Translation happens once at the shim entry; downstream layers (content cache, metadata cache, transport) operate only on resolved physical paths and as-stored bytes. This keeps cache keys coherent with what transport actually reads and writes, so prefetch and on-demand fetches share the same cache slot.
|
||||
|
||||
### IPC
|
||||
|
||||
|
|
@ -106,23 +86,15 @@ The proxy itself is intentionally generic. It forwards method, headers, and body
|
|||
|
||||
### Workspaces in browser tabs
|
||||
|
||||
Obsidian's Workspaces core plugin lets you save a window layout under a name. Ignis adds a `?workspace=<name>` URL parameter that binds a tab to a specific layout. The bridge plugin's "Open workspace in new tab" command opens the picked workspace in a fresh tab.
|
||||
Obsidian's Workspaces core plugin lets you save a window layout under a name. Ignis adds a `?workspace=<name>` URL parameter that binds a tab to a specific layout. The bridge plugin's "Open workspace in new tab" command opens the picked workspace at `?workspace=<name>` in a fresh tab.
|
||||
|
||||
The implementation uses all three transforms (above): a path resolver redirects `.obsidian/workspace.json` to `.obsidian/workspace.<name>.json` so each tab has its own state file; a read transform overrides the `active` field on `workspaces.json` so the current tab's menu shows its own workspace as selected; a write transform keeps the canonical `active` value stable on disk so concurrent tabs don't clobber each other.
|
||||
The fs shim redirects reads and writes of `.obsidian/workspace.json` to a per-workspace file (`.obsidian/workspace.<name>.json`), giving each tab its own layout. It also rewrites the active field on reads of `workspaces.json` so each tab's menu shows its own workspace as active.
|
||||
|
||||
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 sharing a vault stay in sync through the file watcher.
|
||||
|
||||
## Bridge
|
||||
### Obsidian Plugin Compatibility
|
||||
|
||||
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.
|
||||
Obsidian evals plugin code with its own require that checks its internal module map first, then falls back to the window-level require, which is the shim. Plugins that use the filesystem, path utilities, or crypto get shim implementations without any changes. Plugins that need child processes, raw sockets, or native addons will load but throw on use; the error message names the missing API.
|
||||
|
||||
## Vaults
|
||||
|
||||
|
|
@ -137,38 +109,45 @@ An Express server that handles filesystem operations, vault management, static f
|
|||
- `/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/proxy` - cross-origin HTTP proxy used by the fetch and requestUrl shims.
|
||||
- `/api/version` - Ignis version (SemVer), per-build identifier, and pinned Obsidian version.
|
||||
- `/api/version` - server version and git hash.
|
||||
- `/api/plugins/*` - Ignis plugin management (list, enable, disable). __WIP__
|
||||
- `/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.
|
||||
|
||||
**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).
|
||||
|
||||
**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`).
|
||||
**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.
|
||||
|
||||
## Plugins
|
||||
|
||||
Aside from the built-in [Bridge](#bridge), three kinds of plugin exist in Ignis, distinguished by who loads them and where they run.
|
||||
Three things are called "plugin" in this project.
|
||||
|
||||
### 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. They work through the shim layer with no Ignis involvement beyond providing fs, path, and crypto.
|
||||
|
||||
### Bridge Plugin (ignis-bridge)
|
||||
|
||||
An Obsidian plugin auto-installed into every vault by the server. Source lives in `plugin/`, built to `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
|
||||
|
||||
A plugin system for extending the server. Still early, the core lifecycle works but the API surface is minimal and likely to change.
|
||||
A basic plugin system for extending the server. Still early, the core lifecycle works but the API surface is minimal and likely to change.
|
||||
|
||||
An Ignis plugin is a Node.js package under `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>/`.
|
||||
An Ignis plugin is a Node.js package under `server/plugins/<name>/` that exports an id, name, and a `register` function. On load it receives a context object with access to config, the WebSocket server, a file watcher, an Express router, a logger, and a persistent data directory. Plugins are enabled and disabled per vault, with state persisted in `data/plugin-config.json`.
|
||||
|
||||
An Ignis plugin 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.
|
||||
When enabled, a plugin's Express router is mounted at `/api/ext/<pluginId>/`. A plugin can also optionally bundle an Obsidian plugin, a directory containing a standard Obsidian plugin (manifest.json, main.js) that gets auto-installed into the vault on enable and removed on disable. This bridges the server and client sides: the Ignis plugin handles server logic and routes, while the bundled Obsidian plugin provides the in-app UI or behavior.
|
||||
|
||||
The one Ignis plugin currently in the repo is **headless-sync** (`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.
|
||||
The one Ignis plugin currently in the repo is **headless-sync** (`server/plugins/headless-sync/`). It wraps the [obsidian-headless](https://github.com/Yuri-Khomyakov/obsidian-headless) CLI (`ob`) and runs `ob sync --continuous` as a per-vault child process, optionally with `--pull-only` or `--mirror-remote`. Process state (running/stopped/error, pid, last activity, recent log lines) is broadcast over the WebSocket via a small per-vault subscription protocol. The bundled Obsidian plugin (`ignis-headless-sync`) adds a status bar item, a settings tab with start/stop/unlink controls, and a core-sync guard that hides Obsidian's own Sync setting from `core-plugins.json` reads while headless sync is active for that vault, so a different device syncing the "Active core plugins list" can't accidentally re-enable it.
|
||||
|
||||
## Demo mode
|
||||
|
||||
|
|
@ -185,4 +164,4 @@ Other demo behaviors:
|
|||
- Server-side plugins (e.g. headless-sync) hidden from the client; enable/disable returns 403.
|
||||
- The bridge plugin disables any `<input type="email">` or `<input type="password">` it sees anywhere in the document, with a placeholder telling users not to enter credentials.
|
||||
|
||||
All server-side demo code lives in `apps/ignis-server/server/demo/`. The client-side hooks live in `packages/shim/src/demo.js`. The deployment example is in `apps/ignis-server/examples/demo/` (tmpfs-mounted vaults, restricted proxy, all the env vars).
|
||||
All server-side demo code lives in `server/demo/`. The client-side hooks live in `src/shims/demo.js`. The deployment example is in `examples/demo/` (tmpfs-mounted vaults, restricted proxy, all the env vars).
|
||||
|
|
@ -9,8 +9,7 @@
|
|||
services:
|
||||
ignis-demo:
|
||||
build:
|
||||
context: ../../../..
|
||||
dockerfile: apps/ignis-server/Dockerfile
|
||||
context: ../..
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
120
package-lock.json
generated
120
package-lock.json
generated
|
|
@ -1,16 +1,12 @@
|
|||
{
|
||||
"name": "ignis-monorepo",
|
||||
"version": "0.8.2",
|
||||
"name": "ignis",
|
||||
"version": "0.8.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ignis-monorepo",
|
||||
"version": "0.8.2",
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
"apps/*"
|
||||
],
|
||||
"name": "ignis",
|
||||
"version": "0.8.0",
|
||||
"dependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
"chokidar": "^3.6.0",
|
||||
|
|
@ -30,19 +26,11 @@
|
|||
"vitest": "^3.2.4"
|
||||
}
|
||||
},
|
||||
"apps/ignis": {
|
||||
"name": "@ignis/app",
|
||||
"version": "0.8.1",
|
||||
"extraneous": true
|
||||
},
|
||||
"apps/ignis-server": {
|
||||
"name": "@ignis/app",
|
||||
"version": "0.0.0-internal"
|
||||
},
|
||||
"node_modules/@ampproject/remapping": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
||||
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
|
|
@ -494,30 +482,6 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@ignis/app": {
|
||||
"resolved": "apps/ignis-server",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@ignis/bridge": {
|
||||
"resolved": "packages/bridge",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@ignis/server-core": {
|
||||
"resolved": "packages/server-core",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@ignis/services": {
|
||||
"resolved": "packages/services",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@ignis/shim": {
|
||||
"resolved": "packages/shim",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@ignis/ui": {
|
||||
"resolved": "packages/ui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
|
|
@ -539,6 +503,7 @@
|
|||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
|
|
@ -549,6 +514,7 @@
|
|||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
|
|
@ -558,12 +524,14 @@
|
|||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
|
|
@ -574,6 +542,7 @@
|
|||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
|
|
@ -1003,6 +972,7 @@
|
|||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
|
|
@ -1149,6 +1119,7 @@
|
|||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
|
|
@ -1234,6 +1205,7 @@
|
|||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
|
||||
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
|
|
@ -1265,6 +1237,7 @@
|
|||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
|
|
@ -1594,6 +1567,7 @@
|
|||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz",
|
||||
"integrity": "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.4.15",
|
||||
|
|
@ -1778,6 +1752,7 @@
|
|||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",
|
||||
"integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mdn-data": "2.0.30",
|
||||
|
|
@ -1969,6 +1944,7 @@
|
|||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
|
|
@ -2393,6 +2369,7 @@
|
|||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
||||
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.6"
|
||||
|
|
@ -2490,6 +2467,7 @@
|
|||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
||||
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
|
|
@ -2515,6 +2493,7 @@
|
|||
"version": "0.577.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.577.0.tgz",
|
||||
"integrity": "sha512-0i88o57KsaHWnc80J57fY99CWzlZsSdtH5kKjLUJa7z8dum/9/AbINNLzJ7NiRFUdOgMnfAmJt8jFbW2zeC5qQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"svelte": "^3 || ^4 || ^5.0.0-next.42"
|
||||
|
|
@ -2524,6 +2503,7 @@
|
|||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
|
|
@ -2542,6 +2522,7 @@
|
|||
"version": "2.0.30",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
|
||||
"integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==",
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
|
|
@ -2738,6 +2719,7 @@
|
|||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
|
|
@ -2792,6 +2774,7 @@
|
|||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz",
|
||||
"integrity": "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0",
|
||||
|
|
@ -3201,6 +3184,7 @@
|
|||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
|
|
@ -3362,6 +3346,7 @@
|
|||
"version": "4.2.20",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.20.tgz",
|
||||
"integrity": "sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.1",
|
||||
|
|
@ -4427,59 +4412,6 @@
|
|||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"packages/bridge": {
|
||||
"name": "@ignis/bridge",
|
||||
"version": "0.0.0-internal",
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.20.0"
|
||||
}
|
||||
},
|
||||
"packages/bridge-plugin": {
|
||||
"name": "@ignis/bridge-plugin",
|
||||
"version": "0.0.0-internal",
|
||||
"extraneous": true,
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.20.0"
|
||||
}
|
||||
},
|
||||
"packages/server-core": {
|
||||
"name": "@ignis/server-core",
|
||||
"version": "0.0.0-internal",
|
||||
"dependencies": {
|
||||
"chokidar": "^3.6.0",
|
||||
"ws": "^8.16.0"
|
||||
}
|
||||
},
|
||||
"packages/services": {
|
||||
"name": "@ignis/services",
|
||||
"version": "0.0.0-internal"
|
||||
},
|
||||
"packages/shim": {
|
||||
"name": "@ignis/shim",
|
||||
"version": "0.0.0-internal",
|
||||
"dependencies": {
|
||||
"@ignis/services": "*",
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"pako": "^2.1.0",
|
||||
"path-browserify": "^1.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.20.0"
|
||||
}
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@ignis/ui",
|
||||
"version": "0.0.0-internal",
|
||||
"dependencies": {
|
||||
"@ignis/services": "*",
|
||||
"lucide-svelte": "^0.577.0",
|
||||
"svelte": "^4.2.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.20.0",
|
||||
"esbuild-svelte": "^0.9.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
12
package.json
12
package.json
|
|
@ -1,15 +1,11 @@
|
|||
{
|
||||
"name": "ignis-monorepo",
|
||||
"version": "0.8.4",
|
||||
"name": "ignis",
|
||||
"version": "0.8.0",
|
||||
"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/.",
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
"apps/*"
|
||||
],
|
||||
"description": "An Electron shim and server bridge for running Obsidian in a browser.",
|
||||
"scripts": {
|
||||
"build": "node build.js",
|
||||
"dev:server": "node apps/ignis-server/server/index.js",
|
||||
"dev:server": "node server/index.js",
|
||||
"dev": "npm run build && npm run dev:server",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"name": "@ignis/bridge",
|
||||
"version": "0.0.0-internal",
|
||||
"private": true,
|
||||
"main": "src/main.js"
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
||||
module.exports = { initStatusBar };
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"name": "@ignis/server-core",
|
||||
"version": "0.0.0-internal",
|
||||
"private": true,
|
||||
"main": "src/index.js",
|
||||
"dependencies": {
|
||||
"chokidar": "^3.6.0",
|
||||
"ws": "^8.16.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
const writeCoalescer = require("./write-coalescer");
|
||||
const watcher = require("./watcher");
|
||||
const { setupWebSocket } = require("./ws");
|
||||
const {
|
||||
encodeContentDispositionFilename,
|
||||
resolveVaultPath,
|
||||
} = require("./path-utils");
|
||||
|
||||
module.exports = {
|
||||
writeCoalescer,
|
||||
watcher,
|
||||
setupWebSocket,
|
||||
encodeContentDispositionFilename,
|
||||
resolveVaultPath,
|
||||
};
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
const path = require("path");
|
||||
|
||||
/**
|
||||
* Encode a filename for use in Content-Disposition header.
|
||||
* Handles non-ASCII characters and special characters to prevent header injection.
|
||||
* Uses RFC 5987 encoding for filename* parameter when needed.
|
||||
*/
|
||||
function encodeContentDispositionFilename(filename) {
|
||||
const hasNonASCII = /[^\x00-\x7F]/.test(filename);
|
||||
|
||||
// Escape quotes and backslashes in ASCII filename
|
||||
const escapedFilename = filename.replace(/["\\ ]/g, function (match) {
|
||||
if (match === '"') return '\\"';
|
||||
if (match === "\\") return "\\\\";
|
||||
return match;
|
||||
});
|
||||
|
||||
// Remove any control characters that could cause header injection
|
||||
const sanitizedFilename = escapedFilename.replace(/[\x00-\x1F\x7F]/g, "");
|
||||
|
||||
if (!hasNonASCII) {
|
||||
// Simple ASCII filename - use standard format
|
||||
return `attachment; filename="${sanitizedFilename}"`;
|
||||
}
|
||||
|
||||
// Non-ASCII filename - use RFC 5987 encoding
|
||||
// Encode using percent-encoding for UTF-8
|
||||
const encodedFilename = encodeURIComponent(filename)
|
||||
.replace(/['()]/g, function (c) {
|
||||
return "%" + c.charCodeAt(0).toString(16).toUpperCase();
|
||||
})
|
||||
.replace(/\*/g, "%2A");
|
||||
|
||||
// Provide both filename (ASCII fallback) and filename* (UTF-8 encoded)
|
||||
// For fallback, replace non-ASCII with underscores
|
||||
const asciiFallback = filename
|
||||
.replace(/[^\x00-\x7F]/g, "_")
|
||||
.replace(/["\\ ]/g, function (match) {
|
||||
if (match === '"') return '\\"';
|
||||
if (match === "\\") return "\\\\";
|
||||
return match;
|
||||
});
|
||||
|
||||
return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodedFilename}`;
|
||||
}
|
||||
|
||||
// Resolve a client-provided path to an absolute path within a vault.
|
||||
// Strips leading slashes so paths from the client are always treated as relative to the vault root.
|
||||
// Rejects nullish input so missing-field bugs in callers don't silently target the vault root.
|
||||
function resolveVaultPath(vaultRoot, relativePath) {
|
||||
if (relativePath === null || relativePath === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cleaned = relativePath.replace(/^\/+/, "");
|
||||
const resolved = path.resolve(vaultRoot, cleaned);
|
||||
|
||||
const resolvedRoot = path.resolve(vaultRoot);
|
||||
|
||||
if (
|
||||
resolved !== resolvedRoot &&
|
||||
!resolved.startsWith(resolvedRoot + path.sep)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
module.exports = { encodeContentDispositionFilename, resolveVaultPath };
|
||||
|
|
@ -1,214 +0,0 @@
|
|||
const { WebSocketServer } = require("ws");
|
||||
const url = require("url");
|
||||
const watcher = require("./watcher");
|
||||
|
||||
function setupWebSocket(server, opts = {}) {
|
||||
const { getVaultPath, originAllowlist } = opts;
|
||||
|
||||
if (typeof getVaultPath !== "function") {
|
||||
throw new Error("setupWebSocket: opts.getVaultPath is required");
|
||||
}
|
||||
|
||||
// Null / undefined / empty array = no Origin check.
|
||||
const originSet =
|
||||
Array.isArray(originAllowlist) && originAllowlist.length > 0
|
||||
? new Set(originAllowlist)
|
||||
: null;
|
||||
|
||||
const wss = new WebSocketServer({ server, path: "/ws" });
|
||||
|
||||
// Global message handlers: type -> handler(msg, ws).
|
||||
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) => {
|
||||
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 vaultId = params.get("vault");
|
||||
|
||||
if (!vaultId || !getVaultPath(vaultId)) {
|
||||
ws.close(4001, "Invalid or missing vault ID");
|
||||
return;
|
||||
}
|
||||
|
||||
const vaultPath = getVaultPath(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)
|
||||
watcher.startWatching(vaultId, vaultPath);
|
||||
|
||||
// Per-client listener that forwards file events over WebSocket
|
||||
const listener = (event) => {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(JSON.stringify(event));
|
||||
}
|
||||
};
|
||||
|
||||
watcher.addListener(vaultId, listener);
|
||||
|
||||
// Dispatch incoming messages to registered handlers.
|
||||
ws.on("message", (data) => {
|
||||
let msg;
|
||||
|
||||
try {
|
||||
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 (e) {
|
||||
console.warn(
|
||||
`[ws] handler for ${msg.channel ? msg.channel + ":" : ""}${msg.type} threw:`,
|
||||
e.message,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
console.log(`[ws] Client disconnected from vault: ${vaultId}`);
|
||||
watcher.removeListener(vaultId, listener);
|
||||
|
||||
const set = clientsByVault.get(vaultId);
|
||||
|
||||
if (set) {
|
||||
set.delete(ws);
|
||||
|
||||
if (set.size === 0) {
|
||||
clientsByVault.delete(vaultId);
|
||||
}
|
||||
}
|
||||
|
||||
channelSubsByClient.delete(ws);
|
||||
});
|
||||
});
|
||||
|
||||
return wss;
|
||||
}
|
||||
|
||||
module.exports = { setupWebSocket };
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"name": "@ignis/services",
|
||||
"version": "0.0.0-internal",
|
||||
"private": true,
|
||||
"main": "src/index.js"
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { vaultService } from "./vault-service.js";
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
const esbuild = require("esbuild");
|
||||
const path = require("path");
|
||||
|
||||
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({
|
||||
entryPoints: [path.join(__dirname, "src", "loader.js")],
|
||||
bundle: true,
|
||||
outfile: path.join(__dirname, "dist", "shim-loader.js"),
|
||||
format: "iife",
|
||||
platform: "browser",
|
||||
target: ["chrome90"],
|
||||
alias: {
|
||||
path: "path-browserify",
|
||||
},
|
||||
loader: {
|
||||
".css": "text",
|
||||
},
|
||||
external: ["obsidian", "fs"],
|
||||
define: {
|
||||
__IGNIS_VERSION__: JSON.stringify(semver),
|
||||
__IGNIS_BUILD__: JSON.stringify(build),
|
||||
},
|
||||
logLevel: "info",
|
||||
});
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
{
|
||||
"name": "@ignis/shim",
|
||||
"version": "0.0.0-internal",
|
||||
"private": true,
|
||||
"main": "src/loader.js",
|
||||
"scripts": {
|
||||
"build": "node build.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ignis/services": "*",
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"pako": "^2.1.0",
|
||||
"path-browserify": "^1.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.20.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
// 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),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
// 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));
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
// Bridges WebSocket file events to the fs shim's metadata/content caches and fs.watch listeners.
|
||||
// The WebSocket itself is owned by ws-client.js; this module is a consumer.
|
||||
|
||||
import { isRecentLocalOp } from "./echo-guard.js";
|
||||
|
||||
export function createWatcherClient(metadataCache, contentCache, fsWatch, wsClient) {
|
||||
function handleCreated(msg) {
|
||||
const { path, stat } = msg;
|
||||
|
||||
if (!path || isRecentLocalOp(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (stat) {
|
||||
metadataCache.set(path, {
|
||||
type: "file",
|
||||
size: stat.size,
|
||||
mtime: stat.mtime,
|
||||
ctime: stat.ctime,
|
||||
});
|
||||
}
|
||||
|
||||
contentCache.invalidate(path);
|
||||
fsWatch._dispatch("created", path);
|
||||
}
|
||||
|
||||
function handleFolderCreated(msg) {
|
||||
const { path } = msg;
|
||||
|
||||
if (!path || isRecentLocalOp(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
metadataCache.set(path, { type: "directory" });
|
||||
fsWatch._dispatch("folder-created", path);
|
||||
}
|
||||
|
||||
function handleModified(msg) {
|
||||
const { path, stat } = msg;
|
||||
|
||||
if (!path || isRecentLocalOp(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (stat) {
|
||||
metadataCache.set(path, {
|
||||
type: "file",
|
||||
size: stat.size,
|
||||
mtime: stat.mtime,
|
||||
ctime: stat.ctime,
|
||||
});
|
||||
}
|
||||
|
||||
contentCache.invalidate(path);
|
||||
fsWatch._dispatch("modified", path);
|
||||
}
|
||||
|
||||
function handleDeleted(msg) {
|
||||
const { path } = msg;
|
||||
|
||||
if (!path || isRecentLocalOp(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
metadataCache.delete(path);
|
||||
contentCache.invalidate(path);
|
||||
fsWatch._dispatch("deleted", path);
|
||||
}
|
||||
|
||||
wsClient.subscribe("created", handleCreated);
|
||||
wsClient.subscribe("folder-created", handleFolderCreated);
|
||||
wsClient.subscribe("modified", handleModified);
|
||||
wsClient.subscribe("deleted", handleDeleted);
|
||||
|
||||
function connect(vaultId) {
|
||||
wsClient.connect(vaultId);
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
wsClient.disconnect();
|
||||
}
|
||||
|
||||
return {
|
||||
connect,
|
||||
disconnect,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// 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 || {};
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
import { installRequire } from "./require.js";
|
||||
import { installGlobals } from "./globals.js";
|
||||
import { installCssOverrides } from "./css-overrides.js";
|
||||
import { initialize, getBootstrapVirtualPlugins } from "./init.js";
|
||||
import { fsShim } from "./fs/index.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__ (semver) and __IGNIS_BUILD__ are replaced at build time.
|
||||
window.__ignis = { version: __IGNIS_VERSION__, build: __IGNIS_BUILD__ };
|
||||
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)
|
||||
installRequire(); // shim registry, window.require
|
||||
installCssOverrides(); // browser-specific CSS fixes
|
||||
|
||||
// Set EmulateMobile flag for small viewports so Obsidian activates its mobile UI
|
||||
if (window.innerWidth < 600) {
|
||||
localStorage.setItem("EmulateMobile", "true");
|
||||
} else {
|
||||
localStorage.removeItem("EmulateMobile");
|
||||
}
|
||||
|
||||
initialize(); // vault config, metadata cache, plugin prompt
|
||||
|
||||
// Connect the shared WebSocket after everything is initialized; watcher and live-toggle subscribers attach to the same client.
|
||||
if (window.__currentVaultId) {
|
||||
fsShim._watcherClient.connect(window.__currentVaultId);
|
||||
watchPluginToggles(wsClient);
|
||||
}
|
||||
|
||||
extractObsidianModule()
|
||||
.then(async () => {
|
||||
// Dynamic import so bridge's top-level require("obsidian") fires 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");
|
||||
|
|
@ -1,164 +0,0 @@
|
|||
// Obsidian's native-menu path uses Electron Menu APIs that can't render in a browser.
|
||||
// Use transforms to keep nativeMenus = false in browser context while preserving user config on disk.
|
||||
// Also disable the settings toggle and patch setConfig.
|
||||
|
||||
import {
|
||||
registerReadTransform,
|
||||
registerWriteTransform,
|
||||
} from "./fs/transforms.js";
|
||||
|
||||
const APPEARANCE_PATH = ".obsidian/appearance.json";
|
||||
|
||||
// undefined = key absent on disk; write transform keeps it absent.
|
||||
let preservedNativeMenus = undefined;
|
||||
|
||||
function snapshotAppearance(vaultId) {
|
||||
if (!vaultId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const url =
|
||||
"/api/fs/readFile?vault=" +
|
||||
encodeURIComponent(vaultId) +
|
||||
"&path=" +
|
||||
encodeURIComponent(APPEARANCE_PATH) +
|
||||
"&encoding=utf-8";
|
||||
|
||||
xhr.open("GET", url, false);
|
||||
xhr.send();
|
||||
|
||||
if (xhr.status !== 200) {
|
||||
return;
|
||||
}
|
||||
|
||||
const obj = JSON.parse(xhr.responseText);
|
||||
|
||||
if ("nativeMenus" in obj) {
|
||||
preservedNativeMenus = obj.nativeMenus;
|
||||
}
|
||||
} catch {
|
||||
// File missing or malformed; preservedNativeMenus stays undefined.
|
||||
}
|
||||
}
|
||||
|
||||
function readTransform(data) {
|
||||
const text = typeof data === "string" ? data : new TextDecoder().decode(data);
|
||||
|
||||
try {
|
||||
const obj = JSON.parse(text);
|
||||
|
||||
if (obj.nativeMenus) {
|
||||
obj.nativeMenus = false;
|
||||
return JSON.stringify(obj);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function writeTransform(data) {
|
||||
const text = typeof data === "string" ? data : new TextDecoder().decode(data);
|
||||
|
||||
try {
|
||||
const obj = JSON.parse(text);
|
||||
|
||||
if (preservedNativeMenus === undefined) {
|
||||
delete obj.nativeMenus;
|
||||
} else {
|
||||
obj.nativeMenus = preservedNativeMenus;
|
||||
}
|
||||
|
||||
return JSON.stringify(obj);
|
||||
} catch {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent setting from being set during runtime.
|
||||
function patchSetConfig() {
|
||||
const tryPatch = () => {
|
||||
const vault = window.app && window.app.vault;
|
||||
|
||||
if (!vault || typeof vault.setConfig !== "function") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (vault.__ignisNativeMenuGuarded) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const orig = vault.setConfig.bind(vault);
|
||||
|
||||
vault.setConfig = function (key, value) {
|
||||
if (key === "nativeMenus") {
|
||||
return orig("nativeMenus", false);
|
||||
}
|
||||
|
||||
return orig(key, value);
|
||||
};
|
||||
vault.__ignisNativeMenuGuarded = true;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
if (tryPatch()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
if (tryPatch()) {
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Disable the "Native menus" toggle in appearance settings.
|
||||
function disableNativeMenuToggle() {
|
||||
const apply = () => {
|
||||
document.querySelectorAll(".setting-item-name").forEach((nameEl) => {
|
||||
if (!/native.?menu/i.test(nameEl.textContent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item = nameEl.closest(".setting-item");
|
||||
const input = item && item.querySelector('input[type="checkbox"]');
|
||||
|
||||
if (!input || input.__ignisDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
input.disabled = true;
|
||||
input.__ignisDisabled = true;
|
||||
|
||||
const container = input.closest(".checkbox-container");
|
||||
|
||||
if (container) {
|
||||
container.title =
|
||||
"Forced off in Ignis - browser context can't render native menus.";
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const observer = new MutationObserver(apply);
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function initNativeMenuGuard(vaultId) {
|
||||
// Snapshot before registering transforms so the write transform has the original disk value to substitute back.
|
||||
snapshotAppearance(vaultId);
|
||||
registerReadTransform(APPEARANCE_PATH, readTransform);
|
||||
registerWriteTransform(APPEARANCE_PATH, writeTransform);
|
||||
patchSetConfig();
|
||||
disableNativeMenuToggle();
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
// Use a runtime registry to avoid bloating bundles with imported component code.
|
||||
|
||||
let handlers = {};
|
||||
|
||||
export function registerUI(impls) {
|
||||
handlers = { ...handlers, ...impls };
|
||||
}
|
||||
|
||||
function proxy(name) {
|
||||
return (...args) => {
|
||||
const fn = handlers[name];
|
||||
|
||||
if (typeof fn !== "function") {
|
||||
console.warn(`[ignis] UI handler '${name}' not registered`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return fn(...args);
|
||||
};
|
||||
}
|
||||
|
||||
export const showVaultManager = proxy("showVaultManager");
|
||||
export const showMessageDialog = proxy("showMessageDialog");
|
||||
export const showConfirmDialog = proxy("showConfirmDialog");
|
||||
export const showPromptDialog = proxy("showPromptDialog");
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
// Base64 codec for the binary bodies exchanged with the server proxy.
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export { arrayBufferToBase64, base64ToArrayBuffer };
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
// Canonical key form for fs paths: backslashes to forward slashes, no leading or trailing slash.
|
||||
// Used by caches and registries that key on path.
|
||||
function normalize(p) {
|
||||
return (p || "").replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export { normalize };
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
// True when a request URL targets the page's own origin (so it can skip the cross-origin proxy).
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export { isSameOrigin };
|
||||
|
|
@ -1,239 +0,0 @@
|
|||
// Capture the obsidian module via a one-shot synthetic plugin so virtual plugins (bridge, future bundled) can require("obsidian").
|
||||
|
||||
import { setVirtualFile, removeVirtualFile } from "./fs/virtual-files.js";
|
||||
import { registerShim } from "./require.js";
|
||||
|
||||
const EXTRACTOR_ID = "ignis-obsidian-extractor";
|
||||
const EXTRACTOR_DIR = ".ignis/virtual/" + EXTRACTOR_ID;
|
||||
const EXTRACTOR_PATH = EXTRACTOR_DIR + "/main.js";
|
||||
|
||||
const EXTRACTOR_SRC = `
|
||||
const obsidian = require("obsidian");
|
||||
window.__ignisCapturedObsidian = obsidian;
|
||||
module.exports = class extends obsidian.Plugin {
|
||||
onload() {}
|
||||
};
|
||||
`;
|
||||
|
||||
const EXTRACTOR_MANIFEST = {
|
||||
id: EXTRACTOR_ID,
|
||||
name: "Ignis Obsidian Module Extractor",
|
||||
version: "0.0.0",
|
||||
minAppVersion: "1.0.0",
|
||||
description: "Internal: captures the obsidian module for virtual plugins.",
|
||||
author: "ignis",
|
||||
authorUrl: "",
|
||||
isDesktopOnly: false,
|
||||
dir: EXTRACTOR_DIR,
|
||||
};
|
||||
|
||||
function waitForApp() {
|
||||
return new Promise((resolve) => {
|
||||
if (window.app && window.app.plugins && window.app.workspace) {
|
||||
return resolve();
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (window.app && window.app.plugins && window.app.workspace) {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}
|
||||
}, 20);
|
||||
});
|
||||
}
|
||||
|
||||
export async function extractObsidianModule() {
|
||||
if (window.__ignis.obsidian) {
|
||||
return window.__ignis.obsidian;
|
||||
}
|
||||
|
||||
await waitForApp();
|
||||
|
||||
const plugins = window.app.plugins;
|
||||
|
||||
// loadPlugin gates on isEnabled(). Force-enable, restore on cleanup.
|
||||
const wasEnabled = plugins.isEnabled();
|
||||
let toggledOn = false;
|
||||
|
||||
if (!wasEnabled) {
|
||||
try {
|
||||
await plugins.setEnable(true);
|
||||
toggledOn = true;
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
"[ignis] could not enable community plugins for extractor:",
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setVirtualFile(EXTRACTOR_PATH, EXTRACTOR_SRC);
|
||||
plugins.manifests[EXTRACTOR_ID] = EXTRACTOR_MANIFEST;
|
||||
|
||||
try {
|
||||
await plugins.loadPlugin(EXTRACTOR_ID);
|
||||
} catch (e) {
|
||||
console.error("[ignis] extractor load failed:", e);
|
||||
}
|
||||
|
||||
const captured = window.__ignisCapturedObsidian;
|
||||
|
||||
try {
|
||||
await plugins.unloadPlugin(EXTRACTOR_ID);
|
||||
} catch {}
|
||||
|
||||
delete plugins.manifests[EXTRACTOR_ID];
|
||||
removeVirtualFile(EXTRACTOR_PATH);
|
||||
delete window.__ignisCapturedObsidian;
|
||||
|
||||
if (toggledOn) {
|
||||
try {
|
||||
await plugins.setEnable(false);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!captured) {
|
||||
console.error("[ignis] obsidian module extraction failed");
|
||||
return null;
|
||||
}
|
||||
|
||||
window.__ignis.obsidian = captured;
|
||||
registerShim("obsidian", captured);
|
||||
|
||||
console.log("[ignis] obsidian module captured");
|
||||
return captured;
|
||||
}
|
||||
|
||||
function assertSameOrigin(url) {
|
||||
if (new URL(url, location.origin).origin !== location.origin) {
|
||||
throw new Error(`refusing cross-origin plugin URL: ${url}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize per-id load/unload so rapid toggles can't race.
|
||||
const inFlight = new Map();
|
||||
|
||||
function serialized(id, fn) {
|
||||
const prev = inFlight.get(id) || Promise.resolve();
|
||||
const next = prev.then(fn, fn);
|
||||
inFlight.set(id, next);
|
||||
next.finally(() => {
|
||||
if (inFlight.get(id) === next) {
|
||||
inFlight.delete(id);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
export function loadVirtualPlugin(entry) {
|
||||
return serialized(entry.id, async () => {
|
||||
window.__ignis.plugins = window.__ignis.plugins || {};
|
||||
|
||||
if (window.__ignis.plugins[entry.id]) {
|
||||
console.log(`[ignis] virtual plugin already loaded: ${entry.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
assertSameOrigin(entry.scriptUrl);
|
||||
|
||||
if (entry.cssUrl) {
|
||||
assertSameOrigin(entry.cssUrl);
|
||||
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = entry.cssUrl;
|
||||
link.setAttribute("data-ignis-virtual-plugin", entry.id);
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
const res = await fetch(entry.scriptUrl);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`fetch ${entry.scriptUrl} -> ${res.status} ${res.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const src =
|
||||
(await res.text()) + `\n//# sourceURL=ignis-virtual/${entry.id}.js`;
|
||||
|
||||
const module = { exports: {} };
|
||||
const localRequire = (name) =>
|
||||
name === "obsidian" ? window.__ignis.obsidian : window.require(name);
|
||||
|
||||
new Function("module", "exports", "require", src)(
|
||||
module,
|
||||
module.exports,
|
||||
localRequire,
|
||||
);
|
||||
|
||||
const PluginClass = module.exports.default || module.exports;
|
||||
const instance = new PluginClass(window.app, entry.manifest);
|
||||
|
||||
// _loaded = true makes instance.unload() walk the Plugin's _register list later.
|
||||
// Cleans up addCommand / addStatusBarItem / addRibbonIcon / addSettingTab / registerEvent.
|
||||
instance._loaded = true;
|
||||
await instance.onload();
|
||||
|
||||
window.__ignis.plugins[entry.id] = { instance, manifest: entry.manifest };
|
||||
});
|
||||
}
|
||||
|
||||
export function unloadVirtualPlugin(id) {
|
||||
return serialized(id, async () => {
|
||||
const tracked = window.__ignis?.plugins?.[id];
|
||||
|
||||
if (!tracked) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await tracked.instance.unload();
|
||||
} catch (e) {
|
||||
reportUnloadFailure(id, e);
|
||||
}
|
||||
|
||||
document
|
||||
.querySelectorAll(`link[data-ignis-virtual-plugin="${id}"]`)
|
||||
.forEach((el) => el.remove());
|
||||
|
||||
delete window.__ignis.plugins[id];
|
||||
});
|
||||
}
|
||||
|
||||
//TODO: move to ignis API object?
|
||||
function notice(text) {
|
||||
try {
|
||||
new window.__ignis.obsidian.Notice(text);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function reportLoadFailure(id, e) {
|
||||
console.error(`[ignis] virtual plugin load failed: ${id}`, e);
|
||||
notice(`Failed to load plugin '${id}': ${e.message}`);
|
||||
}
|
||||
|
||||
export function reportUnloadFailure(id, e) {
|
||||
console.warn(`[ignis] virtual plugin unload failed: ${id}`, e);
|
||||
notice(`Failed to unload plugin '${id}': ${e.message}`);
|
||||
}
|
||||
|
||||
export function watchPluginToggles(wsClient) {
|
||||
wsClient.subscribe("virtual-plugin-enable", (msg) => {
|
||||
if (msg.vault !== window.__currentVaultId) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadVirtualPlugin(msg.entry).catch((e) =>
|
||||
reportLoadFailure(msg.entry?.id, e),
|
||||
);
|
||||
});
|
||||
|
||||
wsClient.subscribe("virtual-plugin-disable", (msg) => {
|
||||
if (msg.vault !== window.__currentVaultId) {
|
||||
return;
|
||||
}
|
||||
|
||||
unloadVirtualPlugin(msg.id).catch((e) => reportUnloadFailure(msg.id, e));
|
||||
});
|
||||
}
|
||||
|
|
@ -1,267 +0,0 @@
|
|||
// Vault-scoped WebSocket client.Single connection per shim instance.
|
||||
// Multiple consumers attach via subscribe/channel.
|
||||
|
||||
const RECONNECT_DELAY_MS = 2000;
|
||||
|
||||
export function createWsClient() {
|
||||
let ws = null;
|
||||
let vaultId = null;
|
||||
let reconnectTimer = null;
|
||||
let manuallyClosed = false;
|
||||
let state = "closed"; // "closed" | "connecting" | "open"
|
||||
|
||||
const globalSubs = new Map(); // type -> Set<handler>
|
||||
const channelSubs = new Map(); // channelName -> Map<type, Set<handler>>
|
||||
const channelSubCount = new Map(); // channelName -> integer
|
||||
const stateSubs = new Set(); // handler(state)
|
||||
|
||||
function setState(next) {
|
||||
if (state === next) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = next;
|
||||
|
||||
for (const fn of stateSubs) {
|
||||
try {
|
||||
fn(state);
|
||||
} catch (e) {
|
||||
console.error("[ws] state subscriber threw:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function postRaw(message) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
function sendSubscribeChannel(name) {
|
||||
postRaw({ type: "subscribe-channel", channel: name });
|
||||
}
|
||||
|
||||
function sendUnsubscribeChannel(name) {
|
||||
postRaw({ type: "unsubscribe-channel", channel: name });
|
||||
}
|
||||
|
||||
function dispatch(msg) {
|
||||
if (msg.channel) {
|
||||
const types = channelSubs.get(msg.channel);
|
||||
const handlers = types && types.get(msg.type);
|
||||
|
||||
if (handlers) {
|
||||
for (const fn of handlers) {
|
||||
try {
|
||||
fn(msg);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`[ws] channel subscriber for ${msg.channel}:${msg.type} threw:`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const handlers = globalSubs.get(msg.type);
|
||||
|
||||
if (handlers) {
|
||||
for (const fn of handlers) {
|
||||
try {
|
||||
fn(msg);
|
||||
} catch (e) {
|
||||
console.error(`[ws] subscriber for ${msg.type} threw:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openSocket() {
|
||||
if (ws) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState("connecting");
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const url = `${protocol}//${window.location.host}/ws?vault=${encodeURIComponent(vaultId)}`;
|
||||
|
||||
try {
|
||||
ws = new WebSocket(url);
|
||||
} catch (e) {
|
||||
console.error("[ws] failed to create WebSocket:", e);
|
||||
ws = null;
|
||||
setState("closed");
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log("[ws] connected");
|
||||
setState("open");
|
||||
|
||||
// Re-establish channel subscriptions on the new connection.
|
||||
for (const name of channelSubCount.keys()) {
|
||||
sendSubscribeChannel(name);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
let msg;
|
||||
|
||||
try {
|
||||
msg = JSON.parse(event.data);
|
||||
} catch (e) {
|
||||
console.error("[ws] failed to parse message:", e);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(msg);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
ws = null;
|
||||
setState("closed");
|
||||
|
||||
if (!manuallyClosed) {
|
||||
scheduleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (e) => {
|
||||
console.error("[ws] error:", e);
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (reconnectTimer || manuallyClosed) {
|
||||
return;
|
||||
}
|
||||
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
console.log("[ws] reconnecting...");
|
||||
openSocket();
|
||||
}, RECONNECT_DELAY_MS);
|
||||
}
|
||||
|
||||
function connect(id) {
|
||||
if (!id) {
|
||||
console.warn("[ws] no vault id; skipping connect");
|
||||
return;
|
||||
}
|
||||
|
||||
vaultId = id;
|
||||
manuallyClosed = false;
|
||||
openSocket();
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
manuallyClosed = true;
|
||||
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
|
||||
if (ws) {
|
||||
ws.close();
|
||||
ws = null;
|
||||
}
|
||||
|
||||
setState("closed");
|
||||
}
|
||||
|
||||
function subscribe(type, handler) {
|
||||
if (!globalSubs.has(type)) {
|
||||
globalSubs.set(type, new Set());
|
||||
}
|
||||
|
||||
globalSubs.get(type).add(handler);
|
||||
|
||||
return () => {
|
||||
globalSubs.get(type)?.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
function send(type, payload) {
|
||||
postRaw({ type, ...(payload || {}) });
|
||||
}
|
||||
|
||||
function channel(name) {
|
||||
return {
|
||||
subscribe(type, handler) {
|
||||
if (!channelSubs.has(name)) {
|
||||
channelSubs.set(name, new Map());
|
||||
}
|
||||
|
||||
const types = channelSubs.get(name);
|
||||
|
||||
if (!types.has(type)) {
|
||||
types.set(type, new Set());
|
||||
}
|
||||
|
||||
types.get(type).add(handler);
|
||||
|
||||
// First subscriber for this channel: upgrade the server-side gate.
|
||||
const prevCount = channelSubCount.get(name) || 0;
|
||||
channelSubCount.set(name, prevCount + 1);
|
||||
|
||||
if (prevCount === 0) {
|
||||
sendSubscribeChannel(name);
|
||||
}
|
||||
|
||||
return () => {
|
||||
const set = types.get(type);
|
||||
|
||||
if (!set || !set.has(handler)) {
|
||||
return;
|
||||
}
|
||||
|
||||
set.delete(handler);
|
||||
|
||||
const newCount = (channelSubCount.get(name) || 0) - 1;
|
||||
|
||||
if (newCount <= 0) {
|
||||
channelSubCount.delete(name);
|
||||
sendUnsubscribeChannel(name);
|
||||
} else {
|
||||
channelSubCount.set(name, newCount);
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
send(type, payload) {
|
||||
postRaw({ channel: name, type, ...(payload || {}) });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isOpen() {
|
||||
return state === "open";
|
||||
}
|
||||
|
||||
function onStateChange(handler) {
|
||||
stateSubs.add(handler);
|
||||
|
||||
return () => {
|
||||
stateSubs.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
connect,
|
||||
disconnect,
|
||||
subscribe,
|
||||
send,
|
||||
channel,
|
||||
isOpen,
|
||||
onStateChange,
|
||||
};
|
||||
}
|
||||
|
||||
// Singleton instance. The shim has one WebSocket per page; consumers all share it.
|
||||
export const wsClient = createWsClient();
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
const esbuild = require("esbuild");
|
||||
const sveltePlugin = require("esbuild-svelte");
|
||||
const path = require("path");
|
||||
|
||||
module.exports = esbuild.build({
|
||||
entryPoints: [path.join(__dirname, "src", "index.js")],
|
||||
bundle: true,
|
||||
outfile: path.join(__dirname, "dist", "ignis-ui.js"),
|
||||
format: "iife",
|
||||
globalName: "IgnisUI",
|
||||
platform: "browser",
|
||||
target: ["chrome90"],
|
||||
mainFields: ["svelte", "browser", "module", "main"],
|
||||
conditions: ["svelte", "browser"],
|
||||
plugins: [sveltePlugin({ compilerOptions: { css: "injected" } })],
|
||||
logLevel: "info",
|
||||
});
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
{
|
||||
"name": "@ignis/ui",
|
||||
"version": "0.0.0-internal",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "node build.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ignis/services": "*",
|
||||
"lucide-svelte": "^0.577.0",
|
||||
"svelte": "^4.2.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.20.0",
|
||||
"esbuild-svelte": "^0.9.4"
|
||||
}
|
||||
}
|
||||
10
plugin/manifest.json
Normal file
10
plugin/manifest.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"id": "ignis-bridge",
|
||||
"name": "Ignis Bridge",
|
||||
"version": "0.8.0",
|
||||
"minAppVersion": "1.12.4",
|
||||
"description": "Additional Ignis specific functionality and ignis plugin management.",
|
||||
"author": "Nystik",
|
||||
"authorUrl": "https://github.com/Nystik-gh/ignis",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
|
|
@ -13,6 +13,8 @@ const { initStatusBar } = require("./status-bar");
|
|||
const { WorkspacePickerModal } = require("./workspace-picker");
|
||||
const { startDemoGuards, stopDemoGuards } = require("./demo-guards");
|
||||
|
||||
window.__obsidianAPI = require("obsidian");
|
||||
|
||||
class IgnisBridgePlugin extends Plugin {
|
||||
async onload() {
|
||||
if (!window.__ignis) {
|
||||
|
|
@ -4,13 +4,13 @@ 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";
|
||||
}
|
||||
|
||||
// SemVer build metadata (`+xyz`) is informational and ignored for precedence.
|
||||
function stripBuildMetadata(version) {
|
||||
return (version || "").split("+")[0];
|
||||
function getVersion(app) {
|
||||
try {
|
||||
const manifest = app.plugins.getPlugin("ignis-bridge")?.manifest;
|
||||
return manifest?.version || "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
async function checkForUpdate(currentVersion) {
|
||||
|
|
@ -22,11 +22,10 @@ async function checkForUpdate(currentVersion) {
|
|||
}
|
||||
|
||||
const data = await res.json();
|
||||
const latest = stripBuildMetadata(data.tag_name?.replace(/^v/, ""));
|
||||
const current = stripBuildMetadata(currentVersion);
|
||||
const latest = data.tag_name?.replace(/^v/, "");
|
||||
|
||||
if (latest && latest !== current) {
|
||||
return { version: latest, url: data.html_url };
|
||||
if (latest && latest !== currentVersion) {
|
||||
return latest;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
@ -36,7 +35,7 @@ async function checkForUpdate(currentVersion) {
|
|||
}
|
||||
|
||||
function display(containerEl, app) {
|
||||
const version = getVersion();
|
||||
const version = getVersion(app);
|
||||
|
||||
const header = containerEl.createDiv("ignis-header");
|
||||
|
||||
|
|
@ -60,10 +59,9 @@ function display(containerEl, app) {
|
|||
cls: "ignis-header-version",
|
||||
});
|
||||
|
||||
const updateIndicator = versionCol.createEl("a", {
|
||||
const updateIndicator = versionCol.createEl("span", {
|
||||
text: "Checking...",
|
||||
cls: "ignis-update-indicator",
|
||||
attr: { target: "_blank", rel: "noopener noreferrer" },
|
||||
});
|
||||
|
||||
const githubLink = right.createEl("a", {
|
||||
|
|
@ -77,11 +75,10 @@ function display(containerEl, app) {
|
|||
attr: { src: "/assets/github.svg", alt: "GitHub" },
|
||||
});
|
||||
|
||||
checkForUpdate(version).then((latest) => {
|
||||
if (latest) {
|
||||
updateIndicator.textContent = `v${latest.version} available`;
|
||||
checkForUpdate(version).then((latestVersion) => {
|
||||
if (latestVersion) {
|
||||
updateIndicator.textContent = `v${latestVersion} available`;
|
||||
updateIndicator.addClass("ignis-update-available");
|
||||
updateIndicator.href = latest.url;
|
||||
} else {
|
||||
updateIndicator.textContent = "Up to date";
|
||||
}
|
||||
|
|
@ -90,44 +87,64 @@ function display(containerEl, app) {
|
|||
addServerStatus(containerEl);
|
||||
}
|
||||
|
||||
const STATUS_LABELS = {
|
||||
open: "Connected",
|
||||
connecting: "Connecting...",
|
||||
closed: "Disconnected",
|
||||
};
|
||||
function getWsStatus() {
|
||||
const ws = window.__ignisWs;
|
||||
|
||||
const STATUS_DOT_CLASSES = {
|
||||
open: "ignis-status-connected",
|
||||
connecting: "ignis-status-connecting",
|
||||
closed: "ignis-status-disconnected",
|
||||
};
|
||||
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 ws = window.__ignis.ws;
|
||||
const status = getWsStatus();
|
||||
|
||||
const setting = new Setting(containerEl).setName("Server status");
|
||||
|
||||
const dotEl = setting.controlEl.createEl("span", {
|
||||
cls: "ignis-status-dot",
|
||||
cls: `ignis-status-dot ignis-status-${status}`,
|
||||
});
|
||||
|
||||
const labelEl = setting.controlEl.createEl("span", {
|
||||
text: statusLabel(status),
|
||||
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;
|
||||
}
|
||||
const update = () => {
|
||||
const s = getWsStatus();
|
||||
dotEl.className = `ignis-status-dot ignis-status-${s}`;
|
||||
labelEl.textContent = statusLabel(s);
|
||||
};
|
||||
|
||||
render(ws.isOpen() ? "open" : "closed");
|
||||
const pollInterval = setInterval(update, 3000);
|
||||
|
||||
const unsub = ws.onStateChange(render);
|
||||
|
||||
// Detach when the settings tab DOM goes away.
|
||||
const observer = new MutationObserver(() => {
|
||||
if (!containerEl.isConnected) {
|
||||
unsub();
|
||||
clearInterval(pollInterval);
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
|
|
@ -2,7 +2,6 @@ const generalTab = require("./general-tab");
|
|||
const serverPluginsTab = require("./server-plugins-tab");
|
||||
const { createNavEl, createTab, createGroup } = require("./settings-ui");
|
||||
const {
|
||||
allIgnisNavEls,
|
||||
setupPluginTabs,
|
||||
reconcilePluginTabs,
|
||||
hideIgnisFromCommunityPlugins,
|
||||
|
|
@ -25,6 +24,10 @@ 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) {
|
||||
const container = setting.tabContentContainer || setting.contentEl;
|
||||
|
||||
|
|
@ -114,7 +117,7 @@ function injectIgnisSettings(setting, app, plugin) {
|
|||
setting.tabHeadersEl.appendChild(corePlugins.group);
|
||||
|
||||
hideIgnisFromCommunityPlugins(setting);
|
||||
setupPluginTabs(setting, corePlugins.items);
|
||||
setupPluginTabs(setting, corePlugins.items, allIgnisNavEls);
|
||||
}
|
||||
|
||||
function patchSettingsModal(plugin) {
|
||||
|
|
@ -139,4 +142,7 @@ function unpatchSettingsModal(plugin) {
|
|||
clearOwnedPluginIds();
|
||||
}
|
||||
|
||||
window.__ignisReconcilePluginTabs = (setting) =>
|
||||
reconcilePluginTabs(setting, allIgnisNavEls);
|
||||
|
||||
module.exports = { patchSettingsModal, unpatchSettingsModal, reconcilePluginTabs };
|
||||
|
|
@ -2,14 +2,10 @@ const { setIcon } = require("obsidian");
|
|||
const { findGroupByTitle } = require("./settings-ui");
|
||||
const { isIgnisPlugin } = require("../plugin-registry");
|
||||
|
||||
// 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.
|
||||
const ownedPluginIds = new Set();
|
||||
|
||||
function addPluginNavItem(pluginId, setting, corePluginsItems) {
|
||||
function addPluginNavItem(pluginId, setting, corePluginsItems, ignisNavEls) {
|
||||
const tab = setting.pluginTabs.find((t) => t.id === pluginId);
|
||||
|
||||
if (!tab) {
|
||||
|
|
@ -45,16 +41,16 @@ function addPluginNavItem(pluginId, setting, corePluginsItems) {
|
|||
|
||||
corePluginsItems.appendChild(nav);
|
||||
ownedPluginIds.add(pluginId);
|
||||
allIgnisNavEls.set(pluginId, nav);
|
||||
ignisNavEls.set(pluginId, nav);
|
||||
}
|
||||
|
||||
function removePluginNavItem(pluginId) {
|
||||
const nav = allIgnisNavEls.get(pluginId);
|
||||
function removePluginNavItem(pluginId, ignisNavEls) {
|
||||
const nav = ignisNavEls.get(pluginId);
|
||||
|
||||
if (nav && ownedPluginIds.has(pluginId)) {
|
||||
nav.remove();
|
||||
ownedPluginIds.delete(pluginId);
|
||||
allIgnisNavEls.delete(pluginId);
|
||||
ignisNavEls.delete(pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -120,11 +116,11 @@ function hideIgnisNavFromCommunityGroup(setting) {
|
|||
communityGroup.style.display = hasVisible ? "" : "none";
|
||||
}
|
||||
|
||||
function hideCorePluginsGroupIfEmpty() {
|
||||
function hideCorePluginsGroupIfEmpty(ignisNavEls) {
|
||||
let hasConnected = false;
|
||||
|
||||
for (const id of ownedPluginIds) {
|
||||
const nav = allIgnisNavEls.get(id);
|
||||
const nav = ignisNavEls.get(id);
|
||||
|
||||
if (nav?.isConnected) {
|
||||
hasConnected = true;
|
||||
|
|
@ -144,15 +140,15 @@ function hideCorePluginsGroupIfEmpty() {
|
|||
}
|
||||
}
|
||||
|
||||
function setupPluginTabs(setting, corePluginsItems) {
|
||||
function setupPluginTabs(setting, corePluginsItems, ignisNavEls) {
|
||||
for (const tab of setting.pluginTabs) {
|
||||
if (isIgnisPlugin(tab.id) && tab.id !== "ignis-bridge") {
|
||||
addPluginNavItem(tab.id, setting, corePluginsItems);
|
||||
addPluginNavItem(tab.id, setting, corePluginsItems, ignisNavEls);
|
||||
}
|
||||
}
|
||||
|
||||
hideIgnisNavFromCommunityGroup(setting);
|
||||
hideCorePluginsGroupIfEmpty();
|
||||
hideCorePluginsGroupIfEmpty(ignisNavEls);
|
||||
|
||||
const communityGroup = findGroupByTitle(
|
||||
setting.tabHeadersEl,
|
||||
|
|
@ -163,12 +159,12 @@ function setupPluginTabs(setting, corePluginsItems) {
|
|||
const observer = new MutationObserver(() => {
|
||||
for (const tab of setting.pluginTabs) {
|
||||
if (isIgnisPlugin(tab.id) && tab.id !== "ignis-bridge") {
|
||||
addPluginNavItem(tab.id, setting, corePluginsItems);
|
||||
addPluginNavItem(tab.id, setting, corePluginsItems, ignisNavEls);
|
||||
}
|
||||
}
|
||||
|
||||
hideIgnisNavFromCommunityGroup(setting);
|
||||
hideCorePluginsGroupIfEmpty();
|
||||
hideCorePluginsGroupIfEmpty(ignisNavEls);
|
||||
});
|
||||
|
||||
observer.observe(communityGroup, { childList: true, subtree: true });
|
||||
|
|
@ -190,7 +186,7 @@ function setupPluginTabs(setting, corePluginsItems) {
|
|||
}
|
||||
}
|
||||
|
||||
function reconcilePluginTabs(setting) {
|
||||
function reconcilePluginTabs(setting, ignisNavEls) {
|
||||
const corePluginsGroup = findGroupByTitle(
|
||||
setting.tabHeadersEl,
|
||||
"Ignis Core Plugins",
|
||||
|
|
@ -216,16 +212,16 @@ function reconcilePluginTabs(setting) {
|
|||
|
||||
for (const id of ownedPluginIds) {
|
||||
if (!activeIds.has(id)) {
|
||||
removePluginNavItem(id);
|
||||
removePluginNavItem(id, ignisNavEls);
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of activeIds) {
|
||||
addPluginNavItem(id, setting, corePluginsItems);
|
||||
addPluginNavItem(id, setting, corePluginsItems, ignisNavEls);
|
||||
}
|
||||
|
||||
hideIgnisNavFromCommunityGroup(setting);
|
||||
hideCorePluginsGroupIfEmpty();
|
||||
hideCorePluginsGroupIfEmpty(ignisNavEls);
|
||||
}
|
||||
|
||||
function clearOwnedPluginIds() {
|
||||
|
|
@ -233,7 +229,6 @@ function clearOwnedPluginIds() {
|
|||
}
|
||||
|
||||
module.exports = {
|
||||
allIgnisNavEls,
|
||||
setupPluginTabs,
|
||||
reconcilePluginTabs,
|
||||
hideIgnisFromCommunityPlugins,
|
||||
|
|
@ -1,10 +1,18 @@
|
|||
const { Setting, Notice } = require("obsidian");
|
||||
const { reconcilePluginTabs } = require("./plugin-tabs");
|
||||
|
||||
function getVaultId() {
|
||||
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() {
|
||||
const res = await fetch("/api/plugins");
|
||||
|
||||
|
|
@ -15,7 +23,7 @@ async function fetchPlugins() {
|
|||
return res.json();
|
||||
}
|
||||
|
||||
async function togglePlugin(pluginId, enable) {
|
||||
async function togglePlugin(pluginId, enable, app) {
|
||||
const action = enable ? "enable" : "disable";
|
||||
const vaultId = getVaultId();
|
||||
|
||||
|
|
@ -33,10 +41,25 @@ async function togglePlugin(pluginId, enable) {
|
|||
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) {
|
||||
containerEl.createEl("h2", { text: "Ignis Core Plugins" });
|
||||
|
||||
containerEl.createEl("p", {
|
||||
const descEl = containerEl.createEl("p", {
|
||||
text:
|
||||
"Ignis plugins extend server functionality and run alongside your vaults. " +
|
||||
"They are separate from Obsidian's built-in plugins.",
|
||||
|
|
@ -69,16 +92,28 @@ function display(containerEl, app) {
|
|||
toggle.setValue(enabled);
|
||||
toggle.onChange(async (value) => {
|
||||
try {
|
||||
await togglePlugin(plugin.id, value);
|
||||
await togglePlugin(plugin.id, value, app);
|
||||
|
||||
if (value && plugin.bundledPluginId) {
|
||||
await refreshPluginCache(plugin.bundledPluginId);
|
||||
}
|
||||
|
||||
await activateBundledPlugin(
|
||||
plugin.bundledPluginId,
|
||||
value,
|
||||
app,
|
||||
);
|
||||
|
||||
new Notice(
|
||||
`${plugin.name} ${value ? "enabled" : "disabled"} for this vault.`,
|
||||
);
|
||||
|
||||
// The server's WS broadcast drives the actual load/unload via virtual-plugin-loader.
|
||||
// Reconcile the settings sidebar so the new plugin's settings tab gets grouped correctly.
|
||||
// Give Obsidian a moment to update its plugin tabs,
|
||||
// then reconcile our sidebar groups.
|
||||
setTimeout(() => {
|
||||
reconcilePluginTabs(app.setting);
|
||||
if (typeof window.__ignisReconcilePluginTabs === "function") {
|
||||
window.__ignisReconcilePluginTabs(app.setting);
|
||||
}
|
||||
}, 100);
|
||||
} catch (e) {
|
||||
new Notice(`Failed: ${e.message}`);
|
||||
48
plugin/src/status-bar.js
Normal file
48
plugin/src/status-bar.js
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
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 };
|
||||
|
|
@ -54,17 +54,12 @@
|
|||
.ignis-update-indicator {
|
||||
font-size: var(--font-ui-smaller);
|
||||
color: var(--text-faint);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ignis-update-indicator.ignis-update-available {
|
||||
color: var(--text-accent);
|
||||
}
|
||||
|
||||
.ignis-update-indicator.ignis-update-available:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ignis-github-link {
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
|
|
@ -68,4 +68,4 @@ else
|
|||
fi
|
||||
|
||||
# Run as the determined user
|
||||
exec gosu "$RUN_USER" node /app/apps/ignis-server/server/index.js
|
||||
exec gosu "$RUN_USER" node /app/server/index.js
|
||||
|
Before Width: | Height: | Size: 984 B After Width: | Height: | Size: 984 B |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
|
@ -41,8 +41,8 @@
|
|||
<div id="ignis-status-label">Loading Obsidian...</div>
|
||||
</div>
|
||||
<!-- Ignis shims: must run before any Obsidian code. -->
|
||||
<script type="text/javascript" src="__SHIM_LOADER_SRC__"></script>
|
||||
<script type="text/javascript" src="__IGNIS_UI_SRC__"></script>
|
||||
<script type="text/javascript" src="__SHIM_LOADER_SRC__"></script>
|
||||
<!-- Obsidian scripts injected dynamically to avoid touching their files. -->
|
||||
<script>
|
||||
(function () {
|
||||
|
|
@ -8,9 +8,3 @@
|
|||
.is-hidden-frameless:not(.is-fullscreen):not(.mod-macos) .workspace-tabs.mod-top-right-space .workspace-tab-header-container:after {
|
||||
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;
|
||||
}
|
||||
70
server/bridge-plugin.js
Normal file
70
server/bridge-plugin.js
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const {
|
||||
installObsidianPlugin,
|
||||
isObsidianPluginInstalled,
|
||||
} = require("./plugin-system/obsidian-plugin");
|
||||
|
||||
const BRIDGE_PLUGIN_ID = "ignis-bridge";
|
||||
const BRIDGE_PLUGIN_DIR = path.join(__dirname, "..", "plugin");
|
||||
|
||||
// .ignis metadata helpers
|
||||
|
||||
async function getIgnisMeta(vaultPath) {
|
||||
const metaFile = path.join(vaultPath, ".ignis", "meta.json");
|
||||
|
||||
try {
|
||||
const content = await fs.promises.readFile(metaFile, "utf-8");
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function setIgnisMeta(vaultPath, data) {
|
||||
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))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = await fs.promises.readdir(vaultRoot, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const vaultPath = path.join(vaultRoot, entry.name);
|
||||
const installed = await installBridgePlugin(vaultPath);
|
||||
|
||||
if (installed) {
|
||||
console.log(`[ignis] Installed bridge plugin in vault: ${entry.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
installBridgePlugin,
|
||||
updateBridgePluginInAllVaults,
|
||||
isBridgePluginInstalled,
|
||||
getIgnisMeta,
|
||||
setIgnisMeta,
|
||||
};
|
||||
|
|
@ -1,13 +1,12 @@
|
|||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, "..", "..", "..");
|
||||
|
||||
// VAULT_ROOT: a directory that contains vault folders.
|
||||
// Each subdirectory is a vault. New vaults are created as new subdirs.
|
||||
const vaultRoot = process.env.VAULT_ROOT || path.join(REPO_ROOT, "vaults");
|
||||
const vaultRoot =
|
||||
process.env.VAULT_ROOT || path.join(__dirname, "..", "vaults");
|
||||
|
||||
const dataRoot = process.env.DATA_ROOT || path.join(REPO_ROOT, "data");
|
||||
const dataRoot = process.env.DATA_ROOT || path.join(__dirname, "..", "data");
|
||||
|
||||
// Ensure required directories exist
|
||||
try {
|
||||
|
|
@ -80,12 +79,6 @@ module.exports = {
|
|||
? parseInt(process.env.WRITE_COALESCE_MS)
|
||||
: 5000,
|
||||
|
||||
wsOrigins: process.env.WS_ORIGINS
|
||||
? process.env.WS_ORIGINS.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: null,
|
||||
|
||||
demoMode: process.env.DEMO_MODE === "true",
|
||||
demoMaxSessions: parseInt(process.env.DEMO_MAX_SESSIONS) || 20,
|
||||
demoVaultsPerSession: parseInt(process.env.DEMO_VAULTS_PER_SESSION) || 3,
|
||||
|
|
@ -93,11 +86,12 @@ module.exports = {
|
|||
parseInt(process.env.DEMO_SESSION_QUOTA_BYTES) || 700 * 1024,
|
||||
demoTimeoutMs: parseInt(process.env.DEMO_TIMEOUT_MS) || 30 * 60 * 1000,
|
||||
demoTemplateDir:
|
||||
process.env.DEMO_TEMPLATE_DIR || path.join(__dirname, "demo-template"),
|
||||
process.env.DEMO_TEMPLATE_DIR ||
|
||||
path.join(__dirname, "demo-template"),
|
||||
|
||||
obsidianAssetsPath:
|
||||
process.env.OBSIDIAN_ASSETS_PATH ||
|
||||
path.join(REPO_ROOT, "investigation", "obsidian_1.12.7_unpacked"),
|
||||
path.join(__dirname, "..", "investigation", "obsidian_1.12.7_unpacked"),
|
||||
|
||||
get obsidianVersion() {
|
||||
const assetsPath =
|
||||
|
|
@ -5,7 +5,7 @@ const fsp = fs.promises;
|
|||
const path = require("path");
|
||||
|
||||
const config = require("../config");
|
||||
const { watcher } = require("@ignis/server-core");
|
||||
const watcher = require("../watcher");
|
||||
const bootstrapRoutes = require("../routes/bootstrap");
|
||||
|
||||
const {
|
||||
|
|
@ -272,36 +272,16 @@ function trackVaultLifecycle(req, res, next) {
|
|||
|
||||
if (s) {
|
||||
if (req.path === "/create" && body.id) {
|
||||
// body.id is storage-prefixed at this point (outboundTranslator runs after us).
|
||||
// Translate to the user-visible name so it matches what pageLoadHandler queries with.
|
||||
const userName = tryParseUserVaultName(sessionId, body.id);
|
||||
|
||||
if (userName !== null) {
|
||||
s.vaults.add(userName);
|
||||
} else {
|
||||
console.warn(
|
||||
"[demo] trackVaultLifecycle: could not parse user name from create response id:",
|
||||
body.id,
|
||||
);
|
||||
}
|
||||
s.vaults.add(body.id);
|
||||
} else if (req.path === "/rename") {
|
||||
const oldName = req._demoOriginalVault;
|
||||
const oldName = req.body && req.body._origVault;
|
||||
|
||||
if (oldName) {
|
||||
s.vaults.delete(oldName);
|
||||
}
|
||||
|
||||
if (body.id) {
|
||||
const userName = tryParseUserVaultName(sessionId, body.id);
|
||||
|
||||
if (userName !== null) {
|
||||
s.vaults.add(userName);
|
||||
} else {
|
||||
console.warn(
|
||||
"[demo] trackVaultLifecycle: could not parse user name from rename response id:",
|
||||
body.id,
|
||||
);
|
||||
}
|
||||
s.vaults.add(body.id);
|
||||
}
|
||||
} else if (req.method === "DELETE" && req.path === "/remove") {
|
||||
const removed = req._demoOriginalVault;
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
// Vault provisioning for demo sessions.
|
||||
//
|
||||
// Copies the template into a session-prefixed dir and registers the vault on the session.
|
||||
// Copies the template into a session-prefixed dir, installs the bridge plugin, and registers the vault on the session.
|
||||
// Re-provisions if disk was wiped under an existing session.
|
||||
|
||||
const fs = require("fs");
|
||||
|
|
@ -8,6 +8,7 @@ const fsp = fs.promises;
|
|||
const path = require("path");
|
||||
|
||||
const config = require("../config");
|
||||
const { installBridgePlugin } = require("../bridge-plugin");
|
||||
const bootstrapRoutes = require("../routes/bootstrap");
|
||||
|
||||
const { sessions, makeStorageName } = require("./demo-sessions");
|
||||
|
|
@ -95,6 +96,9 @@ async function provisionVault(sessionId, userVaultName) {
|
|||
// Copy template (default: Welcome.md, Getting Started.md, .obsidian/*).
|
||||
await fsp.cp(config.demoTemplateDir, vaultPath, { recursive: true });
|
||||
|
||||
// Install bridge plugin
|
||||
await installBridgePlugin(vaultPath);
|
||||
|
||||
config.refreshVaults();
|
||||
bootstrapRoutes.invalidateVault(storageName);
|
||||
|
||||
|
|
@ -4,27 +4,14 @@ const path = require("path");
|
|||
const compression = require("compression");
|
||||
const config = require("./config");
|
||||
const { getVersion } = require("./version");
|
||||
const {
|
||||
setupWebSocket,
|
||||
watcher,
|
||||
writeCoalescer,
|
||||
} = require("@ignis/server-core");
|
||||
const {
|
||||
BRIDGE_PLUGIN_ID,
|
||||
migratePluginsFromAllVaults,
|
||||
} = require("./bridge-plugin");
|
||||
const {
|
||||
initPlugins,
|
||||
shutdownPlugins,
|
||||
getBundledPluginDirs,
|
||||
} = require("./plugin-system/manager");
|
||||
const { setupWebSocket } = require("./ws");
|
||||
const watcher = require("./watcher");
|
||||
const { updateBridgePluginInAllVaults } = require("./bridge-plugin");
|
||||
const { initPlugins, shutdownPlugins } = require("./plugin-system/manager");
|
||||
const pluginRoutes = require("./routes/plugins");
|
||||
writeCoalescer.configure({ writeCoalesceMs: config.writeCoalesceMs });
|
||||
const { flushAll } = writeCoalescer;
|
||||
const { flushAll } = require("./write-coalescer");
|
||||
const { setupDemo, wireDemoWebSocket } = require("./demo");
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, "..", "..", "..");
|
||||
|
||||
const ANSI_RED = "\x1b[31m";
|
||||
const ANSI_YELLOW = "\x1b[33m";
|
||||
const ANSI_GREEN = "\x1b[32m";
|
||||
|
|
@ -103,7 +90,9 @@ app.use("/vault-files", (req, res, next) => {
|
|||
express.static(vaultPath)(req, res, next);
|
||||
});
|
||||
|
||||
// Serve our own index.html. Obsidian's scripts are discovered at startup and injected dynamically by the client.
|
||||
// Serve our own index.html. Obsidian's scripts are discovered at startup
|
||||
// and injected dynamically by the client -- no Obsidian files are read or
|
||||
// transformed in the response.
|
||||
let cachedHtml = null;
|
||||
|
||||
function buildIndexHtml() {
|
||||
|
|
@ -150,7 +139,7 @@ app.get(["/", "/index.html"], (req, res) => {
|
|||
});
|
||||
|
||||
app.get("/favicon.png", (req, res) => {
|
||||
res.sendFile(path.join(REPO_ROOT, "images", "favicon.png"));
|
||||
res.sendFile(path.join(__dirname, "..", "images", "favicon.png"));
|
||||
});
|
||||
|
||||
// Serve dist files with cache headers based on version param
|
||||
|
|
@ -167,8 +156,7 @@ app.use((req, res, next) => {
|
|||
next();
|
||||
});
|
||||
|
||||
app.use(express.static(path.join(REPO_ROOT, "packages", "ui", "dist")));
|
||||
app.use(express.static(path.join(REPO_ROOT, "packages", "shim", "dist")));
|
||||
app.use(express.static(path.join(__dirname, "..", "dist")));
|
||||
|
||||
app.use(express.static(config.obsidianAssetsPath));
|
||||
|
||||
|
|
@ -177,28 +165,14 @@ const server = app.listen(config.port, async () => {
|
|||
console.log(`[ignis] Vault root: ${config.vaultRoot}`);
|
||||
console.log(`[ignis] Vaults: ${Object.keys(config.vaults).join(", ")}`);
|
||||
|
||||
await updateBridgePluginInAllVaults(config.vaultRoot);
|
||||
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
|
||||
.warmUp()
|
||||
.catch((e) => console.warn("[bootstrap] warm-up error:", e.message));
|
||||
});
|
||||
|
||||
const wss = setupWebSocket(server, {
|
||||
getVaultPath: config.getVaultPath,
|
||||
originAllowlist: config.wsOrigins,
|
||||
});
|
||||
const wss = setupWebSocket(server);
|
||||
wireDemoWebSocket(server);
|
||||
|
||||
async function gracefulShutdown(signal) {
|
||||
|
|
@ -40,16 +40,17 @@ function discoverPlugins(pluginsDir) {
|
|||
continue;
|
||||
}
|
||||
|
||||
let bundledManifest = null;
|
||||
let bundledPluginId = null;
|
||||
|
||||
if (plugin.obsidianPlugin) {
|
||||
try {
|
||||
bundledManifest = JSON.parse(
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(plugin.obsidianPlugin, "manifest.json"),
|
||||
"utf-8",
|
||||
),
|
||||
);
|
||||
bundledPluginId = manifest.id;
|
||||
} catch {
|
||||
// No valid bundled plugin manifest
|
||||
}
|
||||
|
|
@ -60,8 +61,7 @@ function discoverPlugins(pluginsDir) {
|
|||
name: plugin.name,
|
||||
description: plugin.description || "",
|
||||
obsidianPlugin: plugin.obsidianPlugin || null,
|
||||
bundledPluginId: bundledManifest ? bundledManifest.id : null,
|
||||
bundledManifest,
|
||||
bundledPluginId,
|
||||
module: plugin,
|
||||
});
|
||||
|
||||
|
|
@ -3,7 +3,10 @@ const path = require("path");
|
|||
const express = require("express");
|
||||
const { discoverPlugins } = require("./discovery");
|
||||
const configStore = require("./config-store");
|
||||
const { getVersion } = require("../version");
|
||||
const {
|
||||
installObsidianPlugin,
|
||||
removeObsidianPlugin,
|
||||
} = require("./obsidian-plugin");
|
||||
|
||||
let discoveredPlugins = new Map();
|
||||
const loadedPlugins = new Map();
|
||||
|
|
@ -47,6 +50,18 @@ async function initPlugins(ctx) {
|
|||
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);
|
||||
|
||||
if (loaded?.module?.onVaultEnabled) {
|
||||
|
|
@ -167,28 +182,30 @@ async function enablePluginForVault(pluginId, vaultId) {
|
|||
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);
|
||||
|
||||
if (loaded?.module?.onVaultEnabled) {
|
||||
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) {
|
||||
|
|
@ -210,6 +227,25 @@ async function disablePluginForVault(pluginId, vaultId) {
|
|||
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 updated = enabledVaults.filter((id) => id !== vaultId);
|
||||
configStore.setEnabledVaults(pluginConfig, pluginId, updated);
|
||||
|
|
@ -218,55 +254,6 @@ async function disablePluginForVault(pluginId, vaultId) {
|
|||
if (updated.length === 0) {
|
||||
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() {
|
||||
|
|
@ -293,6 +280,4 @@ module.exports = {
|
|||
enablePluginForVault,
|
||||
disablePluginForVault,
|
||||
getDiscoveredPlugins,
|
||||
getBundledPluginDirs,
|
||||
getVirtualPluginsForVault,
|
||||
};
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { getObHome } = require("./ob-cli");
|
||||
const os = require("os");
|
||||
|
||||
function getObAuthFile(dataDir) {
|
||||
function getObAuthFile() {
|
||||
return path.join(
|
||||
getObHome(dataDir),
|
||||
os.homedir(),
|
||||
".config",
|
||||
"obsidian-headless",
|
||||
"auth_token",
|
||||
|
|
@ -23,14 +23,14 @@ function loadToken(dataDir) {
|
|||
const data = JSON.parse(fs.readFileSync(internalFile, "utf-8"));
|
||||
|
||||
if (data && data.token) {
|
||||
syncToObCli(dataDir, data.token);
|
||||
syncToObCli(data.token);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Fall back to ob CLI's own auth file
|
||||
const obAuthFile = getObAuthFile(dataDir);
|
||||
const obAuthFile = getObAuthFile();
|
||||
|
||||
try {
|
||||
if (fs.existsSync(obAuthFile)) {
|
||||
|
|
@ -49,7 +49,7 @@ function loadToken(dataDir) {
|
|||
|
||||
function saveToken(dataDir, tokenData) {
|
||||
saveInternal(dataDir, tokenData);
|
||||
syncToObCli(dataDir, tokenData.token);
|
||||
syncToObCli(tokenData.token);
|
||||
}
|
||||
|
||||
function clearToken(dataDir) {
|
||||
|
|
@ -61,7 +61,7 @@ function clearToken(dataDir) {
|
|||
}
|
||||
} catch {}
|
||||
|
||||
const obAuthFile = getObAuthFile(dataDir);
|
||||
const obAuthFile = getObAuthFile();
|
||||
|
||||
try {
|
||||
if (fs.existsSync(obAuthFile)) {
|
||||
|
|
@ -83,36 +83,28 @@ function isAuthenticated(dataDir) {
|
|||
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) {
|
||||
const internalFile = getInternalTokenFile(dataDir);
|
||||
const dir = path.dirname(internalFile);
|
||||
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
writeSecret(internalFile, JSON.stringify(tokenData, null, 2));
|
||||
fs.writeFileSync(internalFile, JSON.stringify(tokenData, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
function syncToObCli(dataDir, token) {
|
||||
const obAuthFile = getObAuthFile(dataDir);
|
||||
function syncToObCli(token) {
|
||||
const obAuthFile = getObAuthFile();
|
||||
|
||||
try {
|
||||
const dir = path.dirname(obAuthFile);
|
||||
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
writeSecret(obAuthFile, token);
|
||||
fs.writeFileSync(obAuthFile, token, "utf-8");
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
|
@ -132,10 +124,4 @@ function getTokenInfo(dataDir) {
|
|||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loadToken,
|
||||
saveToken,
|
||||
clearToken,
|
||||
isAuthenticated,
|
||||
getTokenInfo,
|
||||
};
|
||||
module.exports = { loadToken, saveToken, clearToken, isAuthenticated, getTokenInfo };
|
||||
58
server/plugins/headless-sync/broadcaster.js
Normal file
58
server/plugins/headless-sync/broadcaster.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
const CHANNEL = "plugin:headless-sync";
|
||||
|
||||
class SyncBroadcaster {
|
||||
constructor(wss) {
|
||||
this._wss = wss;
|
||||
this._logSubscriptions = new Map();
|
||||
}
|
||||
|
||||
subscribeToLogs(vaultId) {
|
||||
this._logSubscriptions.set(vaultId, { expires: Date.now() + 10000 });
|
||||
}
|
||||
|
||||
broadcastLog(vaultId, line) {
|
||||
if (!this._wss?.clients) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sub = this._logSubscriptions.get(vaultId);
|
||||
|
||||
if (!sub || Date.now() > sub.expires) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._send({
|
||||
channel: CHANNEL,
|
||||
type: "sync-log",
|
||||
payload: { vaultId, line },
|
||||
});
|
||||
}
|
||||
|
||||
broadcastStatus(state) {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._send({
|
||||
channel: CHANNEL,
|
||||
type: "sync-status",
|
||||
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 };
|
||||
|
|
@ -11,7 +11,7 @@ module.exports = {
|
|||
version: "0.3.0",
|
||||
//TODO: add server plugin manifest
|
||||
|
||||
obsidianPlugin: path.join(__dirname, "obsidian"),
|
||||
obsidianPlugin: path.join(__dirname, "plugin"),
|
||||
|
||||
_ctx: null,
|
||||
_obStatus: null,
|
||||
|
|
@ -29,10 +29,6 @@ module.exports = {
|
|||
ctx.log("ob CLI not found. Install obsidian-headless to enable sync.");
|
||||
}
|
||||
|
||||
// Redirect ob's HOME under the plugin's data dir so its config (per-vault sync setups, etc.)
|
||||
// survives container recreates. Must happen before auth.loadToken since loadToken pushes the token into ob's config location via syncToObCli.
|
||||
obCli.configure({ dataDir: ctx.dataDir });
|
||||
|
||||
const token = auth.loadToken(ctx.dataDir);
|
||||
|
||||
if (token) {
|
||||
|
|
@ -63,9 +59,22 @@ module.exports = {
|
|||
|
||||
const { mountRoutes } = require("./routes");
|
||||
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() {
|
||||
if (this._ctx?.wss?.messageHandlers) {
|
||||
this._ctx.wss.messageHandlers.delete("subscribe-logs");
|
||||
}
|
||||
|
||||
if (this._syncManager) {
|
||||
await this._syncManager.shutdown();
|
||||
this._syncManager = null;
|
||||
|
|
@ -1,28 +1,8 @@
|
|||
const { spawn, execSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const isWindows = process.platform === "win32";
|
||||
|
||||
// When set via configure(), HOME for the spawned ob points under the plugin's data dir so
|
||||
// ob's config dir (~/.config/obsidian-headless/) survives container recreates.
|
||||
let configuredDataDir = null;
|
||||
|
||||
function getObHome(dataDir) {
|
||||
return path.join(dataDir, "ob-home");
|
||||
}
|
||||
|
||||
function configure(opts) {
|
||||
configuredDataDir = opts && opts.dataDir ? opts.dataDir : null;
|
||||
|
||||
if (configuredDataDir) {
|
||||
try {
|
||||
fs.mkdirSync(getObHome(configuredDataDir), { recursive: true });
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function checkInstalled() {
|
||||
try {
|
||||
const output = execSync("ob --version", {
|
||||
|
|
@ -39,12 +19,8 @@ function checkInstalled() {
|
|||
}
|
||||
|
||||
function spawnOb(args, opts = {}) {
|
||||
const home = configuredDataDir
|
||||
? getObHome(configuredDataDir)
|
||||
: os.homedir();
|
||||
|
||||
return spawn("ob", args, {
|
||||
env: { ...process.env, HOME: home },
|
||||
env: { ...process.env, HOME: os.homedir() },
|
||||
shell: isWindows,
|
||||
windowsHide: true,
|
||||
...opts,
|
||||
|
|
@ -82,10 +58,4 @@ function runCommand(args, opts = {}) {
|
|||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkInstalled,
|
||||
spawnOb,
|
||||
runCommand,
|
||||
configure,
|
||||
getObHome,
|
||||
};
|
||||
module.exports = { checkInstalled, spawnOb, runCommand };
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"id": "ignis-headless-sync",
|
||||
"name": "Headless Sync",
|
||||
"name": "Ignis Headless Sync",
|
||||
"version": "0.3.0",
|
||||
"minAppVersion": "1.12.4",
|
||||
"description": "Client-side companion for server-side Obsidian Sync",
|
||||
|
|
@ -32,12 +32,13 @@ function showConflictWarning(title, message) {
|
|||
});
|
||||
}
|
||||
|
||||
function startCoreSyncGuard(plugin, api) {
|
||||
function startCoreSyncGuard(plugin, api, wsListener) {
|
||||
const app = plugin.app;
|
||||
const vaultId = app.vault.getName();
|
||||
|
||||
// Monkey-patch syncPlugin.enable() to clear the shim flag before Obsidian writes core-plugins.json.
|
||||
// This ensures the read transform doesn't block a user-initiated core sync enable.
|
||||
// Monkey-patch syncPlugin.enable() to clear the shim flag before
|
||||
// Obsidian writes core-plugins.json. This ensures the read transform
|
||||
// doesn't block a user-initiated core sync enable.
|
||||
const syncPlugin = app.internalPlugins.getPluginById("sync");
|
||||
let origEnable = null;
|
||||
|
||||
|
|
@ -51,13 +52,16 @@ function startCoreSyncGuard(plugin, api) {
|
|||
};
|
||||
}
|
||||
|
||||
// Watch for core-plugins.json changes via WebSocket.
|
||||
let wasEnabled = isCoreSyncEnabled();
|
||||
|
||||
const unsubModified = window.__ignis.ws.subscribe("modified", (msg) => {
|
||||
if (msg.path === CORE_PLUGINS_PATH) {
|
||||
const rawHandler = (msg) => {
|
||||
if (msg.type === "modified" && msg.path === CORE_PLUGINS_PATH) {
|
||||
handleCoreSyncChange();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
wsListener.onRaw(rawHandler);
|
||||
|
||||
function handleCoreSyncChange() {
|
||||
const enabled = isCoreSyncEnabled();
|
||||
|
|
@ -76,7 +80,7 @@ function startCoreSyncGuard(plugin, api) {
|
|||
|
||||
return {
|
||||
cleanup() {
|
||||
unsubModified();
|
||||
wsListener.offRaw();
|
||||
|
||||
if (syncPlugin && origEnable) {
|
||||
syncPlugin.enable = origEnable;
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
const api = require("./api");
|
||||
|
||||
const CHANNEL = "plugin:headless-sync";
|
||||
|
||||
async function renderLogViewer(containerEl, vaultId) {
|
||||
async function renderLogViewer(containerEl, vaultId, wsListener) {
|
||||
const details = containerEl.createEl("details", {
|
||||
cls: "ignis-log-details",
|
||||
});
|
||||
|
|
@ -34,12 +32,19 @@ async function renderLogViewer(containerEl, vaultId) {
|
|||
|
||||
logBox.scrollTop = logBox.scrollHeight;
|
||||
|
||||
const channel = window.__ignis.ws.channel(CHANNEL);
|
||||
let unsubLog = null;
|
||||
if (!wsListener) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const onLog = (msg) => {
|
||||
const payload = msg.payload || {};
|
||||
details.addEventListener("toggle", () => {
|
||||
if (details.open) {
|
||||
wsListener.subscribeLogs(vaultId);
|
||||
} else {
|
||||
wsListener.unsubscribeLogs();
|
||||
}
|
||||
});
|
||||
|
||||
const onLog = (payload) => {
|
||||
if (payload.vaultId !== vaultId) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -61,22 +66,11 @@ async function renderLogViewer(containerEl, vaultId) {
|
|||
}
|
||||
};
|
||||
|
||||
details.addEventListener("toggle", () => {
|
||||
if (details.open) {
|
||||
if (!unsubLog) {
|
||||
unsubLog = channel.subscribe("sync-log", onLog);
|
||||
}
|
||||
} else if (unsubLog) {
|
||||
unsubLog();
|
||||
unsubLog = null;
|
||||
}
|
||||
});
|
||||
wsListener.on("sync-log", onLog);
|
||||
|
||||
return () => {
|
||||
if (unsubLog) {
|
||||
unsubLog();
|
||||
unsubLog = null;
|
||||
}
|
||||
wsListener.off("sync-log", onLog);
|
||||
wsListener.unsubscribeLogs();
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
const { Plugin } = require("obsidian");
|
||||
const { HeadlessSyncSettingTab } = require("./settings-tab");
|
||||
const { WsListener } = require("./ws-listener");
|
||||
const { initSyncStatusBar } = require("./sync-status-bar");
|
||||
const { startCoreSyncGuard } = require("./core-sync-guard");
|
||||
const api = require("./api");
|
||||
|
|
@ -13,11 +14,14 @@ class IgnisHeadlessSyncPlugin extends Plugin {
|
|||
return;
|
||||
}
|
||||
|
||||
this._syncStatusBarCleanup = initSyncStatusBar(this);
|
||||
this.wsListener = new WsListener();
|
||||
this.wsListener.start();
|
||||
|
||||
this._syncStatusBarCleanup = initSyncStatusBar(this, this.wsListener);
|
||||
|
||||
this.addSettingTab(new HeadlessSyncSettingTab(this.app, this));
|
||||
|
||||
this._coreSyncGuard = startCoreSyncGuard(this, api);
|
||||
this._coreSyncGuard = startCoreSyncGuard(this, api, this.wsListener);
|
||||
|
||||
this.addCommand({
|
||||
id: "start-sync",
|
||||
|
|
@ -71,6 +75,11 @@ class IgnisHeadlessSyncPlugin extends Plugin {
|
|||
this._syncStatusBarCleanup();
|
||||
this._syncStatusBarCleanup = null;
|
||||
}
|
||||
|
||||
if (this.wsListener) {
|
||||
this.wsListener.stop();
|
||||
this.wsListener = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue