From 269e4b0e4a5a3458f71af969572e9ec9645c96ab Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Mon, 20 Jul 2026 08:44:17 +0200 Subject: [PATCH] fix: reduced app size by a good margin --- .cargo/config.toml | 16 ++ Cargo.lock | 4 + Cargo.toml | 4 + README.md | 12 +- RUST_PLAN_CORE.md | 6 +- RUST_PLAN_EXTENSION.md | 12 +- crates/bds-cli/src/lib.rs | 52 ++++- crates/bds-cli/src/main.rs | 45 +++- crates/bds-core/Cargo.toml | 3 + crates/bds-core/src/engine/cli_launcher.rs | 61 +++++- crates/bds-core/src/engine/git.rs | 2 +- crates/bds-package-runtime/Cargo.toml | 6 + crates/bds-package-runtime/src/main.rs | 231 +++++++++++++++++++++ crates/bds-server/Cargo.toml | 3 + crates/bds-server/build.rs | 9 + crates/bds-server/src/main.rs | 41 ---- crates/bds-ui/Cargo.toml | 10 +- crates/bds-ui/build.rs | 6 + crates/bds-ui/tests/packaging_assets.rs | 37 +++- specs/cli.allium | 27 ++- specs/server.allium | 3 +- 21 files changed, 494 insertions(+), 96 deletions(-) create mode 100644 crates/bds-package-runtime/Cargo.toml create mode 100644 crates/bds-package-runtime/src/main.rs create mode 100644 crates/bds-server/build.rs delete mode 100644 crates/bds-server/src/main.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index 1d1b798..7a61ed1 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -2,3 +2,19 @@ bundle-macos = "packager --release --packages bds-ui --formats app --formats dmg" bundle-windows = "packager --release --packages bds-ui --formats nsis" 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"] diff --git a/Cargo.lock b/Cargo.lock index 806a43f..3fdca57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -994,6 +994,10 @@ dependencies = [ "tempfile", ] +[[package]] +name = "bds-package-runtime" +version = "0.1.0" + [[package]] name = "bds-server" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 3349893..7ccc384 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "crates/bds-cli", "crates/bds-mcp", "crates/bds-server", + "crates/bds-package-runtime", ] [workspace.package] @@ -78,3 +79,6 @@ bds-core = { path = "crates/bds-core" } bds-editor = { path = "crates/bds-editor" } bds-mcp = { path = "crates/bds-mcp" } bds-server = { path = "crates/bds-server" } + +[profile.release] +strip = "symbols" diff --git a/README.md b/README.md index f4fa411..6752522 100644 --- a/README.md +++ b/README.md @@ -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`. - 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. -- 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. - 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. @@ -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. +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 - `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-cli` — headless automation CLI over the shared engines - `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 - `fixtures` — compatibility projects and generated-site fixtures - `locales` — UI and native-menu translations @@ -54,4 +60,4 @@ Contributor workflow and project invariants are documented in [AGENTS.md](AGENTS ## 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. diff --git a/RUST_PLAN_CORE.md b/RUST_PLAN_CORE.md index 4b4fc26..bf330ed 100644 --- a/RUST_PLAN_CORE.md +++ b/RUST_PLAN_CORE.md @@ -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. -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 @@ -25,11 +25,11 @@ Status in this document describes the current source code as of 2026-07-19. It d | 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-ui` | Iced application, native menus/dialogs, platform lifecycle, and embedded preview | | `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 diff --git a/RUST_PLAN_EXTENSION.md b/RUST_PLAN_EXTENSION.md index 97943d0..4b2c5d2 100644 --- a/RUST_PLAN_EXTENSION.md +++ b/RUST_PLAN_EXTENSION.md @@ -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. -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 @@ -44,7 +44,7 @@ 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. - 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. @@ -74,9 +74,9 @@ 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. -- 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. -- 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. - 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. @@ -93,11 +93,13 @@ 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. - 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. - 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 diff --git a/crates/bds-cli/src/lib.rs b/crates/bds-cli/src/lib.rs index 78d94fe..054933b 100644 --- a/crates/bds-cli/src/lib.rs +++ b/crates/bds-cli/src/lib.rs @@ -1,4 +1,5 @@ use std::fmt::Write as _; +use std::net::IpAddr; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; @@ -72,6 +73,8 @@ pub enum Command { #[command(subcommand)] command: ProjectCommand, }, + /// Start the authenticated headless SSH server. + Server(ServerArgs), /// Start interactive TUI mode (normally intercepted by the packaged launcher). Tui, /// Run an enabled utility Lua script from the active project. @@ -84,6 +87,22 @@ pub enum Command { Install, } +#[derive(Debug, Args, Default)] +pub struct ServerArgs { + /// SSH listen address. Defaults to loopback; external access must be explicit. + #[arg(long)] + pub bind: Option, + /// SSH listen port. + #[arg(long)] + pub port: Option, + /// Application database path. + #[arg(long)] + pub database: Option, + /// Private application data directory containing SSH key material. + #[arg(long)] + pub data_dir: Option, +} + #[derive(Debug, Clone, Copy, ValueEnum)] pub enum RepairPart { PostLinks, @@ -257,10 +276,8 @@ fn execute(command: Command, context: &RunContext, airplane: bool) -> Result Result config(&db, command), Command::Project { command } => project(&db, command), 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::Config { .. } => "config", Command::Project { .. } => "project", + Command::Server(_) => "server", Command::Tui => "tui", Command::Lua { .. } => "lua", Command::Install => "install", @@ -1223,10 +1241,24 @@ mod tests { let help = String::from_utf8(help).unwrap(); for command in [ "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"); } + 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", "render", "--incremental", "--force"]).is_err()); assert!(Cli::try_parse_from(["bds-cli", "repair", "unknown"]).is_err()); @@ -1511,9 +1543,11 @@ mod tests { ) .unwrap(); let target = fixture.home_dir.join(".local/bin/bds-cli"); - assert_eq!( - target.canonicalize().unwrap(), - executable.canonicalize().unwrap() + assert!(!target.is_symlink()); + assert!( + std::fs::read_to_string(target) + .unwrap() + .contains(executable.canonicalize().unwrap().to_str().unwrap()) ); } } diff --git a/crates/bds-cli/src/main.rs b/crates/bds-cli/src/main.rs index 6b1e924..65b2600 100644 --- a/crates/bds-cli/src/main.rs +++ b/crates/bds-cli/src/main.rs @@ -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) { let data_root = bds_core::util::application_data_dir(); let config = match bds_server::ServerConfig::from_environment( @@ -32,13 +59,7 @@ fn main() -> ExitCode { return ExitCode::from(1); } }; - return match bds_server::run_headless(bds_server::boot::BootMode::Tui, config) { - Ok(()) => ExitCode::SUCCESS, - Err(error) => { - eprintln!("Error: {error:#}"); - ExitCode::from(1) - } - }; + return run_headless(bds_server::boot::BootMode::Tui, config); } 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) + } + } +} diff --git a/crates/bds-core/Cargo.toml b/crates/bds-core/Cargo.toml index 2c81053..fb1ec98 100644 --- a/crates/bds-core/Cargo.toml +++ b/crates/bds-core/Cargo.toml @@ -4,6 +4,9 @@ edition.workspace = true version.workspace = true license.workspace = true +[lib] +crate-type = ["dylib"] + [dependencies] uuid = { workspace = true } serde = { workspace = true } diff --git a/crates/bds-core/src/engine/cli_launcher.rs b/crates/bds-core/src/engine/cli_launcher.rs index d36447c..755146b 100644 --- a/crates/bds-core/src/engine/cli_launcher.rs +++ b/crates/bds-core/src/engine/cli_launcher.rs @@ -14,14 +14,18 @@ pub fn install_launcher(executable: &Path, home_dir: &Path) -> EngineResult EngineResult 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. pub fn install_packaged_launcher(home_dir: &Path) -> EngineResult { let app = std::env::current_exe()?; @@ -59,10 +79,9 @@ mod tests { std::fs::write(&executable, b"binary").unwrap(); let home = root.path().join("home"); let target = install_launcher(&executable, &home).unwrap(); - assert_eq!( - target.canonicalize().unwrap(), - executable.canonicalize().unwrap() - ); + assert!(!target.is_symlink()); + let launcher = std::fs::read_to_string(&target).unwrap(); + assert!(launcher.contains(executable.canonicalize().unwrap().to_str().unwrap())); assert_eq!(install_launcher(&executable, &home).unwrap(), target); std::fs::remove_file(&target).unwrap(); @@ -70,4 +89,24 @@ mod tests { assert!(install_launcher(&executable, &home).is_err()); 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") + ); + } } diff --git a/crates/bds-core/src/engine/git.rs b/crates/bds-core/src/engine/git.rs index 50d2b72..de1c307 100644 --- a/crates/bds-core/src/engine/git.rs +++ b/crates/bds-core/src/engine/git.rs @@ -1599,7 +1599,7 @@ mod tests { let engine = GitEngine::with_executable_and_timeouts( dir.path(), executable, - Duration::from_secs(1), + Duration::from_secs(5), Duration::from_secs(1), ); diff --git a/crates/bds-package-runtime/Cargo.toml b/crates/bds-package-runtime/Cargo.toml new file mode 100644 index 0000000..b7a6cce --- /dev/null +++ b/crates/bds-package-runtime/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "bds-package-runtime" +edition.workspace = true +version.workspace = true +license.workspace = true + diff --git a/crates/bds-package-runtime/src/main.rs b/crates/bds-package-runtime/src/main.rs new file mode 100644 index 0000000..52a8542 --- /dev/null +++ b/crates/bds-package-runtime/src/main.rs @@ -0,0 +1,231 @@ +use std::{ + env, + error::Error, + ffi::OsStr, + fs, + path::{Path, PathBuf}, + process::Command, +}; + +fn main() -> Result<(), Box> { + 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::>(); + 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> { + #[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> { + if command.status()?.success() { + Ok(()) + } else { + Err(format!("{description} failed").into()) + } +} + +fn copy_runtime(source: &Path, staging: &Path) -> Result<(), Box> { + 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> { + 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::>(); + 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> { + 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))); + } + } +} diff --git a/crates/bds-server/Cargo.toml b/crates/bds-server/Cargo.toml index 33320bc..5cbb5c7 100644 --- a/crates/bds-server/Cargo.toml +++ b/crates/bds-server/Cargo.toml @@ -4,6 +4,9 @@ edition.workspace = true version.workspace = true license.workspace = true +[lib] +crate-type = ["dylib"] + [dependencies] bds-core = { workspace = true } bds-editor = { workspace = true } diff --git a/crates/bds-server/build.rs b/crates/bds-server/build.rs new file mode 100644 index 0000000..751709d --- /dev/null +++ b/crates/bds-server/build.rs @@ -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"); + } +} diff --git a/crates/bds-server/src/main.rs b/crates/bds-server/src/main.rs deleted file mode 100644 index db9fe8c..0000000 --- a/crates/bds-server/src/main.rs +++ /dev/null @@ -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, - /// SSH listen port. - #[arg(long)] - port: Option, - /// Application database path. - #[arg(long)] - database: Option, - /// Private application data directory containing SSH key material. - #[arg(long)] - data_dir: Option, -} - -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) -} diff --git a/crates/bds-ui/Cargo.toml b/crates/bds-ui/Cargo.toml index cbc0948..dccdbc8 100644 --- a/crates/bds-ui/Cargo.toml +++ b/crates/bds-ui/Cargo.toml @@ -39,12 +39,15 @@ winresource = "0.1" product-name = "Blogging Desktop Server" identifier = "de.rfc1437.ruds" 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 = [ { path = "bds-ui", main = true }, { path = "bds-cli" }, { path = "bds-mcp" }, - { path = "bds-server" }, +] +resources = [ + { src = "../../target/release/bds-runtime/*", target = "." }, ] deep-link-protocols = [{ schemes = ["ruds"] }] icons = [ @@ -54,8 +57,7 @@ icons = [ ] [package.metadata.packager.macos] -minimum-system-version = "13.0" -signing-identity = "-" +minimum-system-version = "26.0" [package.metadata.packager.nsis] installer-icon = "assets/app-icons/bds.ico" diff --git a/crates/bds-ui/build.rs b/crates/bds-ui/build.rs index d554c7c..88eda96 100644 --- a/crates/bds-ui/build.rs +++ b/crates/bds-ui/build.rs @@ -1,6 +1,12 @@ fn main() { 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") { winresource::WindowsResource::new() .set_icon("assets/app-icons/bds.ico") diff --git a/crates/bds-ui/tests/packaging_assets.rs b/crates/bds-ui/tests/packaging_assets.rs index a71b193..c16ad38 100644 --- a/crates/bds-ui/tests/packaging_assets.rs +++ b/crates/bds-ui/tests/packaging_assets.rs @@ -24,22 +24,55 @@ fn desktop_packages_have_native_icons_and_cargo_commands() { "assets/app-icons/bds.icns", "identifier = \"de.rfc1437.ruds\"", "deep-link-protocols = [{ schemes = [\"ruds\"] }]", - "signing-identity = \"-\"", - "cargo build --release -p bds-ui -p bds-cli -p bds-mcp", + "minimum-system-version = \"26.0\"", + "../../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\" }", ] { 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")) .expect("Cargo packaging aliases"); for alias in ["bundle-macos", "bundle-windows", "bundle-linux"] { 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(); assert!(main.contains("assets/app-icons/bds.png")); let build = fs::read_to_string(Path::new(CRATE_DIR).join("build.rs")).unwrap(); 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")); } diff --git a/specs/cli.allium b/specs/cli.allium index 7819048..4823038 100644 --- a/specs/cli.allium +++ b/specs/cli.allium @@ -6,7 +6,7 @@ entity CliInvocation { command: rebuild | repair | render | upload | push | pull | post | media - | gallery | config | project | tui | lua | install + | gallery | config | project | server | tui | lua | install incremental: Boolean force: Boolean airplane: Boolean @@ -163,11 +163,19 @@ rule Projects { rule Tui { when: CliCommandExecuted(command: tui) - -- Handled by the launcher script: exec the release in BDS_MODE=tui - -- so the interactive terminal UI owns the terminal. + -- The native CLI enters TUI boot mode so the interactive terminal UI owns + -- the terminal. 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 { when: CliCommandExecuted(command: lua) -- Runs a utility ("task", long-running) script from the database by @@ -180,14 +188,15 @@ rule RunLuaTask { rule InstallLauncher { when: CliInstallRequested() or CliCommandExecuted(command: install) -- Settings/Data, the future TUI settings action, and `bds-cli install` - -- share one guarded installer. A launcher pointing at the packaged native - -- CLI is written to ~/.local/bin/bds-cli. Existing unrelated files are - -- never overwritten; outside a packaged release the action reports that - -- the packaged CLI executable is required. + -- share one guarded installer. A forwarding launcher written to + -- ~/.local/bin/bds-cli executes the packaged native CLI in place, so it + -- uses the same packaged runtime as the desktop app. Existing unrelated + -- files are never overwritten; outside a packaged release the action + -- reports that the packaged CLI executable is required. ensures: LauncherInstalled() } invariant NativeArgv { - -- The Rust launcher is the native bds-cli executable, so operating-system - -- argv is parsed directly without the bDS2 release-eval environment shim. + -- The forwarding launcher preserves operating-system argv for the native + -- bds-cli executable without the bDS2 release-eval environment shim. } diff --git a/specs/server.allium b/specs/server.allium index 7ca27d0..2571c56 100644 --- a/specs/server.allium +++ b/specs/server.allium @@ -59,7 +59,8 @@ rule DesktopBoot { rule ServerBoot { 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() -- HTTP endpoint stays bound to 127.0.0.1; never exposed directly ensures: CliSyncWatcherStarted()