fix: reduced app size by a good margin

This commit is contained in:
2026-07-20 08:44:17 +02:00
parent 7ba2699b68
commit 269e4b0e4a
21 changed files with 494 additions and 96 deletions

View File

@@ -2,3 +2,19 @@
bundle-macos = "packager --release --packages bds-ui --formats app --formats dmg" bundle-macos = "packager --release --packages bds-ui --formats app --formats dmg"
bundle-windows = "packager --release --packages bds-ui --formats nsis" bundle-windows = "packager --release --packages bds-ui --formats nsis"
bundle-linux = "packager --release --packages bds-ui --formats deb --formats appimage" bundle-linux = "packager --release --packages bds-ui --formats deb --formats appimage"
[target.'cfg(target_os = "macos")']
rustflags = [
"-C", "prefer-dynamic",
"-C", "link-arg=-mmacosx-version-min=26.0",
"-C", "link-arg=-Wl,-rpath,@executable_path/../Resources",
]
[target.'cfg(target_os = "linux")']
rustflags = [
"-C", "prefer-dynamic",
"-C", "link-arg=-Wl,-rpath,$ORIGIN/../lib/bds-ui",
]
[target.'cfg(target_os = "windows")']
rustflags = ["-C", "prefer-dynamic"]

4
Cargo.lock generated
View File

@@ -994,6 +994,10 @@ dependencies = [
"tempfile", "tempfile",
] ]
[[package]]
name = "bds-package-runtime"
version = "0.1.0"
[[package]] [[package]]
name = "bds-server" name = "bds-server"
version = "0.1.0" version = "0.1.0"

View File

@@ -7,6 +7,7 @@ members = [
"crates/bds-cli", "crates/bds-cli",
"crates/bds-mcp", "crates/bds-mcp",
"crates/bds-server", "crates/bds-server",
"crates/bds-package-runtime",
] ]
[workspace.package] [workspace.package]
@@ -78,3 +79,6 @@ bds-core = { path = "crates/bds-core" }
bds-editor = { path = "crates/bds-editor" } bds-editor = { path = "crates/bds-editor" }
bds-mcp = { path = "crates/bds-mcp" } bds-mcp = { path = "crates/bds-mcp" }
bds-server = { path = "crates/bds-server" } bds-server = { path = "crates/bds-server" }
[profile.release]
strip = "symbols"

View File

@@ -19,7 +19,7 @@ The project is under active development. Core blogging workflows are broadly ava
- Headless `bds-cli` automation for rebuild/repair/render, publishing and Git sync, post/media/gallery creation, shared settings/projects, utility Lua tasks, JSON I/O, airplane-mode AI routing, and guarded launcher installation from Settings → Data or `bds-cli install`. - Headless `bds-cli` automation for rebuild/repair/render, publishing and Git sync, post/media/gallery creation, shared settings/projects, utility Lua tasks, JSON I/O, airplane-mode AI routing, and guarded launcher installation from Settings → Data or `bds-cli install`.
- Local MCP automation over stdio or a localhost-only stateless HTTP endpoint, with project resources, read/search/count tools, inert write proposals, explicit desktop approval, and opt-in Claude Code/Copilot configuration. - Local MCP automation over stdio or a localhost-only stateless HTTP endpoint, with project resources, read/search/count tools, inert write proposals, explicit desktop approval, and opt-in Claude Code/Copilot configuration.
- A full Ratatui terminal workspace, available locally through `bds-cli tui`/`BDS_MODE=tui` and remotely through authenticated SSH shell sessions, with shared post/template/script editing and publishing, project/search/command overlays, settings, tags, Git, reports, task progress, live multi-client updates, locale changes, and airplane-mode AI gating. - A full Ratatui terminal workspace, available locally through `bds-cli tui`/`BDS_MODE=tui` and remotely through authenticated SSH shell sessions, with shared post/template/script editing and publishing, project/search/command overlays, settings, tags, Git, reports, task progress, live multi-client updates, locale changes, and airplane-mode AI gating.
- Headless `bds-server` hosting the shared application engines over a loopback-by-default, public-key-only SSH service, with restrictive private key material, live authorization updates, terminal-session transport, CLI-change synchronization, ordered domain/task events, and native desktop remote-project selection. - `bds-cli server` hosting the shared application engines over a loopback-by-default, public-key-only SSH service, with restrictive private key material, live authorization updates, terminal-session transport, CLI-change synchronization, ordered domain/task events, and native desktop remote-project selection.
- Markdown/Liquid rendering with native macros, multilingual routes, feeds, sitemap, Pagefind, and incremental site generation through cancellable section task groups. - Markdown/Liquid rendering with native macros, multilingual routes, feeds, sitemap, Pagefind, and incremental site generation through cancellable section task groups.
- Local preview in the app or system browser. - Local preview in the app or system browser.
- Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations using online or local OpenAI-compatible endpoints with airplane-mode gating. - Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations using online or local OpenAI-compatible endpoints with airplane-mode gating.
@@ -30,6 +30,12 @@ The project is under active development. Core blogging workflows are broadly ava
RuDS uses no JavaScript application runtime and loads no CSS or JavaScript from CDNs. The preview is served by the Rust application and displayed by the operating-system webview. RuDS uses no JavaScript application runtime and loads no CSS or JavaScript from CDNs. The preview is served by the Rust application and displayed by the operating-system webview.
The packaged Apple Silicon application requires macOS 26 or newer.
Packaged executables share native `bds-core` and `bds-server` dynamic libraries. ONNX Runtime is statically contained in `bds-core`; the package does not ship or download a separate ONNX library. The **Install CLI** action writes a small forwarding launcher to `~/.local/bin`, so the command continues to execute the packaged CLI beside the same runtime libraries instead of copying them.
Local macOS packages are ad-hoc signed without hardened runtime. A Developer ID and notarization remain optional release-channel steps for downloads that should pass Gatekeeper without a user override.
## Repository Map ## Repository Map
- `crates/bds-core` — data, engines, rendering, AI, publishing, and Lua - `crates/bds-core` — data, engines, rendering, AI, publishing, and Lua
@@ -37,7 +43,7 @@ RuDS uses no JavaScript application runtime and loads no CSS or JavaScript from
- `crates/bds-ui` — desktop application and platform integration - `crates/bds-ui` — desktop application and platform integration
- `crates/bds-cli` — headless automation CLI over the shared engines - `crates/bds-cli` — headless automation CLI over the shared engines
- `crates/bds-mcp` — packaged stdio MCP transport over the shared MCP engine - `crates/bds-mcp` — packaged stdio MCP transport over the shared MCP engine
- `crates/bds-server` — headless engine host, SSH transport, remote protocol, and desktop client - `crates/bds-server` reusable headless host, SSH transport, remote protocol, and desktop client library
- `specs` — authoritative Allium behavior specifications - `specs` — authoritative Allium behavior specifications
- `fixtures` — compatibility projects and generated-site fixtures - `fixtures` — compatibility projects and generated-site fixtures
- `locales` — UI and native-menu translations - `locales` — UI and native-menu translations
@@ -54,4 +60,4 @@ Contributor workflow and project invariants are documented in [AGENTS.md](AGENTS
## Headless server ## Headless server
Run `bds-server` for a dedicated headless process, or set `BDS_MODE=server` when launching `bds-ui`. The SSH listener defaults to `127.0.0.1:2222`; use `--bind`/`BDS_SSH_BIND` and `--port`/`BDS_SSH_PORT` to opt into another address. Startup prints the private `authorized_keys` path. The desktop creates its own private `id_ed25519.pub`; add that public key to the server file, then use **File → Connect to Server…** and select a remote project. Host keys are recorded on first connection and verified thereafter. Run `bds-cli tui` or set `BDS_MODE=tui` for the local terminal workspace; an authenticated SSH shell opens the same workspace against server-side data and locale. Press `:` for its command list and `:?` for help. Run `bds-cli server` for a dedicated headless process, or set `BDS_MODE=server` when launching `bds-ui`. The SSH listener defaults to `127.0.0.1:2222`; use `--bind`/`BDS_SSH_BIND` and `--port`/`BDS_SSH_PORT` to opt into another address. Startup prints the private `authorized_keys` path. The desktop creates its own private `id_ed25519.pub`; add that public key to the server file, then use **File → Connect to Server…** and select a remote project. Host keys are recorded on first connection and verified thereafter. Run `bds-cli tui` or set `BDS_MODE=tui` for the local terminal workspace; an authenticated SSH shell opens the same workspace against server-side data and locale. Press `:` for its command list and `:?` for help.

View File

@@ -6,7 +6,7 @@ RuDS is the native Rust replacement for bDS2. Core covers the complete everyday
The behavioural contract is `specs/*.allium`. When a spec is ambiguous, `../bDS2` is the reference implementation. [RUST_PLAN_EXTENSION.md](RUST_PLAN_EXTENSION.md) contains optional and advanced surfaces. The behavioural contract is `specs/*.allium`. When a spec is ambiguous, `../bDS2` is the reference implementation. [RUST_PLAN_EXTENSION.md](RUST_PLAN_EXTENSION.md) contains optional and advanced surfaces.
Status in this document describes the current source code as of 2026-07-19. It deliberately does not track build runs, test runs, release gates, or implementation history. Status in this document describes the current source code as of 2026-07-20. It deliberately does not track build runs, test runs, release gates, or implementation history.
## Non-Negotiable Constraints ## Non-Negotiable Constraints
@@ -25,11 +25,11 @@ Status in this document describes the current source code as of 2026-07-19. It d
| Crate | Responsibility | | Crate | Responsibility |
|---|---| |---|---|
| `bds-core` | Models, SQLite, filesystem formats, engines, rendering, generation, AI, publishing, and Lua | | `bds-core` | Shared dynamic library for models, SQLite, filesystem formats, engines, rendering, generation, AI, publishing, and Lua |
| `bds-editor` | Reusable Ropey/Syntect/Cosmic Text editor widget | | `bds-editor` | Reusable Ropey/Syntect/Cosmic Text editor widget |
| `bds-ui` | Iced application, native menus/dialogs, platform lifecycle, and embedded preview | | `bds-ui` | Iced application, native menus/dialogs, platform lifecycle, and embedded preview |
| `bds-cli` | Extension-only headless automation surface over the shared engines | | `bds-cli` | Extension-only headless automation surface over the shared engines |
| `bds-server` | Extension-only headless engine host and authenticated SSH transport | | `bds-server` | Reusable extension-only headless host and authenticated SSH transport library |
## Current Core Status ## Current Core Status

View File

@@ -4,7 +4,7 @@
Extensions add migration, collaboration, automation, alternate clients, and advanced discovery on top of the core blogging workflow. They must reuse core engines and formats, remain reachable through real UI or automation surfaces, and preserve airplane-mode and localization rules. Extensions add migration, collaboration, automation, alternate clients, and advanced discovery on top of the core blogging workflow. They must reuse core engines and formats, remain reachable through real UI or automation surfaces, and preserve airplane-mode and localization rules.
Status describes the current source code as of 2026-07-19. Core one-shot AI, publishing, rendering, and integrity work is tracked in [RUST_PLAN_CORE.md](RUST_PLAN_CORE.md). Status describes the current source code as of 2026-07-20. Core one-shot AI, publishing, rendering, and integrity work is tracked in [RUST_PLAN_CORE.md](RUST_PLAN_CORE.md).
## Current Extension Status ## Current Extension Status
@@ -44,7 +44,7 @@ Done:
### Embeddings, semantic search, and duplicates — Done ### Embeddings, semantic search, and duplicates — Done
- Lazy, locally cached `multilingual-e5-small` inference with 384-dimensional mean-pooled, normalized vectors and native Core ML acceleration on macOS or DirectML on Windows. - Lazy, locally cached `multilingual-e5-small` inference with 384-dimensional mean-pooled, normalized vectors and statically linked ONNX Runtime inside the shared core library, with native Core ML acceleration on macOS or DirectML on Windows.
- Project-isolated USearch HNSW indexes with SQLite BLOB vectors as the recovery source, debounced persistence, lifecycle updates, rebuild/backfill, and CLI repair. - Project-isolated USearch HNSW indexes with SQLite BLOB vectors as the recovery source, debounced persistence, lifecycle updates, rebuild/backfill, and CLI repair.
- Semantic sidebar search, link ranking, editor tag suggestions, and the bDS2-compatible `bds.embeddings` Lua API. - Semantic sidebar search, link ranking, editor tag suggestions, and the bDS2-compatible `bds.embeddings` Lua API.
- Localized duplicate review with exact-match detection, 500-pair pagination, canonical single/batch dismissal, and post navigation. - Localized duplicate review with exact-match detection, 500-pair pagination, canonical single/batch dismissal, and post navigation.
@@ -74,9 +74,9 @@ Done:
Done: Done:
- Domain event bus from `events.allium` for desktop, CLI, TUI, server, and future remote clients, including deterministic subscriptions, project scope, and persisted CLI notification consumption/pruning. - Domain event bus from `events.allium` for desktop, CLI, TUI, server, and future remote clients, including deterministic subscriptions, project scope, and persisted CLI notification consumption/pruning.
- Native `bds-cli` with Clap help/error handling, optional JSON output, shared application paths/database/projects/settings, full and incremental rebuild, derived-data repair, full/targeted/forced generation, publishing, fast-forward Git sync, post/media/gallery creation, offline/local AI routing and translation, project/config operations, sandboxed utility Lua execution, and guarded launcher installation. - Native `bds-cli` with Clap help/error handling, optional JSON output, shared application paths/database/projects/settings, full and incremental rebuild, derived-data repair, full/targeted/forced generation, publishing, fast-forward Git sync, post/media/gallery creation, offline/local AI routing and translation, project/config operations, sandboxed utility Lua execution, `server`/`tui` boot modes, and guarded launcher installation.
- CLI process and dispatch tests use temporary databases/projects; CLI mutations persist deduplicated desktop notifications and imported filesystem metadata survives rebuild. - CLI process and dispatch tests use temporary databases/projects; CLI mutations persist deduplicated desktop notifications and imported filesystem metadata survives rebuild.
- Settings → Data exposes the same localized packaged-launcher installer as `bds-cli install`. - Settings → Data exposes the same localized packaged-launcher installer as `bds-cli install`; its forwarding launcher executes the packaged CLI in place beside the shared runtime.
- MCP exposes the complete `bds://` resource set and typed read/search/count tools through packaged stdio and stateless localhost-only HTTP transports with Origin/Host validation and CORS. - MCP exposes the complete `bds://` resource set and typed read/search/count tools through packaged stdio and stateless localhost-only HTTP transports with Origin/Host validation and CORS.
- Every MCP write is an inert persisted proposal until one desktop approval applies it exactly once through the shared post/media/script/template engines; rejection, expiry, concurrent resolution, result state, and normal domain events are covered. - Every MCP write is an inert persisted proposal until one desktop approval applies it exactly once through the shared post/media/script/template engines; rejection, expiry, concurrent resolution, result state, and normal domain events are covered.
- Settings → MCP provides localized server enablement/status/endpoint, full proposal review controls, and opt-in guarded Claude Code and GitHub Copilot configuration without secrets. - Settings → MCP provides localized server enablement/status/endpoint, full proposal review controls, and opt-in guarded Claude Code and GitHub Copilot configuration without secrets.
@@ -93,11 +93,13 @@ Done:
Done: Done:
- Desktop/server/TUI boot selection occurs before desktop initialization; the dedicated `bds-server` binary never depends on or starts the native UI. - Desktop/server/TUI boot selection occurs before desktop initialization. `bds-cli server` starts the dedicated headless mode; no standalone `bds-server` executable is built or distributed.
- The headless host reuses the application database, project registry, `CoreHost` service operations, task manager, MCP loopback endpoint, ordered domain-event bus, and CLI-notification watcher. - The headless host reuses the application database, project registry, `CoreHost` service operations, task manager, MCP loopback endpoint, ordered domain-event bus, and CLI-notification watcher.
- A loopback-by-default Russh daemon generates and reuses a restrictive RSA host key, validates private directory/file modes, reloads `authorized_keys` for every authentication, and supports explicit external binding only. - A loopback-by-default Russh daemon generates and reuses a restrictive RSA host key, validates private directory/file modes, reloads `authorized_keys` for every authentication, and supports explicit external binding only.
- Versioned native remote-project sessions provide protocol negotiation, server locale, project selection, shared engine calls, replay-safe request IDs, ordered domain events, task snapshots, reconnect, concurrent clients, and graceful shutdown. - Versioned native remote-project sessions provide protocol negotiation, server locale, project selection, shared engine calls, replay-safe request IDs, ordered domain events, task snapshots, reconnect, concurrent clients, and graceful shutdown.
- The desktop uses a restrictive generated Ed25519 identity and TOFU `known_hosts`, with localized File-menu connect/project selection/open/failure/disconnect states. SSH shell channels host server-side terminal sessions and direct forwarding is restricted to the server-owned loopback endpoint. - The desktop uses a restrictive generated Ed25519 identity and TOFU `known_hosts`, with localized File-menu connect/project selection/open/failure/disconnect states. SSH shell channels host server-side terminal sessions and direct forwarding is restricted to the server-owned loopback endpoint.
- Packaged executables dynamically share `bds-core`, `bds-server`, and the matching Rust standard library. ONNX Runtime remains statically linked into `bds-core`, avoiding both executable duplication and a separately managed ONNX binary.
- Local macOS packages use non-hardened ad-hoc signatures; Developer ID signing and notarization are optional public-release steps rather than build requirements.
### Terminal UI — Complete ### Terminal UI — Complete

View File

@@ -1,4 +1,5 @@
use std::fmt::Write as _; use std::fmt::Write as _;
use std::net::IpAddr;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@@ -72,6 +73,8 @@ pub enum Command {
#[command(subcommand)] #[command(subcommand)]
command: ProjectCommand, command: ProjectCommand,
}, },
/// Start the authenticated headless SSH server.
Server(ServerArgs),
/// Start interactive TUI mode (normally intercepted by the packaged launcher). /// Start interactive TUI mode (normally intercepted by the packaged launcher).
Tui, Tui,
/// Run an enabled utility Lua script from the active project. /// Run an enabled utility Lua script from the active project.
@@ -84,6 +87,22 @@ pub enum Command {
Install, Install,
} }
#[derive(Debug, Args, Default)]
pub struct ServerArgs {
/// SSH listen address. Defaults to loopback; external access must be explicit.
#[arg(long)]
pub bind: Option<IpAddr>,
/// SSH listen port.
#[arg(long)]
pub port: Option<u16>,
/// Application database path.
#[arg(long)]
pub database: Option<PathBuf>,
/// Private application data directory containing SSH key material.
#[arg(long)]
pub data_dir: Option<PathBuf>,
}
#[derive(Debug, Clone, Copy, ValueEnum)] #[derive(Debug, Clone, Copy, ValueEnum)]
pub enum RepairPart { pub enum RepairPart {
PostLinks, PostLinks,
@@ -257,10 +276,8 @@ fn execute(command: Command, context: &RunContext, airplane: bool) -> Result<Com
if matches!(command, Command::Install) { if matches!(command, Command::Install) {
return install_launcher(context); return install_launcher(context);
} }
if matches!(command, Command::Tui) { if matches!(command, Command::Tui | Command::Server(_)) {
bail!( bail!("server and TUI modes are started directly by the native bds-cli entry point");
"the packaged bds-cli launcher starts TUI mode directly; a direct CLI invocation cannot own the terminal"
);
} }
let db = open_database(&context.database_path)?; let db = open_database(&context.database_path)?;
@@ -277,7 +294,7 @@ fn execute(command: Command, context: &RunContext, airplane: bool) -> Result<Com
Command::Config { command } => config(&db, command), Command::Config { command } => config(&db, command),
Command::Project { command } => project(&db, command), Command::Project { command } => project(&db, command),
Command::Lua { script, args } => run_lua(&db, &script, &args, airplane), Command::Lua { script, args } => run_lua(&db, &script, &args, airplane),
Command::Tui | Command::Install => unreachable!(), Command::Server(_) | Command::Tui | Command::Install => unreachable!(),
} }
} }
@@ -1143,6 +1160,7 @@ fn command_name(command: &Command) -> &'static str {
Command::Gallery(_) => "gallery", Command::Gallery(_) => "gallery",
Command::Config { .. } => "config", Command::Config { .. } => "config",
Command::Project { .. } => "project", Command::Project { .. } => "project",
Command::Server(_) => "server",
Command::Tui => "tui", Command::Tui => "tui",
Command::Lua { .. } => "lua", Command::Lua { .. } => "lua",
Command::Install => "install", Command::Install => "install",
@@ -1223,10 +1241,24 @@ mod tests {
let help = String::from_utf8(help).unwrap(); let help = String::from_utf8(help).unwrap();
for command in [ for command in [
"rebuild", "repair", "render", "upload", "push", "pull", "post", "media", "gallery", "rebuild", "repair", "render", "upload", "push", "pull", "post", "media", "gallery",
"config", "project", "tui", "lua", "install", "config", "project", "server", "tui", "lua", "install",
] { ] {
assert!(help.contains(command), "missing {command} from help"); assert!(help.contains(command), "missing {command} from help");
} }
let server = Cli::try_parse_from([
"bds-cli",
"server",
"--bind",
"127.0.0.2",
"--port",
"2233",
"--database",
"/tmp/bds.db",
"--data-dir",
"/tmp/bds",
])
.unwrap();
assert!(matches!(server.command, Command::Server(_)));
assert!(Cli::try_parse_from(["bds-cli", "unknown"]).is_err()); assert!(Cli::try_parse_from(["bds-cli", "unknown"]).is_err());
assert!(Cli::try_parse_from(["bds-cli", "render", "--incremental", "--force"]).is_err()); assert!(Cli::try_parse_from(["bds-cli", "render", "--incremental", "--force"]).is_err());
assert!(Cli::try_parse_from(["bds-cli", "repair", "unknown"]).is_err()); assert!(Cli::try_parse_from(["bds-cli", "repair", "unknown"]).is_err());
@@ -1511,9 +1543,11 @@ mod tests {
) )
.unwrap(); .unwrap();
let target = fixture.home_dir.join(".local/bin/bds-cli"); let target = fixture.home_dir.join(".local/bin/bds-cli");
assert_eq!( assert!(!target.is_symlink());
target.canonicalize().unwrap(), assert!(
executable.canonicalize().unwrap() std::fs::read_to_string(target)
.unwrap()
.contains(executable.canonicalize().unwrap().to_str().unwrap())
); );
} }
} }

View File

@@ -20,6 +20,33 @@ fn main() -> ExitCode {
} }
}; };
if let bds_cli::Command::Server(args) = &cli.command {
let data_root = args
.data_dir
.clone()
.unwrap_or_else(bds_core::util::application_data_dir);
let database_path = args
.database
.clone()
.unwrap_or_else(|| data_root.join("bds.db"));
let config = match bds_server::ServerConfig::from_environment(database_path, data_root) {
Ok(mut config) => {
if let Some(bind) = args.bind {
config.bind = bind;
}
if let Some(port) = args.port {
config.port = port;
}
config
}
Err(error) => {
eprintln!("Error: {error:#}");
return ExitCode::from(1);
}
};
return run_headless(bds_server::boot::BootMode::Server, config);
}
if matches!(cli.command, bds_cli::Command::Tui) { if matches!(cli.command, bds_cli::Command::Tui) {
let data_root = bds_core::util::application_data_dir(); let data_root = bds_core::util::application_data_dir();
let config = match bds_server::ServerConfig::from_environment( let config = match bds_server::ServerConfig::from_environment(
@@ -32,13 +59,7 @@ fn main() -> ExitCode {
return ExitCode::from(1); return ExitCode::from(1);
} }
}; };
return match bds_server::run_headless(bds_server::boot::BootMode::Tui, config) { return run_headless(bds_server::boot::BootMode::Tui, config);
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("Error: {error:#}");
ExitCode::from(1)
}
};
} }
let json = cli.json; let json = cli.json;
@@ -71,3 +92,13 @@ fn main() -> ExitCode {
} }
} }
} }
fn run_headless(mode: bds_server::boot::BootMode, config: bds_server::ServerConfig) -> ExitCode {
match bds_server::run_headless(mode, config) {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("Error: {error:#}");
ExitCode::from(1)
}
}
}

View File

@@ -4,6 +4,9 @@ edition.workspace = true
version.workspace = true version.workspace = true
license.workspace = true license.workspace = true
[lib]
crate-type = ["dylib"]
[dependencies] [dependencies]
uuid = { workspace = true } uuid = { workspace = true }
serde = { workspace = true } serde = { workspace = true }

View File

@@ -14,14 +14,18 @@ pub fn install_launcher(executable: &Path, home_dir: &Path) -> EngineResult<Path
let bin_dir = home_dir.join(".local/bin"); let bin_dir = home_dir.join(".local/bin");
std::fs::create_dir_all(&bin_dir)?; std::fs::create_dir_all(&bin_dir)?;
let target = bin_dir.join(if cfg!(windows) { let target = bin_dir.join(if cfg!(windows) {
"bds-cli.exe" "bds-cli.cmd"
} else { } else {
"bds-cli" "bds-cli"
}); });
if target.exists() {
let existing = target.canonicalize().ok();
let source = executable.canonicalize()?; let source = executable.canonicalize()?;
if existing.as_ref() != Some(&source) { let launcher = launcher_contents(&source);
#[cfg(unix)]
if target.is_symlink() && target.canonicalize().ok().as_ref() == Some(&source) {
std::fs::remove_file(&target)?;
}
if target.exists() {
if std::fs::read(&target).ok().as_deref() != Some(launcher.as_bytes()) {
return Err(EngineError::Conflict(format!( return Err(EngineError::Conflict(format!(
"refusing to overwrite existing launcher at {}", "refusing to overwrite existing launcher at {}",
target.display() target.display()
@@ -30,13 +34,29 @@ pub fn install_launcher(executable: &Path, home_dir: &Path) -> EngineResult<Path
return Ok(target); return Ok(target);
} }
std::fs::write(&target, launcher)?;
#[cfg(unix)] #[cfg(unix)]
std::os::unix::fs::symlink(executable.canonicalize()?, &target)?; {
#[cfg(windows)] use std::os::unix::fs::PermissionsExt as _;
std::fs::copy(executable, &target)?; let mut permissions = std::fs::metadata(&target)?.permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&target, permissions)?;
}
Ok(target) Ok(target)
} }
#[cfg(unix)]
fn launcher_contents(executable: &Path) -> String {
let quoted = executable.to_string_lossy().replace('\'', "'\"'\"'");
format!("#!/bin/sh\nexec '{quoted}' \"$@\"\n")
}
#[cfg(windows)]
fn launcher_contents(executable: &Path) -> String {
let escaped = executable.to_string_lossy().replace('%', "%%");
format!("@echo off\r\n\"{escaped}\" %*\r\n")
}
/// Resolve the CLI shipped beside the desktop executable and install it. /// Resolve the CLI shipped beside the desktop executable and install it.
pub fn install_packaged_launcher(home_dir: &Path) -> EngineResult<PathBuf> { pub fn install_packaged_launcher(home_dir: &Path) -> EngineResult<PathBuf> {
let app = std::env::current_exe()?; let app = std::env::current_exe()?;
@@ -59,10 +79,9 @@ mod tests {
std::fs::write(&executable, b"binary").unwrap(); std::fs::write(&executable, b"binary").unwrap();
let home = root.path().join("home"); let home = root.path().join("home");
let target = install_launcher(&executable, &home).unwrap(); let target = install_launcher(&executable, &home).unwrap();
assert_eq!( assert!(!target.is_symlink());
target.canonicalize().unwrap(), let launcher = std::fs::read_to_string(&target).unwrap();
executable.canonicalize().unwrap() assert!(launcher.contains(executable.canonicalize().unwrap().to_str().unwrap()));
);
assert_eq!(install_launcher(&executable, &home).unwrap(), target); assert_eq!(install_launcher(&executable, &home).unwrap(), target);
std::fs::remove_file(&target).unwrap(); std::fs::remove_file(&target).unwrap();
@@ -70,4 +89,24 @@ mod tests {
assert!(install_launcher(&executable, &home).is_err()); assert!(install_launcher(&executable, &home).is_err());
assert_eq!(std::fs::read(&target).unwrap(), b"mine"); assert_eq!(std::fs::read(&target).unwrap(), b"mine");
} }
#[cfg(unix)]
#[test]
fn replaces_the_previous_installer_symlink_with_a_forwarding_launcher() {
let root = tempfile::tempdir().unwrap();
let executable = root.path().join("packaged-bds-cli");
std::fs::write(&executable, b"binary").unwrap();
let home = root.path().join("home");
let target = home.join(".local/bin/bds-cli");
std::fs::create_dir_all(target.parent().unwrap()).unwrap();
std::os::unix::fs::symlink(&executable, &target).unwrap();
assert_eq!(install_launcher(&executable, &home).unwrap(), target);
assert!(!target.is_symlink());
assert!(
std::fs::read_to_string(target)
.unwrap()
.starts_with("#!/bin/sh")
);
}
} }

View File

@@ -1599,7 +1599,7 @@ mod tests {
let engine = GitEngine::with_executable_and_timeouts( let engine = GitEngine::with_executable_and_timeouts(
dir.path(), dir.path(),
executable, executable,
Duration::from_secs(1), Duration::from_secs(5),
Duration::from_secs(1), Duration::from_secs(1),
); );

View File

@@ -0,0 +1,6 @@
[package]
name = "bds-package-runtime"
edition.workspace = true
version.workspace = true
license.workspace = true

View File

@@ -0,0 +1,231 @@
use std::{
env,
error::Error,
ffi::OsStr,
fs,
path::{Path, PathBuf},
process::Command,
};
fn main() -> Result<(), Box<dyn Error>> {
let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
if env::args_os().nth(1).as_deref() == Some(OsStr::new("sign-app-for-dmg")) {
return sign_app_for_dmg(&workspace);
}
run(
Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into()))
.current_dir(&workspace)
.args([
"build",
"--release",
"-p",
"bds-ui",
"-p",
"bds-cli",
"-p",
"bds-mcp",
]),
"release build",
)?;
let target = env::var_os("CARGO_TARGET_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| workspace.join("target"));
let release = target.join("release");
let staging = release.join("bds-runtime");
if staging.exists() {
fs::remove_dir_all(&staging)?;
}
fs::create_dir_all(&staging)?;
let shared_libraries = shared_library_names().map(|name| release.join(name));
for library in &shared_libraries {
copy_runtime(library, &staging)?;
}
let rustc = env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
let output = Command::new(rustc)
.args(["--print", "target-libdir"])
.output()?;
if !output.status.success() {
return Err("rustc --print target-libdir failed".into());
}
let rust_lib_dir = PathBuf::from(String::from_utf8(output.stdout)?.trim());
let mut std_libraries = fs::read_dir(&rust_lib_dir)?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| is_dynamic_std(path))
.collect::<Vec<_>>();
std_libraries.sort();
if std_libraries.is_empty() {
return Err(format!(
"no dynamic Rust standard library found in {}",
rust_lib_dir.display()
)
.into());
}
for library in &std_libraries {
copy_runtime(&library, &staging)?;
}
#[cfg(target_os = "macos")]
rewrite_macos_install_names(&release, &staging, &shared_libraries, &std_libraries)?;
Ok(())
}
fn sign_app_for_dmg(workspace: &Path) -> Result<(), Box<dyn Error>> {
#[cfg(target_os = "macos")]
if env::var_os("CARGO_PACKAGER_FORMAT").as_deref() == Some(OsStr::new("dmg")) {
let target = env::var_os("CARGO_TARGET_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| workspace.join("target"));
run(
Command::new("codesign")
.args(["--force", "--sign", "-"])
.arg(target.join("release/Blogging Desktop Server.app")),
"macOS app ad-hoc signing",
)?;
}
Ok(())
}
fn run(command: &mut Command, description: &str) -> Result<(), Box<dyn Error>> {
if command.status()?.success() {
Ok(())
} else {
Err(format!("{description} failed").into())
}
}
fn copy_runtime(source: &Path, staging: &Path) -> Result<(), Box<dyn Error>> {
let name = source
.file_name()
.ok_or_else(|| format!("runtime path has no filename: {}", source.display()))?;
let destination = staging.join(name);
fs::copy(source, &destination)?;
let mut permissions = fs::metadata(&destination)?.permissions();
permissions.set_readonly(false);
fs::set_permissions(&destination, permissions)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
fs::set_permissions(&destination, fs::Permissions::from_mode(0o755))?;
}
Ok(())
}
fn shared_library_names() -> [&'static str; 2] {
if cfg!(target_os = "windows") {
["bds_core.dll", "bds_server.dll"]
} else if cfg!(target_os = "macos") {
["libbds_core.dylib", "libbds_server.dylib"]
} else {
["libbds_core.so", "libbds_server.so"]
}
}
fn is_dynamic_std(path: &Path) -> bool {
let name = path.file_name().and_then(OsStr::to_str).unwrap_or_default();
if cfg!(target_os = "windows") {
name.starts_with("std-") && name.ends_with(".dll")
} else if cfg!(target_os = "macos") {
name.starts_with("libstd-") && name.ends_with(".dylib")
} else {
name.starts_with("libstd-") && name.ends_with(".so")
}
}
#[cfg(target_os = "macos")]
fn rewrite_macos_install_names(
release: &Path,
staging: &Path,
shared_libraries: &[PathBuf],
std_libraries: &[PathBuf],
) -> Result<(), Box<dyn Error>> {
let mut replacements = Vec::new();
for source in shared_libraries.iter().chain(std_libraries) {
let old_name = macho_install_name(source)?;
let file_name = source
.file_name()
.and_then(OsStr::to_str)
.ok_or_else(|| format!("invalid dylib filename: {}", source.display()))?;
replacements.push((old_name, format!("@rpath/{file_name}")));
}
let mut targets = ["bds-ui", "bds-cli", "bds-mcp"]
.map(|name| release.join(name))
.into_iter()
.collect::<Vec<_>>();
targets.extend(
shared_libraries
.iter()
.filter_map(|source| source.file_name().map(|name| staging.join(name))),
);
targets.extend(
std_libraries
.iter()
.filter_map(|source| source.file_name().map(|name| staging.join(name))),
);
for target in &targets {
for (old_name, new_name) in &replacements {
run(
Command::new("install_name_tool")
.args(["-change", old_name, new_name])
.arg(target),
"Mach-O dependency rewrite",
)?;
}
}
for (_, new_name) in &replacements {
let file_name = new_name.trim_start_matches("@rpath/");
run(
Command::new("install_name_tool")
.args(["-id", new_name])
.arg(staging.join(file_name)),
"Mach-O install-name rewrite",
)?;
}
for target in targets {
run(
Command::new("codesign")
.args(["--force", "--sign", "-"])
.arg(target),
"Mach-O ad-hoc signing",
)?;
}
Ok(())
}
#[cfg(target_os = "macos")]
fn macho_install_name(library: &Path) -> Result<String, Box<dyn Error>> {
let output = Command::new("otool").arg("-D").arg(library).output()?;
if !output.status.success() {
return Err(format!("otool -D failed for {}", library.display()).into());
}
String::from_utf8(output.stdout)?
.lines()
.nth(1)
.map(str::to_owned)
.ok_or_else(|| format!("no Mach-O install name in {}", library.display()).into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recognizes_only_the_platform_dynamic_standard_library() {
let valid = if cfg!(target_os = "windows") {
"std-123.dll"
} else if cfg!(target_os = "macos") {
"libstd-123.dylib"
} else {
"libstd-123.so"
};
assert!(is_dynamic_std(Path::new(valid)));
assert!(!is_dynamic_std(Path::new("libstd-123.rlib")));
for library in shared_library_names() {
assert!(!is_dynamic_std(Path::new(library)));
}
}
}

View File

@@ -4,6 +4,9 @@ edition.workspace = true
version.workspace = true version.workspace = true
license.workspace = true license.workspace = true
[lib]
crate-type = ["dylib"]
[dependencies] [dependencies]
bds-core = { workspace = true } bds-core = { workspace = true }
bds-editor = { workspace = true } bds-editor = { workspace = true }

View File

@@ -0,0 +1,9 @@
fn main() {
// syntect and fastembed both reach onig_sys across separate Rust dylibs.
// Keep the vendored static archive on this dylib's final native link line.
if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") {
println!("cargo:rustc-link-arg=onig.lib");
} else {
println!("cargo:rustc-link-arg=-lonig");
}
}

View File

@@ -1,41 +0,0 @@
use std::net::IpAddr;
use std::path::PathBuf;
use bds_server::boot::BootMode;
use clap::Parser;
#[derive(Debug, Parser)]
#[command(
name = "bds-server",
about = "Headless RuDS engine host over authenticated SSH"
)]
struct Args {
/// SSH listen address. Defaults to loopback; external access must be explicit.
#[arg(long)]
bind: Option<IpAddr>,
/// SSH listen port.
#[arg(long)]
port: Option<u16>,
/// Application database path.
#[arg(long)]
database: Option<PathBuf>,
/// Private application data directory containing SSH key material.
#[arg(long)]
data_dir: Option<PathBuf>,
}
fn main() -> anyhow::Result<()> {
let args = Args::parse();
let data_root = args
.data_dir
.unwrap_or_else(bds_core::util::application_data_dir);
let database_path = args.database.unwrap_or_else(|| data_root.join("bds.db"));
let mut config = bds_server::ServerConfig::from_environment(database_path, data_root)?;
if let Some(bind) = args.bind {
config.bind = bind;
}
if let Some(port) = args.port {
config.port = port;
}
bds_server::run_headless(BootMode::Server, config)
}

View File

@@ -39,12 +39,15 @@ winresource = "0.1"
product-name = "Blogging Desktop Server" product-name = "Blogging Desktop Server"
identifier = "de.rfc1437.ruds" identifier = "de.rfc1437.ruds"
description = "A desktop application for writing and publishing static blogs." description = "A desktop application for writing and publishing static blogs."
before-packaging-command = "cargo build --release -p bds-ui -p bds-cli -p bds-mcp -p bds-server" before-packaging-command = "cargo run --quiet -p bds-package-runtime"
before-each-package-command = "cargo run --quiet -p bds-package-runtime -- sign-app-for-dmg"
binaries = [ binaries = [
{ path = "bds-ui", main = true }, { path = "bds-ui", main = true },
{ path = "bds-cli" }, { path = "bds-cli" },
{ path = "bds-mcp" }, { path = "bds-mcp" },
{ path = "bds-server" }, ]
resources = [
{ src = "../../target/release/bds-runtime/*", target = "." },
] ]
deep-link-protocols = [{ schemes = ["ruds"] }] deep-link-protocols = [{ schemes = ["ruds"] }]
icons = [ icons = [
@@ -54,8 +57,7 @@ icons = [
] ]
[package.metadata.packager.macos] [package.metadata.packager.macos]
minimum-system-version = "13.0" minimum-system-version = "26.0"
signing-identity = "-"
[package.metadata.packager.nsis] [package.metadata.packager.nsis]
installer-icon = "assets/app-icons/bds.ico" installer-icon = "assets/app-icons/bds.ico"

View File

@@ -1,6 +1,12 @@
fn main() { fn main() {
println!("cargo:rerun-if-changed=assets/app-icons/bds.ico"); println!("cargo:rerun-if-changed=assets/app-icons/bds.ico");
// Wry and bds-core both reach objc2's native exception helper across
// separate Rust dylibs, so retain the static helper on the UI link line.
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") {
println!("cargo:rustc-link-arg=-lobjc2_exception_helper_0_1");
}
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
winresource::WindowsResource::new() winresource::WindowsResource::new()
.set_icon("assets/app-icons/bds.ico") .set_icon("assets/app-icons/bds.ico")

View File

@@ -24,22 +24,55 @@ fn desktop_packages_have_native_icons_and_cargo_commands() {
"assets/app-icons/bds.icns", "assets/app-icons/bds.icns",
"identifier = \"de.rfc1437.ruds\"", "identifier = \"de.rfc1437.ruds\"",
"deep-link-protocols = [{ schemes = [\"ruds\"] }]", "deep-link-protocols = [{ schemes = [\"ruds\"] }]",
"signing-identity = \"-\"", "minimum-system-version = \"26.0\"",
"cargo build --release -p bds-ui -p bds-cli -p bds-mcp", "../../target/release/bds-runtime/*",
"cargo run --quiet -p bds-package-runtime",
"cargo run --quiet -p bds-package-runtime -- sign-app-for-dmg",
"{ path = \"bds-mcp\" }", "{ path = \"bds-mcp\" }",
] { ] {
assert!(manifest.contains(required), "missing {required}"); assert!(manifest.contains(required), "missing {required}");
} }
assert!(!manifest.contains("signing-identity"));
let cargo_config = fs::read_to_string(Path::new(CRATE_DIR).join("../../.cargo/config.toml")) let cargo_config = fs::read_to_string(Path::new(CRATE_DIR).join("../../.cargo/config.toml"))
.expect("Cargo packaging aliases"); .expect("Cargo packaging aliases");
for alias in ["bundle-macos", "bundle-windows", "bundle-linux"] { for alias in ["bundle-macos", "bundle-windows", "bundle-linux"] {
assert!(cargo_config.contains(alias), "missing cargo {alias}"); assert!(cargo_config.contains(alias), "missing cargo {alias}");
} }
assert!(cargo_config.contains("prefer-dynamic"));
assert!(cargo_config.contains("-mmacosx-version-min=26.0"));
assert!(cargo_config.contains("@executable_path/../Resources"));
assert!(cargo_config.contains("$ORIGIN/../lib/bds-ui"));
let main = fs::read_to_string(Path::new(CRATE_DIR).join("src/main.rs")).unwrap(); let main = fs::read_to_string(Path::new(CRATE_DIR).join("src/main.rs")).unwrap();
assert!(main.contains("assets/app-icons/bds.png")); assert!(main.contains("assets/app-icons/bds.png"));
let build = fs::read_to_string(Path::new(CRATE_DIR).join("build.rs")).unwrap(); let build = fs::read_to_string(Path::new(CRATE_DIR).join("build.rs")).unwrap();
assert!(build.contains("set_icon(\"assets/app-icons/bds.ico\")")); assert!(build.contains("set_icon(\"assets/app-icons/bds.ico\")"));
let workspace_manifest =
fs::read_to_string(Path::new(CRATE_DIR).join("../../Cargo.toml")).unwrap();
assert!(workspace_manifest.contains("strip = \"symbols\""));
assert!(workspace_manifest.contains("ort-download-binaries-rustls-tls"));
let core_manifest =
fs::read_to_string(Path::new(CRATE_DIR).join("../bds-core/Cargo.toml")).unwrap();
assert!(core_manifest.contains("crate-type = [\"dylib\"]"));
let server_manifest =
fs::read_to_string(Path::new(CRATE_DIR).join("../bds-server/Cargo.toml")).unwrap();
assert!(server_manifest.contains("crate-type = [\"dylib\"]"));
assert!(!manifest.contains("{ path = \"bds-server\" }"));
assert!(!manifest.contains("-p bds-server"));
assert!(
!Path::new(CRATE_DIR)
.join("../bds-server/src/main.rs")
.exists()
);
let packager =
fs::read_to_string(Path::new(CRATE_DIR).join("../bds-package-runtime/src/main.rs"))
.unwrap();
assert!(packager.contains("Command::new(\"codesign\")"));
assert!(packager.contains("CARGO_PACKAGER_FORMAT"));
assert!(!packager.contains("--options"));
} }

View File

@@ -6,7 +6,7 @@
entity CliInvocation { entity CliInvocation {
command: rebuild | repair | render | upload | push | pull | post | media command: rebuild | repair | render | upload | push | pull | post | media
| gallery | config | project | tui | lua | install | gallery | config | project | server | tui | lua | install
incremental: Boolean incremental: Boolean
force: Boolean force: Boolean
airplane: Boolean airplane: Boolean
@@ -163,11 +163,19 @@ rule Projects {
rule Tui { rule Tui {
when: CliCommandExecuted(command: tui) when: CliCommandExecuted(command: tui)
-- Handled by the launcher script: exec the release in BDS_MODE=tui -- The native CLI enters TUI boot mode so the interactive terminal UI owns
-- so the interactive terminal UI owns the terminal. -- the terminal.
ensures: TuiStarted() ensures: TuiStarted()
} }
rule Server {
when: CliCommandExecuted(command: server)
-- Starts the dedicated headless SSH host with optional bind address, port,
-- database path, and data-directory overrides. The standalone bds-server
-- executable is not distributed.
ensures: SshDaemonStarted()
}
rule RunLuaTask { rule RunLuaTask {
when: CliCommandExecuted(command: lua) when: CliCommandExecuted(command: lua)
-- Runs a utility ("task", long-running) script from the database by -- Runs a utility ("task", long-running) script from the database by
@@ -180,14 +188,15 @@ rule RunLuaTask {
rule InstallLauncher { rule InstallLauncher {
when: CliInstallRequested() or CliCommandExecuted(command: install) when: CliInstallRequested() or CliCommandExecuted(command: install)
-- Settings/Data, the future TUI settings action, and `bds-cli install` -- Settings/Data, the future TUI settings action, and `bds-cli install`
-- share one guarded installer. A launcher pointing at the packaged native -- share one guarded installer. A forwarding launcher written to
-- CLI is written to ~/.local/bin/bds-cli. Existing unrelated files are -- ~/.local/bin/bds-cli executes the packaged native CLI in place, so it
-- never overwritten; outside a packaged release the action reports that -- uses the same packaged runtime as the desktop app. Existing unrelated
-- the packaged CLI executable is required. -- files are never overwritten; outside a packaged release the action
-- reports that the packaged CLI executable is required.
ensures: LauncherInstalled() ensures: LauncherInstalled()
} }
invariant NativeArgv { invariant NativeArgv {
-- The Rust launcher is the native bds-cli executable, so operating-system -- The forwarding launcher preserves operating-system argv for the native
-- argv is parsed directly without the bDS2 release-eval environment shim. -- bds-cli executable without the bDS2 release-eval environment shim.
} }

View File

@@ -59,7 +59,8 @@ rule DesktopBoot {
rule ServerBoot { rule ServerBoot {
when: ApplicationStarted(mode: server) when: ApplicationStarted(mode: server)
-- Headless: no wx children at all. Works on macOS, Windows, Linux. -- Started by `bds-cli server` or explicit desktop boot mode. Headless: no
-- desktop UI children at all. Works on macOS, Windows, Linux.
ensures: LoopbackEndpointStarted() ensures: LoopbackEndpointStarted()
-- HTTP endpoint stays bound to 127.0.0.1; never exposed directly -- HTTP endpoint stays bound to 127.0.0.1; never exposed directly
ensures: CliSyncWatcherStarted() ensures: CliSyncWatcherStarted()