Refactor: remove desktop/Tauri bundle, add i18n, CI/CD pipeline
Nightly Build / build (push) Failing after 6s

- Remove desktop (Tauri) bundle: docs/desktop.md, icons/, tauri.conf.json,
  src/desktop/mod.rs, gen/schemas/
- Remove build.rs (no longer needed)
- Add i18n system (crates/core-api, plugin-mobile-connector, web)
- Refactor config system (src/config.rs, boot_format.rs)
- Add mobile connector features (app, router, device pairing)
- Plugin system improvements (skald-core)
- Update dependencies (Cargo.lock, Cargo.toml)
- CI/CD: Gitea Actions workflows (nightly + release), package.sh,
  verify-version.sh, builds.skaldagent.net config
This commit is contained in:
2026-07-19 22:35:06 +01:00
parent ba911ae8cb
commit fb3eeeeec6
54 changed files with 823 additions and 8002 deletions
+7 -10
View File
@@ -2,10 +2,8 @@
# #
# Why 10.15: the default `whisper-local` feature compiles whisper.cpp (C++), # Why 10.15: the default `whisper-local` feature compiles whisper.cpp (C++),
# whose ggml-backend-reg.cpp uses `std::filesystem::path`, introduced in macOS # whose ggml-backend-reg.cpp uses `std::filesystem::path`, introduced in macOS
# 10.15. `cargo tauri build` injects MACOSX_DEPLOYMENT_TARGET=10.13 (Tauri's # 10.15. On an older deployment target that symbol is marked *unavailable* → the
# default, from bundle.macOS.minimumSystemVersion), on which that symbol is # ggml build fails with ~20 "'path' is unavailable" errors.
# marked *unavailable* → the ggml build fails with ~20 "'path' is unavailable"
# errors.
# #
# Two variables are needed because the C++ compile ends up with TWO # Two variables are needed because the C++ compile ends up with TWO
# `-mmacosx-version-min` flags and clang lets the LAST one win: # `-mmacosx-version-min` flags and clang lets the LAST one win:
@@ -15,13 +13,12 @@
# * CMAKE_OSX_DEPLOYMENT_TARGET → whisper-rs-sys's build.rs forwards any # * CMAKE_OSX_DEPLOYMENT_TARGET → whisper-rs-sys's build.rs forwards any
# `CMAKE_*` env var to cmake as `-DCMAKE_OSX_DEPLOYMENT_TARGET=…`, which # `CMAKE_*` env var to cmake as `-DCMAKE_OSX_DEPLOYMENT_TARGET=…`, which
# sets CMake's OWN `-mmacosx-version-min` and overrides any value cached in # sets CMake's OWN `-mmacosx-version-min` and overrides any value cached in
# a stale CMakeCache.txt. Without this, CMake's cached 10.13 wins and the # a stale CMakeCache.txt. Without this, a stale cached target could win and
# CFLAGS' 10.15 is ignored. # the CFLAGS' 10.15 be ignored.
# #
# `force = true` makes cargo override whatever the Tauri CLI (or the ambient # `force = true` makes cargo override whatever the ambient environment sets, so
# environment) sets, so both are deterministically 10.15 regardless of Tauri. # both are deterministically 10.15. Both variables are macOS-only; ignored on
# Keep in sync with tauri.conf.json > bundle > macOS > minimumSystemVersion. # Linux/Windows builds.
# Both variables are macOS-only; ignored on Linux/Windows builds.
[env] [env]
MACOSX_DEPLOYMENT_TARGET = { value = "10.15", force = true } MACOSX_DEPLOYMENT_TARGET = { value = "10.15", force = true }
CMAKE_OSX_DEPLOYMENT_TARGET = { value = "10.15", force = true } CMAKE_OSX_DEPLOYMENT_TARGET = { value = "10.15", force = true }
+48
View File
@@ -0,0 +1,48 @@
name: Nightly Build
on:
push:
branches:
- main
jobs:
build:
runs-on: linux-amd64
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Build native (linux/amd64)
run: ./build.sh
- name: Cross-compile (linux/arm64)
env:
CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc
AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar
run: |
cargo build --release --target aarch64-unknown-linux-gnu
cargo build --release -p skald-setup --target aarch64-unknown-linux-gnu
- name: Package amd64
run: |
./scripts/package.sh \
--version nightly \
--arch amd64 \
--target-dir target/release \
--output dist/
- name: Package arm64
run: |
./scripts/package.sh \
--version nightly \
--arch arm64 \
--target-dir target/aarch64-unknown-linux-gnu/release \
--output dist/
- name: Deploy to builds.skaldagent.net
run: |
mkdir -p /var/www/builds.skaldagent.net/nightly
cp dist/*.tar.gz /var/www/builds.skaldagent.net/nightly/
echo "[nightly] Deployed:"
ls -lh /var/www/builds.skaldagent.net/nightly/
+81
View File
@@ -0,0 +1,81 @@
name: Release
on:
push:
branches:
- release
pull_request:
branches:
- release
jobs:
# ── PR check: verify the version is not already built ───────────────────────
verify-version:
if: github.event_name == 'pull_request'
runs-on: linux-amd64
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Verify version is new
run: ./scripts/verify-version.sh --builds-dir /var/www/builds.skaldagent.net
# ── Push/merge: build, package, and deploy the release ──────────────────────
release:
if: github.event_name == 'push'
runs-on: linux-amd64
outputs:
version: ${{ steps.extract-version.outputs.version }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Extract version from Cargo.toml
id: extract-version
run: |
VER="v$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')"
echo "version=$VER" >> "$GITHUB_OUTPUT"
echo "[release] Building version $VER"
# Also run verify-version on push to catch any race (belt-and-suspenders)
- name: Verify version is new
run: ./scripts/verify-version.sh --builds-dir /var/www/builds.skaldagent.net
- name: Build native (linux/amd64)
run: ./build.sh
- name: Cross-compile (linux/arm64)
env:
CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc
AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar
run: |
cargo build --release --target aarch64-unknown-linux-gnu
cargo build --release -p skald-setup --target aarch64-unknown-linux-gnu
- name: Package amd64
run: |
./scripts/package.sh \
--version "${{ steps.extract-version.outputs.version }}" \
--arch amd64 \
--target-dir target/release \
--output dist/
- name: Package arm64
run: |
./scripts/package.sh \
--version "${{ steps.extract-version.outputs.version }}" \
--arch arm64 \
--target-dir target/aarch64-unknown-linux-gnu/release \
--output dist/
- name: Deploy to builds.skaldagent.net
run: |
VERSION="${{ steps.extract-version.outputs.version }}"
TARGET="/var/www/builds.skaldagent.net/releases/${VERSION}"
mkdir -p "$TARGET"
cp dist/*.tar.gz "$TARGET/"
echo "[release] Deployed $VERSION:"
ls -lh "$TARGET/"
+10 -19
View File
@@ -45,15 +45,15 @@ The application core is the `skald-core` crate; the binaries are **shells** arou
| Crate | Role | | Crate | Role |
| ---- | ---- | | ---- | ---- |
| `crates/skald-core/` | Storage, identity, crypto, LLM stack, tools, MCP, sessions. Knows nothing about what runs it: no Tauri, no HTTP server, and **no concrete plugin crate**`PluginManager` only ever sees `Arc<dyn Plugin>` from `core-api` | | `crates/skald-core/` | Storage, identity, crypto, LLM stack, tools, MCP, sessions. Knows nothing about what runs it: no HTTP server and **no concrete plugin crate**`PluginManager` only ever sees `Arc<dyn Plugin>` from `core-api` |
| `skald` (root, `src/`) | The server shell: `main.rs`, the Axum `frontend/`, the Tauri `desktop/`, `config.rs`. Constructs the plugin list and hands it to `Skald::new` | | `skald` (root, `src/`) | The server shell: `main.rs`, the Axum `frontend/`, `config.rs`. Constructs the plugin list and hands it to `Skald::new`. Runs headless as a background daemon under the `run.sh` supervisor |
| `crates/skald-setup/` | Guided first-run setup — a terminal shell over `skald-core`. Creates the first admin via `UserManager::register_user` (asking interface language, whether to encrypt — default yes — and password). The chosen language becomes the instance default (`ui_locale`). A separate binary so the server never links TTY-prompt deps, and so a future GUI installer is a third shell over the same `UserManager`. `run.sh` runs it before the server loop; it prompts only when `users` is empty **and** stdin is a terminal, otherwise a no-op. `--check` reports readiness by exit code (0 done, 1 needed) | | `crates/skald-setup/` | Guided first-run setup — a terminal shell over `skald-core`. Creates the first admin via `UserManager::register_user` (asking interface language, whether to encrypt — default yes — and password). The chosen language becomes the instance default (`ui_locale`). A separate binary so the server never links TTY-prompt deps, and so a future GUI installer is a third shell over the same `UserManager`. `run.sh` runs it before the server loop; it prompts only when `users` is empty **and** stdin is a terminal, otherwise a no-op. `--check` reports readiness by exit code (0 done, 1 needed) |
| `crates/core-api/` | The contracts both sides share: `Plugin`, `Tool`, event buses, provider types | | `crates/core-api/` | The contracts both sides share: `Plugin`, `Tool`, event buses, provider types |
Two rules keep the boundary real, and both are enforced by the compiler: Two rules keep the boundary real, and both are enforced by the compiler:
- **The core never names a plugin.** A plugin contributes tools through `Plugin::tools(self: Arc<Self>)` — the sibling of `http_router()` — so nothing in the core has to downcast to a concrete type. Naming one would drag every plugin in the tree into the core, including a C build via `plugin-transcribe-whisper-local`. - **The core never names a plugin.** A plugin contributes tools through `Plugin::tools(self: Arc<Self>)` — the sibling of `http_router()` — so nothing in the core has to downcast to a concrete type. Naming one would drag every plugin in the tree into the core, including a C build via `plugin-transcribe-whisper-local`.
- **The core never learns about the process shell.** The `restart` tool defaults to the supervisor protocol (`exit(-1)`); a shell with different needs installs `tools::restart::set_restart_handler` at startup. The Tauri shell installs teardown-and-respawn there. This is why `skald-core` has no `desktop` feature. - **The core never learns about the process shell.** The `restart` tool defaults to the supervisor protocol (`exit(-1)`); a shell with different needs (e.g. one with no supervisor) can install its own `tools::restart::set_restart_handler` at startup. The default server shell installs none and relies on `run.sh`. The seam stays even though nothing installs a handler today.
**Plugin visibility & per-user config.** Plugins are managed from the `#plugins` page, not only by the agent. Enable/disable + instance config + access grants are gated by the `plugin.manage` capability (admin-only by construction). Visibility is **opt-in**: a row in `plugin_access(plugin_id, user_id)` grants a user sight of an enabled plugin (`plugin_id` is bare TEXT, never a FK — a `plugins` row exists only after the first toggle). A plugin with a non-empty `Plugin::user_config_schema()` exposes per-user settings, stored in `plugin_user_configs` (**admin-readable system.db — never secrets**) and applied through the `Plugin::update_user_config` hook, whose default just stores the blob via the `PluginUserConfigApi` on `PluginContext.user_config`. Telegram is the reference impl: the user pastes the bot's pairing code in their Plugins page, the override turns it into a `chat_id → user_id` binding (same write path as the `telegram_pairing` tool) and stores a `{linked, chat_id}` status blob for the UI. Endpoints: admin `GET/PUT /api/plugins[/{id}]` + `GET/PUT /api/plugins/{id}/access`; user `GET /api/plugins/mine` + `PUT /api/plugins/{id}/my-config`. **Plugin visibility & per-user config.** Plugins are managed from the `#plugins` page, not only by the agent. Enable/disable + instance config + access grants are gated by the `plugin.manage` capability (admin-only by construction). Visibility is **opt-in**: a row in `plugin_access(plugin_id, user_id)` grants a user sight of an enabled plugin (`plugin_id` is bare TEXT, never a FK — a `plugins` row exists only after the first toggle). A plugin with a non-empty `Plugin::user_config_schema()` exposes per-user settings, stored in `plugin_user_configs` (**admin-readable system.db — never secrets**) and applied through the `Plugin::update_user_config` hook, whose default just stores the blob via the `PluginUserConfigApi` on `PluginContext.user_config`. Telegram is the reference impl: the user pastes the bot's pairing code in their Plugins page, the override turns it into a `chat_id → user_id` binding (same write path as the `telegram_pairing` tool) and stores a `{linked, chat_id}` status blob for the UI. Endpoints: admin `GET/PUT /api/plugins[/{id}]` + `GET/PUT /api/plugins/{id}/access`; user `GET /api/plugins/mine` + `PUT /api/plugins/{id}/my-config`.
@@ -65,8 +65,7 @@ Two rules keep the boundary real, and both are enforced by the compiler:
| Path | Role | | Path | Role |
| ---- | ---- | | ---- | ---- |
| `src/main.rs` | Thin entry point: tracing → `Skald::new``WebFrontend::start` → shutdown. Branches on the `desktop` feature: under `--features desktop` enters `desktop::run()` (Tauri event loop) instead of blocking on a tokio runtime. Exposes `run_backend()` / `shutdown_backend()` shared by both entry points | | `src/main.rs` | Thin entry point: tracing → `Skald::new``WebFrontend::start` → shutdown. Builds a tokio runtime and blocks on `async_main`, which runs the backend until a SIGINT/SIGTERM. Exposes `run_backend()` / `shutdown_backend()` |
| `src/desktop/mod.rs` | Tauri shell — **only compiled under `--features desktop`**. Builds the system-tray icon + menu (`Open` / `Quit`), creates the main `WebviewWindow` (URL = `http://127.0.0.1:{config.port}`), spawns the backend on Tauri's shared tokio runtime, handles graceful shutdown. Holds the `OnceLock<AppHandle>`, and installs the core's restart handler. See [docs/desktop.md](docs/desktop.md) |
| `crates/skald-core/src/skald/` | `Skald` — headless application core. `mod.rs` (struct + staged `new()` / `shutdown()`), `runtime.rs` (cross-cutting `Runtime` context), `bundles.rs` (8 domain bundles + `build()`), `wiring.rs` (`wire()` + `spawn_background()`), `supervisor.rs` (`TaskSupervisor`), `accessors.rs` (per-manager accessor facade — the API surface the frontend uses) | | `crates/skald-core/src/skald/` | `Skald` — headless application core. `mod.rs` (struct + staged `new()` / `shutdown()`), `runtime.rs` (cross-cutting `Runtime` context), `bundles.rs` (8 domain bundles + `build()`), `wiring.rs` (`wire()` + `spawn_background()`), `supervisor.rs` (`TaskSupervisor`), `accessors.rs` (per-manager accessor facade — the API surface the frontend uses) |
| `crates/skald-core/src/session/handler/` | Core LLM loop — `mod.rs`, `llm_loop.rs` (`run_agent_turn`), `agent_dispatch.rs`, `dispatcher.rs`, `approval.rs`, `resume.rs`, `messages.rs`, `config.rs`, `interface_tools.rs`, `media.rs` (multimodal attachments — see below) | | `crates/skald-core/src/session/handler/` | Core LLM loop — `mod.rs`, `llm_loop.rs` (`run_agent_turn`), `agent_dispatch.rs`, `dispatcher.rs`, `approval.rs`, `resume.rs`, `messages.rs`, `config.rs`, `interface_tools.rs`, `media.rs` (multimodal attachments — see below) |
| `crates/skald-core/src/session/manager.rs` | Creates/retrieves `ChatSessionHandler` per session | | `crates/skald-core/src/session/manager.rs` | Creates/retrieves `ChatSessionHandler` per session |
@@ -80,7 +79,7 @@ Two rules keep the boundary real, and both are enforced by the compiler:
| `crates/skald-core/src/db/` | sqlx SQLite — see below | | `crates/skald-core/src/db/` | sqlx SQLite — see below |
| `crates/skald-core/src/users/` | `UserManager` (§11): user directory CRUD on `system.db`, credential check, and the map `userid → SqlitePool` of **unlocked** databases. The pool *is* the unlock token — its connect options carry the DEK as SQLCipher's raw key, so an open pool means the key is in RAM (§9) and dropping it re-locks. Knows nothing about cookies: whatever maps an HTTP session to a user id sits above it | | `crates/skald-core/src/users/` | `UserManager` (§11): user directory CRUD on `system.db`, credential check, and the map `userid → SqlitePool` of **unlocked** databases. The pool *is* the unlock token — its connect options carry the DEK as SQLCipher's raw key, so an open pool means the key is in RAM (§9) and dropping it re-locks. Knows nothing about cookies: whatever maps an HTTP session to a user id sits above it |
| `crates/skald-core/src/crypto/` | Envelope encryption (§4/§5.1). A random 256-bit DEK encrypts `{userid}.db`; `users.database_password` holds it sealed with AES-256-GCM under `Argon2id(password, salt)`. **The AEAD tag is the password verifier** — one derivation both authenticates and yields the key, and no second hash sits in the admin-readable DB. Cleartext users store the Argon2id output directly, compared constant-time. Argon2 runs in `spawn_blocking` behind a 2-permit semaphore (256 MiB per derivation) | | `crates/skald-core/src/crypto/` | Envelope encryption (§4/§5.1). A random 256-bit DEK encrypts `{userid}.db`; `users.database_password` holds it sealed with AES-256-GCM under `Argon2id(password, salt)`. **The AEAD tag is the password verifier** — one derivation both authenticates and yields the key, and no second hash sits in the admin-readable DB. Cleartext users store the Argon2id output directly, compared constant-time. Argon2 runs in `spawn_blocking` behind a 2-permit semaphore (256 MiB per derivation) |
| `src/config.rs` | Loads `config.yml`; LLM clients, strength/use_cases, data root. Also hosts `bootstrap_data_dir()` — under the `desktop` feature, relocates the process cwd to a per-user data dir when running inside a `.app` bundle (no-op in dev mode and headless mode) | | `src/config.rs` | Loads `config.yml`; LLM clients, strength/use_cases, data root. All relative paths (db, logs, data, …) resolve against the launch cwd |
| `crates/skald-core/src/mcp/` | MCP runtimes + the `McpProvider` seam (§7): the shared host **global** runtime and the per-user **container** runtimes, unioned per session as `UserMcpView`. See the MCP connectors section | | `crates/skald-core/src/mcp/` | MCP runtimes + the `McpProvider` seam (§7): the shared host **global** runtime and the per-user **container** runtimes, unioned per session as `UserMcpView`. See the MCP connectors section |
| `crates/skald-core/src/plugin/` | Plugin system: discovery, enable/disable, tool registration, per-user access grants + per-user config | | `crates/skald-core/src/plugin/` | Plugin system: discovery, enable/disable, tool registration, per-user access grants + per-user config |
| `crates/skald-core/src/cron/` | Scheduled job runner | | `crates/skald-core/src/cron/` | Scheduled job runner |
@@ -213,10 +212,9 @@ Resolution is **source-agnostic**: the WS + Inbox paths resolve by `request_id`;
## Restart ## Restart
`restart` **no longer rebuilds anything**neither mode compiles. `restart` **no longer rebuilds anything**it does not compile.
- **Headless** (default): no handler installed, so `restart` calls `libc::_exit(-1)` (= exit code 255); `run.sh` re-executes the same binary *by path*. No restart handler is installed, so `restart` calls `libc::_exit(-1)` (= exit code 255); `run.sh` re-executes the same binary *by path*. (The `set_restart_handler` seam stays for a hypothetical shell without a supervisor, but nothing installs a handler today.)
- **Desktop** (`--features desktop`): the Tauri shell installs a handler via `tools::restart::set_restart_handler` — cleanup + respawn of the bundled binary + `exit(0)`. The core does not know Tauri exists.
Use it to pick up `config.yml` / `providers.yaml` / database changes, which are only read at startup. To load new **code**: `./build.sh`, then restart — the supervisor picks up the new binary on the next loop, since `build.sh` installs it with an atomic rename. Use it to pick up `config.yml` / `providers.yaml` / database changes, which are only read at startup. To load new **code**: `./build.sh`, then restart — the supervisor picks up the new binary on the next loop, since `build.sh` installs it with an atomic rename.
@@ -230,7 +228,7 @@ Use it to pick up `config.yml` / `providers.yaml` / database changes, which are
./run.sh # first-run setup, then the supervisor loop — never compiles ./run.sh # first-run setup, then the supervisor loop — never compiles
``` ```
`build.sh` builds and installs **both** binaries; forwarded args (e.g. `--features desktop`) go to the server only. `build.sh` builds and installs **both** binaries; any forwarded args go to the server only.
`run.sh` resolves the server binary as `$SKALD_BIN``bin/skald``target/release/skald`, and warns when sources are newer than it. Before the loop it runs `skald-setup` (found next to the server, or `$SKALD_SETUP_BIN`); a non-zero exit there — a failed or cancelled wizard — stops `run.sh` before the server starts. Server exit `0` stops the loop, `255` re-executes, anything else propagates. `run.sh` resolves the server binary as `$SKALD_BIN``bin/skald``target/release/skald`, and warns when sources are newer than it. Before the loop it runs `skald-setup` (found next to the server, or `$SKALD_SETUP_BIN`); a non-zero exit there — a failed or cancelled wizard — stops `run.sh` before the server starts. Server exit `0` stops the loop, `255` re-executes, anything else propagates.
@@ -238,15 +236,6 @@ Use it to pick up `config.yml` / `providers.yaml` / database changes, which are
Tracing filter: `RUST_LOG=skald=debug,info` Tracing filter: `RUST_LOG=skald=debug,info`
### Desktop bundle (Tauri)
```sh
cargo run --features desktop # dev: real window + tray, no bundle
cargo tauri build --features desktop # release bundle: .app / .exe / .AppImage
```
Requires `cargo install tauri-cli --version "^2"`. The `desktop` feature is default-off.
## Adding an agent ## Adding an agent
Create `agents/<id>/meta.json` and `agents/<id>/AGENT.md`. The agent is discovered at runtime (no restart needed for prompt edits). Optionally set `"client": "<name>"` in meta.json to pin a specific LLM. Create `agents/<id>/meta.json` and `agents/<id>/AGENT.md`. The agent is discovered at runtime (no restart needed for prompt edits). Optionally set `"client": "<name>"` in meta.json to pin a specific LLM.
@@ -279,6 +268,8 @@ All extend `LightElement` from `web/lib/base.js` (Lit). `ChatSession` (`web/lib/
**i18n** (`web/lib/i18n.js` + `web/i18n/{en,it,fr}.js`): `t(key)` helper, `I18nMixin` re-renders on `locale-changed`. Resolution order: user preference (`users.locale`, editable on the profile page) → instance default (registry config key `ui_locale`, editable by the admin in Settings — declared in `skald_core::i18n::config_set`) → English. **Server-side, never re-implement that chain**: `skald_core::i18n::resolve_locale(pool, user_locale)` is the one function (with `default_locale(pool)` and `language_name(locale)` for prompt rendering); they read through `db::config` because the bus only matters for writes and callers like `MessageBuilder` hold pools, not the manager. Pre-auth screens use the localStorage cache. Default locale is English. First-run setup asks the language in both shells — the console wizard writes `ui_locale` via `skald_core::i18n::set_default_locale` (no system bus exists there), the web setup page sends `locale` to `POST /api/setup/user`, which writes it through `GlobalConfigManager::set`. Supported locales are centralized in `skald_core::i18n::SUPPORTED_LOCALES` and enforced server-side on every write. Translated so far: chrome (sidebar/topbar), chat + approval cards, login/setup, profile, inbox; deep admin pages are still English (fallback is automatic per-key). Copy is the only place domain words may appear (§0.1). **i18n** (`web/lib/i18n.js` + `web/i18n/{en,it,fr}.js`): `t(key)` helper, `I18nMixin` re-renders on `locale-changed`. Resolution order: user preference (`users.locale`, editable on the profile page) → instance default (registry config key `ui_locale`, editable by the admin in Settings — declared in `skald_core::i18n::config_set`) → English. **Server-side, never re-implement that chain**: `skald_core::i18n::resolve_locale(pool, user_locale)` is the one function (with `default_locale(pool)` and `language_name(locale)` for prompt rendering); they read through `db::config` because the bus only matters for writes and callers like `MessageBuilder` hold pools, not the manager. Pre-auth screens use the localStorage cache. Default locale is English. First-run setup asks the language in both shells — the console wizard writes `ui_locale` via `skald_core::i18n::set_default_locale` (no system bus exists there), the web setup page sends `locale` to `POST /api/setup/user`, which writes it through `GlobalConfigManager::set`. Supported locales are centralized in `skald_core::i18n::SUPPORTED_LOCALES` and enforced server-side on every write. Translated so far: chrome (sidebar/topbar), chat + approval cards, login/setup, profile, inbox; deep admin pages are still English (fallback is automatic per-key). Copy is the only place domain words may appear (§0.1).
**Plugin & backend i18n** — two seams, both keyed the same way. A plugin **page fragment** (served from its own router) localizes client-side: it ships a `web/i18n.js` module (`export default { en, it, fr }`, keys namespaced `plugin.<id>.<key>`) and calls `addStrings(dicts)` (in `web/lib/i18n.js`) once at module load to merge into the host's shared `DICTS`, then uses the same `t()`/`I18nMixin` as the app (the fragment imports them from the absolute `/lib/i18n.js` — the *same* module instance the host uses, so `t()` and `locale-changed` are shared; no endpoint, no per-locale fetch — all locales ride in the fragment, so a language switch is instant). Mobile-connector is the reference: `common.js` registers the dict + re-exports `t`, and `MobileBase extends I18nMixin(LitElement)`. **Backend-generated strings** (a plugin's HTTP error/response text, notifications) go through `core_api::i18n`: a plugin declares `Plugin::i18n() -> Vec<LocaleBundle>` (mobile-connector loads them from embedded `i18n/{en,it,fr}.json` via `include_str!`), the `PluginManager` merges every plugin's bundles once at boot into an `I18nCatalog` (`skald_core::i18n`) and injects it as `PluginContext.i18n: Arc<dyn I18nApi>`. At request time the handler resolves the caller (`Caller.user_id` from the auth layer) and calls `i18n.for_user(user_id, key, args).await` — which reads `users.locale`, runs it through the same `resolve_locale` chain, and renders `locale → en → key` with `{name}` placeholders. The frontend surfaces these already-translated: `jf()` throws the server's response text verbatim. Front and back keep **separate** tables (UI labels ≠ error strings; overlap is minimal) but share the `plugin.<id>.` namespace convention. The mechanism is general (any plugin, and eventually the core, registers the same way); only mobile-connector uses it so far.
**Role-driven interface** (§0.1 — data, not enums): `roles.attrs` JSON may carry `"ui_mode": "simple"`. `/api/auth/me` resolves it (`admin` is always `full`) and the sidebar renders chat + inbox only for simple-mode members; the role editor exposes it as an "Interface" select. Hiding links is never access control — routes stay capability-gated server-side. `MeResponse` also carries `locale`, `default_locale` and `encrypted`. **Role-driven interface** (§0.1 — data, not enums): `roles.attrs` JSON may carry `"ui_mode": "simple"`. `/api/auth/me` resolves it (`admin` is always `full`) and the sidebar renders chat + inbox only for simple-mode members; the role editor exposes it as an "Interface" select. Hiding links is never access control — routes stay capability-gated server-side. `MeResponse` also carries `locale`, `default_locale` and `encrypted`.
| File | Element | Notes | | File | Element | Notes |
Generated
+117 -2528
View File
File diff suppressed because it is too large Load Diff
-15
View File
@@ -29,12 +29,6 @@ edition = "2024"
[features] [features]
default = ["whisper-local"] default = ["whisper-local"]
whisper-local = ["dep:plugin-transcribe-whisper-local"] whisper-local = ["dep:plugin-transcribe-whisper-local"]
# Desktop bundle mode: wraps the headless server in a Tauri webview with a
# system-tray icon (menu-bar on macOS, notification area on Windows, AppIndicator
# on Linux). When enabled, `main.rs` enters the Tauri event loop instead of the
# plain tokio blocking path; the backend runs as a task on Tauri's shared runtime.
# Build a distributable bundle with: cargo tauri build --features desktop
desktop = ["dep:tauri", "dep:dirs", "dep:tauri-build"]
# Embedded (pure-Rust) Tailscale provider. Off by default: the `tailscale` crate # Embedded (pure-Rust) Tailscale provider. Off by default: the `tailscale` crate
# forces the `aws-lc-rs` crypto backend (a cmake/NASM C build) back into the # forces the `aws-lc-rs` crypto backend (a cmake/NASM C build) back into the
# tree, defeating the ring-only crypto path. The recommended `tailscale_sys` # tree, defeating the ring-only crypto path. The recommended `tailscale_sys`
@@ -42,9 +36,6 @@ desktop = ["dep:tauri", "dep:dirs", "dep:tauri-build"]
# self-contained embedded mesh (re-introduces the aws-lc-rs C build). # self-contained embedded mesh (re-introduces the aws-lc-rs C build).
embedded-tailscale = ["plugin-tailscale-remote/remote-tailscale"] embedded-tailscale = ["plugin-tailscale-remote/remote-tailscale"]
[build-dependencies]
tauri-build = { version = "2", optional = true , features = [] }
[dependencies] [dependencies]
skald-core = { path = "crates/skald-core" } skald-core = { path = "crates/skald-core" }
@@ -93,9 +84,3 @@ plugin-comfyui = { path = "crates/plugin-comfyui" }
plugin-tts-orpheus-3b = { path = "crates/plugin-tts-orpheus-3b" } plugin-tts-orpheus-3b = { path = "crates/plugin-tts-orpheus-3b" }
plugin-tts-kokoro = { path = "crates/plugin-tts-kokoro" } plugin-tts-kokoro = { path = "crates/plugin-tts-kokoro" }
plugin-elevenlabs = { path = "crates/plugin-elevenlabs" } plugin-elevenlabs = { path = "crates/plugin-elevenlabs" }
# ── Desktop bundle (Tauri) ───────────────────────────────────────────────────
# Optional, activated by the `desktop` feature. Wraps the headless server in a
# Tauri webview with a system-tray icon. See src/desktop/ and docs/desktop.md.
tauri = { version = "2", optional = true, features = ["tray-icon"] }
dirs = { version = "5", optional = true }
+2 -2
View File
@@ -68,7 +68,7 @@ The interface is translated (English, Italiano, Français), and each family memb
### 📱 Everywhere in the house ### 📱 Everywhere in the house
The web app runs on any browser, phone included — add it to your Home Screen to chat, approve requests and check the inbox. There's a native **desktop app** (macOS / Windows / Linux), a companion **iOS app** with push notifications ([SkaldAgent/skald-ios](https://github.com/SkaldAgent/skald-ios)), and a **Telegram** bridge if you prefer to chat from there. The web app runs on any browser, phone included — add it to your Home Screen to chat, approve requests and check the inbox. There's a companion **iOS app** with push notifications ([SkaldAgent/skald-ios](https://github.com/SkaldAgent/skald-ios)), and a **Telegram** bridge if you prefer to chat from there.
## Privacy & security — the honest version ## Privacy & security — the honest version
@@ -105,7 +105,7 @@ The multi-user foundation — accounts, roles, encrypted spaces, shared memory a
On first launch a short wizard creates the family admin account. Then open **http://localhost:9000**, sign in, and add at least one **LLM provider + model** in the Models Hub — credentials are managed entirely from the web UI. Invite the rest of the family from the Users page. On first launch a short wizard creates the family admin account. Then open **http://localhost:9000**, sign in, and add at least one **LLM provider + model** in the Models Hub — credentials are managed entirely from the web UI. Invite the rest of the family from the Users page.
Prefer an app window? `cargo run --features desktop` runs the native desktop shell; `cargo tauri build --features desktop` produces an installable bundle. Meant to run as a background service on an always-on machine (a mini-server, a spare box on the LAN): `run.sh` supervises the process and restarts it on demand, so the assistant is reachable from every device in the house.
## Status ## Status
+17 -6
View File
@@ -22,16 +22,27 @@ Tutti gli 11 agenti hanno ora icone in stile **Vector Paintings** (painterly vec
| TIC | 👁️ Gatto | ✅ | | TIC | 👁️ Gatto | ✅ |
| Business Analyst | 💼 Gazza | ✅ | | Business Analyst | 💼 Gazza | ✅ |
- Business Analyst aveva `meta.json` senza campo `icon` — aggiunto. ### Auto-build CI/CD 🚧
- `agents/README.md` riscritto con nuova guida stile Vector Paintings.
- Stile: `VectorPaintDaal` trigger, palette calde (terracotta, ambra, oro, corallo, teal), animali come personaggi. Implementazione in corso per build automatica su NiPoGi con Gitea Actions:
| Componente | File | Stato |
|---|---|---|
| `scripts/package.sh` | Crea tarball distributivi da binari compilati | ✅ |
| `scripts/verify-version.sh` | Verifica che una release non sia già buildata | ✅ |
| `.gitea/workflows/nightly.yml` | Push su `main` → build amd64+arm64 → nightly/ | ✅ |
| `.gitea/workflows/release.yml` | PR check `verify-version` + merge → build → releases/v{ver}/ | ✅ |
| **act_runner** su NiPoGi | Esegue i workflow | 🔧 Da installare |
| **Cross toolchain** (arm64) | `gcc-aarch64-linux-gnu` per cross-compilazione | 🔧 Da installare |
| **Caddy `builds.skaldagent.net`** | Serve i tarball + `install.sh` | 🔧 Da configurare |
| **`install.sh`** | Script one-liner `curl ... | bash` | ⏳ Da creare |
### Prossimi passi ### Prossimi passi
- Sviluppare l'app Skald Circle vera e propria - Configurare NiPoGi: act_runner, toolchain, Caddy
- Testare i workflow con una PR su `release`
- Creare `install.sh`
### Future ideas (TODO) ### Future ideas (TODO)
- **Auto-build on push**: webhook Gitea → systemd service su NiPoGi → `cargo build --release` → pacchetto pronto
- **One-liner install**: sito web con comando bash da copiare-incollare su macOS/Linux che fa installazione automatica - **One-liner install**: sito web con comando bash da copiare-incollare su macOS/Linux che fa installazione automatica
- **Package hosting**: servire builds via Caddy su `builds.skaldagent.net`
-9
View File
@@ -1,9 +0,0 @@
// Build script.
//
// In headless mode (default) it is a no-op. Under the `desktop` feature it
// delegates to `tauri_build`, which merges `tauri.conf.json` + `capabilities/`
// and emits the cfg flags that `tauri::generate_context!()` relies on at runtime.
fn main() {
#[cfg(feature = "desktop")]
tauri_build::build()
}
+6 -5
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env sh #!/usr/bin/env sh
# Build Skald and install the binary into ./bin. # Build Skald and install the binary into ./bin.
# #
# ./build.sh release build → bin/skald # ./build.sh release build → bin/skald
# ./build.sh -d debug build → bin/skald # ./build.sh -d debug build → bin/skald
# ./build.sh --features desktop extra args are forwarded to cargo #
# Extra args after the profile flag are forwarded to the server cargo build.
# #
# The binary is staged as bin/skald.new and renamed into place. A plain `cp` # The binary is staged as bin/skald.new and renamed into place. A plain `cp`
# over a live binary fails with ETXTBSY on Linux, and rebuilding while run.sh # over a live binary fails with ETXTBSY on Linux, and rebuilding while run.sh
@@ -26,8 +27,8 @@ fi
RUSTFLAGS="-A warnings" RUSTFLAGS="-A warnings"
export RUSTFLAGS export RUSTFLAGS
# The server takes any forwarded args (e.g. --features desktop); the setup wizard # The server takes any forwarded args; the setup wizard is a plain binary and is
# is a plain binary and is always built on its own, without them. # always built on its own, without them.
if [ "$PROFILE" = "release" ]; then if [ "$PROFILE" = "release" ]; then
cargo build --release "$@" cargo build --release "$@"
cargo build --release -p skald-setup cargo build --release -p skald-setup
-14
View File
@@ -1,14 +0,0 @@
{
"identifier": "default",
"description": "Default capabilities for the Skald desktop bundle. The webview loads the local Skald server (http://127.0.0.1) and does not invoke any Tauri JS API, so the surface stays minimal.",
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-show",
"core:window:allow-hide",
"core:window:allow-set-focus",
"core:window:allow-close",
"core:window:allow-unminimize",
"core:webview:allow-internal-toggle-devtools"
]
}
+55
View File
@@ -0,0 +1,55 @@
//! Backend localization contract shared by the core and every plugin.
//!
//! Two halves:
//! - [`LocaleBundle`] — what a plugin *declares* (its translation table for one
//! locale), returned from `Plugin::i18n()` and collected into a single catalog
//! at boot. Keys must be namespaced (`plugin.<id>.<key>`) so bundles from
//! different plugins — and the core — merge without clobbering each other, and
//! so the same key can back the frontend fragment's `t()` string.
//! - [`I18nApi`] — what a plugin *calls* at request time to turn a key into text
//! for the caller. Injected into `PluginContext.i18n`; the concrete impl lives
//! in `skald-core` (it owns the locale-resolution chain and the system pool).
//!
//! The core never emits user-facing text through a hardcoded English literal
//! once it can go through this seam — a plugin's own error/generated strings
//! reach the user in the user's language, mirroring the frontend `i18n.js`.
use std::collections::HashMap;
use async_trait::async_trait;
/// One namespace's translation table for a single locale, as declared by a
/// plugin (or the core). Merged into the boot-time catalog keyed by locale;
/// keys collide across bundles only if two authors reuse the same fully
/// qualified key, which the `plugin.<id>.` convention prevents.
#[derive(Debug, Clone)]
pub struct LocaleBundle {
/// Locale code — `"en"`, `"it"`, `"fr"`. Must match a supported locale;
/// anything else is simply never selected by the resolver.
pub locale: String,
/// Fully qualified key → translated string. Placeholders are `{name}`.
pub strings: HashMap<String, String>,
}
impl LocaleBundle {
pub fn new(locale: impl Into<String>, strings: HashMap<String, String>) -> Self {
Self { locale: locale.into(), strings }
}
}
/// Runtime translation, injected into [`crate::plugin::PluginContext`].
///
/// The catalog behind it is built once at boot from every plugin's
/// `Plugin::i18n()`. Resolution mirrors the rest of the system: a user's
/// `users.locale` override → the instance default → built-in English → the raw
/// key as a last resort. Placeholders (`{name}`) are filled from `args`.
#[async_trait]
pub trait I18nApi: Send + Sync {
/// Translate `key` for `user_id`, resolving *their* effective locale. Use
/// this from any request/notification path where the target user is known.
async fn for_user(&self, user_id: &str, key: &str, args: &[(&str, &str)]) -> String;
/// Translate for an already-resolved locale — for contexts with no single
/// user (boot logs, broadcast copy) that have decided a locale by other means.
fn get(&self, locale: &str, key: &str, args: &[(&str, &str)]) -> String;
}
+1
View File
@@ -9,6 +9,7 @@ pub mod chatbot;
pub mod chat_hub; pub mod chat_hub;
pub mod command; pub mod command;
pub mod events; pub mod events;
pub mod i18n;
pub mod image_generate; pub mod image_generate;
pub mod inbox; pub mod inbox;
pub mod interface_tool; pub mod interface_tool;
+15 -1
View File
@@ -7,6 +7,7 @@ use tokio::sync::RwLock;
use crate::command::CommandApi; use crate::command::CommandApi;
use crate::config_api::ConfigApi; use crate::config_api::ConfigApi;
use crate::i18n::I18nApi;
use crate::system_bus::SystemEventBus; use crate::system_bus::SystemEventBus;
use crate::image_generate::ImageGenerateRegistry; use crate::image_generate::ImageGenerateRegistry;
use crate::location::LocationUpdater; use crate::location::LocationUpdater;
@@ -90,6 +91,10 @@ pub struct PluginContext {
/// Per-user plugin configuration store (`plugin_user_configs` table). /// Per-user plugin configuration store (`plugin_user_configs` table).
/// Admin-readable — never secrets. /// Admin-readable — never secrets.
pub user_config: Arc<dyn PluginUserConfigApi>, pub user_config: Arc<dyn PluginUserConfigApi>,
/// Backend localization. Turns a plugin's namespaced string key into text in
/// the caller's language (`i18n.for_user(user_id, key, args)`). The catalog
/// is built at boot from every plugin's [`Plugin::i18n`]. See `core_api::i18n`.
pub i18n: Arc<dyn I18nApi>,
pub web_port: u16, pub web_port: u16,
pub remote_slot: Arc<RwLock<Option<Arc<dyn RemoteAccess>>>>, pub remote_slot: Arc<RwLock<Option<Arc<dyn RemoteAccess>>>>,
pub router_factory: RouterFactory, pub router_factory: RouterFactory,
@@ -174,7 +179,9 @@ pub trait Plugin: Send + Sync {
/// `/api/plugin/<id>/…` — no host APIs are injected; /// `/api/plugin/<id>/…` — no host APIs are injected;
/// - it runs with the full privileges of the logged-in session (plugins are /// - it runs with the full privileges of the logged-in session (plugins are
/// trusted — they ship in the binary); /// trusted — they ship in the binary);
/// - it carries its own UI strings (reads the locale from `/api/auth/me`). /// - it localizes by shipping its own `{en,it,fr}` string table and
/// registering it via `addStrings` into the host's shared `i18n.js`, then
/// using the same `t()`/`I18nMixin` (keys namespaced `plugin.<id>.`).
/// ///
/// Default: no pages. /// Default: no pages.
fn web_pages(&self) -> Vec<PluginPage> { Vec::new() } fn web_pages(&self) -> Vec<PluginPage> { Vec::new() }
@@ -192,6 +199,13 @@ pub trait Plugin: Send + Sync {
/// is stopped. Default: no tools. /// is stopped. Default: no tools.
fn tools(self: Arc<Self>) -> Vec<Arc<dyn crate::tool::Tool>> { Vec::new() } fn tools(self: Arc<Self>) -> Vec<Arc<dyn crate::tool::Tool>> { Vec::new() }
/// Backend translation tables this plugin contributes — one
/// [`crate::i18n::LocaleBundle`] per locale it ships. Collected once at boot
/// into the shared catalog behind [`PluginContext::i18n`]. Keys must be
/// namespaced (`plugin.<id>.<key>`). Default: no strings (plugin emits no
/// localized backend text). See `core_api::i18n`.
fn i18n(&self) -> Vec<crate::i18n::LocaleBundle> { Vec::new() }
/// Returns a [`Memory`] backend if this plugin provides one. /// Returns a [`Memory`] backend if this plugin provides one.
fn memory(&self) -> Option<Arc<dyn Memory>> { None } fn memory(&self) -> Option<Arc<dyn Memory>> { None }
@@ -0,0 +1,6 @@
{
"plugin.mobile-connector.err.relay_not_connected": "Relay not connected. Set the connector's relay_url and make sure the relay is reachable, then try again.",
"plugin.mobile-connector.err.admin_only": "Admin only.",
"plugin.mobile-connector.err.user_id_empty": "The user must not be empty.",
"plugin.mobile-connector.err.pubkey_hex": "The device key must be 32-byte hex."
}
@@ -0,0 +1,6 @@
{
"plugin.mobile-connector.err.relay_not_connected": "Relais non connecté. Renseignez le relay_url du connecteur et assurez-vous que le relais est joignable, puis réessayez.",
"plugin.mobile-connector.err.admin_only": "Administrateur uniquement.",
"plugin.mobile-connector.err.user_id_empty": "L'utilisateur ne doit pas être vide.",
"plugin.mobile-connector.err.pubkey_hex": "La clé de l'appareil doit être en hexadécimal de 32 octets."
}
@@ -0,0 +1,6 @@
{
"plugin.mobile-connector.err.relay_not_connected": "Relay non connesso. Imposta il relay_url del connettore e assicurati che il relay sia raggiungibile, poi riprova.",
"plugin.mobile-connector.err.admin_only": "Solo amministratore.",
"plugin.mobile-connector.err.user_id_empty": "L'utente non può essere vuoto.",
"plugin.mobile-connector.err.pubkey_hex": "La chiave del dispositivo deve essere esadecimale di 32 byte."
}
+13
View File
@@ -24,6 +24,7 @@ use tokio_util::sync::CancellationToken;
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
use core_api::config_api::ConfigApi; use core_api::config_api::ConfigApi;
use core_api::i18n::I18nApi;
use core_api::user_channel::UserChannelApi; use core_api::user_channel::UserChannelApi;
use skald_relay_client::{ClientState, RelayClient, RelayEvent}; use skald_relay_client::{ClientState, RelayClient, RelayEvent};
@@ -39,6 +40,9 @@ pub struct RelayApp {
pub(crate) user_channel: Arc<dyn UserChannelApi>, pub(crate) user_channel: Arc<dyn UserChannelApi>,
/// Config store — used to persist binding removals (logout/revoke). /// Config store — used to persist binding removals (logout/revoke).
config: Arc<dyn ConfigApi>, config: Arc<dyn ConfigApi>,
/// Backend localization — turns a namespaced key into text in the caller's
/// language for the router's error/response strings.
i18n: Arc<dyn I18nApi>,
/// Device→user bindings, cached in memory; kept in sync by `auth::config_listener`. /// Device→user bindings, cached in memory; kept in sync by `auth::config_listener`.
pub(crate) bindings: RwLock<MobileConfig>, pub(crate) bindings: RwLock<MobileConfig>,
/// When true, a freshly paired device stays Pending until an admin binds it /// When true, a freshly paired device stays Pending until an admin binds it
@@ -60,10 +64,12 @@ pub struct RelayApp {
} }
impl RelayApp { impl RelayApp {
#[allow(clippy::too_many_arguments)]
pub fn new( pub fn new(
client: Arc<RelayClient>, client: Arc<RelayClient>,
user_channel: Arc<dyn UserChannelApi>, user_channel: Arc<dyn UserChannelApi>,
config: Arc<dyn ConfigApi>, config: Arc<dyn ConfigApi>,
i18n: Arc<dyn I18nApi>,
bindings: MobileConfig, bindings: MobileConfig,
require_device_confirmation: bool, require_device_confirmation: bool,
notify_delay: Duration, notify_delay: Duration,
@@ -73,6 +79,7 @@ impl RelayApp {
client, client,
user_channel, user_channel,
config, config,
i18n,
bindings: RwLock::new(bindings), bindings: RwLock::new(bindings),
require_device_confirmation, require_device_confirmation,
notify_delay, notify_delay,
@@ -100,6 +107,12 @@ impl RelayApp {
&self.client &self.client
} }
/// Backend localizer — the router resolves its error strings to the caller's
/// language through this (`app.i18n().for_user(user_id, key, &[])`).
pub(crate) fn i18n(&self) -> &Arc<dyn I18nApi> {
&self.i18n
}
/// Cancellation token for this run's spawned tasks. /// Cancellation token for this run's spawned tasks.
pub(crate) fn cancel(&self) -> CancellationToken { pub(crate) fn cancel(&self) -> CancellationToken {
self.cancel.clone() self.cancel.clone()
@@ -0,0 +1,35 @@
//! Backend translation bundles for the mobile-connector.
//!
//! These are the plugin's **backend** strings — the error/response text its
//! router returns, resolved to the caller's language via `PluginContext.i18n`
//! (see `core_api::i18n`). The frontend fragment's UI strings live separately in
//! `web/i18n.js` (registered client-side); the two sets barely overlap, so each
//! side owns its own table rather than sharing one over an endpoint.
//!
//! The tables ship as JSON embedded at compile time — one file per locale, keys
//! namespaced `plugin.mobile-connector.*`. A malformed file is skipped (its
//! locale simply falls back to English) rather than failing the build path.
use std::collections::HashMap;
use core_api::i18n::LocaleBundle;
/// Every locale bundle this plugin contributes, parsed from the embedded JSON.
pub fn bundles() -> Vec<LocaleBundle> {
[
("en", include_str!("../i18n/en.json")),
("it", include_str!("../i18n/it.json")),
("fr", include_str!("../i18n/fr.json")),
]
.into_iter()
.filter_map(|(locale, raw)| {
match serde_json::from_str::<HashMap<String, String>>(raw) {
Ok(strings) => Some(LocaleBundle::new(locale, strings)),
Err(e) => {
tracing::warn!(locale, error = %e, "mobile-connector i18n bundle failed to parse");
None
}
}
})
.collect()
}
@@ -31,6 +31,7 @@ mod agent;
mod app; mod app;
mod auth; mod auth;
mod events; mod events;
mod i18n;
mod notifier; mod notifier;
mod payloads; mod payloads;
mod proxy; mod proxy;
@@ -140,6 +141,7 @@ impl MobileConnectorPlugin {
Arc::clone(&client), Arc::clone(&client),
Arc::clone(&ctx.user_channel), Arc::clone(&ctx.user_channel),
Arc::clone(&ctx.config), Arc::clone(&ctx.config),
Arc::clone(&ctx.i18n),
bindings, bindings,
require_device_confirmation, require_device_confirmation,
notify_delay, notify_delay,
@@ -339,6 +341,12 @@ impl Plugin for MobileConnectorPlugin {
crate::tools::mobile_tools(self) crate::tools::mobile_tools(self)
} }
/// Backend translation tables — the router's error/response strings,
/// namespaced `plugin.mobile-connector.*`. See `crate::i18n`.
fn i18n(&self) -> Vec<core_api::i18n::LocaleBundle> {
crate::i18n::bundles()
}
fn as_any(&self) -> &dyn std::any::Any { self } fn as_any(&self) -> &dyn std::any::Any { self }
fn as_arc_any(self: Arc<Self>) -> Arc<dyn std::any::Any + Send + Sync> { self } fn as_arc_any(self: Arc<Self>) -> Arc<dyn std::any::Any + Send + Sync> { self }
} }
+24 -12
View File
@@ -36,6 +36,14 @@ use crate::PLUGIN_ID;
/// Cloned cheaply and safely shared between the plugin and the router. /// Cloned cheaply and safely shared between the plugin and the router.
type StateCell = Arc<Mutex<Option<Arc<RelayApp>>>>; type StateCell = Arc<Mutex<Option<Arc<RelayApp>>>>;
// Namespaced i18n keys for the router's user-facing strings (backend tables in
// `../i18n/*.json`). Resolved to the caller's language via `app.i18n()`. Every
// use sits after `admin_app`, so the app — hence the localizer — is present.
const KEY_RELAY_NOT_CONNECTED: &str = "plugin.mobile-connector.err.relay_not_connected";
const KEY_ADMIN_ONLY: &str = "plugin.mobile-connector.err.admin_only";
const KEY_USER_ID_EMPTY: &str = "plugin.mobile-connector.err.user_id_empty";
const KEY_PUBKEY_HEX: &str = "plugin.mobile-connector.err.pubkey_hex";
/// Build the plugin's router. Takes the shared state cell so each request /// Build the plugin's router. Takes the shared state cell so each request
/// resolves the *current* `RelayApp` — not a snapshot from startup. /// resolves the *current* `RelayApp` — not a snapshot from startup.
pub fn build(state_cell: StateCell) -> Router { pub fn build(state_cell: StateCell) -> Router {
@@ -45,6 +53,7 @@ pub fn build(state_cell: StateCell) -> Router {
.route("/web/pairing.js", get(|| async { serve_js(include_str!("../web/pairing.js")) })) .route("/web/pairing.js", get(|| async { serve_js(include_str!("../web/pairing.js")) }))
.route("/web/devices.js", get(|| async { serve_js(include_str!("../web/devices.js")) })) .route("/web/devices.js", get(|| async { serve_js(include_str!("../web/devices.js")) }))
.route("/web/common.js", get(|| async { serve_js(include_str!("../web/common.js")) })) .route("/web/common.js", get(|| async { serve_js(include_str!("../web/common.js")) }))
.route("/web/i18n.js", get(|| async { serve_js(include_str!("../web/i18n.js")) }))
// Admin pairing console API. // Admin pairing console API.
.route("/pairing", post(start_pairing).delete(stop_pairing)) .route("/pairing", post(start_pairing).delete(stop_pairing))
.route("/devices", get(list_devices)) .route("/devices", get(list_devices))
@@ -69,7 +78,8 @@ async fn require_admin(app: &RelayApp, caller: &Caller) -> Result<(), Response>
if app.user_channel.plugin_access(PLUGIN_ID, &caller.user_id).await { if app.user_channel.plugin_access(PLUGIN_ID, &caller.user_id).await {
Ok(()) Ok(())
} else { } else {
Err((StatusCode::FORBIDDEN, "admin only").into_response()) let msg = app.i18n().for_user(&caller.user_id, KEY_ADMIN_ONLY, &[]).await;
Err((StatusCode::FORBIDDEN, msg).into_response())
} }
} }
@@ -84,9 +94,14 @@ fn bad_request(msg: impl Into<String>) -> Response {
(StatusCode::BAD_REQUEST, msg.into()).into_response() (StatusCode::BAD_REQUEST, msg.into()).into_response()
} }
fn decode_pubkey(hex: &str) -> Result<[u8; 32], Response> { async fn decode_pubkey(app: &RelayApp, caller: &Caller, hex: &str) -> Result<[u8; 32], Response> {
skald_relay_common::crypto::decode_hex::<32>(hex) match skald_relay_common::crypto::decode_hex::<32>(hex) {
.ok_or_else(|| bad_request("`pubkey` must be 32-byte hex")) Some(pk) => Ok(pk),
None => {
let msg = app.i18n().for_user(&caller.user_id, KEY_PUBKEY_HEX, &[]).await;
Err(bad_request(msg))
}
}
} }
// ── POST/DELETE /pairing ──────────────────────────────────────────────────────── // ── POST/DELETE /pairing ────────────────────────────────────────────────────────
@@ -110,11 +125,8 @@ async fn start_pairing(
// send `pairing_start` on ("WS outbound channel closed"). Fail with an // send `pairing_start` on ("WS outbound channel closed"). Fail with an
// actionable message instead of the transport-level one. // actionable message instead of the transport-level one.
if !app.client().is_connected() { if !app.client().is_connected() {
return ( let msg = app.i18n().for_user(&caller.user_id, KEY_RELAY_NOT_CONNECTED, &[]).await;
StatusCode::SERVICE_UNAVAILABLE, return (StatusCode::SERVICE_UNAVAILABLE, msg).into_response();
"Relay not connected. Set the connector's relay_url and make sure the relay is reachable, then try again.",
)
.into_response();
} }
let ttl = body.ttl.unwrap_or(0).min(600); let ttl = body.ttl.unwrap_or(0).min(600);
app.set_pending_owner(Some(caller.user_id.clone())).await; app.set_pending_owner(Some(caller.user_id.clone())).await;
@@ -192,9 +204,9 @@ async fn bind_device(
Json(body): Json<BindBody>, Json(body): Json<BindBody>,
) -> Response { ) -> Response {
let app = match admin_app(&cell, &caller).await { Ok(a) => a, Err(r) => return r }; let app = match admin_app(&cell, &caller).await { Ok(a) => a, Err(r) => return r };
let pk = match decode_pubkey(&body.pubkey) { Ok(p) => p, Err(r) => return r }; let pk = match decode_pubkey(&app, &caller, &body.pubkey).await { Ok(p) => p, Err(r) => return r };
if body.user_id.trim().is_empty() { if body.user_id.trim().is_empty() {
return bad_request("`user_id` must not be empty"); return bad_request(app.i18n().for_user(&caller.user_id, KEY_USER_ID_EMPTY, &[]).await);
} }
match app.bind_device(pk, body.user_id, body.display).await { match app.bind_device(pk, body.user_id, body.display).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(), Ok(()) => StatusCode::NO_CONTENT.into_response(),
@@ -214,7 +226,7 @@ async fn revoke_device(
Json(body): Json<RevokeBody>, Json(body): Json<RevokeBody>,
) -> Response { ) -> Response {
let app = match admin_app(&cell, &caller).await { Ok(a) => a, Err(r) => return r }; let app = match admin_app(&cell, &caller).await { Ok(a) => a, Err(r) => return r };
let pk = match decode_pubkey(&body.pubkey) { Ok(p) => p, Err(r) => return r }; let pk = match decode_pubkey(&app, &caller, &body.pubkey).await { Ok(p) => p, Err(r) => return r };
match app.revoke_device(pk).await { match app.revoke_device(pk).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(), Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
+24 -11
View File
@@ -6,10 +6,23 @@
// `Plugin::web_pages` contract): they talk only to `/api/plugin/<id>/…` and, // `Plugin::web_pages` contract): they talk only to `/api/plugin/<id>/…` and,
// for the user directory used by the reassign dropdown, the host `/api/users` // for the user directory used by the reassign dropdown, the host `/api/users`
// (the fragment runs with the logged-in admin's full session privileges). // (the fragment runs with the logged-in admin's full session privileges).
//
// i18n: the plugin ships its own dictionary (`./i18n.js`) and registers it into
// the host's shared strings via `addStrings` (imported from the app root by the
// absolute `/lib/i18n.js` specifier — the same module the host app uses, so
// `t()` and `locale-changed` are shared). `MobileBase` mixes in `I18nMixin` so
// every fragment re-renders on a language switch. Register once, at module load.
import { LitElement } from 'lit'; import { LitElement } from 'lit';
import { t, addStrings, I18nMixin } from '/lib/i18n.js';
import STRINGS from './i18n.js';
addStrings(STRINGS);
export { t };
/// JSON fetch that throws the server's error text on non-2xx and tolerates an /// JSON fetch that throws the server's error text on non-2xx and tolerates an
/// empty (204) body. /// empty (204) body. The server's error text is already localized (the backend
/// resolves the caller's locale), so it is safe to surface directly.
export async function jf(url, opts = {}) { export async function jf(url, opts = {}) {
const res = await fetch(url, { const res = await fetch(url, {
headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) }, headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
@@ -25,27 +38,27 @@ export async function jf(url, opts = {}) {
} }
/// Base for the console fragments: renders into light DOM (so Bootstrap classes /// Base for the console fragments: renders into light DOM (so Bootstrap classes
/// and the app's theme CSS variables apply) and exposes the plugin's API root /// and the app's theme CSS variables apply), re-renders on locale change, and
/// from the host-set `plugin-id` attribute. /// exposes the plugin's API root from the host-set `plugin-id` attribute.
export class MobileBase extends LitElement { export class MobileBase extends I18nMixin(LitElement) {
createRenderRoot() { return this; } createRenderRoot() { return this; }
get api() { return `/api/plugin/${this.getAttribute('plugin-id') || 'mobile-connector'}`; } get api() { return `/api/plugin/${this.getAttribute('plugin-id') || 'mobile-connector'}`; }
} }
/// Human-friendly "time ago" for a Unix-ms timestamp (or "—" when absent). /// Human-friendly, localized "time ago" for a Unix-ms timestamp (or "—" when absent).
export function ago(ms) { export function ago(ms) {
if (!ms) return '—'; if (!ms) return t('plugin.mobile-connector.time.never');
const s = Math.max(0, Math.floor((Date.now() - ms) / 1000)); const s = Math.max(0, Math.floor((Date.now() - ms) / 1000));
if (s < 60) return `${s}s ago`; if (s < 60) return t('plugin.mobile-connector.time.ago_s', { n: s });
const m = Math.floor(s / 60); const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`; if (m < 60) return t('plugin.mobile-connector.time.ago_m', { n: m });
const h = Math.floor(m / 60); const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`; if (h < 24) return t('plugin.mobile-connector.time.ago_h', { n: h });
return `${Math.floor(h / 24)}d ago`; return t('plugin.mobile-connector.time.ago_d', { n: Math.floor(h / 24) });
} }
/// Best-effort device label from the `device_info` JSON a phone sends on hello. /// Best-effort device label from the `device_info` JSON a phone sends on hello.
export function deviceLabel(d) { export function deviceLabel(d) {
const info = d.device_info || {}; const info = d.device_info || {};
return info.name || info.model || info.device || d.platform || 'Unknown device'; return info.name || info.model || info.device || d.platform || t('plugin.mobile-connector.devices.unknown');
} }
+13 -11
View File
@@ -6,7 +6,9 @@
// from the host `/api/users` (the fragment runs with the admin's session). // from the host `/api/users` (the fragment runs with the admin's session).
// Default-exports the element class; the host registers it. // Default-exports the element class; the host registers it.
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { MobileBase, jf, ago, deviceLabel } from './common.js'; import { MobileBase, jf, ago, deviceLabel, t } from './common.js';
const P = 'plugin.mobile-connector';
export default class MobileDevicesPage extends MobileBase { export default class MobileDevicesPage extends MobileBase {
static get properties() { static get properties() {
@@ -67,7 +69,7 @@ export default class MobileDevicesPage extends MobileBase {
} }
async _revoke(pubkey) { async _revoke(pubkey) {
if (!confirm('Revoke this device? It loses access immediately.')) return; if (!confirm(t(`${P}.devices.revoke_confirm`))) return;
try { try {
await jf(`${this.api}/devices/revoke`, { method: 'POST', body: JSON.stringify({ pubkey }) }); await jf(`${this.api}/devices/revoke`, { method: 'POST', body: JSON.stringify({ pubkey }) });
await this._load(); await this._load();
@@ -79,13 +81,13 @@ export default class MobileDevicesPage extends MobileBase {
return html` return html`
<div class="um-page"> <div class="um-page">
<div class="um-header d-flex justify-content-between align-items-center"> <div class="um-header d-flex justify-content-between align-items-center">
<h2 class="um-title"><i class="bi bi-phone me-2"></i>Mobile devices</h2> <h2 class="um-title"><i class="bi bi-phone me-2"></i>${t(`${P}.devices.title`)}</h2>
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._load()}> <button class="btn btn-sm btn-outline-secondary" @click=${() => this._load()}>
<i class="bi bi-arrow-repeat me-1"></i>Refresh</button> <i class="bi bi-arrow-repeat me-1"></i>${t(`${P}.devices.refresh`)}</button>
</div> </div>
<div style="padding:0 1.25rem 1.5rem"> <div style="padding:0 1.25rem 1.5rem">
${this._error ? html`<div class="alert alert-danger py-2" style="font-size:.85rem">${this._error}</div>` : nothing} ${this._error ? html`<div class="alert alert-danger py-2" style="font-size:.85rem">${this._error}</div>` : nothing}
${loading ? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> Loading…</div>` : this._renderList()} ${loading ? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> ${t(`${P}.devices.loading`)}</div>` : this._renderList()}
</div> </div>
</div>`; </div>`;
} }
@@ -94,15 +96,15 @@ export default class MobileDevicesPage extends MobileBase {
const rows = this._devices || []; const rows = this._devices || [];
if (!rows.length) { if (!rows.length) {
return html`<div class="um-empty" style="padding:1rem"> return html`<div class="um-empty" style="padding:1rem">
<i class="bi bi-phone"></i><p>No paired devices yet.</p> <i class="bi bi-phone"></i><p>${t(`${P}.devices.empty`)}</p>
<p style="font-size:.8rem;opacity:.7">Use the <em>Pair a device</em> page to add one.</p> <p style="font-size:.8rem;opacity:.7">${t(`${P}.devices.empty_hint`)}</p>
</div>`; </div>`;
} }
return html` return html`
<div class="table-responsive"> <div class="table-responsive">
<table class="table align-middle" style="font-size:.88rem"> <table class="table align-middle" style="font-size:.88rem">
<thead><tr> <thead><tr>
<th>Device</th><th>State</th><th>Bound to</th><th>Last seen</th><th class="text-end">Actions</th> <th>${t(`${P}.devices.col_device`)}</th><th>${t(`${P}.devices.col_state`)}</th><th>${t(`${P}.devices.col_bound`)}</th><th>${t(`${P}.devices.col_last_seen`)}</th><th class="text-end">${t(`${P}.devices.col_actions`)}</th>
</tr></thead> </tr></thead>
<tbody>${rows.map(d => this._renderRow(d))}</tbody> <tbody>${rows.map(d => this._renderRow(d))}</tbody>
</table> </table>
@@ -119,7 +121,7 @@ export default class MobileDevicesPage extends MobileBase {
${d.pubkey.slice(0, 16)}…</div> ${d.pubkey.slice(0, 16)}…</div>
</td> </td>
<td> <td>
<span class="badge ${authorized ? 'text-bg-success' : 'text-bg-secondary'}">${d.state}</span> <span class="badge ${authorized ? 'text-bg-success' : 'text-bg-secondary'}">${t(`${P}.devices.state_${d.state}`)}</span>
</td> </td>
<td>${d.bound_user ? this._userName(d.bound_user) : html`<span class="text-body-secondary">—</span>`}</td> <td>${d.bound_user ? this._userName(d.bound_user) : html`<span class="text-body-secondary">—</span>`}</td>
<td class="text-body-secondary">${ago(d.last_seen)}</td> <td class="text-body-secondary">${ago(d.last_seen)}</td>
@@ -128,12 +130,12 @@ export default class MobileDevicesPage extends MobileBase {
<select class="form-select form-select-sm" style="width:auto" <select class="form-select form-select-sm" style="width:auto"
.value=${this._pick[d.pubkey] || d.bound_user || ''} .value=${this._pick[d.pubkey] || d.bound_user || ''}
@change=${(e) => { this._pick = { ...this._pick, [d.pubkey]: e.target.value }; }}> @change=${(e) => { this._pick = { ...this._pick, [d.pubkey]: e.target.value }; }}>
<option value="">Assign to…</option> <option value="">${t(`${P}.devices.assign_to`)}</option>
${this._users.map(u => html`<option value=${u.id}>${u.display_name || u.username}</option>`)} ${this._users.map(u => html`<option value=${u.id}>${u.display_name || u.username}</option>`)}
</select> </select>
<button class="btn btn-sm btn-primary" <button class="btn btn-sm btn-primary"
?disabled=${!this._pick[d.pubkey] || this._pick[d.pubkey] === d.bound_user} ?disabled=${!this._pick[d.pubkey] || this._pick[d.pubkey] === d.bound_user}
@click=${() => this._bind(d.pubkey)}>Bind</button> @click=${() => this._bind(d.pubkey)}>${t(`${P}.devices.bind`)}</button>
<button class="btn btn-sm btn-outline-danger" @click=${() => this._revoke(d.pubkey)}> <button class="btn btn-sm btn-outline-danger" @click=${() => this._revoke(d.pubkey)}>
<i class="bi bi-trash"></i></button> <i class="bi bi-trash"></i></button>
</div> </div>
+116
View File
@@ -0,0 +1,116 @@
// Frontend translations for the mobile-connector page fragments.
//
// Served at `/api/plugin/mobile-connector/web/i18n.js` and imported by
// `common.js`, which registers it into the host's shared dictionaries via
// `addStrings` (see `web/lib/i18n.js`). Keys are namespaced `plugin.mobile-
// connector.*` so they never collide with core keys. These are the *frontend*
// UI strings; the plugin's backend error strings live in `../i18n/*.json` and
// reach the browser already translated as HTTP response text.
const P = 'plugin.mobile-connector';
export default {
en: {
[`${P}.pairing.title`]: 'Pair a device',
[`${P}.pairing.intro`]: 'Open a pairing window, then scan the QR code with the Skald mobile app. The device is linked to you and works immediately — you can reassign it to another user from the Mobile devices page.',
[`${P}.pairing.open`]: 'Open pairing window',
[`${P}.pairing.opening`]: 'Opening…',
[`${P}.pairing.qr_alt`]: 'Pairing QR',
[`${P}.pairing.expired`]: 'Window expired',
[`${P}.pairing.scan_within`]: 'Scan within {n}s',
[`${P}.pairing.new_code`]: 'New code',
[`${P}.pairing.close`]: 'Close',
[`${P}.devices.title`]: 'Mobile devices',
[`${P}.devices.refresh`]: 'Refresh',
[`${P}.devices.loading`]: 'Loading…',
[`${P}.devices.empty`]: 'No paired devices yet.',
[`${P}.devices.empty_hint`]: 'Use the Pair a device page to add one.',
[`${P}.devices.col_device`]: 'Device',
[`${P}.devices.col_state`]: 'State',
[`${P}.devices.col_bound`]: 'Bound to',
[`${P}.devices.col_last_seen`]: 'Last seen',
[`${P}.devices.col_actions`]: 'Actions',
[`${P}.devices.state_authorized`]: 'authorized',
[`${P}.devices.state_pending`]: 'pending',
[`${P}.devices.assign_to`]: 'Assign to…',
[`${P}.devices.bind`]: 'Bind',
[`${P}.devices.revoke_confirm`]: 'Revoke this device? It loses access immediately.',
[`${P}.devices.unknown`]: 'Unknown device',
[`${P}.time.never`]: '—',
[`${P}.time.ago_s`]: '{n}s ago',
[`${P}.time.ago_m`]: '{n}m ago',
[`${P}.time.ago_h`]: '{n}h ago',
[`${P}.time.ago_d`]: '{n}d ago',
},
it: {
[`${P}.pairing.title`]: 'Associa un dispositivo',
[`${P}.pairing.intro`]: 'Apri una finestra di associazione, poi scansiona il codice QR con lapp Skald sul telefono. Il dispositivo viene collegato a te e funziona subito — puoi riassegnarlo a un altro utente dalla pagina Dispositivi mobili.',
[`${P}.pairing.open`]: 'Apri finestra di associazione',
[`${P}.pairing.opening`]: 'Apertura…',
[`${P}.pairing.qr_alt`]: 'QR di associazione',
[`${P}.pairing.expired`]: 'Finestra scaduta',
[`${P}.pairing.scan_within`]: 'Scansiona entro {n}s',
[`${P}.pairing.new_code`]: 'Nuovo codice',
[`${P}.pairing.close`]: 'Chiudi',
[`${P}.devices.title`]: 'Dispositivi mobili',
[`${P}.devices.refresh`]: 'Aggiorna',
[`${P}.devices.loading`]: 'Caricamento…',
[`${P}.devices.empty`]: 'Nessun dispositivo associato.',
[`${P}.devices.empty_hint`]: 'Usa la pagina Associa un dispositivo per aggiungerne uno.',
[`${P}.devices.col_device`]: 'Dispositivo',
[`${P}.devices.col_state`]: 'Stato',
[`${P}.devices.col_bound`]: 'Assegnato a',
[`${P}.devices.col_last_seen`]: 'Ultimo accesso',
[`${P}.devices.col_actions`]: 'Azioni',
[`${P}.devices.state_authorized`]: 'autorizzato',
[`${P}.devices.state_pending`]: 'in attesa',
[`${P}.devices.assign_to`]: 'Assegna a…',
[`${P}.devices.bind`]: 'Associa',
[`${P}.devices.revoke_confirm`]: 'Revocare questo dispositivo? Perderà laccesso immediatamente.',
[`${P}.devices.unknown`]: 'Dispositivo sconosciuto',
[`${P}.time.never`]: '—',
[`${P}.time.ago_s`]: '{n}s fa',
[`${P}.time.ago_m`]: '{n}m fa',
[`${P}.time.ago_h`]: '{n}h fa',
[`${P}.time.ago_d`]: '{n}g fa',
},
fr: {
[`${P}.pairing.title`]: 'Associer un appareil',
[`${P}.pairing.intro`]: 'Ouvrez une fenêtre dassociation, puis scannez le QR code avec lapp mobile Skald. Lappareil est lié à vous et fonctionne immédiatement — vous pouvez le réassigner à un autre utilisateur depuis la page Appareils mobiles.',
[`${P}.pairing.open`]: 'Ouvrir la fenêtre dassociation',
[`${P}.pairing.opening`]: 'Ouverture…',
[`${P}.pairing.qr_alt`]: 'QR dassociation',
[`${P}.pairing.expired`]: 'Fenêtre expirée',
[`${P}.pairing.scan_within`]: 'Scannez sous {n}s',
[`${P}.pairing.new_code`]: 'Nouveau code',
[`${P}.pairing.close`]: 'Fermer',
[`${P}.devices.title`]: 'Appareils mobiles',
[`${P}.devices.refresh`]: 'Actualiser',
[`${P}.devices.loading`]: 'Chargement…',
[`${P}.devices.empty`]: 'Aucun appareil associé.',
[`${P}.devices.empty_hint`]: 'Utilisez la page Associer un appareil pour en ajouter un.',
[`${P}.devices.col_device`]: 'Appareil',
[`${P}.devices.col_state`]: 'État',
[`${P}.devices.col_bound`]: 'Assigné à',
[`${P}.devices.col_last_seen`]: 'Vu la dernière fois',
[`${P}.devices.col_actions`]: 'Actions',
[`${P}.devices.state_authorized`]: 'autorisé',
[`${P}.devices.state_pending`]: 'en attente',
[`${P}.devices.assign_to`]: 'Assigner à…',
[`${P}.devices.bind`]: 'Associer',
[`${P}.devices.revoke_confirm`]: 'Révoquer cet appareil ? Il perd laccès immédiatement.',
[`${P}.devices.unknown`]: 'Appareil inconnu',
[`${P}.time.never`]: '—',
[`${P}.time.ago_s`]: 'il y a {n}s',
[`${P}.time.ago_m`]: 'il y a {n}m',
[`${P}.time.ago_h`]: 'il y a {n}h',
[`${P}.time.ago_d`]: 'il y a {n}j',
},
};
+11 -11
View File
@@ -6,7 +6,9 @@
// is usable on the phone immediately and can be reassigned later from the // is usable on the phone immediately and can be reassigned later from the
// Devices page. Default-exports the element class; the host registers it. // Devices page. Default-exports the element class; the host registers it.
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { MobileBase, jf } from './common.js'; import { MobileBase, jf, t } from './common.js';
const P = 'plugin.mobile-connector';
export default class MobilePairingPage extends MobileBase { export default class MobilePairingPage extends MobileBase {
static get properties() { static get properties() {
@@ -73,36 +75,34 @@ export default class MobilePairingPage extends MobileBase {
return html` return html`
<div class="um-page"> <div class="um-page">
<div class="um-header"> <div class="um-header">
<h2 class="um-title"><i class="bi bi-qr-code me-2"></i>Pair a device</h2> <h2 class="um-title"><i class="bi bi-qr-code me-2"></i>${t(`${P}.pairing.title`)}</h2>
</div> </div>
<div style="padding:0 1.25rem 1.5rem; max-width:640px"> <div style="padding:0 1.25rem 1.5rem; max-width:640px">
${this._error ? html`<div class="alert alert-danger py-2" style="font-size:.85rem">${this._error}</div>` : nothing} ${this._error ? html`<div class="alert alert-danger py-2" style="font-size:.85rem">${this._error}</div>` : nothing}
${!this._session ? html` ${!this._session ? html`
<p class="text-body-secondary" style="font-size:.9rem"> <p class="text-body-secondary" style="font-size:.9rem">
Open a pairing window, then scan the QR code with the Skald mobile app. ${t(`${P}.pairing.intro`)}
The device is linked to <strong>you</strong> and works immediately — you can
reassign it to another user from the <em>Mobile devices</em> page.
</p> </p>
<button class="btn btn-primary" ?disabled=${this._busy} @click=${() => this._open()}> <button class="btn btn-primary" ?disabled=${this._busy} @click=${() => this._open()}>
<i class="bi bi-qr-code-scan me-1"></i>${this._busy ? 'Opening…' : 'Open pairing window'} <i class="bi bi-qr-code-scan me-1"></i>${this._busy ? t(`${P}.pairing.opening`) : t(`${P}.pairing.open`)}
</button> </button>
` : html` ` : html`
<div class="d-flex flex-column align-items-center gap-3 p-3" <div class="d-flex flex-column align-items-center gap-3 p-3"
style="border:1px solid var(--border-color,#ddd); border-radius:var(--radius-md,12px)"> style="border:1px solid var(--border-color,#ddd); border-radius:var(--radius-md,12px)">
<img src=${this._session.url} alt="Pairing QR" width="256" height="256" <img src=${this._session.url} alt=${t(`${P}.pairing.qr_alt`)} width="256" height="256"
style="image-rendering:pixelated; ${expired ? 'opacity:.25' : ''}" /> style="image-rendering:pixelated; ${expired ? 'opacity:.25' : ''}" />
${expired ${expired
? html`<div class="text-danger" style="font-size:.9rem"><i class="bi bi-clock-history me-1"></i>Window expired</div>` ? html`<div class="text-danger" style="font-size:.9rem"><i class="bi bi-clock-history me-1"></i>${t(`${P}.pairing.expired`)}</div>`
: html`<div class="text-body-secondary" style="font-size:.9rem"> : html`<div class="text-body-secondary" style="font-size:.9rem">
Scan within <strong>${this._remain}s</strong> ${t(`${P}.pairing.scan_within`, { n: this._remain })}
</div>`} </div>`}
<div class="d-flex gap-2"> <div class="d-flex gap-2">
${expired ${expired
? html`<button class="btn btn-primary btn-sm" @click=${() => this._open()}> ? html`<button class="btn btn-primary btn-sm" @click=${() => this._open()}>
<i class="bi bi-arrow-repeat me-1"></i>New code</button>` <i class="bi bi-arrow-repeat me-1"></i>${t(`${P}.pairing.new_code`)}</button>`
: html`<button class="btn btn-outline-secondary btn-sm" @click=${() => this._stop()}> : html`<button class="btn btn-outline-secondary btn-sm" @click=${() => this._stop()}>
<i class="bi bi-x-lg me-1"></i>Close</button>`} <i class="bi bi-x-lg me-1"></i>${t(`${P}.pairing.close`)}</button>`}
</div> </div>
</div> </div>
`} `}
+3 -3
View File
@@ -6,9 +6,9 @@ edition = "2024"
# The headless application core: database + crypto + identity, the LLM stack, # The headless application core: database + crypto + identity, the LLM stack,
# tools, MCP, plugins-as-a-registry, sessions. # tools, MCP, plugins-as-a-registry, sessions.
# #
# Deliberately knows nothing about the process shell around it. No Tauri, no # Deliberately knows nothing about the process shell around it. No concrete
# concrete plugin crates (it only ever sees `Arc<dyn Plugin>` from `core-api`), # plugin crates (it only ever sees `Arc<dyn Plugin>` from `core-api`), no `axum`
# no `axum` server — `skald` and `skald-setup` are both consumers. # server — `skald` and `skald-setup` are both consumers.
[dependencies] [dependencies]
axum = { version = "0.8", features = ["ws", "multipart"] } axum = { version = "0.8", features = ["ws", "multipart"] }
+96
View File
@@ -5,6 +5,11 @@
//! user can override it on their own profile (`users.locale`); the frontend //! user can override it on their own profile (`users.locale`); the frontend
//! resolves user → instance → built-in English at boot. //! resolves user → instance → built-in English at boot.
use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use core_api::i18n::{I18nApi, LocaleBundle};
use core_api::{ConfigProperty, ConfigSet, PropertyType}; use core_api::{ConfigProperty, ConfigSet, PropertyType};
pub const DEFAULT_LOCALE_KEY: &str = "ui_locale"; pub const DEFAULT_LOCALE_KEY: &str = "ui_locale";
@@ -88,6 +93,70 @@ pub async fn set_default_locale(pool: &sqlx::SqlitePool, locale: &str) -> anyhow
Ok(()) Ok(())
} }
/// The backend translation catalog — the concrete [`I18nApi`] injected into
/// every `PluginContext`. Built once at boot by merging every plugin's
/// [`core_api::plugin::Plugin::i18n`] bundles, keyed by locale. Lookups follow
/// the same chain as the frontend `t()`: resolved locale → English → the raw
/// key, with `{name}` placeholders filled from `args`.
///
/// Immutable after construction: bundles are collected before any request, so
/// no lock is needed on the read path (`get` is a plain map lookup).
pub struct I18nCatalog {
/// System pool — reads `users.locale` and the instance-default `config` key
/// to resolve a user's effective locale (see [`resolve_locale`]).
pool: Arc<sqlx::SqlitePool>,
/// locale → (key → string).
tables: HashMap<String, HashMap<String, String>>,
}
impl I18nCatalog {
/// Merge `bundles` into one catalog. Two bundles for the same locale union
/// their keys (later wins on a collision — the `plugin.<id>.` convention
/// keeps collisions to genuine overrides).
pub fn new(pool: Arc<sqlx::SqlitePool>, bundles: Vec<LocaleBundle>) -> Self {
let mut tables: HashMap<String, HashMap<String, String>> = HashMap::new();
for b in bundles {
tables.entry(b.locale).or_default().extend(b.strings);
}
Self { pool, tables }
}
fn lookup(&self, locale: &str, key: &str) -> Option<&str> {
self.tables.get(locale).and_then(|m| m.get(key)).map(String::as_str)
}
/// Resolve → fall back to English → fall back to the key itself, then fill
/// `{name}` placeholders.
fn render(&self, locale: &str, key: &str, args: &[(&str, &str)]) -> String {
let raw = self
.lookup(locale, key)
.or_else(|| self.lookup("en", key))
.unwrap_or(key);
let mut s = raw.to_string();
for (k, v) in args {
s = s.replace(&format!("{{{k}}}"), v);
}
s
}
}
#[async_trait]
impl I18nApi for I18nCatalog {
async fn for_user(&self, user_id: &str, key: &str, args: &[(&str, &str)]) -> String {
let user_locale = crate::db::users::get(&self.pool, user_id)
.await
.ok()
.flatten()
.and_then(|u| u.locale);
let locale = resolve_locale(&self.pool, user_locale.as_deref()).await;
self.render(&locale, key, args)
}
fn get(&self, locale: &str, key: &str, args: &[(&str, &str)]) -> String {
self.render(locale, key, args)
}
}
pub fn config_set() -> ConfigSet { pub fn config_set() -> ConfigSet {
ConfigSet { ConfigSet {
name: "Interface".into(), name: "Interface".into(),
@@ -154,4 +223,31 @@ mod tests {
pool.close().await; pool.close().await;
cleanup(&path); cleanup(&path);
} }
#[tokio::test]
async fn catalog_renders_with_fallback_and_interpolation() {
let path = temp_db_path("catalog");
let pool = Arc::new(crate::db::init_system_pool(&path).await.unwrap());
let bundle = |loc: &str, pairs: &[(&str, &str)]| LocaleBundle::new(
loc,
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
);
let cat = I18nCatalog::new(Arc::clone(&pool), vec![
bundle("en", &[("p.hi", "Hi {name}"), ("p.only_en", "Only EN")]),
bundle("it", &[("p.hi", "Ciao {name}")]),
]);
// Exact locale hit + placeholder fill.
assert_eq!(cat.get("it", "p.hi", &[("name", "Ada")]), "Ciao Ada");
// Missing key in locale → English fallback.
assert_eq!(cat.get("it", "p.only_en", &[]), "Only EN");
// Missing everywhere → the raw key.
assert_eq!(cat.get("it", "p.absent", &[]), "p.absent");
// Unknown locale → English fallback.
assert_eq!(cat.get("de", "p.hi", &[("name", "Bo")]), "Hi Bo");
pool.close().await;
cleanup(&path);
}
} }
+2 -2
View File
@@ -1,7 +1,7 @@
//! The headless Skald core: storage, identity, LLM stack, tools, sessions. //! The headless Skald core: storage, identity, LLM stack, tools, sessions.
//! //!
//! Nothing here knows what runs it. The process shell — HTTP server, desktop //! Nothing here knows what runs it. The process shell — HTTP server, setup
//! webview, setup wizard — lives in the crates that depend on this one. Concrete //! wizard — lives in the crates that depend on this one. Concrete
//! plugins are never named: `plugin::PluginManager` only ever sees //! plugins are never named: `plugin::PluginManager` only ever sees
//! `Arc<dyn Plugin>`, constructed by the consumer and handed to `Skald::new`. //! `Arc<dyn Plugin>`, constructed by the consumer and handed to `Skald::new`.
+20
View File
@@ -105,6 +105,10 @@ pub struct PluginManager {
router_factory: OnceLock<RouterFactory>, router_factory: OnceLock<RouterFactory>,
/// HTTP port the web server is bound to — provided by WebFrontend before start_enabled(). /// HTTP port the web server is bound to — provided by WebFrontend before start_enabled().
web_port: OnceLock<u16>, web_port: OnceLock<u16>,
/// Backend i18n catalog, built once from every plugin's `Plugin::i18n()` on
/// first context build (all plugins are registered by then). Injected into
/// every `PluginContext` so a plugin can localize its own backend strings.
i18n: OnceLock<Arc<crate::i18n::I18nCatalog>>,
/// Last known (enabled, config_json) per plugin id — used by the watcher. /// Last known (enabled, config_json) per plugin id — used by the watcher.
known_state: Mutex<HashMap<String, (bool, String)>>, known_state: Mutex<HashMap<String, (bool, String)>>,
} }
@@ -118,6 +122,7 @@ impl PluginManager {
skald: OnceLock::new(), skald: OnceLock::new(),
router_factory: OnceLock::new(), router_factory: OnceLock::new(),
web_port: OnceLock::new(), web_port: OnceLock::new(),
i18n: OnceLock::new(),
known_state: Mutex::new(HashMap::new()), known_state: Mutex::new(HashMap::new()),
} }
} }
@@ -149,6 +154,20 @@ impl PluginManager {
.ok_or_else(|| anyhow::anyhow!("PluginManager: skald not initialized")) .ok_or_else(|| anyhow::anyhow!("PluginManager: skald not initialized"))
} }
/// The shared backend i18n catalog, built once by merging every registered
/// plugin's `Plugin::i18n()` bundles. All plugins are registered before the
/// first `build_context`, so a single lazy build is correct.
fn i18n(&self) -> Arc<dyn core_api::i18n::I18nApi> {
let catalog = self.i18n.get_or_init(|| {
let mut bundles = Vec::new();
for plugin in &self.plugins {
bundles.extend(plugin.i18n());
}
Arc::new(crate::i18n::I18nCatalog::new(Arc::clone(&self.db), bundles))
});
Arc::clone(catalog) as Arc<dyn core_api::i18n::I18nApi>
}
fn build_context(&self, skald: &Skald) -> Result<PluginContext> { fn build_context(&self, skald: &Skald) -> Result<PluginContext> {
let router_factory = self.router_factory.get().cloned() let router_factory = self.router_factory.get().cloned()
.ok_or_else(|| anyhow::anyhow!("PluginManager: router_factory not set"))?; .ok_or_else(|| anyhow::anyhow!("PluginManager: router_factory not set"))?;
@@ -170,6 +189,7 @@ impl PluginManager {
system_bus: Arc::clone(skald.system_bus()), system_bus: Arc::clone(skald.system_bus()),
user_channel: self.skald()? as Arc<dyn core_api::user_channel::UserChannelApi>, user_channel: self.skald()? as Arc<dyn core_api::user_channel::UserChannelApi>,
user_config: Arc::clone(&self.user_config) as _, user_config: Arc::clone(&self.user_config) as _,
i18n: self.i18n(),
web_port, web_port,
remote_slot: Arc::clone(skald.remote()), remote_slot: Arc::clone(skald.remote()),
router_factory, router_factory,
+2 -1
View File
@@ -113,7 +113,8 @@ impl Media {
).await?; ).await?;
// Evaluate the await outside the `info!` macro: leaving the temporary // Evaluate the await outside the `info!` macro: leaving the temporary
// `tracing::Value` from the field expression alive across the await // `tracing::Value` from the field expression alive across the await
// makes the surrounding future non-Send, which Tauri's runtime rejects. // makes the surrounding future non-Send, which the multi-threaded
// runtime rejects.
let image_generator_models = image_generator_manager.list_models_info().await.len(); let image_generator_models = image_generator_manager.list_models_info().await.len();
info!( info!(
db_backed = image_generator_models, db_backed = image_generator_models,
+7 -6
View File
@@ -8,10 +8,11 @@ use crate::tools::{Tool, ToolDescriptionLength};
/// How to restart, when exiting for a supervisor is not the answer. /// How to restart, when exiting for a supervisor is not the answer.
/// ///
/// A bundled desktop app has no supervisor watching its exit code: it must tear /// A shell with no supervisor watching its exit code would need to tear itself
/// down its own webview and respawn itself. That is knowledge about the process /// down and respawn on its own. That is knowledge about the process shell, which
/// shell, and the core does not have it — so the shell installs it here. Without /// the core does not have — so such a shell installs it here. The default server
/// a handler, `restart` falls back to the supervisor protocol. /// shell has a supervisor (`run.sh`) and installs no handler, so `restart` falls
/// back to the supervisor protocol below.
/// ///
/// Returns only on failure; a successful handler never comes back. /// Returns only on failure; a successful handler never comes back.
pub type RestartHandler = Box<dyn Fn() -> Result<()> + Send + Sync>; pub type RestartHandler = Box<dyn Fn() -> Result<()> + Send + Sync>;
@@ -50,8 +51,8 @@ impl Tool for Restart {
} }
fn execute(&self, _args: Value) -> Result<String> { fn execute(&self, _args: Value) -> Result<String> {
// A bundled desktop app installs its own teardown-and-respawn. Normally // A shell that installed its own teardown-and-respawn handles it here.
// this never returns. // Normally this never returns.
if let Some(handler) = HANDLER.get() { if let Some(handler) = HANDLER.get() {
info!("restart requested — delegating to the installed handler"); info!("restart requested — delegating to the installed handler");
handler()?; handler()?;
-282
View File
@@ -1,282 +0,0 @@
# Desktop bundle (Tauri)
Skald ships in **two shapes** from the same source tree, selected by the
`desktop` cargo feature:
| Shape | How it runs | How the user reaches it | Typical host |
| --- | --- | --- | --- |
| **Headless server** (default, no `desktop` feature) | The binary blocks on its own tokio runtime, as before. | Browser on `http://<host>:<port>` (LAN-friendly, binds `0.0.0.0`). | Linux server, dev workstation, Docker container. |
| **Desktop bundle** (`--features desktop`) | A Tauri event loop wraps the same headless backend; the backend runs as a task on Tauri's shared tokio runtime. A system-tray icon exposes `Open` / `Quit`; the webview loads `http://127.0.0.1:<port>`. | Double-click the `.app` / `.exe` / `.AppImage`. | End-user macOS / Windows / Linux machine. |
The `desktop` feature is **opt-in and default-off**: every existing build path
(`cargo build`, `run.sh`, `Dockerfile`) is unchanged because `--no-default-features`
already excludes it.
---
## Build
### Dev mode (debug, no bundle)
```sh
cargo run --features desktop
```
A real Tauri window + tray icon appears. The webview loads
`http://127.0.0.1:<config-port>` from the running dev binary; the binary reads
`config.yml`, `agents/`, `skills/`, `web/` from the crate root exactly as the
headless build does (the cwd is **not** relocated in dev mode — see
*Data directory* below).
### Distributable bundle (release, packaged)
```sh
cargo tauri build --features desktop
```
This requires the `tauri-cli`:
```sh
cargo install tauri-cli --version "^2"
```
Produces native installers under `target/release/bundle/`:
| OS | Artifact |
| --- | --- |
| macOS | `Skald.app`, `Skald.dmg` |
| Windows | `Skald.msi`, `Skald.exe` (NSIS) |
| Linux | `Skald.deb`, `Skald.AppImage` |
> **macOS deployment target.** The default `whisper-local` feature compiles
> `whisper.cpp` (C++), whose `ggml-backend-reg.cpp` uses
> `std::filesystem::path` — introduced in macOS 10.15. `cargo tauri build`
> injects `MACOSX_DEPLOYMENT_TARGET` from
> `tauri.conf.json > bundle.macOS.minimumSystemVersion` (Tauri's default is
> `"10.13"`), on which `std::filesystem::path` is marked *unavailable*, so the
> ggml build fails with ~20 `'path' is unavailable` errors.
>
> The fix has **three coordinated pieces**, all pinned to `"10.15"` (the exact
> floor that unblocks `std::filesystem`):
>
> 1. **`.cargo/config.toml`** sets **two** forced env vars. Both are needed
> because the C++ compile otherwise ends up with two conflicting
> `-mmacosx-version-min` flags and clang lets the *last* one win:
> - `MACOSX_DEPLOYMENT_TARGET` → the `cc` crate turns this into the CFLAGS'
> `-mmacosx-version-min`.
> - `CMAKE_OSX_DEPLOYMENT_TARGET` → `whisper-rs-sys`'s `build.rs` forwards any
> `CMAKE_*` env var to cmake as `-DCMAKE_OSX_DEPLOYMENT_TARGET=…`, which
> sets CMake's *own* `-mmacosx-version-min` **and overrides a value cached
> in a stale `CMakeCache.txt`**. Without this, CMake's cached 10.13 wins.
>
> `force = true` makes cargo override whatever the Tauri CLI injects
> (`MACOSX_DEPLOYMENT_TARGET=10.13`), so the C/C++ build is
> **deterministically** 10.15 regardless of Tauri's env-propagation. It also
> gives the headless binary a 10.15 portability floor.
> 2. **`tauri.conf.json > bundle.macOS.minimumSystemVersion = "10.15"`** — the
> value baked into the bundle's `Info.plist` as `LSMinimumSystemVersion`.
> 3. If a build still fails after a target change, **wipe the stale CMake
> caches**: `rm -rf target/*/build/whisper-rs-sys-*` (`cargo clean -p
> whisper-rs-sys` does not always clear the cmake `out/` dir). Piece 1's
> explicit `-D` flag makes this rarely necessary, but it is the escape hatch.
>
> Keep pieces 1 and 2 in sync. Raise all to `"11.0"` / `"12.0"` only if you want
> an Apple-Silicon-only bundle.
For cross-platform CI builds use a GitHub Actions matrix (the
[`tauri-apps/tauri-action`](https://github.com/tauri-apps/tauri-action) is the
canonical setup).
---
## Architecture
```
┌────────────────── main.rs ──────────────────┐
│ install rustls ring provider │
│ init_logging() │
│ │
│ ┌── cfg(feature = "desktop") ──┐ │
│ │ desktop::run() │ │
│ │ tauri::Builder │ │
│ │ .setup(spawn backend) │ │
│ │ .on_window_event(hide) │ │
│ │ .run(ExitRequested→ │ │
│ │ shutdown_backend) │ │
│ └───────────────────────────────┘ │
│ ┌── cfg(not(feature = "desktop")) ─┐ │
│ │ tokio runtime → async_main() │ │
│ │ run_backend() │ │
│ │ wait_for_shutdown_signal() │ │
│ │ shutdown_backend() │ │
│ └───────────────────────────────────┘ │
└──────────────────────────────────────────────┘
```
### Module map
| Path | Role |
| --- | --- |
| `src/main.rs` | Dispatcher. Installs the rustls provider, sets up tracing, then branches on the `desktop` feature. Exposes `run_backend()` and `shutdown_backend()` shared by both entry points. |
| `src/desktop/mod.rs` | Tauri shell. Builds the system tray + menu, creates the main `WebviewWindow` (URL derived from `config.yml`'s `server.port`), spawns the backend on Tauri's shared tokio runtime, and handles graceful shutdown. Compiled only under `--features desktop`. |
| `src/config.rs` | `bootstrap_data_dir()` relocates the cwd to a per-user data dir when running inside a `.app` bundle (no-op in dev mode and headless mode). |
| `src/core/tools/restart.rs` | Branches on the feature: under `desktop`, restarts the Tauri process (cleanup + respawn the same binary, **no rebuild** — the bundle is read-only); otherwise `libc::_exit(-1)` so `run.sh` rebuilds. |
| `build.rs` | Calls `tauri_build::build()` under the `desktop` feature; no-op otherwise. |
| `tauri.conf.json` | Tauri bundle config: identifier, icons, security. The main window is **not** declared here — it is built programmatically in the setup hook so the URL can read the backend port from `config.yml`. |
| `capabilities/default.json` | Tauri v2 capability set for the `main` window (window show/hide/focus permissions). The frontend never invokes Tauri's JS API — it is the regular Skald web app served by Axum. |
| `icons/` | App icon (`icon.png`, `32x32.png`, `128x128.png`, `128x128@2x.png`, `icon.icns`, `icon.ico`) and a monochrome tray template (`tray-template.png`). See *Icons* below. |
### Why a single binary, not a launcher
`skald` is a binary crate (`src/main.rs`, no `src/lib.rs`). Putting the Tauri
shell in `src/desktop/` behind `#[cfg(feature = "desktop")]` — instead of a
separate crate — keeps everything private (no `pub` leakage) and lets
`tauri::generate_context!()` find `tauri.conf.json` in `CARGO_MANIFEST_DIR`.
### Why Tauri's runtime, not a fresh tokio
`tauri::async_runtime::spawn` lands the task on Tauri's internal tokio
runtime. One runtime, one process, no IPC bridge — the webview talks to the
backend over plain HTTP/WS on `127.0.0.1`, exactly like a browser would.
---
## System tray + window policy
* A single tray icon is built in `build_tray()` with two menu items: **Open**
and **Quit**.
* **Left-click** the tray icon toggles the main window (show+focus or hide).
* **Open** menu item shows + focuses the main window.
* The window's close button (traffic-light red on macOS, X on Windows/Linux)
is intercepted in `on_window_event``WindowEvent::CloseRequested` and
turned into a `hide()`. The app keeps running in the tray.
* **Quit** (and Cmd+Q / system shutdown) triggers `RunEvent::ExitRequested`.
The handler spawns `shutdown_backend()` on the async runtime, waits for it
to drain the HTTP server / Skald managers / DB pool, then calls
`app.exit(0)`. An `AtomicBool` re-entrancy guard prevents the second
`ExitRequested` (emitted by the `app.exit(0)` itself) from looping.
---
## Data directory
| Mode | Working directory at startup |
| --- | --- |
| Headless (`cargo run`, `run.sh`, Docker) | Whatever the user launched from (today, the crate root). Unchanged. |
| Desktop dev (`cargo run --features desktop`) | The crate root — same as headless, so `config.yml`, `agents/`, `skills/`, `web/` resolve from the source tree. |
| Desktop bundle (inside `Skald.app/Contents/MacOS/`) | Relocated to the OS per-user data dir, see table below. |
When packaged as a `.app` (or Windows / Linux equivalents), the process cwd is
typically `/`. `bootstrap_data_dir()` detects this (via
`running_from_bundle()` — a `.app/` heuristic on macOS, currently always-false
on Windows/Linux until those targets land) and `set_current_dir`s to the
per-user data dir:
| OS | Location |
| --- | --- |
| macOS | `~/Library/Application Support/Skald` |
| Windows | `%APPDATA%\Skald` (= `C:\Users\<u>\AppData\Roaming\Skald`) |
| Linux | `~/.local/share/Skald` |
This makes every relative path in `config.yml` (`db.path`, `web.static_dir`,
`data/`, `secrets/`, `models/`, …) resolve under the user's data dir without
touching the source tree.
If `config.yml` is missing on first launch, it is seeded from
`DEFAULT_CONFIG_EMBEDDED` (the `default.config.yaml` baked into the binary via
`include_str!`).
**Logs.** `init_logging()` runs *before* the cwd is relocated, so a relative
`logs/` would land against the launch cwd (`/` for a Finder-launched `.app`) and
silently fail to write. `config::resolved_log_dir()` returns an **absolute**
path under the data dir (`~/Library/Application Support/Skald/logs`) in a bundle,
so logs always land in a writable place; headless and desktop-dev keep the
relative `logs/`.
### Read-only bundled assets
The relocated data dir holds only **mutable** state (db, config, logs, secrets,
models, uploads). The **read-only** assets the backend needs — `agents/`,
`web/`, `skills/`, `commands/` — are packaged into the bundle's `Resources/`
dir via `tauri.conf.json > bundle > resources`, and made reachable from the
relocated cwd by `link_bundled_assets()` (in `src/config.rs`), which runs right
after the relocation:
* For each asset name it creates a **symlink** in the data dir pointing at the
copy inside `…/Skald.app/Contents/Resources/<name>`. Symlinking (not copying)
keeps the assets in lock-step with the installed app version.
* A pre-existing **real** directory in the data dir is treated as a user
override and left untouched; only stale symlinks are refreshed each launch.
Without this step `Skald::new` fails on launch with *"Failed to read agents
directory 'agents'"* and the app exits immediately — the assets live next to the
binary, not in the freshly-relocated cwd.
> **Note (App Translocation).** An unsigned, quarantined `.app` launched from
> `~/Downloads` or a mounted `.dmg` is run from an ephemeral read-only path by
> Gatekeeper, so the symlink targets change per launch (they are recreated each
> time, so this is harmless). Moving the app to `/Applications` clears the
> quarantine and stabilises the paths.
---
## `restart` tool behaviour
`src/core/tools/restart.rs` branches on the feature flag:
| Mode | Behaviour |
| --- | --- |
| Headless | `libc::_exit(-1)` (= exit code 255). `run.sh` sees 255, runs `cargo build`, relaunches. **Rebuilds** the binary — used after the agent edits source code. |
| Desktop | `AppHandle` is fetched from `desktop::app_handle()` (a `OnceLock` populated in the setup hook); then `handle.cleanup_before_exit()` + `std::process::Command::new(current_exe).spawn()` + `std::process::exit(0)`. **No rebuild** — the bundled binary is read-only. Useful for picking up `config.yml` / DB changes that are only read at startup. |
See also [self-rewriting.md](self-rewriting.md).
---
## Icons
Two families live under `icons/`:
| Family | Files | Purpose |
| --- | --- | --- |
| App icon (colour) | `icon.png` (1024×1024 source), `32x32.png`, `128x128.png`, `128x128@2x.png`, `icon.icns` (macOS), `icon.ico` (Windows) | Window icon, bundle icon. |
| Tray template (monochrome) | `tray-template.png` (32×32, black on transparent) | macOS menu-bar template image (auto-recoloured by the system for dark/light). On Windows/Linux a coloured icon would be more visible — currently `default_window_icon()` is used as a fallback everywhere until a Tauri 2.x image-loading API is wired in. |
The current subject is a stylised amber feather on a dark rounded square
(Skald = Norse *bard*). To regenerate from a new design, edit
`/tmp/gen_all_icons.py` (Pillow) and re-run `iconutil` for `.icns` and `magick`
for `.ico`. Sources live with the icons; the script is not yet committed.
---
## Known limitations / next steps
* **`LSUIElement` (menu-bar-only on macOS).** Removed from `tauri.conf.json`
because Tauri v2 dropped the `bundle.macOS.infoPlist` key (v1) — the v2 way
is a custom `Info.plist` file under `src-tauri/`. Until the crate layout
grows a `src-tauri/` directory, the app shows both a Dock icon and a tray
icon. Add the `Info.plist` to make it menu-bar-only.
* **Tray template icon.** The bundled `tray-template.png` is not yet wired up
(Tauri 2.11.5's `Image::from_path` / `from_bytes` API surface differs from
later versions). The tray currently reuses `app.default_window_icon()`, which
is the colour app icon — visible on macOS but not template-styled.
* **Bundled assets — done.** `agents/`, `web/`, `skills/`, `commands/` are
packaged via `bundle.resources` and symlinked into the data dir by
`link_bundled_assets()` (see *Read-only bundled assets* above). Remaining
polish: user-agent overrides beyond the symlink escape-hatch, and refreshing
the copy-on-write story for signed/notarized distribution.
* **Windows/Linux bundle.** `running_from_bundle()` currently only detects
`.app/` on macOS; Windows (`Program Files`) and Linux
(`/usr/share`/`/opt`) detection is TBD.
* **CI matrix.** No GitHub Actions workflow yet for cross-platform bundle
builds.
---
## When to update this file
* The `desktop` feature or its dependencies change.
* The tray menu, window policy, or shutdown path change.
* The data-directory relocation heuristic changes (new platform, new path).
* The `restart` tool's desktop-mode behaviour changes.
* The packaging story is completed (`bundle.resources`, `Info.plist`, CI).
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
{"default":{"identifier":"default","description":"Default capabilities for the Skald desktop bundle. The webview loads the local Skald server (http://127.0.0.1) and does not invoke any Tauri JS API, so the surface stays minimal.","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-show","core:window:allow-hide","core:window:allow-set-focus","core:window:allow-close","core:window:allow-unminimize","core:webview:allow-internal-toggle-devtools"]}}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

+2 -2
View File
@@ -1,7 +1,7 @@
//! How this binary renders the bootstrap lines that `skald_core::boot` emits. //! How this binary renders the bootstrap lines that `skald_core::boot` emits.
//! //!
//! Rendering is the shell's business, not the core's: a desktop bundle or a //! Rendering is the shell's business, not the core's: another shell (e.g. the
//! setup wizard would format the same `boot` target differently, or not at all. //! setup wizard) formats the same `boot` target differently, or not at all.
//! Wired in `main.rs` as a stdout layer filtered on `boot::TARGET`, independent //! Wired in `main.rs` as a stdout layer filtered on `boot::TARGET`, independent
//! of `RUST_LOG`, so this output always appears whatever the log filter is. //! of `RUST_LOG`, so this output always appears whatever the log filter is.
+1 -147
View File
@@ -12,15 +12,6 @@ pub use skald_core::config::{
const DEFAULT_CONFIG: &str = "default.config.yaml"; const DEFAULT_CONFIG: &str = "default.config.yaml";
const CONFIG: &str = "config.yml"; const CONFIG: &str = "config.yml";
/// Default config baked into the binary at compile time.
///
/// Used by [`bootstrap_data_dir`] (desktop mode) to seed `config.yml` on first
/// launch, where the bundled binary cannot rely on `default.config.yaml` being
/// next to it on disk (the cwd has already been relocated to the per-user data
/// dir). Headless mode still copies `default.config.yaml` from the source tree
/// as before.
const DEFAULT_CONFIG_EMBEDDED: &str = include_str!("../default.config.yaml");
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct Config { pub struct Config {
pub server: ServerConfig, pub server: ServerConfig,
@@ -104,144 +95,7 @@ impl Config {
} }
} }
/// Absolute directory for log files. /// Directory for log files: a relative `"logs"` under the launch cwd.
///
/// `init_logging()` runs **before** [`bootstrap_data_dir`] relocates the cwd, so
/// a relative `"logs"` path would resolve against the bundle's launch cwd (`/`
/// for a Finder-launched `.app`) — un-writable, so the log folder stays empty.
/// In a packaged bundle, return an absolute path under the per-user data dir
/// instead. In every other mode (headless, desktop dev) keep the historical
/// relative `"logs"`, unchanged.
pub fn resolved_log_dir() -> std::path::PathBuf { pub fn resolved_log_dir() -> std::path::PathBuf {
#[cfg(feature = "desktop")]
{
if running_from_bundle() {
if let Some(dir) = dirs::data_dir() {
return dir.join("Skald").join("logs");
}
}
}
std::path::PathBuf::from("logs") std::path::PathBuf::from("logs")
} }
/// In desktop mode (Tauri bundle), relocate the process working directory to
/// the OS-appropriate per-user data dir so that every relative path in
/// `config.yml` (db, logs, data, secrets, models, agents, …) resolves there
/// instead of `/` (the default cwd of a `.app` bundle on macOS, or the Windows
/// equivalent). Also seeds `config.yml` from the bundled default if missing.
///
/// | OS | Location |
/// |---------|-----------------------------------------------------|
/// | macOS | `~/Library/Application Support/Skald` |
/// | Windows | `%APPDATA%\Skald` (= `C:\Users\<u>\AppData\Roaming`)|
/// | Linux | `~/.local/share/Skald` |
///
/// ## When relocation happens
/// Only when the process is running from a packaged bundle (e.g. inside
/// `Skald.app/Contents/MacOS/`). In dev mode (`cargo run --features desktop`),
/// the cwd is left untouched so all source-tree assets (`agents/`, `skills/`,
/// `web/`, `config.yml`, …) keep resolving from the crate root as in headless
/// mode.
///
/// In headless mode this is always a no-op: the cwd stays as the user launched
/// it, preserving today's behaviour (`./database.db`, `./logs/`, …).
#[cfg(feature = "desktop")]
pub fn bootstrap_data_dir() -> Result<()> {
use tracing::info;
if !running_from_bundle() {
info!("desktop mode (dev): cwd unchanged — using source-tree assets");
return Ok(());
}
let data_dir = dirs::data_dir()
.context("could not determine OS data directory")?
.join("Skald");
std::fs::create_dir_all(&data_dir)
.with_context(|| format!("failed to create data dir at {}", data_dir.display()))?;
info!(path = %data_dir.display(), "desktop mode: relocating cwd to per-user data dir");
std::env::set_current_dir(&data_dir)
.with_context(|| format!("failed to cd to {}", data_dir.display()))?;
// Seed config.yml from the bundled default if absent (first launch).
let config_path = Path::new(CONFIG);
if !config_path.exists() {
let _ = std::fs::write(CONFIG, DEFAULT_CONFIG_EMBEDDED);
info!(path = %data_dir.display(), "seeded config.yml from embedded default");
}
// Make the read-only bundled assets reachable from the relocated cwd.
link_bundled_assets(&data_dir)?;
Ok(())
}
/// Read-only asset directories shipped inside the `.app` bundle's `Resources/`
/// dir (see `tauri.conf.json > bundle > resources`). The backend looks these up
/// by relative path from the cwd (agent discovery reads `agents/`, Axum serves
/// `web/`, etc.), but the cwd has just been relocated to the data dir — where
/// they don't exist. Without this, `Skald::new` fails with
/// "Failed to read agents directory 'agents'" and the app exits on launch.
#[cfg(feature = "desktop")]
const BUNDLED_ASSETS: &[&str] = &["agents", "web", "skills", "commands"];
/// (Re)link each bundled asset dir into the per-user data dir as a symlink to
/// the copy inside the app bundle's `Resources/`, so the existing relative-path
/// lookups resolve while mutable state (db, config, logs, secrets) stays in the
/// data dir itself.
///
/// Symlinking (rather than copying) keeps the assets in sync with the installed
/// app version automatically. A pre-existing **real** directory is treated as a
/// user override and left untouched; only symlinks are refreshed.
#[cfg(feature = "desktop")]
fn link_bundled_assets(data_dir: &Path) -> Result<()> {
use tracing::{info, warn};
let exe = std::env::current_exe().context("could not resolve current_exe")?;
// `.../Skald.app/Contents/MacOS/skald` → `.../Skald.app/Contents/Resources`
let resource_dir = match exe.parent().and_then(|p| p.parent()) {
Some(contents) => contents.join("Resources"),
None => {
warn!("could not derive bundle Resources dir from exe path — skipping asset link");
return Ok(());
}
};
for name in BUNDLED_ASSETS {
let src = resource_dir.join(name);
if !src.exists() {
warn!(asset = name, "bundled asset missing from Resources — skipping");
continue;
}
let dst = data_dir.join(name);
match std::fs::symlink_metadata(&dst) {
// Stale symlink from a previous launch — replace it.
Ok(meta) if meta.file_type().is_symlink() => { let _ = std::fs::remove_file(&dst); }
// A real dir/file the user created — respect it, don't clobber.
Ok(_) => continue,
// Absent — fall through and create.
Err(_) => {}
}
#[cfg(unix)]
std::os::unix::fs::symlink(&src, &dst)
.with_context(|| format!("failed to symlink {} -> {}", dst.display(), src.display()))?;
info!(asset = name, target = %src.display(), "linked bundled asset into data dir");
}
Ok(())
}
/// Heuristic: are we running inside a packaged bundle (e.g. `Foo.app`)?
/// Used to decide whether to relocate the cwd to the per-user data dir.
#[cfg(feature = "desktop")]
fn running_from_bundle() -> bool {
#[cfg(target_os = "macos")]
{
if let Ok(exe) = std::env::current_exe() {
return exe.to_string_lossy().contains(".app/");
}
}
// Windows / Linux: TBD when packaging targets land. For now treat all
// launches as dev mode (no cwd relocation).
false
}
#[cfg(not(feature = "desktop"))]
pub fn bootstrap_data_dir() -> Result<()> {
// Headless mode: keep cwd as launched, no relocation.
Ok(())
}
-248
View File
@@ -1,248 +0,0 @@
//! Desktop (Tauri) entry point — compiled only under `--features desktop`.
//!
//! Wraps the headless Skald backend in a Tauri event loop. The backend runs on
//! Tauri's shared tokio runtime (no dual runtime). A system-tray icon provides
//! `Open` (show+focus the main window) and `Quit` (graceful shutdown).
//!
//! ## Window policy
//! The main window starts hidden. The traffic-light red / window X button
//! *hides* it instead of closing — the app keeps running in the tray. Only the
//! tray's `Quit` menu item (or Cmd+Q / system termination) actually shuts the
//! backend down and exits.
//!
//! ## Restart safety
//! The `tauri::RunEvent::ExitRequested` handler is re-entrant-guarded by an
//! `AtomicBool`: the first trigger prevents the exit, runs the async backend
//! shutdown, then calls `app.exit(0)` (which would otherwise loop).
//!
//! See `docs/desktop.md` for the architecture overview and build instructions.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use tauri::{
menu::{MenuBuilder, MenuItemBuilder},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
Manager, RunEvent, WebviewUrl, WebviewWindowBuilder, WindowEvent,
};
use tracing::{error, info, warn};
use crate::{config::Config, run_backend, shutdown_backend, Backend};
/// Slot for the backend handle, kept in Tauri's managed state.
///
/// `None` until the async `run_backend()` completes; `Some(Backend)` afterwards.
/// The exit handler takes ownership when the user quits, so we need an
/// `Option` rather than a plain `Backend`.
type BackendSlot = Mutex<Option<Backend>>;
/// Process-wide handle to the Tauri app, populated once in the setup hook.
///
/// Used by code paths that don't naturally receive an `AppHandle` (notably the
/// `restart` tool, which is constructed deep inside the tool registry but needs
/// to trigger `AppHandle::restart()` in desktop mode).
static APP_HANDLE: OnceLock<tauri::AppHandle> = OnceLock::new();
/// Take a clone of the Tauri `AppHandle`, if the desktop runtime is up.
/// Always `None` in headless mode (or before the setup hook has run).
pub fn app_handle() -> Option<tauri::AppHandle> {
APP_HANDLE.get().cloned()
}
/// Desktop entry point. Builds the Tauri app, spawns the backend on its
/// shared tokio runtime, wires the system-tray menu, and runs the event loop.
pub fn run() -> anyhow::Result<()> {
info!(version = env!("CARGO_PKG_VERSION"), "starting skald (desktop mode)");
// Re-entrancy guard: the first `ExitRequested` triggers async shutdown and
// then calls `app.exit(0)`, which would itself re-emit `ExitRequested`. The
// flag short-circuits the second trigger so we actually leave the process.
let exiting = Arc::new(AtomicBool::new(false));
tauri::Builder::default()
// Pre-register the backend slot so it exists before the setup hook
// (the setup hook spawns the backend async; state must already be there).
.manage::<BackendSlot>(Mutex::new(None))
.setup(|app| {
// Stash the app handle for code paths without a natural handle
// reference (notably the `restart` tool, reached through the handler
// installed just below).
let _ = APP_HANDLE.set(app.handle().clone());
// Teach the core how to restart *this* shell. A bundled app has no
// supervisor reading its exit code, and the binary is read-only, so
// "restart" means: tear down Tauri, spawn a fresh copy, exit. This
// mirrors what `tauri-plugin-process`'s JS `restart` does internally.
skald_core::tools::restart::set_restart_handler(Box::new(|| {
let handle = app_handle()
.ok_or_else(|| anyhow::anyhow!("Tauri app handle is not set yet"))?;
let exe = std::env::current_exe()
.map_err(|e| anyhow::anyhow!("failed to resolve current_exe: {e}"))?;
// Tauri-side teardown (webview, tray, event loop, windows).
handle.cleanup_before_exit();
// Spawn a fresh copy of the current binary (detached).
let _ = std::process::Command::new(exe).spawn();
std::process::exit(0);
}));
build_tray(app)?;
// Resolve the backend port from config so the webview URL is always
// in sync with where Axum will actually bind. We load the config
// sync here just to read the port; the backend task re-loads it
// (cheap — single YAML parse).
// In desktop mode this also performs the cwd relocation (no-op in
// dev, real relocation inside an `.app` bundle).
let port = match std::panic::catch_unwind(|| {
crate::config::bootstrap_data_dir()
.and_then(|_| Config::load())
.map(|c| c.server.port)
}) {
Ok(Ok(port)) => port,
Ok(Err(e)) => {
error!(error = %e, "failed to load config for window URL");
app.handle().exit(1);
return Ok(());
}
Err(_) => {
error!("config load panicked");
app.handle().exit(1);
return Ok(());
}
};
let url = format!("http://127.0.0.1:{port}");
info!(%url, "creating main window");
let parsed_url = tauri::Url::parse(&url)
.map_err(tauri::Error::InvalidUrl)?;
WebviewWindowBuilder::new(app, "main", WebviewUrl::External(parsed_url))
.title("Skald")
.inner_size(1200.0, 800.0)
.min_inner_size(800.0, 600.0)
.visible(false)
.build()?;
let app_handle = app.handle().clone();
// Spawn the backend on Tauri's shared tokio runtime.
tauri::async_runtime::spawn(async move {
match run_backend().await {
Ok(backend) => {
info!("backend ready — desktop mode");
let slot = app_handle.state::<BackendSlot>();
*slot.lock().unwrap() = Some(backend);
// Reveal the main window now that the backend is serving.
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
Err(e) => {
error!(error = %e, "backend startup failed");
app_handle.exit(1);
}
}
});
Ok(())
})
// Close button (traffic-light red / X) → hide instead of close.
// The window stays alive in the tray; only "Quit" terminates.
.on_window_event(|window, event| {
if let WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
let _ = window.hide();
}
})
.build(tauri::generate_context!())?
.run({
let exiting = exiting.clone();
move |app_handle, event| {
if let RunEvent::ExitRequested { api, .. } = event {
// Second trigger (from our own app.exit(0)) — let it proceed.
if exiting.swap(true, Ordering::SeqCst) {
return;
}
api.prevent_exit();
let app_handle = app_handle.clone();
tauri::async_runtime::spawn(async move {
// Scope the MutexGuard so it is dropped before any `.await`:
// std::sync::MutexGuard is !Send, so holding it across an
// await point would make the whole future !Send (Tauri's
// runtime requires Send futures).
let backend = {
let slot = app_handle.state::<BackendSlot>();
slot.lock().unwrap().take()
};
if let Some(backend) = backend {
info!("graceful shutdown — desktop mode");
shutdown_backend(backend).await;
info!("shutdown complete — desktop mode");
} else {
warn!("exit requested before backend was ready");
}
// Actually leave the process now.
app_handle.exit(0);
});
}
}
});
Ok(())
}
/// Build the system-tray icon, its menu (Open / Quit), and the event handlers.
fn build_tray(app: &tauri::App) -> tauri::Result<()> {
let open = MenuItemBuilder::with_id("open", "Open").build(app)?;
let quit = MenuItemBuilder::with_id("quit", "Quit").build(app)?;
let menu = MenuBuilder::new(app).items(&[&open, &quit]).build()?;
// Tray icon: reuse the app's bundled window icon for now. On macOS the
// system auto-recolors template images for the menubar theme; we set
// `icon_as_template(true)` accordingly. A dedicated monochrome tray PNG
// (loaded via the right Tauri image API for this version) can replace this
// later — see the icon sources under `icons/`.
let icon = app.default_window_icon().cloned()
.ok_or_else(|| tauri::Error::AssetNotFound("default window icon".into()))?;
TrayIconBuilder::with_id("main")
.tooltip("Skald")
.icon(icon)
.icon_as_template(true)
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id().as_ref() {
"open" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}
"quit" => {
// Trigger the graceful path via ExitRequested. The handler in
// `run()` will drain the backend and then call `exit(0)`.
app.exit(0);
}
_ => (),
})
.on_tray_icon_event(|tray, event| {
// Single left-click toggles the main window (show+focus or hide).
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
if window.is_visible().unwrap_or(false) {
let _ = window.hide();
} else {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}
}
})
.build(app)?;
Ok(())
}
+1 -1
View File
@@ -48,7 +48,7 @@ use super::ApiError;
static FEED_URL: std::sync::OnceLock<String> = std::sync::OnceLock::new(); static FEED_URL: std::sync::OnceLock<String> = std::sync::OnceLock::new();
/// Installs the feed URL from config. Called once during frontend construction; /// Installs the feed URL from config. Called once during frontend construction;
/// later calls are ignored, so tests and the desktop shell cannot race it. /// later calls are ignored, so concurrent callers (e.g. tests) cannot race it.
pub fn set_feed_url(url: String) { pub fn set_feed_url(url: String) {
let _ = FEED_URL.set(url.trim_end_matches('/').to_string()); let _ = FEED_URL.set(url.trim_end_matches('/').to_string());
} }
+14 -33
View File
@@ -1,6 +1,4 @@
mod boot_format; mod boot_format;
#[cfg(feature = "desktop")]
mod desktop;
mod frontend; mod frontend;
mod config; mod config;
@@ -11,7 +9,7 @@ use skald_core::boot;
use std::io::IsTerminal; use std::io::IsTerminal;
use std::sync::Arc; use std::sync::Arc;
use anyhow::{Context, Result}; use anyhow::Result;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use tracing::level_filters::LevelFilter; use tracing::level_filters::LevelFilter;
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
@@ -31,9 +29,8 @@ const APP_NAME: &str = env!("CARGO_PKG_NAME");
/// Backend handle — everything that must live until shutdown. /// Backend handle — everything that must live until shutdown.
/// ///
/// Constructed by [`run_backend`], consumed by [`shutdown_backend`]. In /// Constructed by [`run_backend`], consumed by [`shutdown_backend`]; it lives in
/// headless mode it lives in `async_main()`; in desktop mode it's stashed in /// `async_main()` for the lifetime of the process.
/// Tauri's managed state (`app.manage(backend)`) and consumed on Quit.
pub struct Backend { pub struct Backend {
pub skald: Arc<Skald>, pub skald: Arc<Skald>,
pub web: WebServerHandle, pub web: WebServerHandle,
@@ -44,31 +41,22 @@ fn main() -> Result<()> {
// Install the rustls crypto provider (ring) before any TLS handshake. // Install the rustls crypto provider (ring) before any TLS handshake.
// Required because reqwest is built with `rustls-no-provider` (see // Required because reqwest is built with `rustls-no-provider` (see
// Cargo.toml): exactly one process-wide provider must be installed before // Cargo.toml): exactly one process-wide provider must be installed before
// the first Client is built. In headless mode this happened to work // the first Client is built.
// because the first HTTPS request was lazy; in desktop mode the backend
// task fires requests earlier, so install it explicitly up front.
rustls::crypto::ring::default_provider().install_default() rustls::crypto::ring::default_provider().install_default()
.expect("failed to install rustls ring crypto provider"); .expect("failed to install rustls ring crypto provider");
init_logging(); init_logging();
#[cfg(feature = "desktop")] let rt = tokio::runtime::Builder::new_multi_thread()
{ .enable_all()
desktop::run() .build()?;
} rt.block_on(async_main())
#[cfg(not(feature = "desktop"))]
{
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
rt.block_on(async_main())
}
} }
/// Initialise tracing (file + boot stdout layers) and the panic hook. /// Initialise tracing (file + boot stdout layers) and the panic hook.
/// ///
/// Called once at process start, before either the tokio runtime (headless) or /// Called once at process start, before the tokio runtime is built. Not
/// the Tauri event loop (desktop). Not dependent on any async runtime. /// dependent on any async runtime.
fn init_logging() { fn init_logging() {
let log_dir = config::resolved_log_dir(); let log_dir = config::resolved_log_dir();
std::fs::create_dir_all(&log_dir).ok(); std::fs::create_dir_all(&log_dir).ok();
@@ -103,8 +91,8 @@ fn init_logging() {
.init(); .init();
// Route panics through tracing so they land in logs/ (the default hook only // Route panics through tracing so they land in logs/ (the default hook only
// writes to stderr, invisible under supervisors / Tauri). Chain to the // writes to stderr, invisible under a supervisor). Chain to the default hook
// default hook so the human-readable message + backtrace still print. // so the human-readable message + backtrace still print.
let default_panic = std::panic::take_hook(); let default_panic = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| { std::panic::set_hook(Box::new(move |info| {
let location = info.location().map(|l| l.to_string()).unwrap_or_else(|| "unknown".into()); let location = info.location().map(|l| l.to_string()).unwrap_or_else(|| "unknown".into());
@@ -116,8 +104,8 @@ fn init_logging() {
})); }));
} }
/// Headless entry point (no Tauri): run the backend, wait for a shutdown /// Entry point: run the backend, wait for a shutdown signal, then shut
/// signal, then shut everything down. Used only in `cfg(not(feature = "desktop"))`. /// everything down.
async fn async_main() -> Result<()> { async fn async_main() -> Result<()> {
info!(version = env!("CARGO_PKG_VERSION"), "starting {APP_NAME}"); info!(version = env!("CARGO_PKG_VERSION"), "starting {APP_NAME}");
boot::title(format!("{APP_NAME} v{} — starting", env!("CARGO_PKG_VERSION"))); boot::title(format!("{APP_NAME} v{} — starting", env!("CARGO_PKG_VERSION")));
@@ -135,14 +123,7 @@ async fn async_main() -> Result<()> {
/// Boot the Skald backend: load config, build plugins, open the DB pool, /// Boot the Skald backend: load config, build plugins, open the DB pool,
/// construct `Skald`, and start the web frontend. Returns a [`Backend`] whose /// construct `Skald`, and start the web frontend. Returns a [`Backend`] whose
/// components must be shut down via [`shutdown_backend`] for graceful exit. /// components must be shut down via [`shutdown_backend`] for graceful exit.
///
/// Shared by both the headless entry point and the desktop (Tauri) setup hook.
pub async fn run_backend() -> Result<Backend> { pub async fn run_backend() -> Result<Backend> {
// In desktop mode, relocate the process cwd to the OS-appropriate per-user
// data dir before reading any relative path (db, logs, data, …). Headless
// mode keeps the cwd unchanged.
config::bootstrap_data_dir()?;
let cfg = match Config::load() { let cfg = match Config::load() {
Ok(c) => { debug!("config loaded"); c } Ok(c) => { debug!("config loaded"); c }
Err(e) => { error!(error = %e, "failed to load config"); return Err(e); } Err(e) => { error!(error = %e, "failed to load config"); return Err(e); }
-36
View File
@@ -1,36 +0,0 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Skald",
"version": "0.1.0",
"identifier": "ai.skald.desktop",
"build": {
"frontendDist": "./web"
},
"app": {
"withGlobalTauri": false,
"windows": [],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"macOS": {
"minimumSystemVersion": "10.15"
},
"resources": {
"agents/": "agents/",
"web/": "web/",
"skills/": "skills/",
"commands/": "commands/"
},
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
+3 -1
View File
@@ -389,6 +389,7 @@ export class AppCopilot extends I18nMixin(ChatSession) {
</div> </div>
<div class="copilot-input-area"> <div class="copilot-input-area">
${this._renderNoModelsBanner()}
<div class="copilot-composer" <div class="copilot-composer"
@dragover=${(e) => e.preventDefault()} @dragover=${(e) => e.preventDefault()}
@drop=${(e) => this._onDrop(e)}> @drop=${(e) => this._onDrop(e)}>
@@ -416,6 +417,7 @@ export class AppCopilot extends I18nMixin(ChatSession) {
<textarea <textarea
class="copilot-textarea" class="copilot-textarea"
rows="1" rows="1"
?disabled=${this._noModels}
placeholder=${t('chat.placeholder')} placeholder=${t('chat.placeholder')}
@keydown=${this._composerKeydown} @keydown=${this._composerKeydown}
@input=${(e) => { this._autoResize(e.target); this._updateCmdMenu(e.target.value); }} @input=${(e) => { this._autoResize(e.target); this._updateCmdMenu(e.target.value); }}
@@ -469,7 +471,7 @@ export class AppCopilot extends I18nMixin(ChatSession) {
<i class="bi bi-stop-fill"></i> <i class="bi bi-stop-fill"></i>
</button>` </button>`
: nothing} : nothing}
<button class="copilot-send-btn" @click=${() => this._send()} title=${t('chat.send')}> <button class="copilot-send-btn" ?disabled=${this._noModels} @click=${() => this._send()} title=${t('chat.send')}>
<i class="bi bi-send-fill"></i> <i class="bi bi-send-fill"></i>
</button> </button>
</div> </div>
+3
View File
@@ -139,6 +139,7 @@ export class ChatPage extends ChatSession {
</div> </div>
<div class="chat-page-input-area"> <div class="chat-page-input-area">
${this._renderNoModelsBanner()}
<div class="chat-page-composer" <div class="chat-page-composer"
@dragover=${(e) => e.preventDefault()} @dragover=${(e) => e.preventDefault()}
@drop=${(e) => this._onDrop(e)}> @drop=${(e) => this._onDrop(e)}>
@@ -153,6 +154,7 @@ export class ChatPage extends ChatSession {
<textarea <textarea
class="chat-page-textarea" class="chat-page-textarea"
rows="1" rows="1"
?disabled=${this._noModels}
placeholder=${t('chat.mobile.placeholder')} placeholder=${t('chat.mobile.placeholder')}
@input=${(e) => this._autoResize(e.target)} @input=${(e) => this._autoResize(e.target)}
@paste=${(e) => this._onPaste(e)} @paste=${(e) => this._onPaste(e)}
@@ -195,6 +197,7 @@ export class ChatPage extends ChatSession {
: nothing} : nothing}
<button <button
class="chat-page-send" class="chat-page-send"
?disabled=${this._noModels}
@click=${() => this._send()} @click=${() => this._send()}
title=${t('chat.send')} title=${t('chat.send')}
><i class="bi bi-send-fill"></i></button> ><i class="bi bi-send-fill"></i></button>
+8
View File
@@ -6,6 +6,14 @@
flex-shrink: 0; flex-shrink: 0;
} }
.chat-no-models {
margin-bottom: 0.6rem;
padding: 0.7rem 0.9rem;
font-size: 0.82rem;
}
.chat-no-models .home-banner-icon { font-size: 1.2rem; }
.chat-no-models a { color: var(--bs-danger); font-weight: 600; }
.copilot-composer { .copilot-composer {
position: relative; position: relative;
display: flex; display: flex;
+24
View File
@@ -1,3 +1,4 @@
import { html, nothing } from 'lit';
import { LightElement } from './base.js'; import { LightElement } from './base.js';
import { t } from './i18n.js'; import { t } from './i18n.js';
@@ -29,6 +30,7 @@ export class ChatSession extends LightElement {
_expanded: { state: true }, _expanded: { state: true },
_providers: { state: true }, _providers: { state: true },
_selectedClient: { state: true }, _selectedClient: { state: true },
_providersLoaded: { state: true },
_rejectingId: { state: true }, _rejectingId: { state: true },
_rejectNote: { state: true }, _rejectNote: { state: true },
_clarificationAnswer: { state: true }, _clarificationAnswer: { state: true },
@@ -54,6 +56,7 @@ export class ChatSession extends LightElement {
this._ws = null; this._ws = null;
this._providers = []; this._providers = [];
this._selectedClient = null; this._selectedClient = null;
this._providersLoaded = false;
this._rejectingId = null; this._rejectingId = null;
this._rejectNote = ''; this._rejectNote = '';
this._clarificationAnswer = ''; this._clarificationAnswer = '';
@@ -116,9 +119,30 @@ export class ChatSession extends LightElement {
this._selectedClient = def; this._selectedClient = def;
} catch (e) { } catch (e) {
console.error('Failed to load LLM models:', e); console.error('Failed to load LLM models:', e);
} finally {
this._providersLoaded = true;
} }
} }
get _noModels() {
return this._providersLoaded
&& this._providers.filter(p => p !== 'auto').length === 0;
}
_renderNoModelsBanner() {
if (!this._noModels) return nothing;
return html`
<div class="home-banner home-banner--error chat-no-models">
<div class="home-banner-icon"><i class="bi bi-cpu-fill"></i></div>
<div class="home-banner-body">
<strong>${t('dashboard.banner.no_models.title')}</strong>
${t('dashboard.banner.no_models.desc')}
<a href="#llm-providers">${t('dashboard.banner.no_models.action')}</a>
</div>
</div>
`;
}
async _loadHistory() { async _loadHistory() {
try { try {
const res = await fetch(`/api/${this._source}/messages`); const res = await fetch(`/api/${this._source}/messages`);
+16
View File
@@ -23,6 +23,22 @@ export function t(key, params) {
return s; return s;
} }
/**
* Merge additional strings into the dictionaries — the registration seam for
* plugin page fragments, which ship their own `{ en, it, fr }` table and call
* this at module-load time (before their first render). Keys MUST be namespaced
* (`plugin.<id>.<key>`) so a plugin never clobbers a core key or another
* plugin's. Unknown locales are created on demand; missing keys still fall back
* through `t()` (locale → en → key). Merging alone does not re-render — the
* fragment registers before it mounts, and later `locale-changed` events drive
* updates as usual via `I18nMixin`.
*/
export function addStrings(dicts) {
for (const [loc, map] of Object.entries(dicts || {})) {
DICTS[loc] = { ...(DICTS[loc] || {}), ...map };
}
}
export function getLocale() { return _locale; } export function getLocale() { return _locale; }
export function setLocale(locale, { persist = false } = {}) { export function setLocale(locale, { persist = false } = {}) {