Implement the headless SSH server

This commit is contained in:
2026-07-19 20:35:02 +02:00
parent 92a942d52f
commit 45cb0cd502
26 changed files with 4226 additions and 55 deletions

View File

@@ -200,6 +200,13 @@ pub enum Message {
ShowModal(modal::ModalState),
DismissModal,
ConfirmModal(modal::ConfirmAction),
RemoteTargetChanged(String),
RemoteConnectRequested,
RemoteConnected(Result<(Arc<bds_server::DesktopClient>, Vec<Project>), String>),
RemoteProjectSelected(String),
RemoteOpenProjectRequested,
RemoteProjectOpened(Result<Project, String>),
RemoteDisconnectRequested,
FindQueryChanged(String),
ReplaceQueryChanged(String),
FindNext,
@@ -933,6 +940,11 @@ pub struct BdsApp {
// Modal
active_modal: Option<modal::ModalState>,
remote_client: Option<Arc<bds_server::DesktopClient>>,
remote_projects: Vec<Project>,
remote_project: Option<Project>,
remote_display_name: Option<String>,
remote_previous_locale: Option<UiLocale>,
// Local preview
preview_session: Option<PreviewSession>,
@@ -1048,6 +1060,7 @@ impl BdsApp {
registry.set_enabled(MenuAction::Find, false);
registry.set_enabled(MenuAction::Replace, false);
registry.set_enabled(MenuAction::OpenInBrowser, false);
registry.set_enabled(MenuAction::DisconnectServer, false);
let chat_conversations = db
.as_ref()
.and_then(|db| engine::chat::list_conversations(db.conn()).ok())
@@ -1105,6 +1118,11 @@ impl BdsApp {
toasts: Vec::new(),
active_modal: search_index_rebuild_required
.then_some(modal::ModalState::SearchIndexRepair),
remote_client: None,
remote_projects: Vec::new(),
remote_project: None,
remote_display_name: None,
remote_previous_locale: None,
preview_session: None,
mcp_server,
embedded_preview: None,
@@ -1187,6 +1205,11 @@ impl BdsApp {
theme_badge: String::from("pico"),
toasts: Vec::new(),
active_modal: None,
remote_client: None,
remote_projects: Vec::new(),
remote_project: None,
remote_display_name: None,
remote_previous_locale: None,
preview_session: None,
mcp_server: None,
embedded_preview: None,
@@ -2694,6 +2717,212 @@ impl BdsApp {
}
}
}
Message::RemoteTargetChanged(value) => {
if let Some(modal::ModalState::RemoteConnection { target, error, .. }) =
self.active_modal.as_mut()
{
*target = value;
*error = None;
}
Task::none()
}
Message::RemoteConnectRequested => {
let target = match self.active_modal.as_mut() {
Some(modal::ModalState::RemoteConnection {
target,
connecting,
error,
..
}) => {
*connecting = true;
*error = None;
target.clone()
}
_ => return Task::none(),
};
let parsed = match bds_server::RemoteTarget::parse(&target) {
Ok(target) => target,
Err(error) => {
if let Some(modal::ModalState::RemoteConnection {
connecting,
error: shown,
..
}) = self.active_modal.as_mut()
{
*connecting = false;
let _ = error;
*shown = Some(t(self.ui_locale, "remoteConnection.invalidTarget"));
}
return Task::none();
}
};
let data_root = bds_core::util::application_data_dir();
Task::perform(
async move {
tokio::task::spawn_blocking(move || {
let client = Arc::new(
bds_server::DesktopClient::connect(parsed, &data_root)
.map_err(|error| error.to_string())?,
);
let projects =
client.list_projects().map_err(|error| error.to_string())?;
Ok((client, projects))
})
.await
.unwrap_or_else(|error| {
Err(format!("remote connection task failed: {error}"))
})
},
Message::RemoteConnected,
)
}
Message::RemoteConnected(result) => {
match result {
Ok((client, projects)) => {
let server_locale =
bds_core::i18n::normalize_language(client.server_locale());
self.remote_projects.clone_from(&projects);
self.remote_client = Some(client);
if let Some(modal::ModalState::RemoteConnection {
connecting,
connected,
error,
projects: shown_projects,
selected_project_id,
..
}) = self.active_modal.as_mut()
{
*connecting = false;
*connected = true;
*error = None;
shown_projects.clone_from(&projects);
*selected_project_id =
projects.first().map(|project| project.id.clone());
}
self.menu_registry
.set_enabled(MenuAction::DisconnectServer, true);
if self.remote_previous_locale.is_none() {
self.remote_previous_locale = Some(self.ui_locale);
}
self.apply_ui_locale(server_locale);
}
Err(connection_error) => {
let remains_connected = self.remote_client.is_some();
if let Some(modal::ModalState::RemoteConnection {
connecting,
connected,
error,
..
}) = self.active_modal.as_mut()
{
*connecting = false;
*connected = remains_connected;
*error = Some(tw(
self.ui_locale,
"remoteConnection.failed",
&[("reason", &connection_error)],
));
}
}
}
Task::none()
}
Message::RemoteProjectSelected(project_id) => {
if let Some(modal::ModalState::RemoteConnection {
selected_project_id,
error,
..
}) = self.active_modal.as_mut()
{
*selected_project_id = Some(project_id);
*error = None;
}
Task::none()
}
Message::RemoteOpenProjectRequested => {
let project_id = match self.active_modal.as_mut() {
Some(modal::ModalState::RemoteConnection {
selected_project_id: Some(project_id),
connecting,
error,
..
}) => {
*connecting = true;
*error = None;
project_id.clone()
}
_ => return Task::none(),
};
let Some(client) = self.remote_client.clone() else {
return Task::done(Message::RemoteProjectOpened(Err(t(
self.ui_locale,
"remoteConnection.connectionLost",
))));
};
Task::perform(
async move {
tokio::task::spawn_blocking(move || {
client
.open_project(&project_id)
.map_err(|error| error.to_string())
})
.await
.unwrap_or_else(|error| Err(format!("remote project task failed: {error}")))
},
Message::RemoteProjectOpened,
)
}
Message::RemoteProjectOpened(result) => {
match result {
Ok(project) => {
let target = self
.remote_client
.as_ref()
.map(|client| client.target().label())
.unwrap_or_default();
self.remote_display_name = Some(format!("{}{target}", project.name));
self.remote_project = Some(project);
self.active_modal = None;
self.notify(
ToastLevel::Success,
&t(self.ui_locale, "remoteConnection.opened"),
);
}
Err(open_error) => {
if let Some(modal::ModalState::RemoteConnection {
connecting, error, ..
}) = self.active_modal.as_mut()
{
*connecting = false;
*error = Some(tw(
self.ui_locale,
"remoteConnection.openFailed",
&[("reason", &open_error)],
));
}
}
}
Task::none()
}
Message::RemoteDisconnectRequested => {
if let Some(client) = self.remote_client.take() {
client.close();
}
self.remote_projects.clear();
self.remote_project = None;
self.remote_display_name = None;
self.active_modal = None;
self.menu_registry
.set_enabled(MenuAction::DisconnectServer, false);
if let Some(locale) = self.remote_previous_locale.take() {
self.apply_ui_locale(locale);
}
self.notify(
ToastLevel::Info,
&t(self.ui_locale, "remoteConnection.disconnected"),
);
Task::none()
}
Message::FindQueryChanged(value) => {
if let Some(modal::ModalState::FindReplace { query, .. }) =
self.active_modal.as_mut()
@@ -3095,7 +3324,11 @@ impl BdsApp {
}
pub fn view(&self) -> Element<'_, Message> {
let active_name = self.active_project.as_ref().map(|p| p.name.as_str());
let active_name = self.remote_display_name.as_deref().or_else(|| {
self.active_project
.as_ref()
.map(|project| project.name.as_str())
});
let active_post_filter = match self.sidebar_view {
SidebarView::Pages => &self.page_filter,
_ => &self.post_filter,
@@ -3615,6 +3848,47 @@ impl BdsApp {
}
fn process_domain_events(&mut self) -> Task<Message> {
let remote_messages = self
.remote_client
.as_ref()
.map(|client| client.drain_events())
.unwrap_or_default();
for message in remote_messages {
match message {
bds_server::protocol::ServerMessage::Event { sequence, event } => {
self.add_output(&tw(
self.ui_locale,
"remoteConnection.eventReceived",
&[
("sequence", &sequence.to_string()),
("event", &format!("{event:?}")),
],
));
}
bds_server::protocol::ServerMessage::Tasks { tasks, .. } => {
self.add_output(&tw(
self.ui_locale,
"remoteConnection.taskUpdate",
&[("count", &tasks.len().to_string())],
));
}
bds_server::protocol::ServerMessage::Error { code, message, .. } => {
self.notify(ToastLevel::Error, &message);
if remote_error_closes_connection(&code) {
self.remote_client = None;
self.remote_project = None;
self.remote_projects.clear();
self.remote_display_name = None;
self.menu_registry
.set_enabled(MenuAction::DisconnectServer, false);
if let Some(locale) = self.remote_previous_locale.take() {
self.apply_ui_locale(locale);
}
}
}
bds_server::protocol::ServerMessage::Response { .. } => {}
}
}
if let Some(db) = &self.db {
let _ = engine::cli_sync::poll_notifications(db.conn());
}
@@ -3849,6 +4123,26 @@ impl BdsApp {
}
Task::none()
}
MenuAction::ConnectServer => {
let target = self
.remote_client
.as_ref()
.map(|client| client.target().label())
.unwrap_or_default();
self.active_modal = Some(modal::ModalState::RemoteConnection {
target,
connecting: false,
connected: self.remote_client.is_some(),
error: None,
projects: self.remote_projects.clone(),
selected_project_id: self
.remote_project
.as_ref()
.map(|project| project.id.clone()),
});
Task::none()
}
MenuAction::DisconnectServer => Task::done(Message::RemoteDisconnectRequested),
// Edit
MenuAction::Find => {
self.active_modal = Some(modal::ModalState::FindReplace {
@@ -5279,6 +5573,8 @@ impl BdsApp {
),
);
}
self.menu_registry
.set_enabled(MenuAction::DisconnectServer, self.remote_client.is_some());
}
// ── Editor save/publish helpers ──
@@ -8652,16 +8948,21 @@ fn language_label(locale: UiLocale, code: &str) -> String {
}
}
fn remote_error_closes_connection(code: &str) -> bool {
code == "connection_lost"
}
#[cfg(test)]
mod tests {
use super::{
BdsApp, Message, POST_AUTO_SAVE_DELAY_MS, PersistedMediaState, PersistedPostState,
PostStatus, SettingsMsg, month_abbreviation, persist_media_editor_state_impl,
persist_post_editor_preview_state_impl, persist_post_editor_state_impl,
save_editor_settings_state_impl, save_script_editor_state_impl,
save_template_editor_state_impl,
remote_error_closes_connection, save_editor_settings_state_impl,
save_script_editor_state_impl, save_template_editor_state_impl,
};
use crate::i18n::t;
use crate::platform::menu::MenuAction;
use crate::state::ToastLevel;
use crate::state::sidebar_filter::{MediaFilter, PostFilter};
use crate::state::tabs::{Tab, TabType};
@@ -8735,6 +9036,49 @@ mod tests {
(db, project, tempdir)
}
#[test]
fn remote_connection_menu_opens_localized_selection_and_failure_states() {
let (db, project, temp) = setup();
let mut app = BdsApp::new_for_tests(db, project, temp.path().to_path_buf());
let _ = app.dispatch_menu_action(MenuAction::ConnectServer);
assert!(matches!(
app.active_modal,
Some(modal::ModalState::RemoteConnection {
connected: false,
ref projects,
..
}) if projects.is_empty()
));
let _ = app.update(Message::RemoteTargetChanged("not-a-target".into()));
let _ = app.update(Message::RemoteConnectRequested);
assert!(matches!(
app.active_modal,
Some(modal::ModalState::RemoteConnection {
connecting: false,
error: Some(ref error),
..
}) if error == &t(UiLocale::En, "remoteConnection.invalidTarget")
));
let _ = app.update(Message::RemoteConnected(Err("refused".into())));
assert!(matches!(
app.active_modal,
Some(modal::ModalState::RemoteConnection {
connected: false,
error: Some(ref error),
..
}) if error.contains("refused") && error != "refused"
));
}
#[test]
fn only_transport_loss_closes_an_open_remote_session() {
assert!(remote_error_closes_connection("connection_lost"));
assert!(!remote_error_closes_connection("sync_watcher_error"));
assert!(!remote_error_closes_connection("engine_error"));
}
#[test]
fn documentation_external_links_require_confirmation_and_api_help_opens_real_tab() {
let (db, project, temp) = setup();

View File

@@ -1,9 +1,28 @@
#![cfg_attr(target_os = "windows", windows_subsystem = "windows")]
use bds_server::boot::{BootMode, Platform};
use bds_ui::BdsApp;
use bds_ui::components::inputs;
fn main() -> iced::Result {
fn main() -> anyhow::Result<()> {
let resolved = BootMode::resolve(std::env::var("BDS_MODE").ok().as_deref());
let env = std::env::vars().collect();
let mode = resolved.effective(current_platform(), &env);
if mode != BootMode::Desktop {
if mode != resolved {
let locale = bds_core::i18n::detect_os_locale();
eprintln!(
"{}",
bds_core::i18n::translate(locale, "remoteTerminal.headlessFallback")
);
}
let config = bds_server::ServerConfig::from_environment(
bds_core::util::application_database_path(),
bds_core::util::application_data_dir(),
)?;
return bds_server::run_headless(mode, config);
}
let icon =
iced::window::icon::from_file_data(include_bytes!("../assets/app-icons/bds.png"), None)
.expect("bundled application icon must be valid");
@@ -16,5 +35,17 @@ fn main() -> iced::Result {
icon: Some(icon),
..Default::default()
})
.run_with(BdsApp::new)
.run_with(BdsApp::new)?;
Ok(())
}
fn current_platform() -> Platform {
#[cfg(target_os = "macos")]
return Platform::MacOs;
#[cfg(windows)]
return Platform::Windows;
#[cfg(all(unix, not(target_os = "macos")))]
return Platform::Unix;
#[allow(unreachable_code)]
Platform::Windows
}

View File

@@ -20,6 +20,8 @@ pub enum MenuAction {
Save,
OpenInBrowser,
OpenDataFolder,
ConnectServer,
DisconnectServer,
// Edit (custom items only)
Find,
Replace,
@@ -60,6 +62,8 @@ impl MenuAction {
MenuAction::Save,
MenuAction::OpenInBrowser,
MenuAction::OpenDataFolder,
MenuAction::ConnectServer,
MenuAction::DisconnectServer,
MenuAction::Find,
MenuAction::Replace,
MenuAction::EditPreferences,
@@ -95,6 +99,8 @@ impl MenuAction {
"save" => Self::Save,
"open_in_browser" => Self::OpenInBrowser,
"open_data_folder" => Self::OpenDataFolder,
"connect_server" => Self::ConnectServer,
"disconnect_server" => Self::DisconnectServer,
"find" => Self::Find,
"replace" => Self::Replace,
"edit_preferences" => Self::EditPreferences,
@@ -133,6 +139,8 @@ impl MenuAction {
Self::Save => "menu.item.save",
Self::OpenInBrowser => "menu.item.openInBrowser",
Self::OpenDataFolder => "menu.item.openDataFolder",
Self::ConnectServer => "menu.item.connectServer",
Self::DisconnectServer => "menu.item.disconnectServer",
Self::Find => "menu.item.find",
Self::Replace => "menu.item.replace",
Self::EditPreferences => "menu.item.editPreferences",
@@ -342,6 +350,9 @@ pub fn build_menu_bar(locale: UiLocale) -> (Menu, MenuRegistry) {
let _ = file_menu.append(&item(&mut reg, MenuAction::OpenInBrowser, locale, None));
let _ = file_menu.append(&item(&mut reg, MenuAction::OpenDataFolder, locale, None));
let _ = file_menu.append(&PredefinedMenuItem::separator());
let _ = file_menu.append(&item(&mut reg, MenuAction::ConnectServer, locale, None));
let _ = file_menu.append(&item(&mut reg, MenuAction::DisconnectServer, locale, None));
let _ = file_menu.append(&PredefinedMenuItem::separator());
let _ = file_menu.append(&PredefinedMenuItem::close_window(None));
// -- Edit --
@@ -523,16 +534,18 @@ pub fn update_menu_labels(registry: &MenuRegistry, locale: UiLocale) {
}
}
/// Iced subscription that polls muda `MenuEvent`s each frame.
/// Iced subscription that polls muda `MenuEvent`s independently of window input.
///
/// Produces `Message::MenuEvent(MenuId)` so the app can look up the
/// `MenuAction` via its `MenuRegistry`.
/// `MenuAction` via its `MenuRegistry`. Native menu selection does not itself
/// produce an Iced window event, so an `iced::event` listener would leave the
/// action queued until the user next moved or clicked inside the window.
pub fn menu_subscription() -> Subscription<Message> {
iced::event::listen_with(|_event, _status, _id| {
iced::time::every(std::time::Duration::from_millis(50)).map(|_| {
if let Ok(event) = MenuEvent::receiver().try_recv() {
Some(Message::MenuEvent(event.id))
Message::MenuEvent(event.id)
} else {
None
Message::Noop
}
})
}

View File

@@ -104,6 +104,14 @@ pub enum ModalState {
source_language: String,
available_targets: Vec<LanguageTarget>,
},
RemoteConnection {
target: String,
connecting: bool,
connected: bool,
error: Option<String>,
projects: Vec<bds_core::model::Project>,
selected_project_id: Option<String>,
},
}
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -219,6 +227,119 @@ pub fn view(
data_dir: Option<&Path>,
) -> Element<'static, Message> {
let modal_content: Element<'static, Message> = match state {
ModalState::RemoteConnection {
target,
connecting,
connected,
error,
projects,
selected_project_id,
} => {
let target_input =
text_input(&t(locale, "remoteConnection.targetPlaceholder"), &target)
.on_input(Message::RemoteTargetChanged)
.on_submit(Message::RemoteConnectRequested)
.style(inputs::field_style);
let mut content = column![
text(t(locale, "remoteConnection.title"))
.size(16)
.shaping(Shaping::Advanced)
.color(Color::WHITE),
text(t(locale, "remoteConnection.description"))
.size(12)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.70, 0.70, 0.75)),
Space::with_height(8.0),
text(t(locale, "remoteConnection.target"))
.size(12)
.color(Color::from_rgb(0.80, 0.80, 0.85)),
target_input,
]
.spacing(6)
.width(Length::Fill);
if let Some(error) = error {
content = content.push(
inputs::card(text(error).size(12).color(Color::from_rgb(1.0, 0.55, 0.55)))
.width(Length::Fill),
);
}
if !projects.is_empty() {
content = content.push(Space::with_height(6.0));
content = content.push(
text(t(locale, "remoteConnection.projects"))
.size(13)
.color(Color::WHITE),
);
let mut project_rows = column![].spacing(6);
for project in projects {
let selected = selected_project_id.as_deref() == Some(project.id.as_str());
let marker = if selected { "" } else { "" };
project_rows = project_rows.push(
button(
row![
text(marker).size(12),
text(project.name.clone()).size(13),
Space::with_width(Length::Fill),
text(project.description.unwrap_or_default())
.size(11)
.color(Color::from_rgb(0.65, 0.65, 0.70)),
]
.spacing(8)
.align_y(Alignment::Center),
)
.on_press(Message::RemoteProjectSelected(project.id))
.padding([7, 10])
.width(Length::Fill)
.style(if selected {
inputs::primary_button
} else {
inputs::secondary_button
}),
);
}
content = content.push(project_rows);
}
let cancel = button(text(t(locale, "remoteConnection.cancel")).size(13))
.on_press(Message::DismissModal)
.padding([6, 16])
.style(cancel_button_style);
let action = if !connected {
let button = button(
text(if connecting {
t(locale, "remoteConnection.connecting")
} else {
t(locale, "remoteConnection.connect")
})
.size(13),
)
.padding([6, 16])
.style(confirm_button_style);
if connecting || target.trim().is_empty() {
button
} else {
button.on_press(Message::RemoteConnectRequested)
}
} else {
let button = button(text(t(locale, "remoteConnection.open")).size(13))
.padding([6, 16])
.style(confirm_button_style);
if selected_project_id.is_some() {
button.on_press(Message::RemoteOpenProjectRequested)
} else {
button
}
};
content = content.push(Space::with_height(8.0)).push(
row![cancel, Space::with_width(Length::Fill), action].align_y(Alignment::Center),
);
container(content.padding(20))
.width(Length::Fixed(500.0))
.style(modal_box_style)
.into()
}
ModalState::ConfirmDelete {
entity_name,
references,