Compare commits
38 commits
v0.8.0+obs
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| dd0eaf49ea | |||
| 32086b7122 | |||
|
|
b88f9fdc0e | ||
|
|
f0b7f65a36 | ||
|
|
05a3908a7a | ||
|
|
b90752e0ad | ||
|
|
caaf6b3144 | ||
|
|
3833ef2668 | ||
|
|
35118ca190 | ||
|
|
3af8687037 | ||
|
|
5bf120defa | ||
|
|
7d70872f7e | ||
|
|
d5fb9e1e1d | ||
|
|
28effab1ed | ||
|
|
9eeff3c1b3 | ||
|
|
f05ee9e856 | ||
|
|
956a11d0cd | ||
|
|
69f8320d05 | ||
|
|
ec3c38228c | ||
|
|
e6975d631c | ||
|
|
4fff803cbd | ||
|
|
10c6782652 | ||
|
|
840af89feb | ||
|
|
f7fd3d9fba | ||
|
|
85b61a09c4 | ||
|
|
8672fa11a3 | ||
|
|
a6807fe850 | ||
|
|
4a65f142bc | ||
|
|
fe11f30c01 | ||
|
|
a0b44bde58 | ||
|
|
0433f1f8ca | ||
|
|
4da91d017b | ||
|
|
64073968d4 | ||
|
|
23306ff68e | ||
|
|
43778d7bca | ||
|
|
32f21445d4 | ||
|
|
6a719aca7c | ||
|
|
56776e7f13 |
195 changed files with 2651 additions and 1645 deletions
14
.dockerignore
Normal file
14
.dockerignore
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
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,6 +2,7 @@ node_modules/
|
||||||
dist/
|
dist/
|
||||||
investigation/
|
investigation/
|
||||||
vaults/
|
vaults/
|
||||||
plugin/main.js
|
packages/*/dist/
|
||||||
server/plugins/*/plugin/main.js
|
apps/ignis-server/server/build-info.json
|
||||||
demo-vaults/
|
demo-vaults/
|
||||||
|
data/
|
||||||
|
|
|
||||||
38
CHANGELOG.md
38
CHANGELOG.md
|
|
@ -2,6 +2,44 @@
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [0.8.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)
|
## [0.8.0] - Karm (2026-05-16)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
53
Dockerfile
53
Dockerfile
|
|
@ -1,53 +0,0 @@
|
||||||
# Build shim-loader.js
|
|
||||||
FROM node:22-slim AS build
|
|
||||||
|
|
||||||
WORKDIR /build
|
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
|
||||||
RUN npm ci --ignore-scripts
|
|
||||||
|
|
||||||
COPY build.js ./
|
|
||||||
COPY src/ ./src/
|
|
||||||
COPY plugin/src/ ./plugin/src/
|
|
||||||
COPY server/plugins/ ./server/plugins/
|
|
||||||
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# Production image. No Obsidian code included.
|
|
||||||
# On first run, the entrypoint downloads Obsidian.
|
|
||||||
FROM node:22-slim
|
|
||||||
|
|
||||||
LABEL com.thiefling.ignis.obsidian-version="1.12.7"
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
ca-certificates curl gosu \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
|
||||||
RUN npm ci --omit=dev --ignore-scripts
|
|
||||||
|
|
||||||
COPY server/ ./server/
|
|
||||||
COPY scripts/ ./scripts/
|
|
||||||
COPY images/ ./images/
|
|
||||||
COPY plugin/ ./plugin/
|
|
||||||
COPY --from=build /build/dist ./dist
|
|
||||||
COPY --from=build /build/plugin/main.js ./plugin/main.js
|
|
||||||
COPY --from=build /build/server/plugins/headless-sync/plugin/main.js ./server/plugins/headless-sync/plugin/main.js
|
|
||||||
|
|
||||||
RUN chmod +x /app/scripts/entrypoint.sh
|
|
||||||
|
|
||||||
ENV PORT=8080
|
|
||||||
ENV VAULT_ROOT=/vaults
|
|
||||||
ENV OBSIDIAN_VERSION=1.12.7
|
|
||||||
ENV OBSIDIAN_ASSETS_PATH=/app/obsidian-app
|
|
||||||
ENV PUID=1000
|
|
||||||
ENV PGID=1000
|
|
||||||
|
|
||||||
EXPOSE 8080
|
|
||||||
|
|
||||||
VOLUME /vaults
|
|
||||||
VOLUME /app/obsidian-app
|
|
||||||
|
|
||||||
ENTRYPOINT ["/app/scripts/entrypoint.sh"]
|
|
||||||
103
README.md
103
README.md
|
|
@ -28,6 +28,10 @@ What started as an experiment turned out to be more viable than expected, and th
|
||||||
|
|
||||||
Plugin compatibility depends on what APIs a plugin uses; most plugins built on Obsidian's plugin API work, anything requiring Node native modules or `child_process` doesn't. See [What doesn't work](#what-doesnt-work) for the full list of known limitations.
|
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
|
## What works
|
||||||
|
|
||||||
- All core editor features: markdown, canvas, bases, and the command palette.
|
- All core editor features: markdown, canvas, bases, and the command palette.
|
||||||
|
|
@ -62,7 +66,7 @@ Compatibility for specific community plugins is tracked in [Issue #9](https://gi
|
||||||
**Multi-tab and workspaces.**
|
**Multi-tab and workspaces.**
|
||||||
- Live file sync between browser tabs via WebSocket: open the same vault in two tabs and edits propagate within a second.
|
- Live file sync between browser tabs via WebSocket: open the same vault in two tabs and edits propagate within a second.
|
||||||
- Saved workspaces can be opened in separate browser tabs via a `?workspace=` URL parameter, so each tab can hold a different layout of the same vault.
|
- Saved workspaces can be opened in separate browser tabs via a `?workspace=` URL parameter, so each tab can hold a different layout of the same vault.
|
||||||
- The bridge plugin adds an "Open workspace in tab" command to the command palette.
|
- Ignis adds an "Open workspace in tab" command to the command palette.
|
||||||
|
|
||||||
**Server-side sync.**
|
**Server-side sync.**
|
||||||
- Obsidian Headless is implemented as a server-side plugin that performs continuous sync without needing an active browser tab. Only one of Obsidian Sync or Obsidian Headless can run per vault.
|
- Obsidian Headless is implemented as a server-side plugin that performs continuous sync without needing an active browser tab. Only one of Obsidian Sync or Obsidian Headless can run per vault.
|
||||||
|
|
@ -72,6 +76,17 @@ Compatibility for specific community plugins is tracked in [Issue #9](https://gi
|
||||||
- Ignis-specific settings appear as their own tabs inside Obsidian's Settings modal.
|
- Ignis-specific settings appear as their own tabs inside Obsidian's Settings modal.
|
||||||
- Status bar indicators surface server state and headless sync activity.
|
- 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
|
## Performance
|
||||||
|
|
||||||
A few design decisions worth knowing about for someone evaluating Ignis against large vaults or slow storage:
|
A few design decisions worth knowing about for someone evaluating Ignis against large vaults or slow storage:
|
||||||
|
|
@ -85,90 +100,6 @@ A few design decisions worth knowing about for someone evaluating Ignis against
|
||||||
|
|
||||||
Tested in Chrome, Brave, and Firefox, with limited testing in Safari.
|
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
|
## 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.
|
Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, especially on how to report plugin compatibility issues. Check the [open issues](https://github.com/Nystik-gh/ignis/issues) for things to work on.
|
||||||
|
|
@ -187,4 +118,4 @@ Ignis is not affiliated with, endorsed by, or associated with Dynalist Inc. or O
|
||||||
|
|
||||||
This work falls under the interoperability provisions of [Directive 2009/24/EC](https://eur-lex.europa.eu/eli/dir/2009/24/oj/eng) (the EU Software Directive), Article 6. See [LEGAL.md](LEGAL.md) for the full rationale.
|
This 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
|
||||||
|
|
|
||||||
73
apps/ignis-server/Dockerfile
Normal file
73
apps/ignis-server/Dockerfile
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
# 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"]
|
||||||
96
apps/ignis-server/README.md
Normal file
96
apps/ignis-server/README.md
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
# 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,6 +1,8 @@
|
||||||
services:
|
services:
|
||||||
ignis:
|
ignis:
|
||||||
build: .
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: apps/ignis-server/Dockerfile
|
||||||
ports:
|
ports:
|
||||||
- "8082:8080"
|
- "8082:8080"
|
||||||
environment:
|
environment:
|
||||||
|
|
@ -9,7 +9,8 @@
|
||||||
services:
|
services:
|
||||||
ignis-demo:
|
ignis-demo:
|
||||||
build:
|
build:
|
||||||
context: ../..
|
context: ../../../..
|
||||||
|
dockerfile: apps/ignis-server/Dockerfile
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
environment:
|
environment:
|
||||||
5
apps/ignis-server/package.json
Normal file
5
apps/ignis-server/package.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"name": "@ignis/app",
|
||||||
|
"version": "0.0.0-internal",
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
|
|
@ -68,4 +68,4 @@ else
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Run as the determined user
|
# Run as the determined user
|
||||||
exec gosu "$RUN_USER" node /app/server/index.js
|
exec gosu "$RUN_USER" node /app/apps/ignis-server/server/index.js
|
||||||
|
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 id="ignis-status-label">Loading Obsidian...</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Ignis shims: must run before any Obsidian code. -->
|
<!-- Ignis shims: must run before any Obsidian code. -->
|
||||||
<script type="text/javascript" src="__IGNIS_UI_SRC__"></script>
|
|
||||||
<script type="text/javascript" src="__SHIM_LOADER_SRC__"></script>
|
<script type="text/javascript" src="__SHIM_LOADER_SRC__"></script>
|
||||||
|
<script type="text/javascript" src="__IGNIS_UI_SRC__"></script>
|
||||||
<!-- Obsidian scripts injected dynamically to avoid touching their files. -->
|
<!-- Obsidian scripts injected dynamically to avoid touching their files. -->
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
|
|
@ -8,3 +8,9 @@
|
||||||
.is-hidden-frameless:not(.is-fullscreen):not(.mod-macos) .workspace-tabs.mod-top-right-space .workspace-tab-header-container:after {
|
.is-hidden-frameless:not(.is-fullscreen):not(.mod-macos) .workspace-tabs.mod-top-right-space .workspace-tab-header-container:after {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* fix table cell height in firefox in edit mode with live preview */
|
||||||
|
.markdown-source-view.mod-cm6 .cm-table-widget th,
|
||||||
|
.markdown-source-view.mod-cm6 .cm-table-widget td {
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
62
apps/ignis-server/server/bridge-plugin.js
Normal file
62
apps/ignis-server/server/bridge-plugin.js
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
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,12 +1,13 @@
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
|
|
||||||
|
const REPO_ROOT = path.join(__dirname, "..", "..", "..");
|
||||||
|
|
||||||
// VAULT_ROOT: a directory that contains vault folders.
|
// VAULT_ROOT: a directory that contains vault folders.
|
||||||
// Each subdirectory is a vault. New vaults are created as new subdirs.
|
// Each subdirectory is a vault. New vaults are created as new subdirs.
|
||||||
const vaultRoot =
|
const vaultRoot = process.env.VAULT_ROOT || path.join(REPO_ROOT, "vaults");
|
||||||
process.env.VAULT_ROOT || path.join(__dirname, "..", "vaults");
|
|
||||||
|
|
||||||
const dataRoot = process.env.DATA_ROOT || path.join(__dirname, "..", "data");
|
const dataRoot = process.env.DATA_ROOT || path.join(REPO_ROOT, "data");
|
||||||
|
|
||||||
// Ensure required directories exist
|
// Ensure required directories exist
|
||||||
try {
|
try {
|
||||||
|
|
@ -79,6 +80,12 @@ module.exports = {
|
||||||
? parseInt(process.env.WRITE_COALESCE_MS)
|
? parseInt(process.env.WRITE_COALESCE_MS)
|
||||||
: 5000,
|
: 5000,
|
||||||
|
|
||||||
|
wsOrigins: process.env.WS_ORIGINS
|
||||||
|
? process.env.WS_ORIGINS.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: null,
|
||||||
|
|
||||||
demoMode: process.env.DEMO_MODE === "true",
|
demoMode: process.env.DEMO_MODE === "true",
|
||||||
demoMaxSessions: parseInt(process.env.DEMO_MAX_SESSIONS) || 20,
|
demoMaxSessions: parseInt(process.env.DEMO_MAX_SESSIONS) || 20,
|
||||||
demoVaultsPerSession: parseInt(process.env.DEMO_VAULTS_PER_SESSION) || 3,
|
demoVaultsPerSession: parseInt(process.env.DEMO_VAULTS_PER_SESSION) || 3,
|
||||||
|
|
@ -86,12 +93,11 @@ module.exports = {
|
||||||
parseInt(process.env.DEMO_SESSION_QUOTA_BYTES) || 700 * 1024,
|
parseInt(process.env.DEMO_SESSION_QUOTA_BYTES) || 700 * 1024,
|
||||||
demoTimeoutMs: parseInt(process.env.DEMO_TIMEOUT_MS) || 30 * 60 * 1000,
|
demoTimeoutMs: parseInt(process.env.DEMO_TIMEOUT_MS) || 30 * 60 * 1000,
|
||||||
demoTemplateDir:
|
demoTemplateDir:
|
||||||
process.env.DEMO_TEMPLATE_DIR ||
|
process.env.DEMO_TEMPLATE_DIR || path.join(__dirname, "demo-template"),
|
||||||
path.join(__dirname, "demo-template"),
|
|
||||||
|
|
||||||
obsidianAssetsPath:
|
obsidianAssetsPath:
|
||||||
process.env.OBSIDIAN_ASSETS_PATH ||
|
process.env.OBSIDIAN_ASSETS_PATH ||
|
||||||
path.join(__dirname, "..", "investigation", "obsidian_1.12.7_unpacked"),
|
path.join(REPO_ROOT, "investigation", "obsidian_1.12.7_unpacked"),
|
||||||
|
|
||||||
get obsidianVersion() {
|
get obsidianVersion() {
|
||||||
const assetsPath =
|
const assetsPath =
|
||||||
|
|
@ -5,7 +5,7 @@ const fsp = fs.promises;
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
const config = require("../config");
|
const config = require("../config");
|
||||||
const watcher = require("../watcher");
|
const { watcher } = require("@ignis/server-core");
|
||||||
const bootstrapRoutes = require("../routes/bootstrap");
|
const bootstrapRoutes = require("../routes/bootstrap");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -272,16 +272,36 @@ function trackVaultLifecycle(req, res, next) {
|
||||||
|
|
||||||
if (s) {
|
if (s) {
|
||||||
if (req.path === "/create" && body.id) {
|
if (req.path === "/create" && body.id) {
|
||||||
s.vaults.add(body.id);
|
// body.id is storage-prefixed at this point (outboundTranslator runs after us).
|
||||||
|
// Translate to the user-visible name so it matches what pageLoadHandler queries with.
|
||||||
|
const userName = tryParseUserVaultName(sessionId, body.id);
|
||||||
|
|
||||||
|
if (userName !== null) {
|
||||||
|
s.vaults.add(userName);
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
"[demo] trackVaultLifecycle: could not parse user name from create response id:",
|
||||||
|
body.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
} else if (req.path === "/rename") {
|
} else if (req.path === "/rename") {
|
||||||
const oldName = req.body && req.body._origVault;
|
const oldName = req._demoOriginalVault;
|
||||||
|
|
||||||
if (oldName) {
|
if (oldName) {
|
||||||
s.vaults.delete(oldName);
|
s.vaults.delete(oldName);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.id) {
|
if (body.id) {
|
||||||
s.vaults.add(body.id);
|
const userName = tryParseUserVaultName(sessionId, body.id);
|
||||||
|
|
||||||
|
if (userName !== null) {
|
||||||
|
s.vaults.add(userName);
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
"[demo] trackVaultLifecycle: could not parse user name from rename response id:",
|
||||||
|
body.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (req.method === "DELETE" && req.path === "/remove") {
|
} else if (req.method === "DELETE" && req.path === "/remove") {
|
||||||
const removed = req._demoOriginalVault;
|
const removed = req._demoOriginalVault;
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Vault provisioning for demo sessions.
|
// Vault provisioning for demo sessions.
|
||||||
//
|
//
|
||||||
// Copies the template into a session-prefixed dir, installs the bridge plugin, and registers the vault on the session.
|
// Copies the template into a session-prefixed dir and registers the vault on the session.
|
||||||
// Re-provisions if disk was wiped under an existing session.
|
// Re-provisions if disk was wiped under an existing session.
|
||||||
|
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
|
|
@ -8,7 +8,6 @@ const fsp = fs.promises;
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
const config = require("../config");
|
const config = require("../config");
|
||||||
const { installBridgePlugin } = require("../bridge-plugin");
|
|
||||||
const bootstrapRoutes = require("../routes/bootstrap");
|
const bootstrapRoutes = require("../routes/bootstrap");
|
||||||
|
|
||||||
const { sessions, makeStorageName } = require("./demo-sessions");
|
const { sessions, makeStorageName } = require("./demo-sessions");
|
||||||
|
|
@ -96,9 +95,6 @@ async function provisionVault(sessionId, userVaultName) {
|
||||||
// Copy template (default: Welcome.md, Getting Started.md, .obsidian/*).
|
// Copy template (default: Welcome.md, Getting Started.md, .obsidian/*).
|
||||||
await fsp.cp(config.demoTemplateDir, vaultPath, { recursive: true });
|
await fsp.cp(config.demoTemplateDir, vaultPath, { recursive: true });
|
||||||
|
|
||||||
// Install bridge plugin
|
|
||||||
await installBridgePlugin(vaultPath);
|
|
||||||
|
|
||||||
config.refreshVaults();
|
config.refreshVaults();
|
||||||
bootstrapRoutes.invalidateVault(storageName);
|
bootstrapRoutes.invalidateVault(storageName);
|
||||||
|
|
||||||
|
|
@ -4,14 +4,27 @@ const path = require("path");
|
||||||
const compression = require("compression");
|
const compression = require("compression");
|
||||||
const config = require("./config");
|
const config = require("./config");
|
||||||
const { getVersion } = require("./version");
|
const { getVersion } = require("./version");
|
||||||
const { setupWebSocket } = require("./ws");
|
const {
|
||||||
const watcher = require("./watcher");
|
setupWebSocket,
|
||||||
const { updateBridgePluginInAllVaults } = require("./bridge-plugin");
|
watcher,
|
||||||
const { initPlugins, shutdownPlugins } = require("./plugin-system/manager");
|
writeCoalescer,
|
||||||
|
} = require("@ignis/server-core");
|
||||||
|
const {
|
||||||
|
BRIDGE_PLUGIN_ID,
|
||||||
|
migratePluginsFromAllVaults,
|
||||||
|
} = require("./bridge-plugin");
|
||||||
|
const {
|
||||||
|
initPlugins,
|
||||||
|
shutdownPlugins,
|
||||||
|
getBundledPluginDirs,
|
||||||
|
} = require("./plugin-system/manager");
|
||||||
const pluginRoutes = require("./routes/plugins");
|
const pluginRoutes = require("./routes/plugins");
|
||||||
const { flushAll } = require("./write-coalescer");
|
writeCoalescer.configure({ writeCoalesceMs: config.writeCoalesceMs });
|
||||||
|
const { flushAll } = writeCoalescer;
|
||||||
const { setupDemo, wireDemoWebSocket } = require("./demo");
|
const { setupDemo, wireDemoWebSocket } = require("./demo");
|
||||||
|
|
||||||
|
const REPO_ROOT = path.join(__dirname, "..", "..", "..");
|
||||||
|
|
||||||
const ANSI_RED = "\x1b[31m";
|
const ANSI_RED = "\x1b[31m";
|
||||||
const ANSI_YELLOW = "\x1b[33m";
|
const ANSI_YELLOW = "\x1b[33m";
|
||||||
const ANSI_GREEN = "\x1b[32m";
|
const ANSI_GREEN = "\x1b[32m";
|
||||||
|
|
@ -90,9 +103,7 @@ app.use("/vault-files", (req, res, next) => {
|
||||||
express.static(vaultPath)(req, res, next);
|
express.static(vaultPath)(req, res, next);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Serve our own index.html. Obsidian's scripts are discovered at startup
|
// Serve our own index.html. Obsidian's scripts are discovered at startup and injected dynamically by the client.
|
||||||
// and injected dynamically by the client -- no Obsidian files are read or
|
|
||||||
// transformed in the response.
|
|
||||||
let cachedHtml = null;
|
let cachedHtml = null;
|
||||||
|
|
||||||
function buildIndexHtml() {
|
function buildIndexHtml() {
|
||||||
|
|
@ -139,7 +150,7 @@ app.get(["/", "/index.html"], (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/favicon.png", (req, res) => {
|
app.get("/favicon.png", (req, res) => {
|
||||||
res.sendFile(path.join(__dirname, "..", "images", "favicon.png"));
|
res.sendFile(path.join(REPO_ROOT, "images", "favicon.png"));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Serve dist files with cache headers based on version param
|
// Serve dist files with cache headers based on version param
|
||||||
|
|
@ -156,7 +167,8 @@ app.use((req, res, next) => {
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use(express.static(path.join(__dirname, "..", "dist")));
|
app.use(express.static(path.join(REPO_ROOT, "packages", "ui", "dist")));
|
||||||
|
app.use(express.static(path.join(REPO_ROOT, "packages", "shim", "dist")));
|
||||||
|
|
||||||
app.use(express.static(config.obsidianAssetsPath));
|
app.use(express.static(config.obsidianAssetsPath));
|
||||||
|
|
||||||
|
|
@ -165,14 +177,28 @@ const server = app.listen(config.port, async () => {
|
||||||
console.log(`[ignis] Vault root: ${config.vaultRoot}`);
|
console.log(`[ignis] Vault root: ${config.vaultRoot}`);
|
||||||
console.log(`[ignis] Vaults: ${Object.keys(config.vaults).join(", ")}`);
|
console.log(`[ignis] Vaults: ${Object.keys(config.vaults).join(", ")}`);
|
||||||
|
|
||||||
await updateBridgePluginInAllVaults(config.vaultRoot);
|
|
||||||
await initPlugins({ app, config, wss, watcher });
|
await initPlugins({ app, config, wss, watcher });
|
||||||
|
|
||||||
|
const bundledPluginDirs = getBundledPluginDirs();
|
||||||
|
|
||||||
|
for (const { distDir } of bundledPluginDirs) {
|
||||||
|
app.use(express.static(distDir));
|
||||||
|
}
|
||||||
|
|
||||||
|
await migratePluginsFromAllVaults(config.vaultRoot, [
|
||||||
|
BRIDGE_PLUGIN_ID,
|
||||||
|
...bundledPluginDirs.map((d) => d.bundledPluginId),
|
||||||
|
]);
|
||||||
|
|
||||||
bootstrapRoutes
|
bootstrapRoutes
|
||||||
.warmUp()
|
.warmUp()
|
||||||
.catch((e) => console.warn("[bootstrap] warm-up error:", e.message));
|
.catch((e) => console.warn("[bootstrap] warm-up error:", e.message));
|
||||||
});
|
});
|
||||||
|
|
||||||
const wss = setupWebSocket(server);
|
const wss = setupWebSocket(server, {
|
||||||
|
getVaultPath: config.getVaultPath,
|
||||||
|
originAllowlist: config.wsOrigins,
|
||||||
|
});
|
||||||
wireDemoWebSocket(server);
|
wireDemoWebSocket(server);
|
||||||
|
|
||||||
async function gracefulShutdown(signal) {
|
async function gracefulShutdown(signal) {
|
||||||
|
|
@ -40,17 +40,16 @@ function discoverPlugins(pluginsDir) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let bundledPluginId = null;
|
let bundledManifest = null;
|
||||||
|
|
||||||
if (plugin.obsidianPlugin) {
|
if (plugin.obsidianPlugin) {
|
||||||
try {
|
try {
|
||||||
const manifest = JSON.parse(
|
bundledManifest = JSON.parse(
|
||||||
fs.readFileSync(
|
fs.readFileSync(
|
||||||
path.join(plugin.obsidianPlugin, "manifest.json"),
|
path.join(plugin.obsidianPlugin, "manifest.json"),
|
||||||
"utf-8",
|
"utf-8",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
bundledPluginId = manifest.id;
|
|
||||||
} catch {
|
} catch {
|
||||||
// No valid bundled plugin manifest
|
// No valid bundled plugin manifest
|
||||||
}
|
}
|
||||||
|
|
@ -61,7 +60,8 @@ function discoverPlugins(pluginsDir) {
|
||||||
name: plugin.name,
|
name: plugin.name,
|
||||||
description: plugin.description || "",
|
description: plugin.description || "",
|
||||||
obsidianPlugin: plugin.obsidianPlugin || null,
|
obsidianPlugin: plugin.obsidianPlugin || null,
|
||||||
bundledPluginId,
|
bundledPluginId: bundledManifest ? bundledManifest.id : null,
|
||||||
|
bundledManifest,
|
||||||
module: plugin,
|
module: plugin,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -3,10 +3,7 @@ const path = require("path");
|
||||||
const express = require("express");
|
const express = require("express");
|
||||||
const { discoverPlugins } = require("./discovery");
|
const { discoverPlugins } = require("./discovery");
|
||||||
const configStore = require("./config-store");
|
const configStore = require("./config-store");
|
||||||
const {
|
const { getVersion } = require("../version");
|
||||||
installObsidianPlugin,
|
|
||||||
removeObsidianPlugin,
|
|
||||||
} = require("./obsidian-plugin");
|
|
||||||
|
|
||||||
let discoveredPlugins = new Map();
|
let discoveredPlugins = new Map();
|
||||||
const loadedPlugins = new Map();
|
const loadedPlugins = new Map();
|
||||||
|
|
@ -50,18 +47,6 @@ async function initPlugins(ctx) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const discovered = discoveredPlugins.get(pluginId);
|
|
||||||
|
|
||||||
if (discovered.obsidianPlugin) {
|
|
||||||
try {
|
|
||||||
await installObsidianPlugin(discovered.obsidianPlugin, vaultPath);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(
|
|
||||||
`[plugins] Failed to verify bundled plugin for ${pluginId} in ${vaultId}: ${e.message}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loaded = loadedPlugins.get(pluginId);
|
const loaded = loadedPlugins.get(pluginId);
|
||||||
|
|
||||||
if (loaded?.module?.onVaultEnabled) {
|
if (loaded?.module?.onVaultEnabled) {
|
||||||
|
|
@ -182,30 +167,28 @@ async function enablePluginForVault(pluginId, vaultId) {
|
||||||
await loadPlugin(pluginId);
|
await loadPlugin(pluginId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (discovered.obsidianPlugin) {
|
|
||||||
try {
|
|
||||||
const result = await installObsidianPlugin(
|
|
||||||
discovered.obsidianPlugin,
|
|
||||||
vaultPath,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.installed) {
|
|
||||||
console.log(
|
|
||||||
`[plugins] Installed bundled Obsidian plugin for ${pluginId} in vault: ${vaultId}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(
|
|
||||||
`[plugins] Failed to install bundled plugin for ${pluginId}: ${e.message}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loaded = loadedPlugins.get(pluginId);
|
const loaded = loadedPlugins.get(pluginId);
|
||||||
|
|
||||||
if (loaded?.module?.onVaultEnabled) {
|
if (loaded?.module?.onVaultEnabled) {
|
||||||
await loaded.module.onVaultEnabled(vaultId, vaultPath);
|
await loaded.module.onVaultEnabled(vaultId, vaultPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Broadcast to any open tabs on this vault so they load the plugin properly.
|
||||||
|
if (discovered.obsidianPlugin && discovered.bundledPluginId) {
|
||||||
|
const v = `?v=${getVersion()}`;
|
||||||
|
const entry = {
|
||||||
|
id: discovered.bundledPluginId,
|
||||||
|
scriptUrl: `/${discovered.bundledPluginId}.js${v}`,
|
||||||
|
cssUrl: `/${discovered.bundledPluginId}.css${v}`,
|
||||||
|
manifest: discovered.bundledManifest,
|
||||||
|
};
|
||||||
|
|
||||||
|
serverCtx.wss?.broadcastToVault?.(vaultId, {
|
||||||
|
type: "virtual-plugin-enable",
|
||||||
|
vault: vaultId,
|
||||||
|
entry,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function disablePluginForVault(pluginId, vaultId) {
|
async function disablePluginForVault(pluginId, vaultId) {
|
||||||
|
|
@ -227,25 +210,6 @@ async function disablePluginForVault(pluginId, vaultId) {
|
||||||
await loaded.module.onVaultDisabled(vaultId, vaultPath);
|
await loaded.module.onVaultDisabled(vaultId, vaultPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (discovered.obsidianPlugin) {
|
|
||||||
try {
|
|
||||||
const result = await removeObsidianPlugin(
|
|
||||||
discovered.obsidianPlugin,
|
|
||||||
vaultPath,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.removed) {
|
|
||||||
console.log(
|
|
||||||
`[plugins] Removed bundled Obsidian plugin for ${pluginId} from vault: ${vaultId}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(
|
|
||||||
`[plugins] Failed to remove bundled plugin for ${pluginId}: ${e.message}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const enabledVaults = configStore.getEnabledVaults(pluginConfig, pluginId);
|
const enabledVaults = configStore.getEnabledVaults(pluginConfig, pluginId);
|
||||||
const updated = enabledVaults.filter((id) => id !== vaultId);
|
const updated = enabledVaults.filter((id) => id !== vaultId);
|
||||||
configStore.setEnabledVaults(pluginConfig, pluginId, updated);
|
configStore.setEnabledVaults(pluginConfig, pluginId, updated);
|
||||||
|
|
@ -254,6 +218,55 @@ async function disablePluginForVault(pluginId, vaultId) {
|
||||||
if (updated.length === 0) {
|
if (updated.length === 0) {
|
||||||
await unloadPlugin(pluginId);
|
await unloadPlugin(pluginId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (discovered.bundledPluginId) {
|
||||||
|
serverCtx.wss?.broadcastToVault?.(vaultId, {
|
||||||
|
type: "virtual-plugin-disable",
|
||||||
|
vault: vaultId,
|
||||||
|
id: discovered.bundledPluginId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBundledPluginDirs() {
|
||||||
|
const dirs = [];
|
||||||
|
|
||||||
|
for (const [, discovered] of discoveredPlugins) {
|
||||||
|
if (discovered.obsidianPlugin && discovered.bundledPluginId) {
|
||||||
|
dirs.push({
|
||||||
|
bundledPluginId: discovered.bundledPluginId,
|
||||||
|
distDir: path.join(discovered.obsidianPlugin, "dist"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dirs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVirtualPluginsForVault(vaultId, version) {
|
||||||
|
const v = version ? `?v=${version}` : "";
|
||||||
|
const result = [];
|
||||||
|
|
||||||
|
for (const [pluginId, discovered] of discoveredPlugins) {
|
||||||
|
if (!discovered.obsidianPlugin || !discovered.bundledPluginId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabledVaults = configStore.getEnabledVaults(pluginConfig, pluginId);
|
||||||
|
|
||||||
|
if (!enabledVaults.includes(vaultId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
id: discovered.bundledPluginId,
|
||||||
|
scriptUrl: `/${discovered.bundledPluginId}.js${v}`,
|
||||||
|
cssUrl: `/${discovered.bundledPluginId}.css${v}`,
|
||||||
|
manifest: discovered.bundledManifest,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDiscoveredPlugins() {
|
function getDiscoveredPlugins() {
|
||||||
|
|
@ -280,4 +293,6 @@ module.exports = {
|
||||||
enablePluginForVault,
|
enablePluginForVault,
|
||||||
disablePluginForVault,
|
disablePluginForVault,
|
||||||
getDiscoveredPlugins,
|
getDiscoveredPlugins,
|
||||||
|
getBundledPluginDirs,
|
||||||
|
getVirtualPluginsForVault,
|
||||||
};
|
};
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const os = require("os");
|
const { getObHome } = require("./ob-cli");
|
||||||
|
|
||||||
function getObAuthFile() {
|
function getObAuthFile(dataDir) {
|
||||||
return path.join(
|
return path.join(
|
||||||
os.homedir(),
|
getObHome(dataDir),
|
||||||
".config",
|
".config",
|
||||||
"obsidian-headless",
|
"obsidian-headless",
|
||||||
"auth_token",
|
"auth_token",
|
||||||
|
|
@ -23,14 +23,14 @@ function loadToken(dataDir) {
|
||||||
const data = JSON.parse(fs.readFileSync(internalFile, "utf-8"));
|
const data = JSON.parse(fs.readFileSync(internalFile, "utf-8"));
|
||||||
|
|
||||||
if (data && data.token) {
|
if (data && data.token) {
|
||||||
syncToObCli(data.token);
|
syncToObCli(dataDir, data.token);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
// Fall back to ob CLI's own auth file
|
// Fall back to ob CLI's own auth file
|
||||||
const obAuthFile = getObAuthFile();
|
const obAuthFile = getObAuthFile(dataDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(obAuthFile)) {
|
if (fs.existsSync(obAuthFile)) {
|
||||||
|
|
@ -49,7 +49,7 @@ function loadToken(dataDir) {
|
||||||
|
|
||||||
function saveToken(dataDir, tokenData) {
|
function saveToken(dataDir, tokenData) {
|
||||||
saveInternal(dataDir, tokenData);
|
saveInternal(dataDir, tokenData);
|
||||||
syncToObCli(tokenData.token);
|
syncToObCli(dataDir, tokenData.token);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearToken(dataDir) {
|
function clearToken(dataDir) {
|
||||||
|
|
@ -61,7 +61,7 @@ function clearToken(dataDir) {
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
const obAuthFile = getObAuthFile();
|
const obAuthFile = getObAuthFile(dataDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(obAuthFile)) {
|
if (fs.existsSync(obAuthFile)) {
|
||||||
|
|
@ -83,28 +83,36 @@ function isAuthenticated(dataDir) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function writeSecret(file, contents) {
|
||||||
|
fs.writeFileSync(file, contents, { encoding: "utf-8", mode: 0o600 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.chmodSync(file, 0o600);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
function saveInternal(dataDir, tokenData) {
|
function saveInternal(dataDir, tokenData) {
|
||||||
const internalFile = getInternalTokenFile(dataDir);
|
const internalFile = getInternalTokenFile(dataDir);
|
||||||
const dir = path.dirname(internalFile);
|
const dir = path.dirname(internalFile);
|
||||||
|
|
||||||
if (!fs.existsSync(dir)) {
|
if (!fs.existsSync(dir)) {
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.writeFileSync(internalFile, JSON.stringify(tokenData, null, 2), "utf-8");
|
writeSecret(internalFile, JSON.stringify(tokenData, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncToObCli(token) {
|
function syncToObCli(dataDir, token) {
|
||||||
const obAuthFile = getObAuthFile();
|
const obAuthFile = getObAuthFile(dataDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const dir = path.dirname(obAuthFile);
|
const dir = path.dirname(obAuthFile);
|
||||||
|
|
||||||
if (!fs.existsSync(dir)) {
|
if (!fs.existsSync(dir)) {
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.writeFileSync(obAuthFile, token, "utf-8");
|
writeSecret(obAuthFile, token);
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -124,4 +132,10 @@ function getTokenInfo(dataDir) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { loadToken, saveToken, clearToken, isAuthenticated, getTokenInfo };
|
module.exports = {
|
||||||
|
loadToken,
|
||||||
|
saveToken,
|
||||||
|
clearToken,
|
||||||
|
isAuthenticated,
|
||||||
|
getTokenInfo,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
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 };
|
||||||
|
|
@ -11,7 +11,7 @@ module.exports = {
|
||||||
version: "0.3.0",
|
version: "0.3.0",
|
||||||
//TODO: add server plugin manifest
|
//TODO: add server plugin manifest
|
||||||
|
|
||||||
obsidianPlugin: path.join(__dirname, "plugin"),
|
obsidianPlugin: path.join(__dirname, "obsidian"),
|
||||||
|
|
||||||
_ctx: null,
|
_ctx: null,
|
||||||
_obStatus: null,
|
_obStatus: null,
|
||||||
|
|
@ -29,6 +29,10 @@ module.exports = {
|
||||||
ctx.log("ob CLI not found. Install obsidian-headless to enable sync.");
|
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);
|
const token = auth.loadToken(ctx.dataDir);
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
|
|
@ -59,22 +63,9 @@ module.exports = {
|
||||||
|
|
||||||
const { mountRoutes } = require("./routes");
|
const { mountRoutes } = require("./routes");
|
||||||
mountRoutes(ctx.router, this);
|
mountRoutes(ctx.router, this);
|
||||||
|
|
||||||
// Register WebSocket message handler for log subscriptions
|
|
||||||
if (ctx.wss && ctx.wss.messageHandlers) {
|
|
||||||
ctx.wss.messageHandlers.set("subscribe-logs", (msg) => {
|
|
||||||
if (msg.vaultId && this._broadcaster) {
|
|
||||||
this._broadcaster.subscribeToLogs(msg.vaultId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async shutdown() {
|
async shutdown() {
|
||||||
if (this._ctx?.wss?.messageHandlers) {
|
|
||||||
this._ctx.wss.messageHandlers.delete("subscribe-logs");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._syncManager) {
|
if (this._syncManager) {
|
||||||
await this._syncManager.shutdown();
|
await this._syncManager.shutdown();
|
||||||
this._syncManager = null;
|
this._syncManager = null;
|
||||||
|
|
@ -1,8 +1,28 @@
|
||||||
const { spawn, execSync } = require("child_process");
|
const { spawn, execSync } = require("child_process");
|
||||||
|
const fs = require("fs");
|
||||||
const os = require("os");
|
const os = require("os");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
const isWindows = process.platform === "win32";
|
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() {
|
function checkInstalled() {
|
||||||
try {
|
try {
|
||||||
const output = execSync("ob --version", {
|
const output = execSync("ob --version", {
|
||||||
|
|
@ -19,8 +39,12 @@ function checkInstalled() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function spawnOb(args, opts = {}) {
|
function spawnOb(args, opts = {}) {
|
||||||
|
const home = configuredDataDir
|
||||||
|
? getObHome(configuredDataDir)
|
||||||
|
: os.homedir();
|
||||||
|
|
||||||
return spawn("ob", args, {
|
return spawn("ob", args, {
|
||||||
env: { ...process.env, HOME: os.homedir() },
|
env: { ...process.env, HOME: home },
|
||||||
shell: isWindows,
|
shell: isWindows,
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
...opts,
|
...opts,
|
||||||
|
|
@ -58,4 +82,10 @@ function runCommand(args, opts = {}) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { checkInstalled, spawnOb, runCommand };
|
module.exports = {
|
||||||
|
checkInstalled,
|
||||||
|
spawnOb,
|
||||||
|
runCommand,
|
||||||
|
configure,
|
||||||
|
getObHome,
|
||||||
|
};
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"id": "ignis-headless-sync",
|
"id": "ignis-headless-sync",
|
||||||
"name": "Ignis Headless Sync",
|
"name": "Headless Sync",
|
||||||
"version": "0.3.0",
|
"version": "0.3.0",
|
||||||
"minAppVersion": "1.12.4",
|
"minAppVersion": "1.12.4",
|
||||||
"description": "Client-side companion for server-side Obsidian Sync",
|
"description": "Client-side companion for server-side Obsidian Sync",
|
||||||
|
|
@ -32,13 +32,12 @@ function showConflictWarning(title, message) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function startCoreSyncGuard(plugin, api, wsListener) {
|
function startCoreSyncGuard(plugin, api) {
|
||||||
const app = plugin.app;
|
const app = plugin.app;
|
||||||
const vaultId = app.vault.getName();
|
const vaultId = app.vault.getName();
|
||||||
|
|
||||||
// Monkey-patch syncPlugin.enable() to clear the shim flag before
|
// Monkey-patch syncPlugin.enable() to clear the shim flag before Obsidian writes core-plugins.json.
|
||||||
// Obsidian writes core-plugins.json. This ensures the read transform
|
// This ensures the read transform doesn't block a user-initiated core sync enable.
|
||||||
// doesn't block a user-initiated core sync enable.
|
|
||||||
const syncPlugin = app.internalPlugins.getPluginById("sync");
|
const syncPlugin = app.internalPlugins.getPluginById("sync");
|
||||||
let origEnable = null;
|
let origEnable = null;
|
||||||
|
|
||||||
|
|
@ -52,16 +51,13 @@ function startCoreSyncGuard(plugin, api, wsListener) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch for core-plugins.json changes via WebSocket.
|
|
||||||
let wasEnabled = isCoreSyncEnabled();
|
let wasEnabled = isCoreSyncEnabled();
|
||||||
|
|
||||||
const rawHandler = (msg) => {
|
const unsubModified = window.__ignis.ws.subscribe("modified", (msg) => {
|
||||||
if (msg.type === "modified" && msg.path === CORE_PLUGINS_PATH) {
|
if (msg.path === CORE_PLUGINS_PATH) {
|
||||||
handleCoreSyncChange();
|
handleCoreSyncChange();
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
wsListener.onRaw(rawHandler);
|
|
||||||
|
|
||||||
function handleCoreSyncChange() {
|
function handleCoreSyncChange() {
|
||||||
const enabled = isCoreSyncEnabled();
|
const enabled = isCoreSyncEnabled();
|
||||||
|
|
@ -80,7 +76,7 @@ function startCoreSyncGuard(plugin, api, wsListener) {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cleanup() {
|
cleanup() {
|
||||||
wsListener.offRaw();
|
unsubModified();
|
||||||
|
|
||||||
if (syncPlugin && origEnable) {
|
if (syncPlugin && origEnable) {
|
||||||
syncPlugin.enable = origEnable;
|
syncPlugin.enable = origEnable;
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
const api = require("./api");
|
const api = require("./api");
|
||||||
|
|
||||||
async function renderLogViewer(containerEl, vaultId, wsListener) {
|
const CHANNEL = "plugin:headless-sync";
|
||||||
|
|
||||||
|
async function renderLogViewer(containerEl, vaultId) {
|
||||||
const details = containerEl.createEl("details", {
|
const details = containerEl.createEl("details", {
|
||||||
cls: "ignis-log-details",
|
cls: "ignis-log-details",
|
||||||
});
|
});
|
||||||
|
|
@ -32,19 +34,12 @@ async function renderLogViewer(containerEl, vaultId, wsListener) {
|
||||||
|
|
||||||
logBox.scrollTop = logBox.scrollHeight;
|
logBox.scrollTop = logBox.scrollHeight;
|
||||||
|
|
||||||
if (!wsListener) {
|
const channel = window.__ignis.ws.channel(CHANNEL);
|
||||||
return () => {};
|
let unsubLog = null;
|
||||||
}
|
|
||||||
|
|
||||||
details.addEventListener("toggle", () => {
|
const onLog = (msg) => {
|
||||||
if (details.open) {
|
const payload = msg.payload || {};
|
||||||
wsListener.subscribeLogs(vaultId);
|
|
||||||
} else {
|
|
||||||
wsListener.unsubscribeLogs();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const onLog = (payload) => {
|
|
||||||
if (payload.vaultId !== vaultId) {
|
if (payload.vaultId !== vaultId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -66,11 +61,22 @@ async function renderLogViewer(containerEl, vaultId, wsListener) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
wsListener.on("sync-log", onLog);
|
details.addEventListener("toggle", () => {
|
||||||
|
if (details.open) {
|
||||||
|
if (!unsubLog) {
|
||||||
|
unsubLog = channel.subscribe("sync-log", onLog);
|
||||||
|
}
|
||||||
|
} else if (unsubLog) {
|
||||||
|
unsubLog();
|
||||||
|
unsubLog = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
wsListener.off("sync-log", onLog);
|
if (unsubLog) {
|
||||||
wsListener.unsubscribeLogs();
|
unsubLog();
|
||||||
|
unsubLog = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
const { Plugin } = require("obsidian");
|
const { Plugin } = require("obsidian");
|
||||||
const { HeadlessSyncSettingTab } = require("./settings-tab");
|
const { HeadlessSyncSettingTab } = require("./settings-tab");
|
||||||
const { WsListener } = require("./ws-listener");
|
|
||||||
const { initSyncStatusBar } = require("./sync-status-bar");
|
const { initSyncStatusBar } = require("./sync-status-bar");
|
||||||
const { startCoreSyncGuard } = require("./core-sync-guard");
|
const { startCoreSyncGuard } = require("./core-sync-guard");
|
||||||
const api = require("./api");
|
const api = require("./api");
|
||||||
|
|
@ -14,14 +13,11 @@ class IgnisHeadlessSyncPlugin extends Plugin {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.wsListener = new WsListener();
|
this._syncStatusBarCleanup = initSyncStatusBar(this);
|
||||||
this.wsListener.start();
|
|
||||||
|
|
||||||
this._syncStatusBarCleanup = initSyncStatusBar(this, this.wsListener);
|
|
||||||
|
|
||||||
this.addSettingTab(new HeadlessSyncSettingTab(this.app, this));
|
this.addSettingTab(new HeadlessSyncSettingTab(this.app, this));
|
||||||
|
|
||||||
this._coreSyncGuard = startCoreSyncGuard(this, api, this.wsListener);
|
this._coreSyncGuard = startCoreSyncGuard(this, api);
|
||||||
|
|
||||||
this.addCommand({
|
this.addCommand({
|
||||||
id: "start-sync",
|
id: "start-sync",
|
||||||
|
|
@ -75,11 +71,6 @@ class IgnisHeadlessSyncPlugin extends Plugin {
|
||||||
this._syncStatusBarCleanup();
|
this._syncStatusBarCleanup();
|
||||||
this._syncStatusBarCleanup = null;
|
this._syncStatusBarCleanup = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.wsListener) {
|
|
||||||
this.wsListener.stop();
|
|
||||||
this.wsListener = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -316,11 +316,7 @@ class HeadlessSyncSettingTab extends PluginSettingTab {
|
||||||
}
|
}
|
||||||
|
|
||||||
async renderLogs(containerEl, vaultId) {
|
async renderLogs(containerEl, vaultId) {
|
||||||
this._logCleanup = await renderLogViewer(
|
this._logCleanup = await renderLogViewer(containerEl, vaultId);
|
||||||
containerEl,
|
|
||||||
vaultId,
|
|
||||||
this.plugin.wsListener,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hide() {
|
hide() {
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
const { setIcon } = require("obsidian");
|
const { setIcon } = require("obsidian");
|
||||||
const api = require("./api");
|
const api = require("./api");
|
||||||
|
|
||||||
|
const CHANNEL = "plugin:headless-sync";
|
||||||
|
|
||||||
const TOOLTIP_MAP = {
|
const TOOLTIP_MAP = {
|
||||||
running: "Syncing...",
|
running: "Syncing...",
|
||||||
synced: "Synced",
|
synced: "Synced",
|
||||||
|
|
@ -8,8 +10,11 @@ const TOOLTIP_MAP = {
|
||||||
error: "Sync error",
|
error: "Sync error",
|
||||||
};
|
};
|
||||||
|
|
||||||
function initSyncStatusBar(plugin, wsListener) {
|
function initSyncStatusBar(plugin) {
|
||||||
const vaultId = plugin.app.vault.getName();
|
const vaultId = plugin.app.vault.getName();
|
||||||
|
const ws = window.__ignis.ws;
|
||||||
|
const channel = ws.channel(CHANNEL);
|
||||||
|
|
||||||
const item = plugin.addStatusBarItem();
|
const item = plugin.addStatusBarItem();
|
||||||
item.addClass("ignis-sync-statusbar");
|
item.addClass("ignis-sync-statusbar");
|
||||||
item.style.display = "none";
|
item.style.display = "none";
|
||||||
|
|
@ -21,6 +26,7 @@ function initSyncStatusBar(plugin, wsListener) {
|
||||||
let popoverOpen = false;
|
let popoverOpen = false;
|
||||||
let currentStatus = "stopped";
|
let currentStatus = "stopped";
|
||||||
let outsideClickHandler = null;
|
let outsideClickHandler = null;
|
||||||
|
let unsubLog = null;
|
||||||
|
|
||||||
function updateState(status, error) {
|
function updateState(status, error) {
|
||||||
currentStatus = status;
|
currentStatus = status;
|
||||||
|
|
@ -62,7 +68,7 @@ function initSyncStatusBar(plugin, wsListener) {
|
||||||
|
|
||||||
popoverOpen = true;
|
popoverOpen = true;
|
||||||
|
|
||||||
wsListener.subscribeLogs(vaultId);
|
unsubLog = channel.subscribe("sync-log", onLog);
|
||||||
|
|
||||||
outsideClickHandler = (e) => {
|
outsideClickHandler = (e) => {
|
||||||
if (!item.contains(e.target)) {
|
if (!item.contains(e.target)) {
|
||||||
|
|
@ -86,7 +92,11 @@ function initSyncStatusBar(plugin, wsListener) {
|
||||||
outsideClickHandler = null;
|
outsideClickHandler = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
wsListener.unsubscribeLogs();
|
if (unsubLog) {
|
||||||
|
unsubLog();
|
||||||
|
unsubLog = null;
|
||||||
|
}
|
||||||
|
|
||||||
popoverOpen = false;
|
popoverOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,7 +105,7 @@ function initSyncStatusBar(plugin, wsListener) {
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
return "\u2026" + path.slice(-(maxLen - 1));
|
return "…" + path.slice(-(maxLen - 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPopoverText(prefix, path) {
|
function formatPopoverText(prefix, path) {
|
||||||
|
|
@ -115,35 +125,30 @@ function initSyncStatusBar(plugin, wsListener) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractFileActivity(line) {
|
function extractFileActivity(line) {
|
||||||
// Downloading/Downloaded path
|
|
||||||
let match = line.match(/^(?:Downloading|Downloaded)\s+(.+)$/);
|
let match = line.match(/^(?:Downloading|Downloaded)\s+(.+)$/);
|
||||||
|
|
||||||
if (match) {
|
if (match) {
|
||||||
return { prefix: "Syncing", path: match[1].trim() };
|
return { prefix: "Syncing", path: match[1].trim() };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Uploading file / Upload complete path
|
|
||||||
match = line.match(/^(?:Uploading file|Upload complete|New file)\s+(.+)$/);
|
match = line.match(/^(?:Uploading file|Upload complete|New file)\s+(.+)$/);
|
||||||
|
|
||||||
if (match) {
|
if (match) {
|
||||||
return { prefix: "Syncing", path: match[1].trim() };
|
return { prefix: "Syncing", path: match[1].trim() };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deleting path
|
|
||||||
match = line.match(/^Deleting\s+(.+)$/);
|
match = line.match(/^Deleting\s+(.+)$/);
|
||||||
|
|
||||||
if (match) {
|
if (match) {
|
||||||
return { prefix: "Deleting", path: match[1].trim() };
|
return { prefix: "Deleting", path: match[1].trim() };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Push: path (updated)
|
|
||||||
match = line.match(/^Push:\s+(.+?)\s+\(updated\)$/);
|
match = line.match(/^Push:\s+(.+?)\s+\(updated\)$/);
|
||||||
|
|
||||||
if (match) {
|
if (match) {
|
||||||
return { prefix: "Syncing", path: match[1].trim() };
|
return { prefix: "Syncing", path: match[1].trim() };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Push: path (deleted)
|
|
||||||
match = line.match(/^Push:\s+(.+?)\s+\(deleted\)$/);
|
match = line.match(/^Push:\s+(.+?)\s+\(deleted\)$/);
|
||||||
|
|
||||||
if (match) {
|
if (match) {
|
||||||
|
|
@ -157,7 +162,6 @@ function initSyncStatusBar(plugin, wsListener) {
|
||||||
return /Fully synced/i.test(line);
|
return /Fully synced/i.test(line);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Click toggles popover
|
|
||||||
item.addEventListener("click", () => {
|
item.addEventListener("click", () => {
|
||||||
if (popoverOpen) {
|
if (popoverOpen) {
|
||||||
hidePopover();
|
hidePopover();
|
||||||
|
|
@ -166,16 +170,15 @@ function initSyncStatusBar(plugin, wsListener) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for status updates
|
const onStatus = (msg) => {
|
||||||
const onStatus = (payload) => {
|
const payload = msg.payload || {};
|
||||||
|
|
||||||
if (payload.vaultId !== vaultId) {
|
if (payload.vaultId !== vaultId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
item.style.display = "";
|
item.style.display = "";
|
||||||
|
|
||||||
// "running" from server means the process is alive, but we refine
|
|
||||||
// the visual state based on log activity.
|
|
||||||
if (payload.status === "running") {
|
if (payload.status === "running") {
|
||||||
updateState("synced");
|
updateState("synced");
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -183,10 +186,8 @@ function initSyncStatusBar(plugin, wsListener) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
wsListener.on("sync-status", onStatus);
|
const unsubStatus = channel.subscribe("sync-status", onStatus);
|
||||||
|
|
||||||
// Debounce the transition to "synced" state to avoid flickering
|
|
||||||
// during rapid delete cycles (Fully synced -> Deleting -> Fully synced).
|
|
||||||
let syncedTimer = null;
|
let syncedTimer = null;
|
||||||
|
|
||||||
function deferSynced() {
|
function deferSynced() {
|
||||||
|
|
@ -208,8 +209,9 @@ function initSyncStatusBar(plugin, wsListener) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for log lines
|
function onLog(msg) {
|
||||||
const onLog = (payload) => {
|
const payload = msg.payload || {};
|
||||||
|
|
||||||
if (payload.vaultId !== vaultId) {
|
if (payload.vaultId !== vaultId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -226,11 +228,8 @@ function initSyncStatusBar(plugin, wsListener) {
|
||||||
updateState("running");
|
updateState("running");
|
||||||
updatePopoverText(formatPopoverText(activity.prefix, activity.path));
|
updatePopoverText(formatPopoverText(activity.prefix, activity.path));
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
wsListener.on("sync-log", onLog);
|
|
||||||
|
|
||||||
// Fetch initial state
|
|
||||||
api
|
api
|
||||||
.getVaults()
|
.getVaults()
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
|
|
@ -244,16 +243,16 @@ function initSyncStatusBar(plugin, wsListener) {
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
// Poll WebSocket state to detect server disconnect/reconnect
|
// Reflect WebSocket disconnect/reconnect in the indicator.
|
||||||
let wasDisconnected = false;
|
let wasDisconnected = false;
|
||||||
|
|
||||||
const wsCheckInterval = setInterval(() => {
|
const unsubState = ws.onStateChange((state) => {
|
||||||
const disconnected = !wsListener.isConnected();
|
const open = state === "open";
|
||||||
|
|
||||||
if (disconnected && currentStatus === "running") {
|
if (!open && currentStatus === "running") {
|
||||||
updateState("error", "Server connection lost");
|
updateState("error", "Server connection lost");
|
||||||
wasDisconnected = true;
|
wasDisconnected = true;
|
||||||
} else if (!disconnected && wasDisconnected) {
|
} else if (open && wasDisconnected) {
|
||||||
wasDisconnected = false;
|
wasDisconnected = false;
|
||||||
|
|
||||||
api
|
api
|
||||||
|
|
@ -268,14 +267,12 @@ function initSyncStatusBar(plugin, wsListener) {
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
}, 3000);
|
});
|
||||||
|
|
||||||
// Return cleanup function
|
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(wsCheckInterval);
|
|
||||||
cancelDeferredSynced();
|
cancelDeferredSynced();
|
||||||
wsListener.off("sync-status", onStatus);
|
unsubStatus();
|
||||||
wsListener.off("sync-log", onLog);
|
unsubState();
|
||||||
hidePopover();
|
hidePopover();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ const { spawn } = require("child_process");
|
||||||
const { spawnOb, runCommand } = require("./ob-cli");
|
const { spawnOb, runCommand } = require("./ob-cli");
|
||||||
|
|
||||||
const MAX_LOG_ENTRIES = 200;
|
const MAX_LOG_ENTRIES = 200;
|
||||||
|
const MAX_LOG_LINE = 4096;
|
||||||
|
|
||||||
function killProcess(proc) {
|
function killProcess(proc) {
|
||||||
if (!proc) {
|
if (!proc) {
|
||||||
|
|
@ -151,10 +152,13 @@ class SyncManager {
|
||||||
const lines = data.toString().split("\n");
|
const lines = data.toString().split("\n");
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.trim()) {
|
const trimmed = line.trim();
|
||||||
this.addLog(state, line.trim());
|
|
||||||
|
if (trimmed) {
|
||||||
|
const capped = trimmed.slice(0, MAX_LOG_LINE);
|
||||||
|
this.addLog(state, capped);
|
||||||
state.lastActivity = new Date().toISOString();
|
state.lastActivity = new Date().toISOString();
|
||||||
this.broadcaster.broadcastLog(vaultId, line.trim());
|
this.broadcaster.broadcastLog(vaultId, capped);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -302,7 +306,7 @@ class SyncManager {
|
||||||
addLog(state, line) {
|
addLog(state, line) {
|
||||||
state.logs.push({
|
state.logs.push({
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
line,
|
line: line.slice(0, MAX_LOG_LINE),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (state.logs.length > MAX_LOG_ENTRIES) {
|
if (state.logs.length > MAX_LOG_ENTRIES) {
|
||||||
|
|
@ -9,8 +9,11 @@ const fsp = fs.promises;
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const zlib = require("zlib");
|
const zlib = require("zlib");
|
||||||
const config = require("../config");
|
const config = require("../config");
|
||||||
const { isBridgePluginInstalled, getIgnisMeta } = require("../bridge-plugin");
|
const {
|
||||||
const { getDiscoveredPlugins } = require("../plugin-system/manager");
|
getDiscoveredPlugins,
|
||||||
|
getVirtualPluginsForVault,
|
||||||
|
} = require("../plugin-system/manager");
|
||||||
|
const { getVersion } = require("../version");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
|
@ -76,20 +79,13 @@ async function walkTree(rootPath) {
|
||||||
return { tree, dirMtimes };
|
return { tree, dirMtimes };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildVaultInfo(vaultId, vaultPath) {
|
function buildVaultInfo(vaultId, vaultPath) {
|
||||||
const pluginInstalled = await isBridgePluginInstalled(vaultPath);
|
|
||||||
const ignisMeta = await getIgnisMeta(vaultPath);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: vaultId,
|
id: vaultId,
|
||||||
name: vaultId,
|
name: vaultId,
|
||||||
path: vaultPath,
|
path: vaultPath,
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
version: config.obsidianVersion,
|
version: config.obsidianVersion,
|
||||||
ignisPlugin: {
|
|
||||||
installed: pluginInstalled,
|
|
||||||
prompted: ignisMeta.pluginPrompted || false,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -134,10 +130,8 @@ async function buildEntry(vaultId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
const [vault, { tree, dirMtimes }] = await Promise.all([
|
const vault = buildVaultInfo(vaultId, vaultPath);
|
||||||
buildVaultInfo(vaultId, vaultPath),
|
const { tree, dirMtimes } = await walkTree(vaultPath);
|
||||||
walkTree(vaultPath),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
vault,
|
vault,
|
||||||
|
|
@ -145,6 +139,7 @@ async function buildEntry(vaultId) {
|
||||||
tree,
|
tree,
|
||||||
// In demo mode, hide server-side plugins from the client.
|
// In demo mode, hide server-side plugins from the client.
|
||||||
plugins: config.demoMode ? [] : getDiscoveredPlugins(),
|
plugins: config.demoMode ? [] : getDiscoveredPlugins(),
|
||||||
|
virtualPlugins: getVirtualPluginsForVault(vaultId, getVersion()),
|
||||||
};
|
};
|
||||||
|
|
||||||
const jsonBuf = Buffer.from(JSON.stringify(response));
|
const jsonBuf = Buffer.from(JSON.stringify(response));
|
||||||
|
|
@ -3,59 +3,16 @@ const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const archiver = require("archiver");
|
const archiver = require("archiver");
|
||||||
const config = require("../config");
|
const config = require("../config");
|
||||||
const { writeCoalesced, getPending } = require("../write-coalescer");
|
const {
|
||||||
|
writeCoalescer,
|
||||||
|
encodeContentDispositionFilename,
|
||||||
|
resolveVaultPath,
|
||||||
|
} = require("@ignis/server-core");
|
||||||
|
const { writeCoalesced, getPending } = writeCoalescer;
|
||||||
const bootstrapRoutes = require("./bootstrap");
|
const bootstrapRoutes = require("./bootstrap");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
/**
|
|
||||||
* Encode a filename for use in Content-Disposition header.
|
|
||||||
* Handles non-ASCII characters and special characters to prevent header injection.
|
|
||||||
* Uses RFC 5987 encoding for filename* parameter when needed.
|
|
||||||
*
|
|
||||||
* @param {string} filename - The filename to encode
|
|
||||||
* @returns {string} - Properly formatted Content-Disposition value
|
|
||||||
*/
|
|
||||||
function encodeContentDispositionFilename(filename) {
|
|
||||||
// Check if filename contains non-ASCII characters
|
|
||||||
const hasNonASCII = /[^\x00-\x7F]/.test(filename);
|
|
||||||
|
|
||||||
// Escape quotes and backslashes in ASCII filename by prefixing with backslash
|
|
||||||
const escapedFilename = filename.replace(/["\\ ]/g, function (match) {
|
|
||||||
if (match === '"') return '\\"';
|
|
||||||
if (match === "\\") return "\\\\";
|
|
||||||
return match;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove any control characters that could cause header injection
|
|
||||||
const sanitizedFilename = escapedFilename.replace(/[\x00-\x1F\x7F]/g, "");
|
|
||||||
|
|
||||||
if (!hasNonASCII) {
|
|
||||||
// Simple ASCII filename - use standard format
|
|
||||||
return `attachment; filename="${sanitizedFilename}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Non-ASCII filename - use RFC 5987 encoding
|
|
||||||
// Encode using percent-encoding for UTF-8
|
|
||||||
const encodedFilename = encodeURIComponent(filename)
|
|
||||||
.replace(/['()]/g, function (c) {
|
|
||||||
return "%" + c.charCodeAt(0).toString(16).toUpperCase();
|
|
||||||
})
|
|
||||||
.replace(/\*/g, "%2A");
|
|
||||||
|
|
||||||
// Provide both filename (ASCII fallback) and filename* (UTF-8 encoded)
|
|
||||||
// For fallback, replace non-ASCII with underscores
|
|
||||||
const asciiFallback = filename
|
|
||||||
.replace(/[^\x00-\x7F]/g, "_")
|
|
||||||
.replace(/["\\ ]/g, function (match) {
|
|
||||||
if (match === '"') return '\\"';
|
|
||||||
if (match === "\\") return "\\\\";
|
|
||||||
return match;
|
|
||||||
});
|
|
||||||
|
|
||||||
return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodedFilename}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve the vault root for a request. Reads vault ID from query or body.
|
// Resolve the vault root for a request. Reads vault ID from query or body.
|
||||||
function getVaultRoot(req, res) {
|
function getVaultRoot(req, res) {
|
||||||
const vaultId = req.query.vault || req.body?.vault || config.defaultVaultId;
|
const vaultId = req.query.vault || req.body?.vault || config.defaultVaultId;
|
||||||
|
|
@ -76,20 +33,6 @@ function invalidateBootstrap(req) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve a client-provided path to an absolute path within a vault.
|
|
||||||
// Strips leading slashes so paths from the client are always treated as relative to the vault root.
|
|
||||||
function resolveVaultPath(vaultRoot, relativePath) {
|
|
||||||
const cleaned = (relativePath || "").replace(/^\/+/, "");
|
|
||||||
const resolved = path.resolve(vaultRoot, cleaned);
|
|
||||||
|
|
||||||
const resolvedRoot = path.resolve(vaultRoot);
|
|
||||||
|
|
||||||
if (resolved !== resolvedRoot && !resolved.startsWith(resolvedRoot + path.sep)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
function guardPath(req, res, source = "query") {
|
function guardPath(req, res, source = "query") {
|
||||||
const vaultRoot = getVaultRoot(req, res);
|
const vaultRoot = getVaultRoot(req, res);
|
||||||
|
|
||||||
|
|
@ -320,8 +263,12 @@ router.post("/rename", async (req, res) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const oldResolved = resolveVaultPath(vaultRoot, req.body?.oldPath);
|
if (!req.body?.oldPath || !req.body?.newPath) {
|
||||||
const newResolved = resolveVaultPath(vaultRoot, req.body?.newPath);
|
return res.status(400).json({ error: "Missing oldPath or newPath" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldResolved = resolveVaultPath(vaultRoot, req.body.oldPath);
|
||||||
|
const newResolved = resolveVaultPath(vaultRoot, req.body.newPath);
|
||||||
|
|
||||||
if (!oldResolved || !newResolved) {
|
if (!oldResolved || !newResolved) {
|
||||||
return res.status(403).json({ error: "Invalid path" });
|
return res.status(403).json({ error: "Invalid path" });
|
||||||
|
|
@ -345,8 +292,12 @@ router.post("/copyFile", async (req, res) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const srcResolved = resolveVaultPath(vaultRoot, req.body?.src);
|
if (!req.body?.src || !req.body?.dest) {
|
||||||
const destResolved = resolveVaultPath(vaultRoot, req.body?.dest);
|
return res.status(400).json({ error: "Missing src or dest" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const srcResolved = resolveVaultPath(vaultRoot, req.body.src);
|
||||||
|
const destResolved = resolveVaultPath(vaultRoot, req.body.dest);
|
||||||
|
|
||||||
if (!srcResolved || !destResolved) {
|
if (!srcResolved || !destResolved) {
|
||||||
return res.status(403).json({ error: "Invalid path" });
|
return res.status(403).json({ error: "Invalid path" });
|
||||||
|
|
@ -653,5 +604,3 @@ router.get("/download-zip", async (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
module.exports.resolveVaultPath = resolveVaultPath;
|
|
||||||
module.exports.encodeContentDispositionFilename = encodeContentDispositionFilename;
|
|
||||||
|
|
@ -6,7 +6,7 @@ const require = createRequire(import.meta.url);
|
||||||
const {
|
const {
|
||||||
resolveVaultPath,
|
resolveVaultPath,
|
||||||
encodeContentDispositionFilename,
|
encodeContentDispositionFilename,
|
||||||
} = require("./fs.js");
|
} = require("@ignis/server-core");
|
||||||
|
|
||||||
// -- encodeContentDispositionFilename --------------------------------
|
// -- encodeContentDispositionFilename --------------------------------
|
||||||
|
|
||||||
|
|
@ -77,12 +77,12 @@ describe("resolveVaultPath", () => {
|
||||||
expect(resolveVaultPath(root, "")).toBe(path.resolve(root));
|
expect(resolveVaultPath(root, "")).toBe(path.resolve(root));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats null input as vault root", () => {
|
it("returns null for null input", () => {
|
||||||
expect(resolveVaultPath(root, null)).toBe(path.resolve(root));
|
expect(resolveVaultPath(root, null)).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats undefined input as vault root", () => {
|
it("returns null for undefined input", () => {
|
||||||
expect(resolveVaultPath(root, undefined)).toBe(path.resolve(root));
|
expect(resolveVaultPath(root, undefined)).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("strips leading slashes", () => {
|
it("strips leading slashes", () => {
|
||||||
|
|
@ -2,12 +2,6 @@ const express = require("express");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const config = require("../config");
|
const config = require("../config");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const {
|
|
||||||
isBridgePluginInstalled,
|
|
||||||
getIgnisMeta,
|
|
||||||
setIgnisMeta,
|
|
||||||
installBridgePlugin,
|
|
||||||
} = require("../bridge-plugin");
|
|
||||||
const bootstrapRoutes = require("./bootstrap");
|
const bootstrapRoutes = require("./bootstrap");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
@ -34,19 +28,12 @@ router.get("/info", async (req, res) => {
|
||||||
return res.status(404).json({ error: "Vault not found", id: vaultId });
|
return res.status(404).json({ error: "Vault not found", id: vaultId });
|
||||||
}
|
}
|
||||||
|
|
||||||
const pluginInstalled = await isBridgePluginInstalled(vaultPath);
|
|
||||||
const ignisMeta = await getIgnisMeta(vaultPath);
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
id: vaultId,
|
id: vaultId,
|
||||||
name: vaultId,
|
name: vaultId,
|
||||||
path: vaultPath,
|
path: vaultPath,
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
version: config.obsidianVersion,
|
version: config.obsidianVersion,
|
||||||
ignisPlugin: {
|
|
||||||
installed: pluginInstalled,
|
|
||||||
prompted: ignisMeta.pluginPrompted || false,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -66,8 +53,6 @@ router.post("/create", async (req, res) => {
|
||||||
recursive: false,
|
recursive: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await installBridgePlugin(vaultPath);
|
|
||||||
|
|
||||||
config.refreshVaults();
|
config.refreshVaults();
|
||||||
bootstrapRoutes.invalidateVault(name);
|
bootstrapRoutes.invalidateVault(name);
|
||||||
|
|
||||||
|
|
@ -138,42 +123,4 @@ router.delete("/remove", async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/vault/install-plugin { vault, dismiss } - install plugin or mark as prompted
|
|
||||||
router.post("/install-plugin", async (req, res) => {
|
|
||||||
const vaultId = req.body?.vault;
|
|
||||||
const dismiss = req.body?.dismiss || false;
|
|
||||||
|
|
||||||
if (!vaultId) {
|
|
||||||
return res.status(400).json({ error: "Missing vault ID" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const vaultPath = config.getVaultPath(vaultId);
|
|
||||||
|
|
||||||
if (!vaultPath) {
|
|
||||||
return res.status(404).json({ error: "Vault not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const meta = await getIgnisMeta(vaultPath);
|
|
||||||
|
|
||||||
if (dismiss) {
|
|
||||||
// User clicked "Don't Ask Again" or "Not Now"
|
|
||||||
meta.pluginPrompted = true;
|
|
||||||
await setIgnisMeta(vaultPath, meta);
|
|
||||||
|
|
||||||
return res.json({ ok: true, prompted: true });
|
|
||||||
} else {
|
|
||||||
// User wants to install the plugin
|
|
||||||
const installed = await installBridgePlugin(vaultPath);
|
|
||||||
|
|
||||||
meta.pluginPrompted = true;
|
|
||||||
await setIgnisMeta(vaultPath, meta);
|
|
||||||
|
|
||||||
return res.json({ ok: true, installed, prompted: true });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
res.status(500).json({ error: e.message, code: e.code });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
const express = require("express");
|
const express = require("express");
|
||||||
const { getVersion } = require("../version");
|
const { getSemver, getBuild } = require("../version");
|
||||||
const config = require("../config");
|
const config = require("../config");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
// `version` is the display-friendly SemVer. `build` is the per-build stamp for cache-bust.
|
||||||
router.get("/", (req, res) => {
|
router.get("/", (req, res) => {
|
||||||
const pkg = require("../../package.json");
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
version: getVersion(),
|
version: getSemver(),
|
||||||
semver: pkg.version,
|
build: getBuild(),
|
||||||
obsidianVersion: config.obsidianVersion,
|
obsidianVersion: config.obsidianVersion,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
51
apps/ignis-server/server/version.js
Normal file
51
apps/ignis-server/server/version.js
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
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,80 +1,61 @@
|
||||||
const esbuild = require("esbuild");
|
const esbuild = require("esbuild");
|
||||||
const sveltePlugin = require("esbuild-svelte");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
const { version: ignisVersion } = require("./package.json");
|
const headlessSyncDir = path.join(
|
||||||
|
__dirname,
|
||||||
|
"apps",
|
||||||
|
"ignis-server",
|
||||||
|
"server",
|
||||||
|
"plugins",
|
||||||
|
"headless-sync",
|
||||||
|
"obsidian",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Compute version info once and share across per-package builds.
|
||||||
|
const { version: semver } = require("./package.json");
|
||||||
|
const build = process.env.IGNIS_BUILD || Date.now().toString(36).slice(-7);
|
||||||
|
const version = `${semver}+${build}`;
|
||||||
|
|
||||||
|
const buildInfoPath = path.join(
|
||||||
|
__dirname,
|
||||||
|
"apps",
|
||||||
|
"ignis-server",
|
||||||
|
"server",
|
||||||
|
"build-info.json",
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
buildInfoPath,
|
||||||
|
JSON.stringify({ semver, build, version }, null, 2),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Used by packages.
|
||||||
|
process.env.IGNIS_BUILD_RESOLVED = build;
|
||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
// Build shim-loader.js
|
// Build shim-loader.js (delegated to packages/shim)
|
||||||
esbuild.build({
|
require("./packages/shim/build.js"),
|
||||||
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
|
// Build ignis-ui.js (delegated to packages/ui)
|
||||||
esbuild.build({
|
require("./packages/ui/build.js"),
|
||||||
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
|
// Build headless-sync bundled plugin
|
||||||
esbuild.build({
|
esbuild
|
||||||
entryPoints: [
|
.build({
|
||||||
path.join(
|
entryPoints: [path.join(headlessSyncDir, "src", "main.js")],
|
||||||
__dirname,
|
bundle: true,
|
||||||
"server",
|
outfile: path.join(headlessSyncDir, "dist", "ignis-headless-sync.js"),
|
||||||
"plugins",
|
format: "cjs",
|
||||||
"headless-sync",
|
platform: "browser",
|
||||||
"plugin",
|
target: ["chrome90"],
|
||||||
"src",
|
external: ["obsidian", "fs"],
|
||||||
"main.js",
|
logLevel: "info",
|
||||||
),
|
})
|
||||||
],
|
.then(() => {
|
||||||
bundle: true,
|
fs.copyFileSync(
|
||||||
outfile: path.join(
|
path.join(headlessSyncDir, "styles.css"),
|
||||||
__dirname,
|
path.join(headlessSyncDir, "dist", "ignis-headless-sync.css"),
|
||||||
"server",
|
);
|
||||||
"plugins",
|
}),
|
||||||
"headless-sync",
|
|
||||||
"plugin",
|
|
||||||
"main.js",
|
|
||||||
),
|
|
||||||
format: "cjs",
|
|
||||||
platform: "browser",
|
|
||||||
target: ["chrome90"],
|
|
||||||
external: ["obsidian", "fs"], //using fs shim
|
|
||||||
logLevel: "info",
|
|
||||||
}),
|
|
||||||
]).catch(() => process.exit(1));
|
]).catch(() => process.exit(1));
|
||||||
|
|
|
||||||
71
docker-compose.dokploy.yml
Normal file
71
docker-compose.dokploy.yml
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
# =============================================================================
|
||||||
|
# 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:
|
||||||
|
|
@ -2,6 +2,26 @@
|
||||||
|
|
||||||
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.
|
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
|
## Overview
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
@ -12,19 +32,19 @@ Browser Server
|
||||||
│ Shim layer │ <────> │ /api/vault/* │
|
│ Shim layer │ <────> │ /api/vault/* │
|
||||||
│ fs, electron, etc. │ WS │ /api/plugins/* │
|
│ fs, electron, etc. │ WS │ /api/plugins/* │
|
||||||
│ ↕ │ <────> │ /api/ext/:plugin/* │
|
│ ↕ │ <────> │ /api/ext/:plugin/* │
|
||||||
│ Bridge plugin │ │ Ignis plugins │
|
│ Bridge │ │ Ignis plugins │
|
||||||
└──────────────────────┘ └──────────────────────┘
|
└──────────────────────┘ └──────────────────────┘
|
||||||
↕
|
↕
|
||||||
Filesystem (vaults/)
|
Filesystem (vaults/)
|
||||||
```
|
```
|
||||||
|
|
||||||
The shim layer makes Obsidian think it's running in Electron. The bridge plugin adds Ignis-specific features inside Obsidian.
|
The shim layer makes Obsidian think it's running in Electron. The bridge adds Ignis-specific features inside Obsidian.
|
||||||
|
|
||||||
## Shim Layer
|
## Shim Layer
|
||||||
|
|
||||||
### Loading
|
### Loading
|
||||||
|
|
||||||
The server serves its own `index.html` (in `server/assets/`) rather than Obsidian's. At startup it reads Obsidian's `index.html` once to discover which scripts Obsidian expects, then embeds that list in our HTML as a JSON array. The client-side HTML loads the shim loader and UI bundle first (non-deferred), then a small inline script dynamically injects Obsidian's scripts in order. Obsidian's files are never modified on disk, or transformed in transit.
|
The server serves its own `index.html` (in `apps/ignis-server/server/assets/`) rather than Obsidian's. At startup it reads Obsidian's `index.html` once to discover which scripts Obsidian expects, then embeds that list in our HTML as a JSON array. The client-side HTML loads the shim loader and UI bundle first (non-deferred), then a small inline script dynamically injects Obsidian's scripts in order. Obsidian's files are never modified on disk, or transformed in transit.
|
||||||
|
|
||||||
Before injecting Obsidian's scripts, the shim loader sets `localStorage.EmulateMobile` based on viewport width (< 600px) so Obsidian boots into its mobile UI on phones and narrow windows. The loader replaces the module system, then issues a single blocking bootstrap request that returns the vault info, vault list, metadata tree, and Ignis plugin list in one pre-compressed response. The request has to be blocking because Obsidian makes synchronous filesystem calls during page load, before the event loop is running, so the cache has to already be populated.
|
Before injecting Obsidian's scripts, the shim loader sets `localStorage.EmulateMobile` based on viewport width (< 600px) so Obsidian boots into its mobile UI on phones and narrow windows. The loader replaces the module system, then issues a single blocking bootstrap request that returns the vault info, vault list, metadata tree, and Ignis plugin list in one pre-compressed response. The request has to be blocking because Obsidian makes synchronous filesystem calls during page load, before the event loop is running, so the cache has to already be populated.
|
||||||
|
|
||||||
|
|
@ -58,17 +78,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`).
|
Reads not satisfied by ContentCache go through the transport layer to `/api/fs/readFile`. Sync calls use synchronous XHR to keep Obsidian's pre-boot module code working. Async calls use fetch. The transport handles vault id injection, base64 encoding for binary files, and mapping HTTP error codes back to Node errno values (`ENOENT`, `EEXIST`, `ENOTDIR`).
|
||||||
|
|
||||||
Writes go through a server-side write coalescer (`server/write-coalescer.js`) designed for slow filesystems like rclone FUSE mounts. The first write to a path goes to disk immediately. Subsequent writes within a configurable window (default 5 seconds, `WRITE_COALESCE_MS`) are buffered and flushed when the debounce timer fires; the timer resets on each write. Buffered writes return to the HTTP client immediately with synthetic metadata so connection-pool starvation on rapid-fire writes (e.g. `workspace.json` autosaves) doesn't stall unrelated reads. Reads for pending paths serve the buffered content so clients never see stale data. All pending writes are flushed on graceful shutdown.
|
Writes go through a server-side write coalescer (`packages/server-core/src/write-coalescer.js`) designed for slow filesystems like rclone FUSE mounts. The first write to a path goes to disk immediately. Subsequent writes within a configurable window (default 5 seconds, `WRITE_COALESCE_MS`) are buffered and flushed when the debounce timer fires; the timer resets on each write. Buffered writes return to the HTTP client immediately with synthetic metadata so connection-pool starvation on rapid-fire writes (e.g. `workspace.json` autosaves) doesn't stall unrelated reads. Reads for pending paths serve the buffered content so clients never see stale data. All pending writes are flushed on graceful shutdown.
|
||||||
|
|
||||||
### Translation registry
|
### Transforms
|
||||||
|
|
||||||
The shim has a registry (`src/shims/fs/transforms.js`) for hooks applied at the public shim surface, before caches or transport see the path. Three hook types:
|
The shim has a transforms registry (`packages/shim/src/fs/transforms.js`) for hooks applied at the public shim surface, before caches or transport see the path. Three hook types:
|
||||||
|
|
||||||
- **Path resolvers** map a logical path to a physical path. Used by the workspaces shim to redirect reads and writes of `.obsidian/workspace.json` to `.obsidian/workspace.<name>.json` based on the `?workspace=` URL parameter, so each browser tab can hold a separate layout.
|
- **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.
|
- **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.
|
- **Write transforms** pre-process bytes before a write hits the cache or transport. Used to override the `active` field on writes to `workspaces.json` so cross-tab disk state stays canonical.
|
||||||
|
|
||||||
All hooks are synchronous and registered at module load. Translation happens once at the shim entry; downstream layers (content cache, metadata cache, transport) operate only on resolved physical paths and as-stored bytes. This keeps cache keys coherent with what transport actually reads and writes, so prefetch and on-demand fetches share the same cache slot.
|
All hooks are synchronous and registered at module load. They fire once at the shim entry; downstream layers (content cache, metadata cache, transport) operate only on resolved physical paths and as-stored bytes. This keeps cache keys coherent with what transport actually reads and writes, so prefetch and on-demand fetches share the same cache slot.
|
||||||
|
|
||||||
### IPC
|
### IPC
|
||||||
|
|
||||||
|
|
@ -86,15 +106,23 @@ The proxy itself is intentionally generic. It forwards method, headers, and body
|
||||||
|
|
||||||
### Workspaces in browser tabs
|
### Workspaces in browser tabs
|
||||||
|
|
||||||
Obsidian's Workspaces core plugin lets you save a window layout under a name. Ignis adds a `?workspace=<name>` URL parameter that binds a tab to a specific layout. The bridge plugin's "Open workspace in new tab" command opens the picked workspace at `?workspace=<name>` in a fresh tab.
|
Obsidian's Workspaces core plugin lets you save a window layout under a name. Ignis adds a `?workspace=<name>` URL parameter that binds a tab to a specific layout. The bridge plugin's "Open workspace in new tab" command opens the picked workspace in a fresh tab.
|
||||||
|
|
||||||
The fs shim redirects reads and writes of `.obsidian/workspace.json` to a per-workspace file (`.obsidian/workspace.<name>.json`), giving each tab its own layout. It also rewrites the active field on reads of `workspaces.json` so each tab's menu shows its own workspace as active.
|
The implementation uses all three transforms (above): a path resolver redirects `.obsidian/workspace.json` to `.obsidian/workspace.<name>.json` so each tab has its own state file; a read transform overrides the `active` field on `workspaces.json` so the current tab's menu shows its own workspace as selected; a write transform keeps the canonical `active` value stable on disk so concurrent tabs don't clobber each other.
|
||||||
|
|
||||||
Two tabs sharing a vault stay in sync through the file watcher.
|
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.
|
||||||
|
|
||||||
### Obsidian Plugin Compatibility
|
## Bridge
|
||||||
|
|
||||||
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.
|
Ignis's built-in integration with the Obsidian UI. It subclasses Obsidian's `Plugin` to get convenient hooks (commands, ribbon icons, status bar items, settings tabs, workspace events), but it is not a plugin in the managed sense: it isn't discovered, toggled, enabled per vault, or installed into `.obsidian/plugins/`. It's bundled into `shim-loader.js` (source in `packages/bridge/`), instantiated directly by the shim loader after Obsidian boots, and always on.
|
||||||
|
|
||||||
|
The bridge contributes:
|
||||||
|
|
||||||
|
- **File actions**: a ribbon icon for uploading files into the current folder, and right-click menu items: Download (single file), Download as ZIP (folder), and Upload file (folder).
|
||||||
|
- **Commands**: `Open workspace in new tab`.
|
||||||
|
- **Status bar item**: a dot showing the WebSocket connection state to the Ignis server.
|
||||||
|
- **Settings injection**: monkey-patches `app.setting.onOpen` to add two tabs in their own "Ignis" sidebar group. Each enabled Ignis plugin's companion is pulled into a separate "Ignis Core Plugins" sidebar group.
|
||||||
|
- **Demo guards**: in demo mode, a MutationObserver disables every email/password input that appears anywhere in the document.
|
||||||
|
|
||||||
## Vaults
|
## Vaults
|
||||||
|
|
||||||
|
|
@ -109,45 +137,38 @@ An Express server that handles filesystem operations, vault management, static f
|
||||||
- `/api/vault/*` - vault CRUD and config.
|
- `/api/vault/*` - vault CRUD and config.
|
||||||
- `/api/bootstrap` - one-shot cold-start endpoint; returns vault info + list + metadata tree + plugin list as a single pre-compressed response, cached per vault with mtime-based invalidation.
|
- `/api/bootstrap` - one-shot cold-start endpoint; returns vault info + list + metadata tree + plugin list as a single pre-compressed response, cached per vault with mtime-based invalidation.
|
||||||
- `/api/proxy` - cross-origin HTTP proxy used by the fetch and requestUrl shims.
|
- `/api/proxy` - cross-origin HTTP proxy used by the fetch and requestUrl shims.
|
||||||
- `/api/version` - server version and git hash.
|
- `/api/version` - Ignis version (SemVer), per-build identifier, and pinned Obsidian version.
|
||||||
- `/api/plugins/*` - Ignis plugin management (list, enable, disable). __WIP__
|
- `/api/plugins/*` - Ignis plugin management (list, enable, disable). __WIP__
|
||||||
- `/api/ext/:pluginId/*` - routes registered by individual Ignis plugins.
|
- `/api/ext/:pluginId/*` - routes registered by individual Ignis plugins.
|
||||||
- `/vault-files/<vaultId>/<path>` - static file serving rooted at a vault, used by Obsidian for image/attachment resource URLs.
|
- `/vault-files/<vaultId>/<path>` - static file serving rooted at a vault, used by Obsidian for image/attachment resource URLs.
|
||||||
|
|
||||||
**WebSocket:** A file watcher monitors vault directories and pushes change events to connected clients, keeping the client-side metadata and content caches in sync. An echo guard suppresses events caused by the same client's recent writes so they don't bounce back. The watcher also carries plugin-defined message types (e.g. headless-sync status broadcasts).
|
**WebSocket:** A file watcher monitors vault directories and pushes change events to connected clients, keeping the client-side metadata and content caches in sync. An echo guard suppresses events caused by the same client's recent writes so they don't bounce back. The watcher also carries plugin-defined message types (e.g. headless-sync status broadcasts).
|
||||||
|
|
||||||
**Bridge plugin auto-install:** On server startup and on vault creation, the server copies the ignis-bridge plugin into each vault's `.obsidian/plugins/` directory.
|
**Legacy bridge cleanup:** Earlier versions installed the bridge into each vault's `.obsidian/plugins/`. The bridge is now bundled into the shim and loaded client-side, so on startup the server removes any leftover on-disk `ignis-bridge` install from each vault (and strips it from `community-plugins.json`).
|
||||||
|
|
||||||
## Plugins
|
## Plugins
|
||||||
|
|
||||||
Three things are called "plugin" in this project.
|
Aside from the built-in [Bridge](#bridge), three kinds of plugin exist in Ignis, distinguished by who loads them and where they run.
|
||||||
|
|
||||||
### Obsidian Plugins
|
### Obsidian Plugins
|
||||||
|
|
||||||
Standard community and core Obsidian plugins. They work through the shim layer with no Ignis involvement beyond providing fs, path, and crypto.
|
Standard community and core Obsidian plugins. Obsidian evals plugin code with its own require that checks its internal module map first, then falls back to the window-level require, which Ignis replaces with the shim. Plugins that use the filesystem, path utilities, or crypto get shim implementations transparently. Plugins that need child processes, raw sockets, or native addons load but throw on first use; the error message names the missing API.
|
||||||
|
|
||||||
### Bridge Plugin (ignis-bridge)
|
|
||||||
|
|
||||||
An Obsidian plugin auto-installed into every vault by the server. Source lives in `plugin/`, built to `plugin/main.js`.
|
|
||||||
|
|
||||||
It contributes:
|
|
||||||
- **File actions**: a ribbon icon for uploading files into the current folder, and right-click menu items: Download (single file), Download as ZIP (folder), and Upload file (folder).
|
|
||||||
- **Commands**: `Open workspace in new tab` (with a FuzzySuggestModal listing saved workspaces).
|
|
||||||
- **Status bar item**: a dot showing the WebSocket connection state to the Ignis server.
|
|
||||||
- **Settings injection**: monkey-patches `app.setting.onOpen` to add two tabs in their own "Ignis" sidebar group. General (server status, version, GitHub link, update check against the GitHub releases API) and Core plugins (toggle the bundled Obsidian plugins of enabled Ignis plugins on/off per vault). Each enabled Ignis plugin's bundled Obsidian plugin also gets pulled into a separate "Ignis Core Plugins" sidebar group.
|
|
||||||
- **Demo guards**: in demo mode, a MutationObserver disables every email/password input that appears anywhere in the document and rewrites its placeholder.
|
|
||||||
|
|
||||||
Not user-installable through Obsidian's plugin browser. Managed entirely by the server.
|
|
||||||
|
|
||||||
### Ignis Plugins
|
### Ignis Plugins
|
||||||
|
|
||||||
A basic plugin system for extending the server. Still early, the core lifecycle works but the API surface is minimal and likely to change.
|
A plugin system for extending the server. Still early, the core lifecycle works but the API surface is minimal and likely to change.
|
||||||
|
|
||||||
An Ignis plugin is a Node.js package under `server/plugins/<name>/` that exports an id, name, and a `register` function. On load it receives a context object with access to config, the WebSocket server, a file watcher, an Express router, a logger, and a persistent data directory. Plugins are enabled and disabled per vault, with state persisted in `data/plugin-config.json`.
|
An Ignis plugin is a Node.js package under `apps/ignis-server/server/plugins/<name>/` that exports an id, name, and a `register` function. On load it receives a context object with access to config, the WebSocket server, a file watcher, an Express router, a logger, and a persistent data directory. Plugins are enabled and disabled per vault, with state persisted in `data/plugin-config.json`. When enabled, a plugin's Express router is mounted at `/api/ext/<pluginId>/`.
|
||||||
|
|
||||||
When enabled, a plugin's Express router is mounted at `/api/ext/<pluginId>/`. A plugin can also optionally bundle an Obsidian plugin, a directory containing a standard Obsidian plugin (manifest.json, main.js) that gets auto-installed into the vault on enable and removed on disable. This bridges the server and client sides: the Ignis plugin handles server logic and routes, while the bundled Obsidian plugin provides the in-app UI or behavior.
|
An Ignis plugin can optionally ship a **virtual plugin** (see below): an Obsidian-side companion that provides the in-app UI. The Ignis plugin handles server logic and routes; the virtual plugin runs in the browser.
|
||||||
|
|
||||||
The one Ignis plugin currently in the repo is **headless-sync** (`server/plugins/headless-sync/`). It wraps the [obsidian-headless](https://github.com/Yuri-Khomyakov/obsidian-headless) CLI (`ob`) and runs `ob sync --continuous` as a per-vault child process, optionally with `--pull-only` or `--mirror-remote`. Process state (running/stopped/error, pid, last activity, recent log lines) is broadcast over the WebSocket via a small per-vault subscription protocol. The bundled Obsidian plugin (`ignis-headless-sync`) adds a status bar item, a settings tab with start/stop/unlink controls, and a core-sync guard that hides Obsidian's own Sync setting from `core-plugins.json` reads while headless sync is active for that vault, so a different device syncing the "Active core plugins list" can't accidentally re-enable it.
|
The one Ignis plugin currently in the repo is **headless-sync** (`apps/ignis-server/server/plugins/headless-sync/`). It wraps the [obsidian-headless](https://github.com/obsidianmd/obsidian-headless) CLI (`ob`) and runs `ob sync --continuous` as a per-vault child process, optionally with `--pull-only` or `--mirror-remote`. Process state (running/stopped/error, pid, last activity, recent log lines) is broadcast to subscribed clients over a WebSocket channel.
|
||||||
|
|
||||||
|
### Virtual Plugins
|
||||||
|
|
||||||
|
The client-side companion of an Ignis plugin: a standard Obsidian plugin (a `manifest.json` plus a bundled script) that Ignis loads in the browser rather than installing to disk. The virtual-plugin-loader (`packages/shim/src/virtual-plugin-loader.js`) fetches the bundle from the server, evals it, instantiates the plugin class against the live `app`. Loaded instances are tracked in `window.__ignis.plugins` and can be toggled per vault. Nothing is ever written to `.obsidian/plugins/`.
|
||||||
|
|
||||||
|
headless-sync's companion (`ignis-headless-sync`) adds a status bar item, a settings tab with start/stop/unlink controls, and a core-sync guard that hides Obsidian's own Sync setting from `core-plugins.json` reads while headless sync is active for that vault, so a different device syncing the "Active core plugins list" can't accidentally re-enable it.
|
||||||
|
|
||||||
## Demo mode
|
## Demo mode
|
||||||
|
|
||||||
|
|
@ -164,4 +185,4 @@ Other demo behaviors:
|
||||||
- Server-side plugins (e.g. headless-sync) hidden from the client; enable/disable returns 403.
|
- 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.
|
- The bridge plugin disables any `<input type="email">` or `<input type="password">` it sees anywhere in the document, with a placeholder telling users not to enter credentials.
|
||||||
|
|
||||||
All server-side demo code lives in `server/demo/`. The client-side hooks live in `src/shims/demo.js`. The deployment example is in `examples/demo/` (tmpfs-mounted vaults, restricted proxy, all the env vars).
|
All server-side demo code lives in `apps/ignis-server/server/demo/`. The client-side hooks live in `packages/shim/src/demo.js`. The deployment example is in `apps/ignis-server/examples/demo/` (tmpfs-mounted vaults, restricted proxy, all the env vars).
|
||||||
120
package-lock.json
generated
120
package-lock.json
generated
|
|
@ -1,12 +1,16 @@
|
||||||
{
|
{
|
||||||
"name": "ignis",
|
"name": "ignis-monorepo",
|
||||||
"version": "0.8.0",
|
"version": "0.8.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ignis",
|
"name": "ignis-monorepo",
|
||||||
"version": "0.8.0",
|
"version": "0.8.2",
|
||||||
|
"workspaces": [
|
||||||
|
"packages/*",
|
||||||
|
"apps/*"
|
||||||
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"chokidar": "^3.6.0",
|
"chokidar": "^3.6.0",
|
||||||
|
|
@ -26,11 +30,19 @@
|
||||||
"vitest": "^3.2.4"
|
"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": {
|
"node_modules/@ampproject/remapping": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
||||||
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
|
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/gen-mapping": "^0.3.5",
|
"@jridgewell/gen-mapping": "^0.3.5",
|
||||||
|
|
@ -482,6 +494,30 @@
|
||||||
"node": ">=12"
|
"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": {
|
"node_modules/@isaacs/cliui": {
|
||||||
"version": "8.0.2",
|
"version": "8.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||||
|
|
@ -503,7 +539,6 @@
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||||
|
|
@ -514,7 +549,6 @@
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
|
|
@ -524,14 +558,12 @@
|
||||||
"version": "1.5.5",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/trace-mapping": {
|
"node_modules/@jridgewell/trace-mapping": {
|
||||||
"version": "0.3.31",
|
"version": "0.3.31",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/resolve-uri": "^3.1.0",
|
"@jridgewell/resolve-uri": "^3.1.0",
|
||||||
|
|
@ -542,7 +574,6 @@
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 20.19.0"
|
"node": ">= 20.19.0"
|
||||||
|
|
@ -972,7 +1003,6 @@
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/expect": {
|
"node_modules/@vitest/expect": {
|
||||||
|
|
@ -1119,7 +1149,6 @@
|
||||||
"version": "8.16.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
|
|
@ -1205,7 +1234,6 @@
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
|
||||||
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
|
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
|
|
@ -1237,7 +1265,6 @@
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||||
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
|
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
|
|
@ -1567,7 +1594,6 @@
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz",
|
||||||
"integrity": "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==",
|
"integrity": "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.15",
|
"@jridgewell/sourcemap-codec": "^1.4.15",
|
||||||
|
|
@ -1752,7 +1778,6 @@
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",
|
||||||
"integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",
|
"integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mdn-data": "2.0.30",
|
"mdn-data": "2.0.30",
|
||||||
|
|
@ -1944,7 +1969,6 @@
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "^1.0.0"
|
"@types/estree": "^1.0.0"
|
||||||
|
|
@ -2369,7 +2393,6 @@
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
||||||
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
|
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "^1.0.6"
|
"@types/estree": "^1.0.6"
|
||||||
|
|
@ -2467,7 +2490,6 @@
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
||||||
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
|
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
|
|
@ -2493,7 +2515,6 @@
|
||||||
"version": "0.577.0",
|
"version": "0.577.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.577.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.577.0.tgz",
|
||||||
"integrity": "sha512-0i88o57KsaHWnc80J57fY99CWzlZsSdtH5kKjLUJa7z8dum/9/AbINNLzJ7NiRFUdOgMnfAmJt8jFbW2zeC5qQ==",
|
"integrity": "sha512-0i88o57KsaHWnc80J57fY99CWzlZsSdtH5kKjLUJa7z8dum/9/AbINNLzJ7NiRFUdOgMnfAmJt8jFbW2zeC5qQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"svelte": "^3 || ^4 || ^5.0.0-next.42"
|
"svelte": "^3 || ^4 || ^5.0.0-next.42"
|
||||||
|
|
@ -2503,7 +2524,6 @@
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
|
|
@ -2522,7 +2542,6 @@
|
||||||
"version": "2.0.30",
|
"version": "2.0.30",
|
||||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
|
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
|
||||||
"integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==",
|
"integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==",
|
||||||
"dev": true,
|
|
||||||
"license": "CC0-1.0"
|
"license": "CC0-1.0"
|
||||||
},
|
},
|
||||||
"node_modules/media-typer": {
|
"node_modules/media-typer": {
|
||||||
|
|
@ -2719,7 +2738,6 @@
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||||
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
|
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/path-key": {
|
"node_modules/path-key": {
|
||||||
|
|
@ -2774,7 +2792,6 @@
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz",
|
||||||
"integrity": "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==",
|
"integrity": "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "^1.0.0",
|
"@types/estree": "^1.0.0",
|
||||||
|
|
@ -3184,7 +3201,6 @@
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
|
|
@ -3346,7 +3362,6 @@
|
||||||
"version": "4.2.20",
|
"version": "4.2.20",
|
||||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.20.tgz",
|
"resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.20.tgz",
|
||||||
"integrity": "sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q==",
|
"integrity": "sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ampproject/remapping": "^2.2.1",
|
"@ampproject/remapping": "^2.2.1",
|
||||||
|
|
@ -4412,6 +4427,59 @@
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 14"
|
"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,11 +1,15 @@
|
||||||
{
|
{
|
||||||
"name": "ignis",
|
"name": "ignis-monorepo",
|
||||||
"version": "0.8.0",
|
"version": "0.8.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "An Electron shim and server bridge for running Obsidian in a browser.",
|
"description": "Monorepo for Ignis: a browser-based Obsidian client. Self-hosted server in apps/ignis-server; shim, UI, and shared libraries in packages/.",
|
||||||
|
"workspaces": [
|
||||||
|
"packages/*",
|
||||||
|
"apps/*"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node build.js",
|
"build": "node build.js",
|
||||||
"dev:server": "node server/index.js",
|
"dev:server": "node apps/ignis-server/server/index.js",
|
||||||
"dev": "npm run build && npm run dev:server",
|
"dev": "npm run build && npm run dev:server",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
|
|
|
||||||
6
packages/bridge/package.json
Normal file
6
packages/bridge/package.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"name": "@ignis/bridge",
|
||||||
|
"version": "0.0.0-internal",
|
||||||
|
"private": true,
|
||||||
|
"main": "src/main.js"
|
||||||
|
}
|
||||||
|
|
@ -13,8 +13,6 @@ const { initStatusBar } = require("./status-bar");
|
||||||
const { WorkspacePickerModal } = require("./workspace-picker");
|
const { WorkspacePickerModal } = require("./workspace-picker");
|
||||||
const { startDemoGuards, stopDemoGuards } = require("./demo-guards");
|
const { startDemoGuards, stopDemoGuards } = require("./demo-guards");
|
||||||
|
|
||||||
window.__obsidianAPI = require("obsidian");
|
|
||||||
|
|
||||||
class IgnisBridgePlugin extends Plugin {
|
class IgnisBridgePlugin extends Plugin {
|
||||||
async onload() {
|
async onload() {
|
||||||
if (!window.__ignis) {
|
if (!window.__ignis) {
|
||||||
|
|
@ -4,13 +4,13 @@ const GITHUB_URL = "https://github.com/Nystik-gh/ignis";
|
||||||
const GITHUB_API_LATEST =
|
const GITHUB_API_LATEST =
|
||||||
"https://api.github.com/repos/Nystik-gh/ignis/releases/latest";
|
"https://api.github.com/repos/Nystik-gh/ignis/releases/latest";
|
||||||
|
|
||||||
function getVersion(app) {
|
function getVersion() {
|
||||||
try {
|
return window.__ignis?.version || "unknown";
|
||||||
const manifest = app.plugins.getPlugin("ignis-bridge")?.manifest;
|
}
|
||||||
return manifest?.version || "unknown";
|
|
||||||
} catch {
|
// SemVer build metadata (`+xyz`) is informational and ignored for precedence.
|
||||||
return "unknown";
|
function stripBuildMetadata(version) {
|
||||||
}
|
return (version || "").split("+")[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkForUpdate(currentVersion) {
|
async function checkForUpdate(currentVersion) {
|
||||||
|
|
@ -22,10 +22,11 @@ async function checkForUpdate(currentVersion) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const latest = data.tag_name?.replace(/^v/, "");
|
const latest = stripBuildMetadata(data.tag_name?.replace(/^v/, ""));
|
||||||
|
const current = stripBuildMetadata(currentVersion);
|
||||||
|
|
||||||
if (latest && latest !== currentVersion) {
|
if (latest && latest !== current) {
|
||||||
return latest;
|
return { version: latest, url: data.html_url };
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -35,7 +36,7 @@ async function checkForUpdate(currentVersion) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function display(containerEl, app) {
|
function display(containerEl, app) {
|
||||||
const version = getVersion(app);
|
const version = getVersion();
|
||||||
|
|
||||||
const header = containerEl.createDiv("ignis-header");
|
const header = containerEl.createDiv("ignis-header");
|
||||||
|
|
||||||
|
|
@ -59,9 +60,10 @@ function display(containerEl, app) {
|
||||||
cls: "ignis-header-version",
|
cls: "ignis-header-version",
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateIndicator = versionCol.createEl("span", {
|
const updateIndicator = versionCol.createEl("a", {
|
||||||
text: "Checking...",
|
text: "Checking...",
|
||||||
cls: "ignis-update-indicator",
|
cls: "ignis-update-indicator",
|
||||||
|
attr: { target: "_blank", rel: "noopener noreferrer" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const githubLink = right.createEl("a", {
|
const githubLink = right.createEl("a", {
|
||||||
|
|
@ -75,10 +77,11 @@ function display(containerEl, app) {
|
||||||
attr: { src: "/assets/github.svg", alt: "GitHub" },
|
attr: { src: "/assets/github.svg", alt: "GitHub" },
|
||||||
});
|
});
|
||||||
|
|
||||||
checkForUpdate(version).then((latestVersion) => {
|
checkForUpdate(version).then((latest) => {
|
||||||
if (latestVersion) {
|
if (latest) {
|
||||||
updateIndicator.textContent = `v${latestVersion} available`;
|
updateIndicator.textContent = `v${latest.version} available`;
|
||||||
updateIndicator.addClass("ignis-update-available");
|
updateIndicator.addClass("ignis-update-available");
|
||||||
|
updateIndicator.href = latest.url;
|
||||||
} else {
|
} else {
|
||||||
updateIndicator.textContent = "Up to date";
|
updateIndicator.textContent = "Up to date";
|
||||||
}
|
}
|
||||||
|
|
@ -87,64 +90,44 @@ function display(containerEl, app) {
|
||||||
addServerStatus(containerEl);
|
addServerStatus(containerEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWsStatus() {
|
const STATUS_LABELS = {
|
||||||
const ws = window.__ignisWs;
|
open: "Connected",
|
||||||
|
connecting: "Connecting...",
|
||||||
|
closed: "Disconnected",
|
||||||
|
};
|
||||||
|
|
||||||
if (!ws) {
|
const STATUS_DOT_CLASSES = {
|
||||||
return "disconnected";
|
open: "ignis-status-connected",
|
||||||
}
|
connecting: "ignis-status-connecting",
|
||||||
|
closed: "ignis-status-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) {
|
function addServerStatus(containerEl) {
|
||||||
const status = getWsStatus();
|
const ws = window.__ignis.ws;
|
||||||
|
|
||||||
const setting = new Setting(containerEl).setName("Server status");
|
const setting = new Setting(containerEl).setName("Server status");
|
||||||
|
|
||||||
const dotEl = setting.controlEl.createEl("span", {
|
const dotEl = setting.controlEl.createEl("span", {
|
||||||
cls: `ignis-status-dot ignis-status-${status}`,
|
cls: "ignis-status-dot",
|
||||||
});
|
});
|
||||||
|
|
||||||
const labelEl = setting.controlEl.createEl("span", {
|
const labelEl = setting.controlEl.createEl("span", {
|
||||||
text: statusLabel(status),
|
|
||||||
cls: "ignis-status-label",
|
cls: "ignis-status-label",
|
||||||
});
|
});
|
||||||
|
|
||||||
const update = () => {
|
function render(state) {
|
||||||
const s = getWsStatus();
|
dotEl.className = `ignis-status-dot ${STATUS_DOT_CLASSES[state] || STATUS_DOT_CLASSES.closed}`;
|
||||||
dotEl.className = `ignis-status-dot ignis-status-${s}`;
|
labelEl.textContent = STATUS_LABELS[state] || STATUS_LABELS.closed;
|
||||||
labelEl.textContent = statusLabel(s);
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const pollInterval = setInterval(update, 3000);
|
render(ws.isOpen() ? "open" : "closed");
|
||||||
|
|
||||||
|
const unsub = ws.onStateChange(render);
|
||||||
|
|
||||||
|
// Detach when the settings tab DOM goes away.
|
||||||
const observer = new MutationObserver(() => {
|
const observer = new MutationObserver(() => {
|
||||||
if (!containerEl.isConnected) {
|
if (!containerEl.isConnected) {
|
||||||
clearInterval(pollInterval);
|
unsub();
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -2,6 +2,7 @@ const generalTab = require("./general-tab");
|
||||||
const serverPluginsTab = require("./server-plugins-tab");
|
const serverPluginsTab = require("./server-plugins-tab");
|
||||||
const { createNavEl, createTab, createGroup } = require("./settings-ui");
|
const { createNavEl, createTab, createGroup } = require("./settings-ui");
|
||||||
const {
|
const {
|
||||||
|
allIgnisNavEls,
|
||||||
setupPluginTabs,
|
setupPluginTabs,
|
||||||
reconcilePluginTabs,
|
reconcilePluginTabs,
|
||||||
hideIgnisFromCommunityPlugins,
|
hideIgnisFromCommunityPlugins,
|
||||||
|
|
@ -24,10 +25,6 @@ function removeExistingIgnisGroups(tabHeadersEl) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// All ignis-managed nav elements (both Ignis group and Ignis Core Plugins group).
|
|
||||||
// Collected here so the openTab patch can manage is-active across all of them.
|
|
||||||
const allIgnisNavEls = new Map(); // tab id -> nav element
|
|
||||||
|
|
||||||
function replaceInstallerVersionRow(setting, ignisVersion) {
|
function replaceInstallerVersionRow(setting, ignisVersion) {
|
||||||
const container = setting.tabContentContainer || setting.contentEl;
|
const container = setting.tabContentContainer || setting.contentEl;
|
||||||
|
|
||||||
|
|
@ -117,7 +114,7 @@ function injectIgnisSettings(setting, app, plugin) {
|
||||||
setting.tabHeadersEl.appendChild(corePlugins.group);
|
setting.tabHeadersEl.appendChild(corePlugins.group);
|
||||||
|
|
||||||
hideIgnisFromCommunityPlugins(setting);
|
hideIgnisFromCommunityPlugins(setting);
|
||||||
setupPluginTabs(setting, corePlugins.items, allIgnisNavEls);
|
setupPluginTabs(setting, corePlugins.items);
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchSettingsModal(plugin) {
|
function patchSettingsModal(plugin) {
|
||||||
|
|
@ -142,7 +139,4 @@ function unpatchSettingsModal(plugin) {
|
||||||
clearOwnedPluginIds();
|
clearOwnedPluginIds();
|
||||||
}
|
}
|
||||||
|
|
||||||
window.__ignisReconcilePluginTabs = (setting) =>
|
|
||||||
reconcilePluginTabs(setting, allIgnisNavEls);
|
|
||||||
|
|
||||||
module.exports = { patchSettingsModal, unpatchSettingsModal, reconcilePluginTabs };
|
module.exports = { patchSettingsModal, unpatchSettingsModal, reconcilePluginTabs };
|
||||||
|
|
@ -2,10 +2,14 @@ const { setIcon } = require("obsidian");
|
||||||
const { findGroupByTitle } = require("./settings-ui");
|
const { findGroupByTitle } = require("./settings-ui");
|
||||||
const { isIgnisPlugin } = require("../plugin-registry");
|
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.
|
// Tracks which plugin IDs have nav items we created.
|
||||||
const ownedPluginIds = new Set();
|
const ownedPluginIds = new Set();
|
||||||
|
|
||||||
function addPluginNavItem(pluginId, setting, corePluginsItems, ignisNavEls) {
|
function addPluginNavItem(pluginId, setting, corePluginsItems) {
|
||||||
const tab = setting.pluginTabs.find((t) => t.id === pluginId);
|
const tab = setting.pluginTabs.find((t) => t.id === pluginId);
|
||||||
|
|
||||||
if (!tab) {
|
if (!tab) {
|
||||||
|
|
@ -41,16 +45,16 @@ function addPluginNavItem(pluginId, setting, corePluginsItems, ignisNavEls) {
|
||||||
|
|
||||||
corePluginsItems.appendChild(nav);
|
corePluginsItems.appendChild(nav);
|
||||||
ownedPluginIds.add(pluginId);
|
ownedPluginIds.add(pluginId);
|
||||||
ignisNavEls.set(pluginId, nav);
|
allIgnisNavEls.set(pluginId, nav);
|
||||||
}
|
}
|
||||||
|
|
||||||
function removePluginNavItem(pluginId, ignisNavEls) {
|
function removePluginNavItem(pluginId) {
|
||||||
const nav = ignisNavEls.get(pluginId);
|
const nav = allIgnisNavEls.get(pluginId);
|
||||||
|
|
||||||
if (nav && ownedPluginIds.has(pluginId)) {
|
if (nav && ownedPluginIds.has(pluginId)) {
|
||||||
nav.remove();
|
nav.remove();
|
||||||
ownedPluginIds.delete(pluginId);
|
ownedPluginIds.delete(pluginId);
|
||||||
ignisNavEls.delete(pluginId);
|
allIgnisNavEls.delete(pluginId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,11 +120,11 @@ function hideIgnisNavFromCommunityGroup(setting) {
|
||||||
communityGroup.style.display = hasVisible ? "" : "none";
|
communityGroup.style.display = hasVisible ? "" : "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideCorePluginsGroupIfEmpty(ignisNavEls) {
|
function hideCorePluginsGroupIfEmpty() {
|
||||||
let hasConnected = false;
|
let hasConnected = false;
|
||||||
|
|
||||||
for (const id of ownedPluginIds) {
|
for (const id of ownedPluginIds) {
|
||||||
const nav = ignisNavEls.get(id);
|
const nav = allIgnisNavEls.get(id);
|
||||||
|
|
||||||
if (nav?.isConnected) {
|
if (nav?.isConnected) {
|
||||||
hasConnected = true;
|
hasConnected = true;
|
||||||
|
|
@ -140,15 +144,15 @@ function hideCorePluginsGroupIfEmpty(ignisNavEls) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupPluginTabs(setting, corePluginsItems, ignisNavEls) {
|
function setupPluginTabs(setting, corePluginsItems) {
|
||||||
for (const tab of setting.pluginTabs) {
|
for (const tab of setting.pluginTabs) {
|
||||||
if (isIgnisPlugin(tab.id) && tab.id !== "ignis-bridge") {
|
if (isIgnisPlugin(tab.id) && tab.id !== "ignis-bridge") {
|
||||||
addPluginNavItem(tab.id, setting, corePluginsItems, ignisNavEls);
|
addPluginNavItem(tab.id, setting, corePluginsItems);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hideIgnisNavFromCommunityGroup(setting);
|
hideIgnisNavFromCommunityGroup(setting);
|
||||||
hideCorePluginsGroupIfEmpty(ignisNavEls);
|
hideCorePluginsGroupIfEmpty();
|
||||||
|
|
||||||
const communityGroup = findGroupByTitle(
|
const communityGroup = findGroupByTitle(
|
||||||
setting.tabHeadersEl,
|
setting.tabHeadersEl,
|
||||||
|
|
@ -159,12 +163,12 @@ function setupPluginTabs(setting, corePluginsItems, ignisNavEls) {
|
||||||
const observer = new MutationObserver(() => {
|
const observer = new MutationObserver(() => {
|
||||||
for (const tab of setting.pluginTabs) {
|
for (const tab of setting.pluginTabs) {
|
||||||
if (isIgnisPlugin(tab.id) && tab.id !== "ignis-bridge") {
|
if (isIgnisPlugin(tab.id) && tab.id !== "ignis-bridge") {
|
||||||
addPluginNavItem(tab.id, setting, corePluginsItems, ignisNavEls);
|
addPluginNavItem(tab.id, setting, corePluginsItems);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hideIgnisNavFromCommunityGroup(setting);
|
hideIgnisNavFromCommunityGroup(setting);
|
||||||
hideCorePluginsGroupIfEmpty(ignisNavEls);
|
hideCorePluginsGroupIfEmpty();
|
||||||
});
|
});
|
||||||
|
|
||||||
observer.observe(communityGroup, { childList: true, subtree: true });
|
observer.observe(communityGroup, { childList: true, subtree: true });
|
||||||
|
|
@ -186,7 +190,7 @@ function setupPluginTabs(setting, corePluginsItems, ignisNavEls) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function reconcilePluginTabs(setting, ignisNavEls) {
|
function reconcilePluginTabs(setting) {
|
||||||
const corePluginsGroup = findGroupByTitle(
|
const corePluginsGroup = findGroupByTitle(
|
||||||
setting.tabHeadersEl,
|
setting.tabHeadersEl,
|
||||||
"Ignis Core Plugins",
|
"Ignis Core Plugins",
|
||||||
|
|
@ -212,16 +216,16 @@ function reconcilePluginTabs(setting, ignisNavEls) {
|
||||||
|
|
||||||
for (const id of ownedPluginIds) {
|
for (const id of ownedPluginIds) {
|
||||||
if (!activeIds.has(id)) {
|
if (!activeIds.has(id)) {
|
||||||
removePluginNavItem(id, ignisNavEls);
|
removePluginNavItem(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const id of activeIds) {
|
for (const id of activeIds) {
|
||||||
addPluginNavItem(id, setting, corePluginsItems, ignisNavEls);
|
addPluginNavItem(id, setting, corePluginsItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
hideIgnisNavFromCommunityGroup(setting);
|
hideIgnisNavFromCommunityGroup(setting);
|
||||||
hideCorePluginsGroupIfEmpty(ignisNavEls);
|
hideCorePluginsGroupIfEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearOwnedPluginIds() {
|
function clearOwnedPluginIds() {
|
||||||
|
|
@ -229,6 +233,7 @@ function clearOwnedPluginIds() {
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
allIgnisNavEls,
|
||||||
setupPluginTabs,
|
setupPluginTabs,
|
||||||
reconcilePluginTabs,
|
reconcilePluginTabs,
|
||||||
hideIgnisFromCommunityPlugins,
|
hideIgnisFromCommunityPlugins,
|
||||||
|
|
@ -1,18 +1,10 @@
|
||||||
const { Setting, Notice } = require("obsidian");
|
const { Setting, Notice } = require("obsidian");
|
||||||
|
const { reconcilePluginTabs } = require("./plugin-tabs");
|
||||||
|
|
||||||
function getVaultId() {
|
function getVaultId() {
|
||||||
return window.__currentVaultId || "";
|
return window.__currentVaultId || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshPluginCache(bundledPluginId) {
|
|
||||||
const pluginPath = `.obsidian/plugins/${bundledPluginId}`;
|
|
||||||
const fs = require("fs");
|
|
||||||
|
|
||||||
if (fs._refreshSubtree) {
|
|
||||||
await fs._refreshSubtree(pluginPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchPlugins() {
|
async function fetchPlugins() {
|
||||||
const res = await fetch("/api/plugins");
|
const res = await fetch("/api/plugins");
|
||||||
|
|
||||||
|
|
@ -23,7 +15,7 @@ async function fetchPlugins() {
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function togglePlugin(pluginId, enable, app) {
|
async function togglePlugin(pluginId, enable) {
|
||||||
const action = enable ? "enable" : "disable";
|
const action = enable ? "enable" : "disable";
|
||||||
const vaultId = getVaultId();
|
const vaultId = getVaultId();
|
||||||
|
|
||||||
|
|
@ -41,25 +33,10 @@ async function togglePlugin(pluginId, enable, app) {
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function activateBundledPlugin(bundledPluginId, enable, app) {
|
|
||||||
if (!bundledPluginId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const plugins = app.plugins;
|
|
||||||
|
|
||||||
if (enable) {
|
|
||||||
await plugins.loadManifests();
|
|
||||||
await plugins.enablePluginAndSave(bundledPluginId);
|
|
||||||
} else {
|
|
||||||
await plugins.disablePluginAndSave(bundledPluginId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function display(containerEl, app) {
|
function display(containerEl, app) {
|
||||||
containerEl.createEl("h2", { text: "Ignis Core Plugins" });
|
containerEl.createEl("h2", { text: "Ignis Core Plugins" });
|
||||||
|
|
||||||
const descEl = containerEl.createEl("p", {
|
containerEl.createEl("p", {
|
||||||
text:
|
text:
|
||||||
"Ignis plugins extend server functionality and run alongside your vaults. " +
|
"Ignis plugins extend server functionality and run alongside your vaults. " +
|
||||||
"They are separate from Obsidian's built-in plugins.",
|
"They are separate from Obsidian's built-in plugins.",
|
||||||
|
|
@ -92,28 +69,16 @@ function display(containerEl, app) {
|
||||||
toggle.setValue(enabled);
|
toggle.setValue(enabled);
|
||||||
toggle.onChange(async (value) => {
|
toggle.onChange(async (value) => {
|
||||||
try {
|
try {
|
||||||
await togglePlugin(plugin.id, value, app);
|
await togglePlugin(plugin.id, value);
|
||||||
|
|
||||||
if (value && plugin.bundledPluginId) {
|
|
||||||
await refreshPluginCache(plugin.bundledPluginId);
|
|
||||||
}
|
|
||||||
|
|
||||||
await activateBundledPlugin(
|
|
||||||
plugin.bundledPluginId,
|
|
||||||
value,
|
|
||||||
app,
|
|
||||||
);
|
|
||||||
|
|
||||||
new Notice(
|
new Notice(
|
||||||
`${plugin.name} ${value ? "enabled" : "disabled"} for this vault.`,
|
`${plugin.name} ${value ? "enabled" : "disabled"} for this vault.`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Give Obsidian a moment to update its plugin tabs,
|
// The server's WS broadcast drives the actual load/unload via virtual-plugin-loader.
|
||||||
// then reconcile our sidebar groups.
|
// Reconcile the settings sidebar so the new plugin's settings tab gets grouped correctly.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (typeof window.__ignisReconcilePluginTabs === "function") {
|
reconcilePluginTabs(app.setting);
|
||||||
window.__ignisReconcilePluginTabs(app.setting);
|
|
||||||
}
|
|
||||||
}, 100);
|
}, 100);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
new Notice(`Failed: ${e.message}`);
|
new Notice(`Failed: ${e.message}`);
|
||||||
35
packages/bridge/src/status-bar.js
Normal file
35
packages/bridge/src/status-bar.js
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
const STATUS_LABELS = {
|
||||||
|
open: "Ignis server: Connected",
|
||||||
|
connecting: "Ignis server: Connecting...",
|
||||||
|
closed: "Ignis server: Disconnected",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_DOT_CLASSES = {
|
||||||
|
open: "ignis-statusbar-connected",
|
||||||
|
connecting: "ignis-statusbar-connecting",
|
||||||
|
closed: "ignis-statusbar-disconnected",
|
||||||
|
};
|
||||||
|
|
||||||
|
function initStatusBar(plugin) {
|
||||||
|
const ws = window.__ignis.ws;
|
||||||
|
|
||||||
|
const item = plugin.addStatusBarItem();
|
||||||
|
item.addClass("ignis-statusbar-item");
|
||||||
|
|
||||||
|
const dot = item.createEl("span", {
|
||||||
|
cls: "ignis-statusbar-dot",
|
||||||
|
});
|
||||||
|
|
||||||
|
item.setAttribute("data-tooltip-position", "top");
|
||||||
|
|
||||||
|
function render(state) {
|
||||||
|
dot.className = `ignis-statusbar-dot ${STATUS_DOT_CLASSES[state] || STATUS_DOT_CLASSES.closed}`;
|
||||||
|
item.setAttribute("aria-label", STATUS_LABELS[state] || STATUS_LABELS.closed);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(ws.isOpen() ? "open" : "closed");
|
||||||
|
|
||||||
|
return ws.onStateChange(render);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { initStatusBar };
|
||||||
|
|
@ -54,12 +54,17 @@
|
||||||
.ignis-update-indicator {
|
.ignis-update-indicator {
|
||||||
font-size: var(--font-ui-smaller);
|
font-size: var(--font-ui-smaller);
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ignis-update-indicator.ignis-update-available {
|
.ignis-update-indicator.ignis-update-available {
|
||||||
color: var(--text-accent);
|
color: var(--text-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ignis-update-indicator.ignis-update-available:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.ignis-github-link {
|
.ignis-github-link {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
display: flex;
|
display: flex;
|
||||||
10
packages/server-core/package.json
Normal file
10
packages/server-core/package.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"name": "@ignis/server-core",
|
||||||
|
"version": "0.0.0-internal",
|
||||||
|
"private": true,
|
||||||
|
"main": "src/index.js",
|
||||||
|
"dependencies": {
|
||||||
|
"chokidar": "^3.6.0",
|
||||||
|
"ws": "^8.16.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
15
packages/server-core/src/index.js
Normal file
15
packages/server-core/src/index.js
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
const writeCoalescer = require("./write-coalescer");
|
||||||
|
const watcher = require("./watcher");
|
||||||
|
const { setupWebSocket } = require("./ws");
|
||||||
|
const {
|
||||||
|
encodeContentDispositionFilename,
|
||||||
|
resolveVaultPath,
|
||||||
|
} = require("./path-utils");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
writeCoalescer,
|
||||||
|
watcher,
|
||||||
|
setupWebSocket,
|
||||||
|
encodeContentDispositionFilename,
|
||||||
|
resolveVaultPath,
|
||||||
|
};
|
||||||
69
packages/server-core/src/path-utils.js
Normal file
69
packages/server-core/src/path-utils.js
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a filename for use in Content-Disposition header.
|
||||||
|
* Handles non-ASCII characters and special characters to prevent header injection.
|
||||||
|
* Uses RFC 5987 encoding for filename* parameter when needed.
|
||||||
|
*/
|
||||||
|
function encodeContentDispositionFilename(filename) {
|
||||||
|
const hasNonASCII = /[^\x00-\x7F]/.test(filename);
|
||||||
|
|
||||||
|
// Escape quotes and backslashes in ASCII filename
|
||||||
|
const escapedFilename = filename.replace(/["\\ ]/g, function (match) {
|
||||||
|
if (match === '"') return '\\"';
|
||||||
|
if (match === "\\") return "\\\\";
|
||||||
|
return match;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove any control characters that could cause header injection
|
||||||
|
const sanitizedFilename = escapedFilename.replace(/[\x00-\x1F\x7F]/g, "");
|
||||||
|
|
||||||
|
if (!hasNonASCII) {
|
||||||
|
// Simple ASCII filename - use standard format
|
||||||
|
return `attachment; filename="${sanitizedFilename}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-ASCII filename - use RFC 5987 encoding
|
||||||
|
// Encode using percent-encoding for UTF-8
|
||||||
|
const encodedFilename = encodeURIComponent(filename)
|
||||||
|
.replace(/['()]/g, function (c) {
|
||||||
|
return "%" + c.charCodeAt(0).toString(16).toUpperCase();
|
||||||
|
})
|
||||||
|
.replace(/\*/g, "%2A");
|
||||||
|
|
||||||
|
// Provide both filename (ASCII fallback) and filename* (UTF-8 encoded)
|
||||||
|
// For fallback, replace non-ASCII with underscores
|
||||||
|
const asciiFallback = filename
|
||||||
|
.replace(/[^\x00-\x7F]/g, "_")
|
||||||
|
.replace(/["\\ ]/g, function (match) {
|
||||||
|
if (match === '"') return '\\"';
|
||||||
|
if (match === "\\") return "\\\\";
|
||||||
|
return match;
|
||||||
|
});
|
||||||
|
|
||||||
|
return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodedFilename}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a client-provided path to an absolute path within a vault.
|
||||||
|
// Strips leading slashes so paths from the client are always treated as relative to the vault root.
|
||||||
|
// Rejects nullish input so missing-field bugs in callers don't silently target the vault root.
|
||||||
|
function resolveVaultPath(vaultRoot, relativePath) {
|
||||||
|
if (relativePath === null || relativePath === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleaned = relativePath.replace(/^\/+/, "");
|
||||||
|
const resolved = path.resolve(vaultRoot, cleaned);
|
||||||
|
|
||||||
|
const resolvedRoot = path.resolve(vaultRoot);
|
||||||
|
|
||||||
|
if (
|
||||||
|
resolved !== resolvedRoot &&
|
||||||
|
!resolved.startsWith(resolvedRoot + path.sep)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { encodeContentDispositionFilename, resolveVaultPath };
|
||||||
|
|
@ -5,10 +5,18 @@
|
||||||
// Buffered writes respond to the HTTP client right away with synthetic mtime/size. Otherwise the browser's per-host connection cap blocks unrelated reads while writes sit in the buffer.
|
// Buffered writes respond to the HTTP client right away with synthetic mtime/size. Otherwise the browser's per-host connection cap blocks unrelated reads while writes sit in the buffer.
|
||||||
|
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const config = require("./config");
|
|
||||||
|
|
||||||
const FLUSH_TIMEOUT_MS = 10000;
|
const FLUSH_TIMEOUT_MS = 10000;
|
||||||
|
|
||||||
|
// Coalesce window in ms. 0 disables coalescing. Set via configure({ writeCoalesceMs }).
|
||||||
|
let writeCoalesceMs = 0;
|
||||||
|
|
||||||
|
function configure(opts) {
|
||||||
|
if (typeof opts?.writeCoalesceMs === "number") {
|
||||||
|
writeCoalesceMs = opts.writeCoalesceMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// absPath -> timestamp of last completed (or scheduled) write
|
// absPath -> timestamp of last completed (or scheduled) write
|
||||||
const lastWriteTime = new Map();
|
const lastWriteTime = new Map();
|
||||||
|
|
||||||
|
|
@ -51,7 +59,7 @@ function scheduleFlush(absPath) {
|
||||||
}
|
}
|
||||||
|
|
||||||
clearTimeout(entry.timer);
|
clearTimeout(entry.timer);
|
||||||
entry.timer = setTimeout(() => flushEntry(absPath), config.writeCoalesceMs);
|
entry.timer = setTimeout(() => flushEntry(absPath), writeCoalesceMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
function estimateSize(data, encoding) {
|
function estimateSize(data, encoding) {
|
||||||
|
|
@ -67,7 +75,7 @@ function estimateSize(data, encoding) {
|
||||||
* Fresh writes resolve with real mtime/size once data is on disk. Buffered writes resolve immediately with synthetic values; the disk flush happens later when the debounce timer fires.
|
* Fresh writes resolve with real mtime/size once data is on disk. Buffered writes resolve immediately with synthetic values; the disk flush happens later when the debounce timer fires.
|
||||||
*/
|
*/
|
||||||
async function writeCoalesced(absPath, data, encoding) {
|
async function writeCoalesced(absPath, data, encoding) {
|
||||||
const windowMs = config.writeCoalesceMs;
|
const windowMs = writeCoalesceMs;
|
||||||
const last = lastWriteTime.get(absPath);
|
const last = lastWriteTime.get(absPath);
|
||||||
|
|
||||||
// Fast path: coalescing disabled or far enough from the last write.
|
// Fast path: coalescing disabled or far enough from the last write.
|
||||||
|
|
@ -140,9 +148,7 @@ async function flushAll() {
|
||||||
|
|
||||||
const timeout = new Promise((resolve) => {
|
const timeout = new Promise((resolve) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
console.warn(
|
console.warn("[write-coalesce] Flush timeout. Some writes may be lost");
|
||||||
"[write-coalesce] Flush timeout -- some writes may be lost",
|
|
||||||
);
|
|
||||||
resolve();
|
resolve();
|
||||||
}, FLUSH_TIMEOUT_MS);
|
}, FLUSH_TIMEOUT_MS);
|
||||||
});
|
});
|
||||||
|
|
@ -159,4 +165,4 @@ function _reset() {
|
||||||
lastWriteTime.clear();
|
lastWriteTime.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { writeCoalesced, getPending, flushAll, _reset };
|
module.exports = { writeCoalesced, getPending, flushAll, configure, _reset };
|
||||||
|
|
@ -6,15 +6,13 @@ import os from "os";
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const coalescer = require("./write-coalescer.js");
|
const coalescer = require("./write-coalescer.js");
|
||||||
const config = require("./config.js");
|
|
||||||
|
|
||||||
const SHORT_WINDOW_MS = 50;
|
const SHORT_WINDOW_MS = 50;
|
||||||
const originalWindow = config.writeCoalesceMs;
|
|
||||||
|
|
||||||
let tmpDir;
|
let tmpDir;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
config.writeCoalesceMs = SHORT_WINDOW_MS;
|
coalescer.configure({ writeCoalesceMs: SHORT_WINDOW_MS });
|
||||||
coalescer._reset();
|
coalescer._reset();
|
||||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "coalesce-test-"));
|
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "coalesce-test-"));
|
||||||
});
|
});
|
||||||
|
|
@ -22,7 +20,7 @@ beforeEach(async () => {
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
coalescer._reset();
|
coalescer._reset();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
config.writeCoalesceMs = originalWindow;
|
coalescer.configure({ writeCoalesceMs: 0 });
|
||||||
await fs.promises.rm(tmpDir, { recursive: true, force: true });
|
await fs.promises.rm(tmpDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
214
packages/server-core/src/ws.js
Normal file
214
packages/server-core/src/ws.js
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
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 };
|
||||||
6
packages/services/package.json
Normal file
6
packages/services/package.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"name": "@ignis/services",
|
||||||
|
"version": "0.0.0-internal",
|
||||||
|
"private": true,
|
||||||
|
"main": "src/index.js"
|
||||||
|
}
|
||||||
1
packages/services/src/index.js
Normal file
1
packages/services/src/index.js
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { vaultService } from "./vault-service.js";
|
||||||
28
packages/shim/build.js
Normal file
28
packages/shim/build.js
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
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",
|
||||||
|
});
|
||||||
18
packages/shim/package.json
Normal file
18
packages/shim/package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"name": "@ignis/shim",
|
||||||
|
"version": "0.0.0-internal",
|
||||||
|
"private": true,
|
||||||
|
"main": "src/loader.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "node build.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ignis/services": "*",
|
||||||
|
"@noble/hashes": "^2.2.0",
|
||||||
|
"pako": "^2.1.0",
|
||||||
|
"path-browserify": "^1.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"esbuild": "^0.20.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue