first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+213
View File
@@ -0,0 +1,213 @@
//! Bridge between protocol-level control requests and Warp application models.
//!
//! The bridge validates protocol version, selectors, credentials, and settings
//! before routing each supported action to an app-side handler.
use ::local_control::auth::CredentialGrant;
use ::local_control::{
Action, ActionKind, ControlError, ErrorCode, InstanceId, RequestEnvelope, ResponseEnvelope,
};
use warpui::{Entity, ModelContext, SingletonEntity};
use crate::local_control::handlers::{
app_state, close, metadata, metadata_config, settings_surfaces,
};
use crate::local_control::permissions::{
ensure_action_allowed, ensure_feature_enabled, ensure_protocol_version,
};
use crate::local_control::resolver::{validate_action_params, validate_action_target};
/// WarpUI model that executes already-authenticated local-control actions.
pub struct LocalControlBridge {
instance_id: Option<InstanceId>,
}
impl Entity for LocalControlBridge {
type Event = ();
}
impl SingletonEntity for LocalControlBridge {}
impl LocalControlBridge {
pub fn new(_ctx: &mut ModelContext<Self>) -> Self {
Self { instance_id: None }
}
pub(super) fn set_instance_id(&mut self, instance_id: InstanceId) {
self.instance_id = Some(instance_id);
}
pub(super) fn handle_request(
&mut self,
request: RequestEnvelope,
grant: CredentialGrant,
ctx: &mut ModelContext<Self>,
) -> ResponseEnvelope {
if let Err(error) = ensure_feature_enabled() {
return ResponseEnvelope::error(request.request_id, error);
}
if let Err(error) = ensure_protocol_version(request.protocol_version) {
return ResponseEnvelope::error(request.request_id, error);
}
let Some(instance_id) = &self.instance_id else {
return ResponseEnvelope::error(
request.request_id,
ControlError::new(
ErrorCode::BridgeUnavailable,
"local-control bridge has no active instance identity",
),
);
};
if let Err(error) = validate_request_authority(instance_id, &request.action, &grant) {
return ResponseEnvelope::error(request.request_id, error);
}
if let Err(error) = ensure_action_allowed(request.action.kind, ctx) {
return ResponseEnvelope::error(request.request_id, error);
}
if let Err(error) = validate_action_target(request.action.kind, &request.target) {
return ResponseEnvelope::error(request.request_id, error);
}
let result = match request.action.kind {
ActionKind::InstanceList => metadata::instance(&self.instance_id),
ActionKind::InstanceInspect => metadata::inspect(&self.instance_id, ctx),
ActionKind::AppPing => metadata::ping(&self.instance_id),
ActionKind::AppVersion => metadata::version(&self.instance_id),
ActionKind::AppActive => metadata::active(&self.instance_id, ctx),
ActionKind::CapabilityList => Ok(metadata::capability_list()),
ActionKind::CapabilityInspect => metadata::capability_inspect(&request.action),
ActionKind::ActionList => Ok(metadata::action_list()),
ActionKind::ActionInspect => metadata::action_inspect(&request.action),
ActionKind::SurfaceList => metadata::surface_list(ctx),
ActionKind::WindowList => metadata::window_list(&request.target, ctx),
ActionKind::WindowInspect => metadata::window_inspect(&request.target, ctx),
ActionKind::TabList => metadata::tab_list(&request.target, ctx),
ActionKind::TabInspect => metadata::tab_inspect(&request.target, ctx),
ActionKind::AppFocus
| ActionKind::WindowCreate
| ActionKind::WindowFocus
| ActionKind::TabCreate
| ActionKind::TabActivate
| ActionKind::TabMove
| ActionKind::PaneSplit
| ActionKind::PaneFocus
| ActionKind::PaneNavigate
| ActionKind::PaneResize
| ActionKind::PaneMaximize
| ActionKind::PaneUnmaximize
| ActionKind::SessionActivate
| ActionKind::SessionPrevious
| ActionKind::SessionNext
| ActionKind::SessionReopenClosed
| ActionKind::InputInsert
| ActionKind::InputReplace
| ActionKind::SurfaceSettingsOpen
| ActionKind::SurfaceCommandPaletteOpen
| ActionKind::SurfaceCommandSearchOpen
| ActionKind::SurfaceThemePickerOpen
| ActionKind::SurfaceKeybindingsOpen
| ActionKind::SurfaceWarpDriveOpen
| ActionKind::SurfaceWarpDriveToggle
| ActionKind::SurfaceResourceCenterToggle
| ActionKind::SurfaceAiAssistantToggle
| ActionKind::SurfaceCodeReviewOpen
| ActionKind::SurfaceCodeReviewToggle
| ActionKind::SurfaceProjectExplorerOpen
| ActionKind::SurfaceGlobalSearchOpen
| ActionKind::SurfaceConversationListOpen
| ActionKind::SurfaceLeftPanelToggle
| ActionKind::SurfaceRightPanelToggle
| ActionKind::SurfaceVerticalTabsOpen
| ActionKind::SurfaceVerticalTabsToggle
| ActionKind::SurfaceAgentManagementOpen
| ActionKind::FileOpen => app_state::handle(
&self.instance_id,
request.action.kind,
&request.action.params,
&request.target,
ctx,
),
ActionKind::TabRename => metadata_config::tab_rename(
&self.instance_id,
&request.target,
&request.action,
ctx,
),
ActionKind::TabResetName => {
metadata_config::tab_reset_name(&self.instance_id, &request.target, ctx)
}
ActionKind::TabColorSet => metadata_config::tab_color_set(
&self.instance_id,
&request.target,
&request.action,
ctx,
),
ActionKind::TabColorClear => {
metadata_config::tab_color_clear(&self.instance_id, &request.target, ctx)
}
ActionKind::PaneList => metadata::pane_list(&request.target, ctx),
ActionKind::PaneInspect => metadata::pane_inspect(&request.target, ctx),
ActionKind::PaneRename => metadata_config::pane_rename(
&self.instance_id,
&request.target,
&request.action,
ctx,
),
ActionKind::PaneResetName => {
metadata_config::pane_reset_name(&self.instance_id, &request.target, ctx)
}
ActionKind::SessionList => metadata::session_list(&request.target, ctx),
ActionKind::SessionInspect => metadata::session_inspect(&request.target, ctx),
ActionKind::ThemeList => settings_surfaces::theme_list(ctx),
ActionKind::ThemeGet => settings_surfaces::theme_get(ctx),
ActionKind::ThemeSet
| ActionKind::ThemeSystemSet
| ActionKind::ThemeLightSet
| ActionKind::ThemeDarkSet => metadata_config::theme_set(
&self.instance_id,
request.action.kind,
&request.action,
ctx,
),
ActionKind::AppearanceGet => settings_surfaces::appearance_get(ctx),
ActionKind::AppearanceFontSizeIncrease
| ActionKind::AppearanceFontSizeDecrease
| ActionKind::AppearanceFontSizeReset
| ActionKind::AppearanceZoomIncrease
| ActionKind::AppearanceZoomDecrease
| ActionKind::AppearanceZoomReset => {
metadata_config::appearance_mutation(&self.instance_id, request.action.kind, ctx)
}
ActionKind::SettingList => settings_surfaces::setting_list(&request.action, ctx),
ActionKind::SettingGet => settings_surfaces::setting_get(&request.action, ctx),
ActionKind::SettingSet => metadata_config::setting_set(&request.action, ctx),
ActionKind::SettingToggle => metadata_config::setting_toggle(&request.action, ctx),
ActionKind::KeybindingList => settings_surfaces::keybinding_list(ctx),
ActionKind::KeybindingGet => settings_surfaces::keybinding_get(&request.action, ctx),
ActionKind::WindowClose => close::window_close(&self.instance_id, &request, ctx),
ActionKind::TabClose => close::tab_close(&self.instance_id, &request, ctx),
ActionKind::PaneClose => close::pane_close(&self.instance_id, &request, ctx),
};
match result {
Ok(data) => ResponseEnvelope::ok(request.request_id, data),
Err(error) => ResponseEnvelope::error(request.request_id, error),
}
}
}
pub(crate) fn validate_request_authority(
instance_id: &InstanceId,
action: &Action,
grant: &CredentialGrant,
) -> Result<(), ControlError> {
grant.verify_for_action(instance_id, action.kind)?;
if !action.kind.is_implemented() {
return Err(ControlError::new(
ErrorCode::UnsupportedAction,
format!(
"{} is not implemented by this local-control bridge",
action.kind.as_str()
),
));
}
validate_action_params(action)
}
+19
View File
@@ -0,0 +1,19 @@
//! App-side action handlers invoked by the local-control bridge.
use ::local_control::{ActionKind, InstanceId};
use serde_json::json;
pub(super) mod app_state;
pub(super) mod close;
pub(super) mod layout;
pub(super) mod metadata;
pub(super) mod metadata_config;
pub(super) mod settings_surfaces;
/// Standard acknowledgement payload shared by mutation handlers.
pub(crate) fn ack(instance_id: &Option<InstanceId>, action: ActionKind) -> serde_json::Value {
json!({
"action": action.as_str(),
"ok": true,
"instance_id": instance_id.as_ref().map(|id| id.0.as_str()),
})
}
+853
View File
@@ -0,0 +1,853 @@
//! Safe app-state mutation and visible UI intent handlers for local-control actions.
#[cfg(test)]
#[path = "app_state_tests.rs"]
mod tests;
#[cfg(feature = "local_fs")]
use std::path::PathBuf;
use std::str::FromStr;
use ::local_control::protocol::{
Direction as ControlDirection, DirectionParams, FileOpenParams, PageQueryParams, QueryParams,
ResizeParams, TabActivateParams, TabActivationMode, TabCreateParams, TabTarget, TabType,
TargetSelector, TextParams,
};
use ::local_control::{ActionKind, ControlError, ErrorCode, InstanceId};
use serde_json::json;
use warp_util::path::LineAndColumnArg;
#[cfg(feature = "local_fs")]
use warpui::SingletonEntity;
use warpui::{AppContext, ModelContext, TypedActionView};
#[cfg(feature = "local_fs")]
use crate::code::editor_management::CodeSource;
use crate::local_control::handlers::ack;
use crate::local_control::handlers::layout::{create_tab, resolve_shell};
use crate::local_control::handlers::metadata::{surface_unavailable_reason, SurfaceDestination};
use crate::local_control::resolver::{
activate_target, active_target_pane_group, decode_params, focus_explicit_pane_target,
input_target_pane_id, reject_target_families, tab_index_from_target, target_pane_group,
target_pane_id, target_session_pane_id, target_window_id_for_target, target_workspace,
};
use crate::local_control::LocalControlBridge;
use crate::palette::PaletteMode;
use crate::pane_group::{ActivationReason, Direction, PaneGroupAction};
use crate::server::telemetry::PaletteSource;
use crate::settings_view::SettingsSection;
#[cfg(feature = "local_fs")]
use crate::util::file::external_editor::EditorSettings;
#[cfg(feature = "local_fs")]
use crate::util::openable_file_type::{resolve_file_target_to_open_in_warp, EditorLayout};
#[cfg(feature = "local_fs")]
use crate::workspace::PaneViewLocator;
use crate::workspace::{CommandSearchOptions, InitContent, WorkspaceAction};
const MAX_PANE_RESIZE_STEPS: u32 = 1_000;
pub(crate) fn handle(
instance_id: &Option<InstanceId>,
action: ActionKind,
params: &serde_json::Value,
target: &TargetSelector,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
match action {
ActionKind::AppFocus | ActionKind::WindowFocus => {
focus_window(instance_id, action, target, ctx)
}
ActionKind::WindowCreate => window_create(instance_id, params, target, ctx),
ActionKind::TabCreate => create_tab(instance_id, params, target, ctx),
ActionKind::TabActivate => tab_activate(instance_id, params, target, ctx),
ActionKind::TabMove => tab_move(instance_id, params, target, ctx),
ActionKind::PaneSplit => pane_split(instance_id, params, target, ctx),
ActionKind::PaneFocus | ActionKind::SessionActivate => {
pane_focus(instance_id, action, target, ctx)
}
ActionKind::PaneNavigate => pane_direction_action(instance_id, action, params, target, ctx),
ActionKind::PaneResize => pane_resize(instance_id, params, target, ctx),
ActionKind::PaneMaximize => pane_maximize(instance_id, true, target, ctx),
ActionKind::PaneUnmaximize => pane_maximize(instance_id, false, target, ctx),
ActionKind::SessionPrevious => workspace_action(
instance_id,
action,
WorkspaceAction::CyclePrevSession,
target,
ctx,
),
ActionKind::SurfaceKeybindingsOpen => surface_workspace_action(
instance_id,
action,
SurfaceDestination::Keybindings,
WorkspaceAction::ShowSettingsPage(SettingsSection::Keybindings),
target,
ctx,
),
ActionKind::SurfaceWarpDriveOpen => surface_workspace_action(
instance_id,
action,
SurfaceDestination::WarpDrive,
WorkspaceAction::OpenWarpDrive,
target,
ctx,
),
ActionKind::SurfaceAgentManagementOpen => surface_workspace_action(
instance_id,
action,
SurfaceDestination::AgentManagement,
WorkspaceAction::OpenAgentManagementView,
target,
ctx,
),
ActionKind::SessionNext => workspace_action(
instance_id,
action,
WorkspaceAction::CycleNextSession,
target,
ctx,
),
ActionKind::SessionReopenClosed => session_reopen_closed(instance_id, target, ctx),
ActionKind::InputInsert => input_text(instance_id, action, params, target, false, ctx),
ActionKind::InputReplace => input_text(instance_id, action, params, target, true, ctx),
ActionKind::SurfaceSettingsOpen => surface_settings_open(instance_id, params, target, ctx),
ActionKind::SurfaceCommandPaletteOpen => surface_palette_open(
instance_id,
action,
PaletteMode::Command,
params,
target,
ctx,
),
ActionKind::SurfaceCommandSearchOpen => {
surface_command_search_open(instance_id, params, target, ctx)
}
ActionKind::SurfaceThemePickerOpen => surface_theme_picker_open(instance_id, target, ctx),
ActionKind::SurfaceWarpDriveToggle => workspace_action(
instance_id,
action,
WorkspaceAction::ToggleWarpDrive,
target,
ctx,
),
ActionKind::SurfaceResourceCenterToggle => workspace_action(
instance_id,
action,
WorkspaceAction::ToggleResourceCenter,
target,
ctx,
),
ActionKind::SurfaceAiAssistantToggle => workspace_action(
instance_id,
action,
WorkspaceAction::ToggleAIAssistant,
target,
ctx,
),
ActionKind::SurfaceCodeReviewOpen => surface_code_review_open(instance_id, target, ctx),
ActionKind::SurfaceCodeReviewToggle | ActionKind::SurfaceRightPanelToggle => {
workspace_action(
instance_id,
action,
WorkspaceAction::ToggleRightPanel,
target,
ctx,
)
}
ActionKind::SurfaceProjectExplorerOpen => surface_workspace_action(
instance_id,
action,
SurfaceDestination::ProjectExplorer,
WorkspaceAction::OpenProjectExplorer,
target,
ctx,
),
ActionKind::SurfaceGlobalSearchOpen => surface_workspace_action(
instance_id,
action,
SurfaceDestination::GlobalSearch,
WorkspaceAction::OpenGlobalSearch,
target,
ctx,
),
ActionKind::SurfaceConversationListOpen => surface_workspace_action(
instance_id,
action,
SurfaceDestination::ConversationList,
WorkspaceAction::OpenConversationListView,
target,
ctx,
),
ActionKind::SurfaceLeftPanelToggle => workspace_action(
instance_id,
action,
WorkspaceAction::ToggleLeftPanel,
target,
ctx,
),
ActionKind::SurfaceVerticalTabsOpen => surface_workspace_action(
instance_id,
action,
SurfaceDestination::VerticalTabs,
WorkspaceAction::OpenVerticalTabsPanel,
target,
ctx,
),
ActionKind::SurfaceVerticalTabsToggle => workspace_action(
instance_id,
action,
WorkspaceAction::ToggleVerticalTabsPanel,
target,
ctx,
),
ActionKind::FileOpen => file_open(instance_id, params, target, ctx),
_ => Err(ControlError::new(
ErrorCode::UnsupportedAction,
format!("{} is not a safe app-state handler action", action.as_str()),
)),
}
}
fn focus_window(
instance_id: &Option<InstanceId>,
action: ActionKind,
target: &TargetSelector,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
reject_target_families(
action,
target.tab.is_some() || target.pane.is_some() || target.session.is_some(),
"tab, pane, or session selectors",
)?;
let window_id = target_window_id_for_target(ctx, target, action)?;
ctx.windows().show_window_and_focus_app(window_id);
Ok(ack(instance_id, action))
}
fn window_create(
instance_id: &Option<InstanceId>,
params: &serde_json::Value,
target: &TargetSelector,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
reject_target_families(
ActionKind::WindowCreate,
target.window.is_some()
|| target.tab.is_some()
|| target.pane.is_some()
|| target.session.is_some(),
"target selectors",
)?;
let params = decode_params::<TabCreateParams>(params)?;
match params.tab_type {
None | Some(TabType::Terminal | TabType::Default) => {}
Some(TabType::Agent | TabType::CloudAgent) => {
return Err(ControlError::new(
ErrorCode::UnsupportedAction,
"window.create only supports terminal or default window types",
));
}
}
match params.shell.as_deref() {
Some(shell_name) => {
let shell = resolve_shell(shell_name, ctx)?;
ctx.dispatch_global_action("root_view:open_new_with_shell", Some(shell));
}
None => ctx.dispatch_global_action("root_view:open_new", ()),
}
Ok(ack(instance_id, ActionKind::WindowCreate))
}
fn workspace_action(
instance_id: &Option<InstanceId>,
action_kind: ActionKind,
action: WorkspaceAction,
target: &TargetSelector,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
let workspace = target_workspace(action_kind, target, ctx)?;
activate_target(&workspace, action_kind, target, ctx)?;
workspace.update(ctx, |workspace, ctx| {
workspace.handle_action(&action, ctx);
});
Ok(ack(instance_id, action_kind))
}
fn surface_workspace_action(
instance_id: &Option<InstanceId>,
action_kind: ActionKind,
destination: SurfaceDestination,
action: WorkspaceAction,
target: &TargetSelector,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
ensure_surface_available(action_kind, destination, ctx)?;
workspace_action(instance_id, action_kind, action, target, ctx)
}
fn surface_theme_picker_open(
instance_id: &Option<InstanceId>,
target: &TargetSelector,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
let action = ActionKind::SurfaceThemePickerOpen;
ensure_surface_available(action, SurfaceDestination::ThemePicker, ctx)?;
let workspace = target_workspace(action, target, ctx)?;
activate_target(&workspace, action, target, ctx)?;
workspace.update(ctx, |workspace, ctx| {
if !workspace.is_theme_chooser_open() {
workspace.handle_action(&WorkspaceAction::ShowThemeChooserForActiveTheme, ctx);
}
});
Ok(ack(instance_id, action))
}
fn surface_code_review_open(
instance_id: &Option<InstanceId>,
target: &TargetSelector,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
let action = ActionKind::SurfaceCodeReviewOpen;
ensure_surface_available(action, SurfaceDestination::CodeReview, ctx)?;
let workspace = target_workspace(action, target, ctx)?;
activate_target(&workspace, action, target, ctx)?;
#[cfg(feature = "local_fs")]
{
let pane_group = target_pane_group(action, target, ctx)?;
let pane_id = target_pane_id(action, target, &pane_group, ctx)?;
let has_repository = pane_group.read(ctx, |pane_group, ctx| {
pane_group
.terminal_view_from_pane_id(pane_id, ctx)
.is_some_and(|terminal| terminal.as_ref(ctx).current_repo_path().is_some())
});
if !has_repository {
return Err(ControlError::new(
ErrorCode::TargetStateConflict,
"surface.code_review.open requires an active terminal in a repository",
));
}
workspace.update(ctx, |workspace, ctx| {
workspace.handle_action(
&WorkspaceAction::OpenCodeReviewPanel(PaneViewLocator {
pane_group_id: pane_group.id(),
pane_id,
}),
ctx,
);
});
Ok(ack(instance_id, action))
}
#[cfg(not(feature = "local_fs"))]
Err(ControlError::new(
ErrorCode::UnsupportedAction,
"surface.code_review.open is unavailable without local filesystem support",
))
}
fn ensure_surface_available(
action: ActionKind,
destination: SurfaceDestination,
ctx: &AppContext,
) -> Result<(), ControlError> {
let Some(reason) = surface_unavailable_reason(destination, ctx) else {
return Ok(());
};
Err(ControlError::new(
ErrorCode::UnsupportedAction,
format!("{} is unavailable: {reason}", action.as_str()),
))
}
fn session_reopen_closed(
instance_id: &Option<InstanceId>,
target: &TargetSelector,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
reject_target_families(
ActionKind::SessionReopenClosed,
target.tab.is_some() || target.pane.is_some() || target.session.is_some(),
"tab, pane, or session selectors",
)?;
let window_id = target_window_id_for_target(ctx, target, ActionKind::SessionReopenClosed)?;
ctx.windows().show_window_and_focus_app(window_id);
workspace_action(
instance_id,
ActionKind::SessionReopenClosed,
WorkspaceAction::ReopenClosedSession,
target,
ctx,
)
}
fn tab_activate(
instance_id: &Option<InstanceId>,
params: &serde_json::Value,
target: &TargetSelector,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
reject_target_families(
ActionKind::TabActivate,
target.pane.is_some() || target.session.is_some(),
"pane or session selectors",
)?;
let mode = decode_params::<TabActivateParams>(params)?.mode;
if !matches!(mode, TabActivationMode::Target)
&& !matches!(target.tab.as_ref(), None | Some(TabTarget::Active))
{
return Err(ControlError::new(
ErrorCode::InvalidSelector,
"tab.activate navigation modes do not accept a concrete tab selector",
));
}
let workspace = target_workspace(ActionKind::TabActivate, target, ctx)?;
workspace.update(ctx, |workspace, ctx| {
let action = match mode {
TabActivationMode::Target => {
WorkspaceAction::ActivateTab(tab_index_from_target(target, workspace, ctx)?)
}
TabActivationMode::Previous => WorkspaceAction::ActivatePrevTab,
TabActivationMode::Next => WorkspaceAction::ActivateNextTab,
TabActivationMode::Last => WorkspaceAction::ActivateLastTab,
};
workspace.handle_action(&action, ctx);
Ok::<_, ControlError>(())
})?;
Ok(ack(instance_id, ActionKind::TabActivate))
}
fn tab_move(
instance_id: &Option<InstanceId>,
params: &serde_json::Value,
target: &TargetSelector,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
reject_target_families(
ActionKind::TabMove,
target.pane.is_some() || target.session.is_some(),
"pane or session selectors",
)?;
let direction = direction_param(params)?;
let workspace = target_workspace(ActionKind::TabMove, target, ctx)?;
workspace.update(ctx, |workspace, ctx| {
let index = tab_index_from_target(target, workspace, ctx)?;
let action = match direction {
ControlDirection::Left => WorkspaceAction::MoveTabLeft(index),
ControlDirection::Right => WorkspaceAction::MoveTabRight(index),
ControlDirection::Up
| ControlDirection::Down
| ControlDirection::Previous
| ControlDirection::Next => {
return Err(ControlError::new(
ErrorCode::InvalidParams,
"tab.move only accepts left or right",
));
}
};
workspace.handle_action(&action, ctx);
Ok::<_, ControlError>(())
})?;
Ok(ack(instance_id, ActionKind::TabMove))
}
fn pane_direction_action(
instance_id: &Option<InstanceId>,
action_kind: ActionKind,
params: &serde_json::Value,
target: &TargetSelector,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
let direction = direction_param(params)?;
let action = match action_kind {
ActionKind::PaneNavigate => match direction {
ControlDirection::Left => PaneGroupAction::NavigateLeft,
ControlDirection::Right => PaneGroupAction::NavigateRight,
ControlDirection::Up => PaneGroupAction::NavigateUp,
ControlDirection::Down => PaneGroupAction::NavigateDown,
ControlDirection::Previous => PaneGroupAction::NavigatePrev,
ControlDirection::Next => PaneGroupAction::NavigateNext,
},
_ => return invalid_params(action_kind),
};
pane_group_action(instance_id, action_kind, target, action, 1, ctx)
}
/// Splits the targeted pane and reports the created pane's opaque id so
/// callers do not need to diff `pane.list` to find the new pane.
fn pane_split(
instance_id: &Option<InstanceId>,
params: &serde_json::Value,
target: &TargetSelector,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
let action_kind = ActionKind::PaneSplit;
let direction = pane_direction(direction_param(params)?)?;
let pane_group = active_target_pane_group(action_kind, target, ctx)?;
focus_explicit_pane_target(action_kind, target, &pane_group, ctx)?;
let panes_before = pane_group.read(ctx, |pane_group, _| pane_group.visible_pane_ids());
pane_group.update(ctx, |pane_group, ctx| {
pane_group.handle_action(&PaneGroupAction::Add(direction), ctx);
});
let created = pane_group
.read(ctx, |pane_group, _| pane_group.visible_pane_ids())
.into_iter()
.find(|pane_id| !panes_before.contains(pane_id));
let mut response = ack(instance_id, action_kind);
if let Some(created) = created {
response["pane"] = json!({ "id": created.to_string() });
}
Ok(response)
}
fn pane_focus(
instance_id: &Option<InstanceId>,
action_kind: ActionKind,
target: &TargetSelector,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
if target.pane.is_none()
&& (action_kind != ActionKind::SessionActivate || target.session.is_none())
{
return Err(ControlError::new(
ErrorCode::InvalidSelector,
format!("{} requires a pane or session target", action_kind.as_str()),
));
}
let pane_group = active_target_pane_group(action_kind, target, ctx)?;
let pane_id = if action_kind == ActionKind::SessionActivate {
target_session_pane_id(action_kind, target, &pane_group, ctx)?
} else {
target_pane_id(action_kind, target, &pane_group, ctx)?
};
pane_group.update(ctx, |pane_group, ctx| {
pane_group.handle_action(
&PaneGroupAction::Activate(pane_id, ActivationReason::Click),
ctx,
);
});
Ok(ack(instance_id, action_kind))
}
fn pane_resize(
instance_id: &Option<InstanceId>,
params: &serde_json::Value,
target: &TargetSelector,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
let ResizeParams { direction, amount } = decode_params(params)?;
let amount = amount.unwrap_or(1);
if amount > MAX_PANE_RESIZE_STEPS {
return Err(ControlError::new(
ErrorCode::InvalidParams,
format!("pane.resize amount cannot exceed {MAX_PANE_RESIZE_STEPS}"),
));
}
let action = match direction {
ControlDirection::Left => PaneGroupAction::ResizeLeft,
ControlDirection::Right => PaneGroupAction::ResizeRight,
ControlDirection::Up => PaneGroupAction::ResizeUp,
ControlDirection::Down => PaneGroupAction::ResizeDown,
ControlDirection::Previous | ControlDirection::Next => {
return Err(ControlError::new(
ErrorCode::InvalidParams,
"pane.resize only accepts left, right, up, or down",
));
}
};
pane_group_action(
instance_id,
ActionKind::PaneResize,
target,
action,
amount,
ctx,
)
}
fn pane_maximize(
instance_id: &Option<InstanceId>,
should_maximize: bool,
target: &TargetSelector,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
let action_kind = if should_maximize {
ActionKind::PaneMaximize
} else {
ActionKind::PaneUnmaximize
};
let pane_group = active_target_pane_group(action_kind, target, ctx)?;
focus_explicit_pane_target(action_kind, target, &pane_group, ctx)?;
let is_maximized = pane_group.read(ctx, |pane_group, ctx| {
pane_group.is_focused_pane_maximized(ctx)
});
if is_maximized != should_maximize {
pane_group.update(ctx, |pane_group, ctx| {
pane_group.handle_action(&PaneGroupAction::ToggleMaximizePane, ctx);
});
}
Ok(ack(instance_id, action_kind))
}
fn pane_group_action(
instance_id: &Option<InstanceId>,
action_kind: ActionKind,
target: &TargetSelector,
action: PaneGroupAction,
repetitions: u32,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
let pane_group = active_target_pane_group(action_kind, target, ctx)?;
focus_explicit_pane_target(action_kind, target, &pane_group, ctx)?;
pane_group.update(ctx, |pane_group, ctx| {
for _ in 0..repetitions {
pane_group.handle_action(&action, ctx);
}
});
Ok(ack(instance_id, action_kind))
}
fn input_text(
instance_id: &Option<InstanceId>,
action_kind: ActionKind,
params: &serde_json::Value,
target: &TargetSelector,
replace_buffer: bool,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
let text = text_param(params)?;
validate_staged_input_text(action_kind, &text)?;
let pane_group = target_pane_group(action_kind, target, ctx)?;
let pane_id = input_target_pane_id(action_kind, target, &pane_group, ctx)?;
let terminal_view = pane_group
.read(ctx, |pane_group, ctx| {
pane_group.terminal_view_from_pane_id(pane_id, ctx)
})
.ok_or_else(|| {
ControlError::new(
ErrorCode::MissingTarget,
format!("{} requires a terminal input target", action_kind.as_str()),
)
})?;
terminal_view.update(ctx, |terminal_view, ctx| {
terminal_view.input().update(ctx, |input, ctx| {
if replace_buffer {
input.replace_buffer_content(&text, ctx);
} else {
input.append_to_buffer(&text, ctx);
}
});
});
Ok(ack(instance_id, action_kind))
}
pub(super) fn validate_staged_input_text(
action: ActionKind,
text: &str,
) -> Result<(), ControlError> {
if text
.chars()
.any(|character| character == '\n' || character == '\r' || character.is_control())
{
return Err(ControlError::new(
ErrorCode::InvalidParams,
format!(
"{} rejects newlines, carriage returns, and control characters",
action.as_str()
),
));
}
Ok(())
}
fn surface_settings_open(
instance_id: &Option<InstanceId>,
params: &serde_json::Value,
target: &TargetSelector,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
let PageQueryParams { page, query } = decode_params(params)?;
let section = page.map(settings_section).transpose()?;
let action = match (section, query) {
(Some(section), Some(search_query)) => WorkspaceAction::ShowSettingsPageWithSearch {
search_query,
section: Some(section),
},
(Some(section), None) => WorkspaceAction::ShowSettingsPage(section),
(None, Some(search_query)) => WorkspaceAction::ShowSettingsPageWithSearch {
search_query,
section: None,
},
(None, None) => WorkspaceAction::ShowSettings,
};
workspace_action(
instance_id,
ActionKind::SurfaceSettingsOpen,
action,
target,
ctx,
)
}
fn settings_section(page: String) -> Result<SettingsSection, ControlError> {
let section = SettingsSection::from_str(&page).map_err(|_| {
ControlError::new(
ErrorCode::InvalidParams,
format!("surface.settings.open cannot resolve settings page {page:?}"),
)
})?;
if section == SettingsSection::WarpDrive {
return Err(ControlError::new(
ErrorCode::UnsupportedAction,
"surface.settings.open does not open Warp Drive settings",
));
}
Ok(section)
}
fn surface_palette_open(
instance_id: &Option<InstanceId>,
action_kind: ActionKind,
mode: PaletteMode,
params: &serde_json::Value,
target: &TargetSelector,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
let query = decode_params::<QueryParams>(params)?.query;
workspace_action(
instance_id,
action_kind,
WorkspaceAction::OpenPalette {
mode,
source: PaletteSource::Keybinding,
query,
},
target,
ctx,
)
}
fn surface_command_search_open(
instance_id: &Option<InstanceId>,
params: &serde_json::Value,
target: &TargetSelector,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
let query = decode_params::<QueryParams>(params)?.query;
let init_content = query
.map(InitContent::Custom)
.unwrap_or(InitContent::FromInputBuffer);
workspace_action(
instance_id,
ActionKind::SurfaceCommandSearchOpen,
WorkspaceAction::ShowCommandSearch(CommandSearchOptions {
filter: None,
init_content,
}),
target,
ctx,
)
}
fn file_open(
instance_id: &Option<InstanceId>,
params: &serde_json::Value,
target: &TargetSelector,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
let params = decode_params::<FileOpenParams>(params)?;
if params.path.is_empty() {
return Err(ControlError::new(
ErrorCode::InvalidParams,
"file.open requires a non-empty path",
));
}
let line_and_column = line_and_column(&params)?;
let workspace = target_workspace(ActionKind::FileOpen, target, ctx)?;
activate_target(&workspace, ActionKind::FileOpen, target, ctx)?;
#[cfg(feature = "local_fs")]
{
let path = PathBuf::from(params.path);
let layout = params.new_tab.then_some(EditorLayout::NewTab);
let file_target =
resolve_file_target_to_open_in_warp(&path, EditorSettings::as_ref(ctx), layout);
workspace.update(ctx, |workspace, ctx| {
workspace.open_file_with_target(
path.clone(),
file_target,
line_and_column,
CodeSource::Link {
path,
range_start: None,
range_end: None,
},
ctx,
);
});
Ok(ack(instance_id, ActionKind::FileOpen))
}
#[cfg(not(feature = "local_fs"))]
Err(ControlError::new(
ErrorCode::UnsupportedAction,
"file.open is unavailable without local filesystem support",
))
}
fn direction_param(params: &serde_json::Value) -> Result<ControlDirection, ControlError> {
Ok(decode_params::<DirectionParams>(params)?.direction)
}
fn text_param(params: &serde_json::Value) -> Result<String, ControlError> {
Ok(decode_params::<TextParams>(params)?.text)
}
fn invalid_params<T>(action: ActionKind) -> Result<T, ControlError> {
Err(ControlError::new(
ErrorCode::InvalidParams,
format!(
"{} received parameters with the wrong shape",
action.as_str()
),
))
}
fn pane_direction(direction: ControlDirection) -> Result<Direction, ControlError> {
match direction {
ControlDirection::Left => Ok(Direction::Left),
ControlDirection::Right => Ok(Direction::Right),
ControlDirection::Up => Ok(Direction::Up),
ControlDirection::Down => Ok(Direction::Down),
ControlDirection::Previous | ControlDirection::Next => Err(ControlError::new(
ErrorCode::InvalidParams,
"pane.split only accepts left, right, up, or down",
)),
}
}
fn line_and_column(params: &FileOpenParams) -> Result<Option<LineAndColumnArg>, ControlError> {
let Some(line) = params.line else {
if params.column.is_some() {
return Err(ControlError::new(
ErrorCode::InvalidParams,
"file.open column requires a line",
));
}
return Ok(None);
};
let line_num = usize::try_from(line).map_err(|err| {
ControlError::with_details(
ErrorCode::InvalidParams,
"file.open line is out of range",
err.to_string(),
)
})?;
let column_num = params
.column
.map(usize::try_from)
.transpose()
.map_err(|err| {
ControlError::with_details(
ErrorCode::InvalidParams,
"file.open column is out of range",
err.to_string(),
)
})?;
Ok(Some(LineAndColumnArg {
line_num,
column_num,
}))
}
@@ -0,0 +1,34 @@
use ::local_control::{ActionKind, ErrorCode};
use super::{ensure_surface_available, validate_staged_input_text};
use crate::features::FeatureFlag;
use crate::local_control::handlers::metadata::SurfaceDestination;
#[test]
fn staged_input_rejects_line_breaks_and_control_sequences() {
assert!(validate_staged_input_text(ActionKind::InputInsert, "safe staged text").is_ok());
for text in ["line\nbreak", "line\rbreak", "tab\tbreak", "\u{1b}[31m"] {
let error = validate_staged_input_text(ActionKind::InputInsert, text).err();
assert!(error.is_some_and(|error| error.code == ErrorCode::InvalidParams));
}
}
#[test]
fn unavailable_surface_open_returns_structured_error() {
let flag_guard = FeatureFlag::AgentManagementView.override_enabled(false);
warpui::App::test((), |mut app| async move {
let error = app
.update(|ctx| {
ensure_surface_available(
ActionKind::SurfaceAgentManagementOpen,
SurfaceDestination::AgentManagement,
ctx,
)
})
.expect_err("disabled surface is rejected");
assert_eq!(error.code, ErrorCode::UnsupportedAction);
assert!(error.message.contains("surface.agent_management.open"));
});
drop(flag_guard);
}
+121
View File
@@ -0,0 +1,121 @@
//! Close handlers for local-control window, tab, and pane actions.
use ::local_control::protocol::{TabCloseMode, TabCloseParams, TabTarget};
use ::local_control::{Action, ActionKind, ControlError, ErrorCode, InstanceId, RequestEnvelope};
use warpui::platform::TerminationMode;
use warpui::ModelContext;
use crate::local_control::handlers::ack;
use crate::local_control::resolver::{
reject_target_families, tab_index_from_target, target_pane_group, target_pane_id,
target_window_id_for_target, target_workspace,
};
use crate::local_control::LocalControlBridge;
use crate::workspace::view::OpenDialogSource;
fn tab_close_mode(action: &Action) -> Result<TabCloseMode, ControlError> {
Ok(action.params_as::<TabCloseParams>()?.mode)
}
fn validate_empty_params(action: &Action) -> Result<(), ControlError> {
if action
.params
.as_object()
.is_some_and(serde_json::Map::is_empty)
{
return Ok(());
}
Err(ControlError::new(
ErrorCode::InvalidParams,
format!("{} does not accept parameters", action.kind.as_str()),
))
}
pub(crate) fn window_close(
instance_id: &Option<InstanceId>,
request: &RequestEnvelope,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
validate_empty_params(&request.action)?;
reject_target_families(
ActionKind::WindowClose,
request.target.tab.is_some()
|| request.target.pane.is_some()
|| request.target.session.is_some(),
"tab, pane, or session selectors",
)?;
let window_id = target_window_id_for_target(ctx, &request.target, ActionKind::WindowClose)?;
ctx.windows()
.close_window(window_id, TerminationMode::Cancellable);
Ok(ack(instance_id, ActionKind::WindowClose))
}
pub(crate) fn tab_close(
instance_id: &Option<InstanceId>,
request: &RequestEnvelope,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
reject_target_families(
ActionKind::TabClose,
request.target.pane.is_some() || request.target.session.is_some(),
"pane or session selectors",
)?;
let mode = tab_close_mode(&request.action)?;
let workspace = target_workspace(ActionKind::TabClose, &request.target, ctx)?;
let closed = workspace.update(ctx, |workspace, ctx| {
let selected_index = tab_index_from_target(&request.target, workspace, ctx)?;
let tab_count = workspace.tab_count();
let tab_indices: Vec<usize> = match mode {
TabCloseMode::Target => vec![selected_index],
TabCloseMode::Active => {
if !matches!(request.target.tab.as_ref(), None | Some(TabTarget::Active)) {
return Err(ControlError::new(
ErrorCode::InvalidSelector,
"tab.close active does not accept a concrete tab selector",
));
}
vec![workspace.active_tab_index()]
}
TabCloseMode::Others => (0..tab_count)
.filter(|index| *index != selected_index)
.collect(),
TabCloseMode::RightOf => ((selected_index + 1)..tab_count).collect(),
};
if tab_indices.is_empty() {
return Ok(true);
}
let closed = workspace.close_tabs(
tab_indices.into_iter(),
OpenDialogSource::CloseTab {
tab_index: selected_index,
},
false,
true,
ctx,
);
Ok(closed)
})?;
if closed {
return Ok(ack(instance_id, ActionKind::TabClose));
}
Err(ControlError::new(
ErrorCode::TargetStateConflict,
"tab close was cancelled by an existing app warning",
))
}
pub(crate) fn pane_close(
instance_id: &Option<InstanceId>,
request: &RequestEnvelope,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
validate_empty_params(&request.action)?;
reject_target_families(
ActionKind::PaneClose,
request.target.session.is_some(),
"session selectors",
)?;
let pane_group = target_pane_group(ActionKind::PaneClose, &request.target, ctx)?;
let pane_id = target_pane_id(ActionKind::PaneClose, &request.target, &pane_group, ctx)?;
pane_group.update(ctx, |pane_group, ctx| pane_group.close_pane(pane_id, ctx));
Ok(ack(instance_id, ActionKind::PaneClose))
}
+150
View File
@@ -0,0 +1,150 @@
//! Layout mutation handlers for local-control actions.
#[cfg(test)]
#[path = "layout_tests.rs"]
mod tests;
use ::local_control::protocol::{TabCreateParams, TabType, TargetSelector};
use ::local_control::{ActionKind, ControlError, ErrorCode, InstanceId};
use serde::Serialize;
#[cfg(feature = "local_tty")]
use warpui::SingletonEntity;
use warpui::{ModelContext, TypedActionView};
use crate::local_control::resolver::{
decode_params, target_window_id_for_target, validate_tab_create_target, workspace_for_window,
};
use crate::local_control::LocalControlBridge;
use crate::server::telemetry::AddTabWithShellSource;
use crate::terminal::available_shells::AvailableShell;
#[cfg(feature = "local_tty")]
use crate::terminal::available_shells::AvailableShells;
use crate::workspace::WorkspaceAction;
#[derive(Serialize)]
struct TabCreateResponse<'a> {
action: &'static str,
created: bool,
instance_id: Option<&'a str>,
window: TargetWindowResponse,
tab: TabCountsResponse,
}
#[derive(Serialize)]
struct TargetWindowResponse {
selector: &'static str,
id: String,
}
#[derive(Serialize)]
struct TabCountsResponse {
id: String,
previous_count: usize,
count: usize,
active_index: usize,
}
pub(crate) fn create_tab(
instance_id: &Option<InstanceId>,
params: &serde_json::Value,
target: &TargetSelector,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
validate_tab_create_target(target)?;
let window_id = target_window_id_for_target(ctx, target, ActionKind::TabCreate)?;
let workspace = workspace_for_window(window_id, ActionKind::TabCreate, ctx)?;
let action = tab_create_action(params, ctx)?;
let (tab_id, previous_tab_count, tab_count, active_tab_index) =
workspace.update(ctx, |workspace, ctx| {
let previous_tab_count = workspace.tab_count();
workspace.handle_action(&action, ctx);
let tab_id = workspace
.get_pane_group_view(workspace.active_tab_index())
.map(|tab| tab.id().to_string())
.ok_or_else(|| {
ControlError::new(
ErrorCode::Internal,
"tab.create did not produce an active tab identifier",
)
})?;
Ok((
tab_id,
previous_tab_count,
workspace.tab_count(),
workspace.active_tab_index(),
))
})?;
serde_json::to_value(TabCreateResponse {
action: ActionKind::TabCreate.as_str(),
created: true,
instance_id: instance_id.as_ref().map(|id| id.0.as_str()),
window: TargetWindowResponse {
selector: "target",
id: window_id.to_string(),
},
tab: TabCountsResponse {
id: tab_id,
previous_count: previous_tab_count,
count: tab_count,
active_index: active_tab_index,
},
})
.map_err(|err| {
ControlError::with_details(
ErrorCode::Internal,
"failed to serialize local-control tab.create response",
err.to_string(),
)
})
}
fn tab_create_action(
params: &serde_json::Value,
ctx: &ModelContext<LocalControlBridge>,
) -> Result<WorkspaceAction, ControlError> {
let params = decode_params::<TabCreateParams>(params)?;
if let Some(shell_name) = params.shell.as_deref() {
if matches!(params.tab_type, Some(TabType::Agent | TabType::CloudAgent)) {
return Err(ControlError::new(
ErrorCode::InvalidParams,
"tab.create cannot combine an agent tab type with a shell",
));
}
return Ok(WorkspaceAction::AddTabWithShell {
shell: resolve_shell(shell_name, ctx)?,
source: AddTabWithShellSource::CommandPalette,
});
}
match params.tab_type {
None | Some(TabType::Terminal) => Ok(WorkspaceAction::AddTerminalTab {
hide_homepage: false,
}),
Some(TabType::Agent) => Ok(WorkspaceAction::AddAgentTab),
Some(TabType::Default) => Ok(WorkspaceAction::AddDefaultTab),
Some(TabType::CloudAgent) => Err(ControlError::new(
ErrorCode::UnsupportedAction,
"tab.create does not support cloud-agent tabs",
)),
}
}
#[cfg_attr(not(feature = "local_tty"), allow(unused_variables))]
pub(super) fn resolve_shell(
name: &str,
ctx: &ModelContext<LocalControlBridge>,
) -> Result<AvailableShell, ControlError> {
#[cfg(feature = "local_tty")]
{
AvailableShells::as_ref(ctx)
.find_by_command_name(name)
.or_else(|| AvailableShell::try_from(name).ok())
.ok_or_else(|| {
ControlError::new(
ErrorCode::InvalidParams,
format!("cannot resolve requested shell {name:?}"),
)
})
}
#[cfg(not(feature = "local_tty"))]
Err(ControlError::new(
ErrorCode::UnsupportedAction,
format!("shell selection is unavailable for requested shell {name:?}"),
))
}
@@ -0,0 +1,41 @@
use ::local_control::protocol::TargetSelector;
use ::local_control::InstanceId;
use warpui::App;
use super::create_tab;
use crate::local_control::LocalControlBridge;
use crate::workspace::view::tests::{initialize_app, mock_workspace};
#[test]
fn tab_create_handler_adds_and_activates_terminal_tab() {
App::test((), |mut app| async move {
initialize_app(&mut app);
let workspace = mock_workspace(&mut app);
let previous_count = workspace.read(&app, |workspace, _| workspace.tab_count());
let bridge = app.add_singleton_model(LocalControlBridge::new);
let instance_id = InstanceId("inst_test".to_owned());
let response = bridge.update(&mut app, |bridge, ctx| {
bridge.set_instance_id(instance_id.clone());
create_tab(
&Some(instance_id.clone()),
&serde_json::json!({}),
&TargetSelector::default(),
ctx,
)
.expect("tab.create handler succeeds")
});
workspace.read(&app, |workspace, _| {
assert_eq!(workspace.tab_count(), previous_count + 1);
assert_eq!(workspace.active_tab_index(), previous_count);
});
assert_eq!(response["action"], "tab.create");
assert_eq!(response["created"], true);
assert_eq!(response["instance_id"], "inst_test");
assert_eq!(response["tab"]["previous_count"], previous_count);
assert_eq!(response["tab"]["count"], previous_count + 1);
assert_eq!(response["tab"]["active_index"], previous_count);
assert!(response["tab"]["id"].is_string());
});
}
+993
View File
@@ -0,0 +1,993 @@
//! Metadata response builders for local-control introspection actions.
#[cfg(test)]
#[path = "metadata_tests.rs"]
mod tests;
use ::local_control::protocol::{
ActionNameParams, ActiveTargetChain, PaneTarget, SessionTarget, SurfaceListResult,
SurfaceSummary, TabTarget, TargetSelector, WindowTarget,
};
use ::local_control::{
Action, ActionKind, ActionMetadata, ControlError, ErrorCode, InstanceId, PROTOCOL_VERSION,
};
use serde::Serialize;
use serde_json::{json, Value};
use settings::Setting as _;
use galaxy_core::channel::ChannelState;
use warpui::{AppContext, ModelContext, SingletonEntity, ViewHandle, WindowId};
use crate::drive::settings::WarpDriveSettings;
use crate::features::FeatureFlag;
use crate::local_control::resolver::{reject_target_families, require_active_window_id_for_action};
use crate::local_control::LocalControlBridge;
use crate::pane_group::{PaneGroup, PaneId};
use crate::settings::{AISettings, CodeSettings};
use crate::workspace::tab_settings::TabSettings;
use crate::workspace::Workspace;
#[derive(Serialize)]
struct InstanceResponse<'a> {
action: &'static str,
instance_id: Option<&'a str>,
pid: u32,
channel: String,
app_id: String,
protocol_version: u32,
actions: Vec<ActionMetadata>,
}
fn active_session_target(target: &TargetSelector) -> TargetSelector {
if !matches!(target.session, Some(SessionTarget::Active)) {
return target.clone();
}
TargetSelector {
window: target.window.clone().or(Some(WindowTarget::Active)),
tab: target.tab.clone().or(Some(TabTarget::Active)),
pane: target.pane.clone().or(Some(PaneTarget::Active)),
session: target.session.clone(),
}
}
fn select_session_entries(
entries: Vec<SessionEntry>,
session: Option<&SessionTarget>,
action: ActionKind,
) -> Result<Vec<SessionEntry>, ControlError> {
match session {
None => Ok(entries),
Some(SessionTarget::Active) => explicit_matches(
entries
.into_iter()
.filter(|entry| entry.is_active)
.collect(),
action,
"active session",
ErrorCode::MissingTarget,
),
Some(SessionTarget::Id { id }) => explicit_matches(
entries
.into_iter()
.filter(|entry| entry.pane_id.to_string() == id.0)
.collect(),
action,
"session id",
ErrorCode::StaleTarget,
),
}
}
#[derive(Serialize)]
struct PingResponse<'a> {
action: &'static str,
ok: bool,
instance_id: Option<&'a str>,
protocol_version: u32,
}
#[derive(Serialize)]
struct VersionResponse<'a> {
action: &'static str,
instance_id: Option<&'a str>,
protocol_version: u32,
channel: String,
app_id: String,
}
#[derive(Clone, Copy)]
pub(super) struct WindowEntry {
pub(super) window_id: WindowId,
pub(super) index: usize,
}
#[derive(Clone)]
pub(super) struct TabEntry {
pub(super) window_id: WindowId,
pub(super) window_index: usize,
pub(super) index: usize,
pub(super) workspace_active_tab_index: usize,
pub(super) pane_group: ViewHandle<PaneGroup>,
}
#[derive(Clone)]
pub(super) struct PaneEntry {
pub(super) window_id: WindowId,
pub(super) window_index: usize,
pub(super) tab_id: String,
pub(super) tab_index: usize,
pub(super) index: usize,
pub(super) pane_group: ViewHandle<PaneGroup>,
pub(super) pane_id: PaneId,
}
struct SessionEntry {
window_id: WindowId,
window_index: usize,
tab_id: String,
tab_index: usize,
pane_id: PaneId,
pane_index: usize,
is_active: bool,
}
#[derive(Clone, Copy)]
pub(crate) enum SurfaceDestination {
Settings,
CommandPalette,
CommandSearch,
ThemePicker,
Keybindings,
WarpDrive,
ResourceCenter,
AiAssistant,
CodeReview,
ProjectExplorer,
GlobalSearch,
ConversationList,
LeftPanel,
RightPanel,
VerticalTabs,
AgentManagement,
}
impl SurfaceDestination {
const ALL: &[Self] = &[
Self::Settings,
Self::CommandPalette,
Self::CommandSearch,
Self::ThemePicker,
Self::Keybindings,
Self::WarpDrive,
Self::ResourceCenter,
Self::AiAssistant,
Self::CodeReview,
Self::ProjectExplorer,
Self::GlobalSearch,
Self::ConversationList,
Self::LeftPanel,
Self::RightPanel,
Self::VerticalTabs,
Self::AgentManagement,
];
fn name(self) -> &'static str {
match self {
Self::Settings => "settings",
Self::CommandPalette => "command_palette",
Self::CommandSearch => "command_search",
Self::ThemePicker => "theme_picker",
Self::Keybindings => "keybindings",
Self::WarpDrive => "warp_drive",
Self::ResourceCenter => "resource_center",
Self::AiAssistant => "ai_assistant",
Self::CodeReview => "code_review",
Self::ProjectExplorer => "project_explorer",
Self::GlobalSearch => "global_search",
Self::ConversationList => "conversation_list",
Self::LeftPanel => "left_panel",
Self::RightPanel => "right_panel",
Self::VerticalTabs => "vertical_tabs",
Self::AgentManagement => "agent_management",
}
}
}
pub(crate) fn instance(
instance_id: &Option<InstanceId>,
) -> Result<serde_json::Value, ControlError> {
to_json_value(InstanceResponse {
action: ActionKind::InstanceList.as_str(),
instance_id: instance_id.as_ref().map(|id| id.0.as_str()),
pid: std::process::id(),
channel: ChannelState::channel().to_string(),
app_id: ChannelState::app_id().to_string(),
protocol_version: PROTOCOL_VERSION,
actions: ActionKind::implemented_metadata(),
})
}
pub(crate) fn ping(instance_id: &Option<InstanceId>) -> Result<serde_json::Value, ControlError> {
to_json_value(PingResponse {
action: ActionKind::AppPing.as_str(),
ok: true,
instance_id: instance_id.as_ref().map(|id| id.0.as_str()),
protocol_version: PROTOCOL_VERSION,
})
}
pub(crate) fn version(instance_id: &Option<InstanceId>) -> Result<serde_json::Value, ControlError> {
to_json_value(VersionResponse {
action: ActionKind::AppVersion.as_str(),
instance_id: instance_id.as_ref().map(|id| id.0.as_str()),
protocol_version: PROTOCOL_VERSION,
channel: ChannelState::channel().to_string(),
app_id: ChannelState::app_id().to_string(),
})
}
pub(crate) fn active(
instance_id: &Option<InstanceId>,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
Ok(json!({
"action": ActionKind::AppActive.as_str(),
"active": active_chain(instance_id, ctx)?,
}))
}
pub(crate) fn inspect(
instance_id: &Option<InstanceId>,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
Ok(json!({
"action": ActionKind::InstanceInspect.as_str(),
"instance_id": instance_id.as_ref().map(|id| id.0.as_str()),
"pid": std::process::id(),
"channel": ChannelState::channel().to_string(),
"app_id": ChannelState::app_id().to_string(),
"protocol_version": PROTOCOL_VERSION,
"active": active_chain(instance_id, ctx)?,
"actions": ActionKind::implemented_metadata(),
}))
}
pub(crate) fn action_list() -> serde_json::Value {
json!({
"action": ActionKind::ActionList.as_str(),
"actions": ActionKind::implemented_metadata(),
})
}
pub(crate) fn action_inspect(action: &Action) -> Result<serde_json::Value, ControlError> {
let params = action_name_params(action)?;
let metadata = action_metadata_for_name(&params.action)?;
Ok(json!({
"action": ActionKind::ActionInspect.as_str(),
"metadata": metadata,
}))
}
pub(crate) fn capability_list() -> serde_json::Value {
json!({
"action": ActionKind::CapabilityList.as_str(),
"capabilities": ActionKind::implemented_metadata(),
})
}
pub(crate) fn capability_inspect(action: &Action) -> Result<serde_json::Value, ControlError> {
let params = action_name_params(action)?;
let metadata = action_metadata_for_name(&params.action)?;
Ok(json!({
"action": ActionKind::CapabilityInspect.as_str(),
"capability": metadata,
}))
}
pub(crate) fn surface_list(
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
to_json_value(SurfaceListResult {
surfaces: SurfaceDestination::ALL
.iter()
.copied()
.map(|destination| {
let unavailable_reason =
surface_unavailable_reason(destination, ctx).map(str::to_owned);
SurfaceSummary {
name: destination.name().to_owned(),
is_available: unavailable_reason.is_none(),
unavailable_reason,
}
})
.collect(),
})
}
pub(crate) fn surface_unavailable_reason(
destination: SurfaceDestination,
ctx: &AppContext,
) -> Option<&'static str> {
match destination {
SurfaceDestination::Settings
| SurfaceDestination::CommandPalette
| SurfaceDestination::CommandSearch
| SurfaceDestination::ThemePicker
| SurfaceDestination::Keybindings
| SurfaceDestination::ResourceCenter => None,
SurfaceDestination::WarpDrive if !WarpDriveSettings::is_warp_drive_enabled(ctx) => {
Some("Warp Drive is disabled")
}
SurfaceDestination::WarpDrive => None,
SurfaceDestination::AiAssistant if !AISettings::as_ref(ctx).is_any_ai_enabled(ctx) => {
Some("AI features are disabled")
}
SurfaceDestination::AiAssistant => None,
SurfaceDestination::CodeReview | SurfaceDestination::RightPanel
if !cfg!(feature = "local_fs") =>
{
Some("code review is unavailable without local filesystem support")
}
SurfaceDestination::CodeReview | SurfaceDestination::RightPanel => None,
SurfaceDestination::ProjectExplorer
if !cfg!(feature = "local_fs")
|| !*CodeSettings::as_ref(ctx).show_project_explorer.value() =>
{
Some("project explorer is unavailable or disabled")
}
SurfaceDestination::ProjectExplorer => None,
SurfaceDestination::GlobalSearch
if !cfg!(feature = "local_fs")
|| !FeatureFlag::GlobalSearch.is_enabled()
|| !*CodeSettings::as_ref(ctx).show_global_search.value() =>
{
Some("global search is unavailable or disabled")
}
SurfaceDestination::GlobalSearch => None,
SurfaceDestination::ConversationList
if !FeatureFlag::AgentViewConversationListView.is_enabled()
|| !AISettings::as_ref(ctx).is_any_ai_enabled(ctx)
|| !*AISettings::as_ref(ctx).show_conversation_history.value() =>
{
Some("agent conversation history is unavailable or disabled")
}
SurfaceDestination::ConversationList => None,
SurfaceDestination::LeftPanel
if surface_unavailable_reason(SurfaceDestination::ProjectExplorer, ctx).is_some()
&& surface_unavailable_reason(SurfaceDestination::GlobalSearch, ctx).is_some()
&& surface_unavailable_reason(SurfaceDestination::ConversationList, ctx)
.is_some()
&& surface_unavailable_reason(SurfaceDestination::WarpDrive, ctx).is_some() =>
{
Some("the left panel has no available views")
}
SurfaceDestination::LeftPanel => None,
SurfaceDestination::VerticalTabs
if !FeatureFlag::VerticalTabs.is_enabled()
|| !*TabSettings::as_ref(ctx).use_vertical_tabs.value() =>
{
Some("vertical tabs are unavailable or disabled")
}
SurfaceDestination::VerticalTabs => None,
SurfaceDestination::AgentManagement
if !FeatureFlag::AgentManagementView.is_enabled()
|| !AISettings::as_ref(ctx).is_any_ai_enabled(ctx) =>
{
Some("agent management is unavailable or disabled")
}
SurfaceDestination::AgentManagement => None,
}
}
pub(crate) fn window_list(
target: &TargetSelector,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
reject_target_families(
ActionKind::WindowList,
target.tab.is_some() || target.pane.is_some() || target.session.is_some(),
"tab, pane, or session selectors",
)?;
let active_window = ctx.windows().active_window();
let mut windows = Vec::new();
for entry in select_window_entries(target, false, ActionKind::WindowList, ctx)? {
windows.push(json!({
"window_id": entry.window_id.to_string(),
"index": entry.index as u32,
"is_active": Some(entry.window_id) == active_window,
"has_workspace": workspace_for_window(entry.window_id, ActionKind::WindowList, ctx)?.is_some(),
}));
}
Ok(json!({
"action": ActionKind::WindowList.as_str(),
"windows": windows,
}))
}
pub(crate) fn window_inspect(
target: &TargetSelector,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
reject_target_families(
ActionKind::WindowInspect,
target.tab.is_some() || target.pane.is_some() || target.session.is_some(),
"tab, pane, or session selectors",
)?;
let target = TargetSelector {
window: target.window.clone().or(Some(WindowTarget::Active)),
tab: None,
pane: None,
session: None,
};
let data = window_list(&target, ctx)?;
let window = single_entry(data.get("windows"), ActionKind::WindowInspect)?;
Ok(json!({
"action": ActionKind::WindowInspect.as_str(),
"window": window,
}))
}
pub(crate) fn tab_list(
target: &TargetSelector,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
reject_target_families(
ActionKind::TabList,
target.pane.is_some() || target.session.is_some(),
"pane or session selectors",
)?;
let entries = select_tab_entries(target, ActionKind::TabList, ctx)?;
let tabs = entries
.into_iter()
.map(|entry| {
json!({
"tab_id": entry.pane_group.id().to_string(),
"window_id": entry.window_id.to_string(),
"window_index": entry.window_index as u32,
"index": entry.index as u32,
"is_active": entry.index == entry.workspace_active_tab_index,
})
})
.collect::<Vec<_>>();
Ok(json!({
"action": ActionKind::TabList.as_str(),
"tabs": tabs,
}))
}
pub(crate) fn tab_inspect(
target: &TargetSelector,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
reject_target_families(
ActionKind::TabInspect,
target.pane.is_some() || target.session.is_some(),
"pane or session selectors",
)?;
let target = TargetSelector {
window: target.window.clone(),
tab: target.tab.clone().or(Some(TabTarget::Active)),
pane: None,
session: None,
};
let data = tab_list(&target, ctx)?;
let tab = single_entry(data.get("tabs"), ActionKind::TabInspect)?;
Ok(json!({
"action": ActionKind::TabInspect.as_str(),
"tab": tab,
}))
}
pub(crate) fn pane_list(
target: &TargetSelector,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
reject_target_families(
ActionKind::PaneList,
target.session.is_some(),
"session selectors",
)?;
let entries = select_pane_entries(target, ActionKind::PaneList, ctx)?;
let mut panes = Vec::new();
for entry in entries {
let (is_active, has_terminal_session) = entry.pane_group.read(ctx, |pane_group, ctx| {
(
pane_group.focused_pane_id(ctx) == entry.pane_id,
pane_group
.terminal_view_from_pane_id(entry.pane_id, ctx)
.is_some(),
)
});
panes.push(json!({
"pane_id": entry.pane_id.to_string(),
"tab_id": entry.tab_id,
"tab_index": entry.tab_index as u32,
"window_id": entry.window_id.to_string(),
"window_index": entry.window_index as u32,
"index": entry.index as u32,
"is_active": is_active,
"has_terminal_session": has_terminal_session,
}));
}
Ok(json!({
"action": ActionKind::PaneList.as_str(),
"panes": panes,
}))
}
pub(crate) fn pane_inspect(
target: &TargetSelector,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
reject_target_families(
ActionKind::PaneInspect,
target.session.is_some(),
"session selectors",
)?;
let target = TargetSelector {
window: target.window.clone(),
tab: target.tab.clone(),
pane: target.pane.clone().or(Some(PaneTarget::Active)),
session: None,
};
let data = pane_list(&target, ctx)?;
let pane = single_entry(data.get("panes"), ActionKind::PaneInspect)?;
Ok(json!({
"action": ActionKind::PaneInspect.as_str(),
"pane": pane,
}))
}
pub(crate) fn session_list(
target: &TargetSelector,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
let target = active_session_target(target);
let pane_entries = select_pane_entries(&target, ActionKind::SessionList, ctx)?;
let entries = select_session_entries(
session_entries_for_panes(pane_entries, ctx),
target.session.as_ref(),
ActionKind::SessionList,
)?;
let sessions = session_values(entries);
Ok(json!({
"action": ActionKind::SessionList.as_str(),
"sessions": sessions,
}))
}
pub(crate) fn session_inspect(
target: &TargetSelector,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
let target = active_session_target(target);
let pane_entries = select_pane_entries(&target, ActionKind::SessionInspect, ctx)?;
let entries = select_session_entries(
session_entries_for_panes(pane_entries, ctx),
target.session.as_ref().or(Some(&SessionTarget::Active)),
ActionKind::SessionInspect,
)?;
let data = json!({ "sessions": session_values(entries) });
let session = single_entry(data.get("sessions"), ActionKind::SessionInspect)?;
Ok(json!({
"action": ActionKind::SessionInspect.as_str(),
"session": session,
}))
}
pub(crate) fn action_metadata_for_name(action_name: &str) -> Result<ActionMetadata, ControlError> {
ActionKind::implemented_metadata()
.into_iter()
.find(|metadata| metadata.name == action_name)
.ok_or_else(|| {
ControlError::new(
ErrorCode::NotAllowlisted,
"requested action is not an implemented local-control action",
)
})
}
fn action_name_params(action: &Action) -> Result<ActionNameParams, ControlError> {
action.params_as()
}
fn to_json_value<T: Serialize>(response: T) -> Result<serde_json::Value, ControlError> {
serde_json::to_value(response).map_err(|error| {
ControlError::with_details(
ErrorCode::Internal,
"failed to serialize local-control metadata response",
error.to_string(),
)
})
}
fn active_chain(
instance_id: &Option<InstanceId>,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<ActiveTargetChain, ControlError> {
let instance_id = instance_id.as_ref().map(|id| id.0.clone());
let Some(window_id) = ctx.windows().active_window() else {
return Ok(ActiveTargetChain {
instance_id,
window_id: None,
tab_id: None,
pane_id: None,
session_id: None,
});
};
let window_id_string = window_id.to_string();
let Some(workspace) = workspace_for_window(window_id, ActionKind::AppActive, ctx)? else {
return Ok(ActiveTargetChain {
instance_id,
window_id: Some(window_id_string),
tab_id: None,
pane_id: None,
session_id: None,
});
};
let (tab_id, pane_id, session_id) = workspace.read(ctx, |workspace, ctx| {
let pane_group = workspace.active_tab_pane_group();
let pane_group_ref = pane_group.as_ref(ctx);
(
Some(pane_group.id().to_string()),
Some(pane_group_ref.focused_pane_id(ctx).to_string()),
pane_group_ref
.active_session_id(ctx)
.map(|session_id| PaneId::from(session_id).to_string()),
)
});
Ok(ActiveTargetChain {
instance_id,
window_id: Some(window_id_string),
tab_id,
pane_id,
session_id,
})
}
fn select_window_entries(
target: &TargetSelector,
force_active_default: bool,
action: ActionKind,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<Vec<WindowEntry>, ControlError> {
let entries = window_entries(ctx);
match target.window.as_ref() {
None if force_active_default => {
let active =
require_active_window_id_for_action(ctx.windows().active_window(), action)?;
explicit_matches(
entries
.into_iter()
.filter(|entry| entry.window_id == active)
.collect(),
action,
"active window",
ErrorCode::MissingTarget,
)
}
None => Ok(entries),
Some(WindowTarget::Active) => {
let active =
require_active_window_id_for_action(ctx.windows().active_window(), action)?;
explicit_matches(
entries
.into_iter()
.filter(|entry| entry.window_id == active)
.collect(),
action,
"active window",
ErrorCode::MissingTarget,
)
}
Some(WindowTarget::Id { id }) => explicit_matches(
entries
.into_iter()
.filter(|entry| entry.window_id.to_string() == id.0)
.collect(),
action,
"window id",
ErrorCode::StaleTarget,
),
Some(WindowTarget::Index { index }) => explicit_matches(
entries
.into_iter()
.filter(|entry| entry.index as u32 == *index)
.collect(),
action,
"window index",
ErrorCode::StaleTarget,
),
Some(WindowTarget::Title { .. }) => Err(ControlError::new(
ErrorCode::InvalidSelector,
format!(
"{} only supports active, opaque window id, and window index selectors",
action.as_str()
),
)),
}
}
fn select_tab_entries(
target: &TargetSelector,
action: ActionKind,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<Vec<TabEntry>, ControlError> {
let force_active_window = matches!(
target.tab,
Some(TabTarget::Active | TabTarget::Index { .. })
) || matches!(
target.pane,
Some(PaneTarget::Active | PaneTarget::Index { .. })
);
let windows = select_window_entries(target, force_active_window, action, ctx)?;
let entries = tab_entries_for_windows(windows, action, ctx)?;
let requires_active_tab_default = matches!(
target.pane,
Some(PaneTarget::Active | PaneTarget::Index { .. })
);
match target.tab.as_ref() {
None if requires_active_tab_default => explicit_matches(
entries
.into_iter()
.filter(|entry| entry.index == entry.workspace_active_tab_index)
.collect(),
action,
"active tab",
ErrorCode::MissingTarget,
),
None => Ok(entries),
Some(TabTarget::Active) => explicit_matches(
entries
.into_iter()
.filter(|entry| entry.index == entry.workspace_active_tab_index)
.collect(),
action,
"active tab",
ErrorCode::MissingTarget,
),
Some(TabTarget::Id { id }) => explicit_matches(
entries
.into_iter()
.filter(|entry| entry.pane_group.id().to_string() == id.0)
.collect(),
action,
"tab id",
ErrorCode::StaleTarget,
),
Some(TabTarget::Index { index }) => explicit_matches(
entries
.into_iter()
.filter(|entry| entry.index as u32 == *index)
.collect(),
action,
"tab index",
ErrorCode::StaleTarget,
),
Some(TabTarget::Title { .. }) => Err(ControlError::new(
ErrorCode::InvalidSelector,
format!(
"{} only supports active, opaque tab id, and tab index selectors",
action.as_str()
),
)),
}
}
fn select_pane_entries(
target: &TargetSelector,
action: ActionKind,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<Vec<PaneEntry>, ControlError> {
let tabs = select_tab_entries(target, action, ctx)?;
let entries = pane_entries_for_tabs(tabs, ctx);
match target.pane.as_ref() {
None => Ok(entries),
Some(PaneTarget::Active) => explicit_matches(
entries
.into_iter()
.filter(|entry| {
entry.pane_group.read(ctx, |pane_group, ctx| {
pane_group.focused_pane_id(ctx) == entry.pane_id
})
})
.collect(),
action,
"active pane",
ErrorCode::MissingTarget,
),
Some(PaneTarget::Id { id }) => explicit_matches(
entries
.into_iter()
.filter(|entry| entry.pane_id.to_string() == id.0)
.collect(),
action,
"pane id",
ErrorCode::StaleTarget,
),
Some(PaneTarget::Index { index }) => explicit_matches(
entries
.into_iter()
.filter(|entry| entry.index as u32 == *index)
.collect(),
action,
"pane index",
ErrorCode::StaleTarget,
),
}
}
fn window_entries(ctx: &mut ModelContext<LocalControlBridge>) -> Vec<WindowEntry> {
let mut ids = ctx.window_ids().collect::<Vec<_>>();
ids.sort_by_key(ToString::to_string);
ids.into_iter()
.enumerate()
.map(|(index, window_id)| WindowEntry { window_id, index })
.collect()
}
pub(super) fn tab_entries_for_windows(
windows: Vec<WindowEntry>,
action: ActionKind,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<Vec<TabEntry>, ControlError> {
let mut entries = Vec::new();
for window in windows {
let Some(workspace) = workspace_for_window(window.window_id, action, ctx)? else {
continue;
};
entries.extend(workspace.read(ctx, |workspace, _| {
workspace
.tab_views()
.enumerate()
.map(|(index, pane_group)| TabEntry {
window_id: window.window_id,
window_index: window.index,
index,
workspace_active_tab_index: workspace.active_tab_index(),
pane_group: pane_group.clone(),
})
.collect::<Vec<_>>()
}));
}
Ok(entries)
}
pub(super) fn pane_entries_for_tabs(
tabs: Vec<TabEntry>,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Vec<PaneEntry> {
let mut entries = Vec::new();
for tab in tabs {
let tab_id = tab.pane_group.id().to_string();
let pane_group = tab.pane_group.clone();
let pane_ids = tab
.pane_group
.read(ctx, |pane_group, _| pane_group.visible_pane_ids());
entries.extend(
pane_ids
.into_iter()
.enumerate()
.map(|(index, pane_id)| PaneEntry {
window_id: tab.window_id,
window_index: tab.window_index,
tab_id: tab_id.clone(),
tab_index: tab.index,
index,
pane_group: pane_group.clone(),
pane_id,
}),
);
}
entries
}
fn session_entries_for_panes(
panes: Vec<PaneEntry>,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Vec<SessionEntry> {
let mut entries = Vec::new();
for pane in panes {
let (has_terminal_session, is_active) = pane.pane_group.read(ctx, |pane_group, ctx| {
(
pane_group
.terminal_view_from_pane_id(pane.pane_id, ctx)
.is_some(),
pane_group.active_session_id(ctx).map(PaneId::from) == Some(pane.pane_id),
)
});
if has_terminal_session {
entries.push(SessionEntry {
window_id: pane.window_id,
window_index: pane.window_index,
tab_id: pane.tab_id,
tab_index: pane.tab_index,
pane_id: pane.pane_id,
pane_index: pane.index,
is_active,
});
}
}
entries
}
fn session_values(entries: Vec<SessionEntry>) -> Vec<Value> {
entries
.into_iter()
.map(|entry| {
json!({
"session_id": entry.pane_id.to_string(),
"pane_id": entry.pane_id.to_string(),
"pane_index": entry.pane_index as u32,
"tab_id": entry.tab_id,
"tab_index": entry.tab_index as u32,
"window_id": entry.window_id.to_string(),
"window_index": entry.window_index as u32,
"is_active": entry.is_active,
})
})
.collect()
}
fn workspace_for_window(
window_id: WindowId,
action: ActionKind,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<Option<ViewHandle<Workspace>>, ControlError> {
match ctx.views_of_type::<Workspace>(window_id) {
None => Ok(None),
Some(workspaces) => match workspaces.as_slice() {
[] => Ok(None),
[workspace] => Ok(Some(workspace.clone())),
_ => Err(ControlError::new(
ErrorCode::AmbiguousTarget,
format!(
"{} resolved multiple workspaces in one window",
action.as_str()
),
)),
},
}
}
fn explicit_matches<T>(
matches: Vec<T>,
action: ActionKind,
selector: &str,
missing_code: ErrorCode,
) -> Result<Vec<T>, ControlError> {
match matches.len() {
0 => Err(ControlError::new(
missing_code,
format!(
"{} cannot resolve the requested {selector}",
action.as_str()
),
)),
1 => Ok(matches),
_ => Err(ControlError::new(
ErrorCode::AmbiguousTarget,
format!(
"{} resolved multiple targets by {selector}",
action.as_str()
),
)),
}
}
fn single_entry(value: Option<&Value>, action: ActionKind) -> Result<Value, ControlError> {
let Some(items) = value.and_then(Value::as_array) else {
return Err(ControlError::new(
ErrorCode::Internal,
format!("{} handler returned malformed metadata", action.as_str()),
));
};
match items.as_slice() {
[item] => Ok(item.clone()),
[] => Err(ControlError::new(
ErrorCode::MissingTarget,
format!("{} could not resolve a target", action.as_str()),
)),
_ => Err(ControlError::new(
ErrorCode::AmbiguousTarget,
format!("{} resolved multiple targets", action.as_str()),
)),
}
}
@@ -0,0 +1,794 @@
//! Metadata/configuration mutation handlers for local-control actions.
use std::str::FromStr as _;
use ::local_control::protocol::{
BooleanValueParams, ColorValueParams, KeyParams, KeyValueParams, PaneTarget, RenameParams,
TabTarget, TargetSelector, ThemeNameParams, WindowTarget,
};
use ::local_control::{ActionKind, ControlError, ErrorCode, InstanceId};
use serde_json::json;
use settings::Setting as _;
use galaxy_core::ui::theme::AnsiColorIdentifier;
use warpui::{ModelContext, SingletonEntity as _, WindowId};
use super::metadata::{
pane_entries_for_tabs, tab_entries_for_windows, PaneEntry, TabEntry, WindowEntry,
};
use super::settings_surfaces::{
public_theme_name, rejected_setting_key, setting_summary_for_key, ALLOWLISTED_SETTING_KEYS,
};
use crate::local_control::handlers::ack;
use crate::local_control::resolver::{require_active_window_id_for_action, workspace_for_window};
use crate::local_control::LocalControlBridge;
use crate::pane_group::PaneId;
use crate::settings::{AccessibilitySettings, FontSettings, InputSettings, ThemeSettings};
use crate::tab::SelectedTabColor;
use crate::themes::theme::{SelectedSystemThemes, ThemeKind};
use crate::user_config::WarpConfig;
use crate::window_settings::ZoomLevel;
use crate::WindowSettings;
pub(crate) fn tab_rename(
instance_id: &Option<InstanceId>,
target: &TargetSelector,
action: &::local_control::Action,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
let title = rename_title(action)?;
let entry = select_single_tab_entry(target, ActionKind::TabRename, ctx)?;
let tab_id = entry.pane_group.id().to_string();
entry.pane_group.update(ctx, |pane_group, ctx| {
pane_group.set_title(&title, ctx);
});
Ok(tab_mutation_result(
instance_id,
ActionKind::TabRename,
tab_id,
entry.window_id,
))
}
pub(crate) fn tab_reset_name(
instance_id: &Option<InstanceId>,
target: &TargetSelector,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
let entry = select_single_tab_entry(target, ActionKind::TabResetName, ctx)?;
let tab_id = entry.pane_group.id().to_string();
entry.pane_group.update(ctx, |pane_group, ctx| {
pane_group.clear_title(ctx);
});
Ok(tab_mutation_result(
instance_id,
ActionKind::TabResetName,
tab_id,
entry.window_id,
))
}
pub(crate) fn tab_color_set(
instance_id: &Option<InstanceId>,
target: &TargetSelector,
action: &::local_control::Action,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
let color = color_value(action)?;
let entry = select_single_tab_entry(target, ActionKind::TabColorSet, ctx)?;
set_tab_color(entry.clone(), SelectedTabColor::Color(color), ctx)?;
Ok(tab_mutation_result(
instance_id,
ActionKind::TabColorSet,
entry.pane_group.id().to_string(),
entry.window_id,
))
}
pub(crate) fn tab_color_clear(
instance_id: &Option<InstanceId>,
target: &TargetSelector,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
let entry = select_single_tab_entry(target, ActionKind::TabColorClear, ctx)?;
set_tab_color(entry.clone(), SelectedTabColor::Cleared, ctx)?;
Ok(tab_mutation_result(
instance_id,
ActionKind::TabColorClear,
entry.pane_group.id().to_string(),
entry.window_id,
))
}
pub(crate) fn pane_rename(
instance_id: &Option<InstanceId>,
target: &TargetSelector,
action: &::local_control::Action,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
let title = rename_title(action)?;
let entry = select_single_pane_entry(target, ActionKind::PaneRename, ctx)?;
set_pane_name(&entry, Some(title), ctx)?;
Ok(pane_mutation_result(
instance_id,
ActionKind::PaneRename,
entry.pane_id,
entry.tab_id,
))
}
pub(crate) fn pane_reset_name(
instance_id: &Option<InstanceId>,
target: &TargetSelector,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
let entry = select_single_pane_entry(target, ActionKind::PaneResetName, ctx)?;
set_pane_name(&entry, None, ctx)?;
Ok(pane_mutation_result(
instance_id,
ActionKind::PaneResetName,
entry.pane_id,
entry.tab_id,
))
}
pub(crate) fn theme_set(
instance_id: &Option<InstanceId>,
action_kind: ActionKind,
action: &::local_control::Action,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
match action_kind {
ActionKind::ThemeSet => set_theme(theme_name(action)?, ctx)?,
ActionKind::ThemeSystemSet => set_system_theme(boolean_value(action)?, ctx)?,
ActionKind::ThemeLightSet => set_system_theme_variant(theme_name(action)?, true, ctx)?,
ActionKind::ThemeDarkSet => set_system_theme_variant(theme_name(action)?, false, ctx)?,
_ => {
return Err(ControlError::new(
ErrorCode::UnsupportedAction,
format!("{} is not a theme mutation", action_kind.as_str()),
));
}
}
Ok(ack(instance_id, action_kind))
}
pub(crate) fn appearance_mutation(
instance_id: &Option<InstanceId>,
action_kind: ActionKind,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
match action_kind {
ActionKind::AppearanceFontSizeIncrease => {
adjust_font_size(FontSizeAdjustment::Increase, ctx)?
}
ActionKind::AppearanceFontSizeDecrease => {
adjust_font_size(FontSizeAdjustment::Decrease, ctx)?
}
ActionKind::AppearanceFontSizeReset => adjust_font_size(FontSizeAdjustment::Reset, ctx)?,
ActionKind::AppearanceZoomIncrease => adjust_zoom(true, ctx)?,
ActionKind::AppearanceZoomDecrease => adjust_zoom(false, ctx)?,
ActionKind::AppearanceZoomReset => reset_zoom(ctx)?,
_ => {
return Err(ControlError::new(
ErrorCode::UnsupportedAction,
format!("{} is not an appearance mutation", action_kind.as_str()),
));
}
}
Ok(ack(instance_id, action_kind))
}
pub(crate) fn setting_set(
action: &::local_control::Action,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
let (key, value) = key_value(action)?;
set_allowlisted_setting(&key, value, ctx)?;
Ok(json!({
"action": ActionKind::SettingSet.as_str(),
"setting": setting_summary_for_key(&key, ctx)?,
}))
}
pub(crate) fn setting_toggle(
action: &::local_control::Action,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
let key = key_value_key(action)?;
let current = setting_summary_for_key(&key, ctx)?;
let Some(value) = current.value.as_bool() else {
return Err(ControlError::new(
ErrorCode::InvalidParams,
format!("{key} is not a boolean setting and cannot be toggled"),
));
};
set_allowlisted_setting(&key, json!(!value), ctx)?;
Ok(json!({
"action": ActionKind::SettingToggle.as_str(),
"setting": setting_summary_for_key(&key, ctx)?,
}))
}
fn rename_title(action: &::local_control::Action) -> Result<String, ControlError> {
let RenameParams { title } = action.params_as()?;
if title.trim().is_empty() {
Err(ControlError::new(
ErrorCode::InvalidParams,
format!("{} title cannot be empty", action.kind.as_str()),
))
} else {
Ok(title.trim().to_owned())
}
}
fn color_value(action: &::local_control::Action) -> Result<AnsiColorIdentifier, ControlError> {
let ColorValueParams { color } = action.params_as()?;
AnsiColorIdentifier::from_str(&color).map_err(|_| {
ControlError::new(
ErrorCode::InvalidParams,
format!("{color} is not a supported tab color"),
)
})
}
fn theme_name(action: &::local_control::Action) -> Result<String, ControlError> {
let ThemeNameParams { theme_name } = action.params_as()?;
if theme_name.trim().is_empty() {
Err(ControlError::new(
ErrorCode::InvalidParams,
format!("{} theme name cannot be empty", action.kind.as_str()),
))
} else {
Ok(theme_name.trim().to_owned())
}
}
fn boolean_value(action: &::local_control::Action) -> Result<bool, ControlError> {
Ok(action.params_as::<BooleanValueParams>()?.value)
}
fn key_value(
action: &::local_control::Action,
) -> Result<(String, serde_json::Value), ControlError> {
let KeyValueParams { key, value } = action.params_as()?;
Ok((key, value))
}
fn key_value_key(action: &::local_control::Action) -> Result<String, ControlError> {
Ok(action.params_as::<KeyParams>()?.key)
}
fn select_single_tab_entry(
target: &TargetSelector,
action: ActionKind,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<TabEntry, ControlError> {
if target.pane.is_some() || target.session.is_some() {
return Err(ControlError::new(
ErrorCode::InvalidSelector,
format!(
"{} does not accept pane or session selectors",
action.as_str()
),
));
}
let entries = select_tab_entries(target, action, ctx)?;
match entries.as_slice() {
[entry] => Ok(entry.clone()),
[] => Err(ControlError::new(
ErrorCode::MissingTarget,
format!("{} requires a target tab", action.as_str()),
)),
_ => Err(ControlError::new(
ErrorCode::AmbiguousTarget,
format!("{} resolved multiple tabs", action.as_str()),
)),
}
}
fn select_single_pane_entry(
target: &TargetSelector,
action: ActionKind,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<PaneEntry, ControlError> {
if target.session.is_some() {
return Err(ControlError::new(
ErrorCode::InvalidSelector,
format!("{} does not accept session selectors", action.as_str()),
));
}
let tab = select_single_tab_entry_for_pane(target, action, ctx)?;
let entries = pane_entries_for_tabs(vec![tab], ctx);
if entries.is_empty() {
return Err(ControlError::new(
ErrorCode::MissingTarget,
"target tab has no visible panes",
));
}
let selected = match target.pane.as_ref() {
None | Some(PaneTarget::Active) => {
let focused = entries
.first()
.map(|entry| {
entry
.pane_group
.read(ctx, |pane_group, ctx| pane_group.focused_pane_id(ctx))
})
.ok_or_else(|| {
ControlError::new(
ErrorCode::MissingTarget,
format!("{} requires an active pane", action.as_str()),
)
})?;
entries
.into_iter()
.filter(|entry| entry.pane_id == focused)
.collect::<Vec<_>>()
}
Some(PaneTarget::Id { id }) => entries
.into_iter()
.filter(|entry| entry.pane_id.to_string() == id.0)
.collect::<Vec<_>>(),
Some(PaneTarget::Index { index }) => entries
.into_iter()
.filter(|entry| entry.index as u32 == *index)
.collect::<Vec<_>>(),
};
match selected.as_slice() {
[entry] => Ok(entry.clone()),
[] => Err(ControlError::new(
ErrorCode::StaleTarget,
format!("{} cannot resolve the requested pane", action.as_str()),
)),
_ => Err(ControlError::new(
ErrorCode::AmbiguousTarget,
format!("{} resolved multiple panes", action.as_str()),
)),
}
}
fn select_single_tab_entry_for_pane(
target: &TargetSelector,
action: ActionKind,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<TabEntry, ControlError> {
let entries = select_tab_entries(target, action, ctx)?;
match entries.as_slice() {
[entry] => Ok(entry.clone()),
[] => Err(ControlError::new(
ErrorCode::MissingTarget,
format!("{} requires a target tab", action.as_str()),
)),
_ => Err(ControlError::new(
ErrorCode::AmbiguousTarget,
format!("{} resolved multiple tabs", action.as_str()),
)),
}
}
fn select_tab_entries(
target: &TargetSelector,
action: ActionKind,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<Vec<TabEntry>, ControlError> {
let windows = select_window_ids(target, action, ctx)?
.into_iter()
.enumerate()
.map(|(index, window_id)| WindowEntry { window_id, index })
.collect::<Vec<_>>();
let entries = tab_entries_for_windows(windows, action, ctx)?;
match target.tab.as_ref() {
None | Some(TabTarget::Active) => Ok(entries
.into_iter()
.filter(|entry| entry.index == entry.workspace_active_tab_index)
.collect()),
Some(TabTarget::Id { id }) => Ok(entries
.into_iter()
.filter(|entry| entry.pane_group.id().to_string() == id.0)
.collect()),
Some(TabTarget::Index { index }) => Ok(entries
.into_iter()
.filter(|entry| entry.index as u32 == *index)
.collect()),
Some(TabTarget::Title { title }) => Ok(entries
.into_iter()
.filter(|entry| {
entry.pane_group.read(ctx, |pane_group, ctx| {
pane_group.display_title(ctx) == *title
})
})
.collect()),
}
}
fn select_window_ids(
target: &TargetSelector,
action: ActionKind,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<Vec<WindowId>, ControlError> {
match target.window.as_ref() {
None | Some(WindowTarget::Active) => Ok(vec![require_active_window_id_for_action(
ctx.windows().active_window(),
action,
)?]),
Some(WindowTarget::Id { id }) => ctx
.window_ids()
.find(|window_id| window_id.to_string() == id.0)
.map(|window_id| vec![window_id])
.ok_or_else(|| {
ControlError::new(
ErrorCode::StaleTarget,
format!("{} cannot resolve the requested window id", action.as_str()),
)
}),
Some(WindowTarget::Index { .. } | WindowTarget::Title { .. }) => Err(ControlError::new(
ErrorCode::InvalidSelector,
format!(
"{} only supports active and opaque window id selectors",
action.as_str()
),
)),
}
}
fn set_tab_color(
entry: TabEntry,
color: SelectedTabColor,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<(), ControlError> {
let workspace = workspace_for_window(entry.window_id, ActionKind::TabColorSet, ctx)?;
workspace.update(ctx, |workspace, ctx| {
workspace.set_tab_color(entry.index, color, ctx);
});
Ok(())
}
fn set_pane_name(
entry: &PaneEntry,
title: Option<String>,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<(), ControlError> {
entry.pane_group.update(ctx, |pane_group, ctx| {
let Some(pane) = pane_group.pane_by_id(entry.pane_id) else {
return Err(ControlError::new(
ErrorCode::StaleTarget,
"pane metadata mutation cannot resolve the requested pane",
));
};
pane.pane_configuration().update(ctx, |configuration, ctx| {
if let Some(title) = title {
configuration.set_custom_vertical_tabs_title(title, ctx);
} else {
configuration.clear_custom_vertical_tabs_title(ctx);
}
});
ctx.emit(crate::pane_group::Event::AppStateChanged);
Ok(())
})
}
fn set_theme(
theme_name: String,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<(), ControlError> {
let theme = theme_kind_for_name(&theme_name, ctx)?;
ThemeSettings::handle(ctx)
.update(ctx, |theme_settings, ctx| {
theme_settings.use_system_theme.set_value(false, ctx)?;
theme_settings.theme_kind.set_value(theme, ctx)
})
.map_err(|err| settings_write_error(ActionKind::ThemeSet, err))
}
fn set_system_theme(
enabled: bool,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<(), ControlError> {
ThemeSettings::handle(ctx)
.update(ctx, |theme_settings, ctx| {
theme_settings.use_system_theme.set_value(enabled, ctx)
})
.map_err(|err| settings_write_error(ActionKind::ThemeSystemSet, err))
}
fn set_system_theme_variant(
theme_name: String,
light: bool,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<(), ControlError> {
let theme = theme_kind_for_name(&theme_name, ctx)?;
let action = if light {
ActionKind::ThemeLightSet
} else {
ActionKind::ThemeDarkSet
};
ThemeSettings::handle(ctx)
.update(ctx, |theme_settings, ctx| {
let current = theme_settings.selected_system_themes.value().clone();
let next = if light {
SelectedSystemThemes {
light: theme,
dark: current.dark,
}
} else {
SelectedSystemThemes {
light: current.light,
dark: theme,
}
};
theme_settings.selected_system_themes.set_value(next, ctx)
})
.map_err(|err| settings_write_error(action, err))
}
enum FontSizeAdjustment {
Increase,
Decrease,
Reset,
}
fn adjust_font_size(
adjustment: FontSizeAdjustment,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<(), ControlError> {
let current = *FontSettings::as_ref(ctx).monospace_font_size.value();
let next = match adjustment {
FontSizeAdjustment::Increase => (current + 1.0).clamp(5.0, 25.0),
FontSizeAdjustment::Decrease => (current - 1.0).clamp(5.0, 25.0),
FontSizeAdjustment::Reset => crate::settings::MonospaceFontSize::default_value(),
};
FontSettings::handle(ctx)
.update(ctx, |font_settings, ctx| {
font_settings.monospace_font_size.set_value(next, ctx)
})
.map_err(|err| settings_write_error(ActionKind::AppearanceFontSizeReset, err))
}
fn adjust_zoom(
increase: bool,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<(), ControlError> {
let current = *WindowSettings::as_ref(ctx).zoom_level.value();
let next = adjacent_zoom_level(current, increase);
WindowSettings::handle(ctx)
.update(ctx, |window_settings, ctx| {
window_settings.zoom_level.set_value(next, ctx)
})
.map_err(|err| settings_write_error(ActionKind::AppearanceZoomReset, err))
}
fn reset_zoom(ctx: &mut ModelContext<LocalControlBridge>) -> Result<(), ControlError> {
WindowSettings::handle(ctx)
.update(ctx, |window_settings, ctx| {
window_settings
.zoom_level
.set_value(ZoomLevel::default_value(), ctx)
})
.map_err(|err| settings_write_error(ActionKind::AppearanceZoomReset, err))
}
fn adjacent_zoom_level(current: u16, increase: bool) -> u16 {
let default_index = ZoomLevel::VALUES
.iter()
.position(|zoom| *zoom == ZoomLevel::default_value())
.unwrap_or(0);
let current_index = ZoomLevel::VALUES
.iter()
.position(|zoom| *zoom == current)
.unwrap_or(default_index);
let next_index = if increase {
(current_index + 1).min(ZoomLevel::VALUES.len() - 1)
} else {
current_index.saturating_sub(1)
};
ZoomLevel::VALUES[next_index]
}
fn set_allowlisted_setting(
key: &str,
value: serde_json::Value,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<(), ControlError> {
if !ALLOWLISTED_SETTING_KEYS.contains(&key) {
return Err(rejected_setting_key(key));
}
match key {
"appearance.themes.theme" => set_theme(string_setting_value(key, &value)?, ctx),
"appearance.themes.system_theme" => set_system_theme(bool_setting_value(key, &value)?, ctx),
"appearance.themes.light_theme" => {
set_system_theme_variant(string_setting_value(key, &value)?, true, ctx)
}
"appearance.themes.dark_theme" => {
set_system_theme_variant(string_setting_value(key, &value)?, false, ctx)
}
"appearance.text.font_name" => {
let font_name = string_setting_value(key, &value)?;
if font_name.trim().is_empty() {
return Err(ControlError::new(
ErrorCode::InvalidParams,
"appearance.text.font_name cannot be empty",
));
}
FontSettings::handle(ctx)
.update(ctx, |font_settings, ctx| {
font_settings.monospace_font_name.set_value(font_name, ctx)
})
.map_err(|err| settings_write_error(ActionKind::SettingSet, err))
}
"appearance.text.font_size" => {
let font_size = valid_font_size(u32_setting_value(key, &value)?)?;
FontSettings::handle(ctx)
.update(ctx, |font_settings, ctx| {
font_settings.monospace_font_size.set_value(font_size, ctx)
})
.map_err(|err| settings_write_error(ActionKind::SettingSet, err))
}
"appearance.window.zoom_level" => {
let zoom_level = valid_zoom_level(u32_setting_value(key, &value)?)?;
WindowSettings::handle(ctx)
.update(ctx, |window_settings, ctx| {
window_settings.zoom_level.set_value(zoom_level, ctx)
})
.map_err(|err| settings_write_error(ActionKind::SettingSet, err))
}
"terminal.input.syntax_highlighting" => {
let enabled = bool_setting_value(key, &value)?;
InputSettings::handle(ctx)
.update(ctx, |input_settings, ctx| {
input_settings.syntax_highlighting.set_value(enabled, ctx)
})
.map_err(|err| settings_write_error(ActionKind::SettingSet, err))
}
"terminal.input.error_underlining_enabled" => {
let enabled = bool_setting_value(key, &value)?;
InputSettings::handle(ctx)
.update(ctx, |input_settings, ctx| {
input_settings.error_underlining.set_value(enabled, ctx)
})
.map_err(|err| settings_write_error(ActionKind::SettingSet, err))
}
"accessibility.accessibility_verbosity" => {
let verbosity = accessibility_verbosity_value(key, &value)?;
AccessibilitySettings::handle(ctx)
.update(ctx, |accessibility_settings, ctx| {
accessibility_settings
.a11y_verbosity
.set_value(verbosity, ctx)
})
.map_err(|err| settings_write_error(ActionKind::SettingSet, err))
}
_ => Err(rejected_setting_key(key)),
}
}
fn theme_kind_for_name(
name: &str,
ctx: &ModelContext<LocalControlBridge>,
) -> Result<ThemeKind, ControlError> {
let matches = WarpConfig::as_ref(ctx)
.theme_config()
.theme_items()
.filter_map(|(kind, _)| (public_theme_name(kind) == name).then_some(kind.clone()))
.collect::<Vec<_>>();
match matches.as_slice() {
[theme] => Ok(theme.clone()),
[] => Err(ControlError::new(
ErrorCode::InvalidParams,
format!("{name} is not an available theme"),
)),
_ => Err(ControlError::new(
ErrorCode::InvalidParams,
format!("{name} matches multiple themes"),
)),
}
}
fn valid_font_size(value: u32) -> Result<f32, ControlError> {
if (5..=25).contains(&value) {
return Ok(value as f32);
}
Err(ControlError::new(
ErrorCode::InvalidParams,
"font size must be between 5 and 25",
))
}
fn valid_zoom_level(value: u32) -> Result<u16, ControlError> {
let value = u16::try_from(value).map_err(|err| {
ControlError::with_details(
ErrorCode::InvalidParams,
"zoom level is outside the supported range",
err.to_string(),
)
})?;
if ZoomLevel::VALUES.contains(&value) {
return Ok(value);
}
Err(ControlError::new(
ErrorCode::InvalidParams,
"zoom level must be one of the supported zoom percentages",
))
}
fn bool_setting_value(key: &str, value: &serde_json::Value) -> Result<bool, ControlError> {
value.as_bool().ok_or_else(|| {
ControlError::new(
ErrorCode::InvalidParams,
format!("{key} requires a boolean value"),
)
})
}
fn string_setting_value(key: &str, value: &serde_json::Value) -> Result<String, ControlError> {
value.as_str().map(str::to_owned).ok_or_else(|| {
ControlError::new(
ErrorCode::InvalidParams,
format!("{key} requires a string value"),
)
})
}
fn u32_setting_value(key: &str, value: &serde_json::Value) -> Result<u32, ControlError> {
if let Some(value) = value.as_u64().and_then(|value| u32::try_from(value).ok()) {
return Ok(value);
}
Err(ControlError::new(
ErrorCode::InvalidParams,
format!("{key} requires a non-negative integer value"),
))
}
fn accessibility_verbosity_value(
key: &str,
value: &serde_json::Value,
) -> Result<warpui::accessibility::AccessibilityVerbosity, ControlError> {
match string_setting_value(key, value)?.as_str() {
"Verbose" | "verbose" | "VERBOSE" => {
Ok(warpui::accessibility::AccessibilityVerbosity::Verbose)
}
"Concise" | "concise" | "CONCISE" => {
Ok(warpui::accessibility::AccessibilityVerbosity::Concise)
}
_ => Err(ControlError::new(
ErrorCode::InvalidParams,
"accessibility.accessibility_verbosity must be Verbose or Concise",
)),
}
}
fn settings_write_error(action: ActionKind, err: anyhow::Error) -> ControlError {
ControlError::with_details(
ErrorCode::Internal,
format!("{} failed to update app settings", action.as_str()),
err.to_string(),
)
}
fn tab_mutation_result(
instance_id: &Option<InstanceId>,
action: ActionKind,
tab_id: String,
window_id: WindowId,
) -> serde_json::Value {
json!({
"action": action.as_str(),
"ok": true,
"instance_id": instance_id.as_ref().map(|id| id.0.as_str()),
"window_id": window_id.to_string(),
"tab_id": tab_id,
})
}
fn pane_mutation_result(
instance_id: &Option<InstanceId>,
action: ActionKind,
pane_id: PaneId,
tab_id: String,
) -> serde_json::Value {
json!({
"action": action.as_str(),
"ok": true,
"instance_id": instance_id.as_ref().map(|id| id.0.as_str()),
"tab_id": tab_id,
"pane_id": pane_id.to_string(),
})
}
@@ -0,0 +1,16 @@
use super::{surface_unavailable_reason, SurfaceDestination};
use crate::features::FeatureFlag;
#[test]
fn agent_management_surface_reports_feature_flag_unavailable() {
let flag_guard = FeatureFlag::AgentManagementView.override_enabled(false);
warpui::App::test((), |mut app| async move {
assert_eq!(
app.update(|ctx| {
surface_unavailable_reason(SurfaceDestination::AgentManagement, ctx)
}),
Some("agent management is unavailable or disabled")
);
});
drop(flag_guard);
}
@@ -0,0 +1,313 @@
use ::local_control::protocol::{
AppearanceStateResult, BindingNameParams, KeyParams, KeybindingGetResult, KeybindingListResult,
KeybindingSummary, NamespaceParams, SettingGetResult, SettingListResult, SettingSummary,
ThemeListResult, ThemeStateResult, ThemeSummary,
};
use ::local_control::{ControlError, ErrorCode};
use serde::Serialize;
use serde_json::{json, Value};
use settings::Setting as _;
use warpui::keymap::DescriptionContext;
use warpui::{ModelContext, SingletonEntity};
use crate::local_control::LocalControlBridge;
use crate::settings::{
derived_theme_kind, AccessibilitySettings, FontSettings, InputSettings, ThemeSettings,
};
use crate::themes::theme::ThemeKind;
use crate::user_config::WarpConfig;
use crate::util::bindings::trigger_to_keystroke;
use crate::WindowSettings;
pub(super) const ALLOWLISTED_SETTING_KEYS: &[&str] = &[
"accessibility.accessibility_verbosity",
"appearance.text.font_name",
"appearance.text.font_size",
"appearance.themes.dark_theme",
"appearance.themes.light_theme",
"appearance.themes.system_theme",
"appearance.themes.theme",
"appearance.window.zoom_level",
"terminal.input.error_underlining_enabled",
"terminal.input.syntax_highlighting",
];
pub(crate) fn theme_list(
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
to_control_data(theme_list_result(ctx)?)
}
pub(crate) fn theme_get(
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
to_control_data(theme_state_result(ctx)?)
}
pub(crate) fn appearance_get(
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
to_control_data(appearance_state_result(ctx)?)
}
pub(crate) fn setting_list(
action: &::local_control::Action,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
let namespace = optional_namespace(action)?;
to_control_data(setting_list_result(namespace.as_deref(), ctx)?)
}
pub(crate) fn setting_get(
action: &::local_control::Action,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
let key = key(action)?;
to_control_data(setting_get_result(&key, ctx)?)
}
pub(crate) fn keybinding_list(
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
to_control_data(KeybindingListResult {
keybindings: keybinding_summaries(ctx),
})
}
pub(crate) fn keybinding_get(
action: &::local_control::Action,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<serde_json::Value, ControlError> {
let binding_name = binding_name(action)?;
let keybinding = keybinding_summaries(ctx)
.into_iter()
.find(|summary| summary.name == binding_name)
.ok_or_else(|| {
ControlError::new(
ErrorCode::MissingTarget,
format!("keybinding.get could not find {binding_name}"),
)
})?;
to_control_data(KeybindingGetResult { keybinding })
}
fn theme_list_result(
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<ThemeListResult, ControlError> {
let current_theme = active_theme_kind(ThemeSettings::as_ref(ctx), ctx);
let mut themes = WarpConfig::as_ref(ctx)
.theme_config()
.theme_items()
.map(|(kind, _)| ThemeSummary {
name: public_theme_name(kind),
is_current: *kind == current_theme,
})
.collect::<Vec<_>>();
themes.sort_by(|a, b| a.name.cmp(&b.name));
Ok(ThemeListResult { themes })
}
fn theme_state_result(
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<ThemeStateResult, ControlError> {
let theme_settings = ThemeSettings::as_ref(ctx);
let system_themes = theme_settings.selected_system_themes.value();
Ok(ThemeStateResult {
name: public_theme_name(theme_settings.theme_kind.value()),
follow_system_theme: *theme_settings.use_system_theme.value(),
light_theme: Some(public_theme_name(&system_themes.light)),
dark_theme: Some(public_theme_name(&system_themes.dark)),
})
}
fn appearance_state_result(
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<AppearanceStateResult, ControlError> {
let theme_settings = ThemeSettings::as_ref(ctx);
let font_settings = FontSettings::as_ref(ctx);
let window_settings = WindowSettings::as_ref(ctx);
let system_themes = theme_settings.selected_system_themes.value();
Ok(AppearanceStateResult {
theme: Some(public_theme_name(theme_settings.theme_kind.value())),
follow_system_theme: *theme_settings.use_system_theme.value(),
light_theme: Some(public_theme_name(&system_themes.light)),
dark_theme: Some(public_theme_name(&system_themes.dark)),
font_size: rounded_u32(*font_settings.monospace_font_size.value()),
ui_zoom_percent: Some(u32::from(*window_settings.zoom_level.value())),
})
}
fn setting_list_result(
namespace: Option<&str>,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<SettingListResult, ControlError> {
let settings = ALLOWLISTED_SETTING_KEYS
.iter()
.filter(|key| namespace.is_none_or(|namespace| key.starts_with(namespace)))
.map(|key| setting_summary_for_key(key, ctx))
.collect::<Result<Vec<_>, _>>()?;
Ok(SettingListResult { settings })
}
fn setting_get_result(
key: &str,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<SettingGetResult, ControlError> {
Ok(SettingGetResult {
setting: setting_summary_for_key(key, ctx)?,
})
}
pub(super) fn rejected_setting_key(key: &str) -> ControlError {
ControlError::new(
ErrorCode::NotAllowlisted,
format!("{key} is not an allowlisted local-control setting"),
)
}
pub(super) fn setting_summary_for_key(
key: &str,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<SettingSummary, ControlError> {
let theme_settings = ThemeSettings::as_ref(ctx);
let font_settings = FontSettings::as_ref(ctx);
let input_settings = InputSettings::as_ref(ctx);
let accessibility_settings = AccessibilitySettings::as_ref(ctx);
let window_settings = WindowSettings::as_ref(ctx);
match key {
"appearance.themes.theme" => Ok(setting_summary(
key,
json!(public_theme_name(theme_settings.theme_kind.value())),
"string",
)),
"appearance.themes.system_theme" => Ok(setting_summary(
key,
json!(*theme_settings.use_system_theme.value()),
"bool",
)),
"appearance.themes.light_theme" => Ok(setting_summary(
key,
json!(public_theme_name(
&theme_settings.selected_system_themes.value().light
)),
"string",
)),
"appearance.themes.dark_theme" => Ok(setting_summary(
key,
json!(public_theme_name(
&theme_settings.selected_system_themes.value().dark
)),
"string",
)),
"appearance.text.font_name" => Ok(setting_summary(
key,
json!(font_settings.monospace_font_name.value()),
"string",
)),
"appearance.text.font_size" => Ok(setting_summary(
key,
json!(*font_settings.monospace_font_size.value()),
"number",
)),
"appearance.window.zoom_level" => Ok(setting_summary(
key,
json!(*window_settings.zoom_level.value()),
"number",
)),
"terminal.input.syntax_highlighting" => Ok(setting_summary(
key,
json!(*input_settings.syntax_highlighting.value()),
"bool",
)),
"terminal.input.error_underlining_enabled" => Ok(setting_summary(
key,
json!(*input_settings.error_underlining.value()),
"bool",
)),
"accessibility.accessibility_verbosity" => Ok(setting_summary(
key,
json!(format!(
"{:?}",
accessibility_settings.a11y_verbosity.value()
)),
"string",
)),
_ => Err(rejected_setting_key(key)),
}
}
fn setting_summary(key: &str, value: Value, value_type: &str) -> SettingSummary {
SettingSummary {
key: key.to_owned(),
value,
value_type: value_type.to_owned(),
}
}
pub(super) fn public_theme_name(theme: &ThemeKind) -> String {
theme.to_string()
}
fn active_theme_kind(
theme_settings: &ThemeSettings,
ctx: &ModelContext<LocalControlBridge>,
) -> ThemeKind {
derived_theme_kind(theme_settings, ctx.system_theme())
}
fn rounded_u32(value: f32) -> Option<u32> {
if value.is_finite() && value >= 0.0 && value <= u32::MAX as f32 {
return Some(value.round() as u32);
}
None
}
fn keybinding_summaries(ctx: &mut ModelContext<LocalControlBridge>) -> Vec<KeybindingSummary> {
let mut keybindings = ctx
.editable_bindings()
.map(|binding| {
let keystroke = trigger_to_keystroke(binding.trigger);
KeybindingSummary {
name: binding.name.to_owned(),
description: binding
.description
.materialized(ctx)
.in_context(DescriptionContext::Default)
.to_owned(),
group: binding.group.map(str::to_owned),
keystroke: keystroke.as_ref().map(|keystroke| keystroke.displayed()),
normalized_keystroke: keystroke.map(|keystroke| keystroke.normalized()),
}
})
.collect::<Vec<_>>();
keybindings.sort_by(|left, right| {
left.description
.cmp(&right.description)
.then_with(|| left.name.cmp(&right.name))
});
keybindings
.dedup_by(|left, right| left.name == right.name && left.description == right.description);
keybindings
}
fn optional_namespace(action: &::local_control::Action) -> Result<Option<String>, ControlError> {
Ok(action.params_as::<NamespaceParams>()?.namespace)
}
fn key(action: &::local_control::Action) -> Result<String, ControlError> {
Ok(action.params_as::<KeyParams>()?.key)
}
fn binding_name(action: &::local_control::Action) -> Result<String, ControlError> {
Ok(action.params_as::<BindingNameParams>()?.binding_name)
}
fn to_control_data<T: Serialize>(value: T) -> Result<serde_json::Value, ControlError> {
serde_json::to_value(value).map_err(|err| {
ControlError::with_details(
ErrorCode::Internal,
"failed to serialize local-control response",
err.to_string(),
)
})
}
+692
View File
@@ -0,0 +1,692 @@
//! Running app-side server for local Warp control requests.
//!
//! This module owns the in-process listener, discovery registration, credential
//! broker socket, and request handoff from Axum into the WarpUI model graph.
//! It complements `crates/local_control/src/discovery.rs`: that shared module
//! defines how clients find and validate candidate instances, while this module
//! creates the app-owned endpoints and publishes their routing metadata through
//! `RegisteredInstance`.
//!
//! A client uses all three transports in order. It reads the filesystem record
//! to find an instance, connects to that instance's Unix socket to obtain
//! temporary authority, and presents that authority to the instance's loopback
//! HTTP endpoint with one typed action. The filesystem and socket are therefore
//! complementary parts of discovery and credential bootstrap, not competing
//! discovery mechanisms.
//!
//! Credential broker security flow:
//!
//! ```text
//! owner-only discovery record
//! (loopback endpoint + broker path; never a token)
//! |
//! v
//! CLI client -- instance-bound Unix socket --> credential broker
//! [0600 socket + kernel-reported peer UID]
//! |
//! v
//! feature flag + Settings > Scripting gate
//! + protocol + exact action metadata
//! |
//! v
//! short-lived, instance-bound, action-scoped
//! bearer grant stored only in process memory
//! |
//! v
//! CLI client -- loopback HTTP + bearer --> /v1/control
//! [reject browser Origin + require exact Host
//! + validate grant existence, expiry, instance, and scope]
//! |
//! v
//! typed allowlisted action
//! |
//! v
//! main-thread LocalControlBridge
//! [re-check current settings before dispatch]
//! ```
//!
//! These boundaries prevent browser-origin clients, other OS users,
//! unauthenticated clients that only obtain or guess the HTTP endpoint, stale
//! or wrong-instance credentials, and accidentally over-scoped credentials from
//! invoking actions. The broker authenticates the OS account, not the calling
//! application: malicious software already running as the same user remains
//! outside this boundary.
//!
//! The Settings > Scripting gates used here are local-only settings backed by
//! Warp's secure storage provider.
//!
//! Discovery records never include raw bearer tokens: discovery only exposes
//! endpoint metadata and credential broker references while Scripting is enabled.
mod bridge;
mod handlers;
mod permissions;
mod resolver;
use std::collections::HashMap;
#[cfg(unix)]
use std::fs::Permissions;
use std::net::SocketAddr;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt as _;
use std::sync::{Arc, Mutex};
use ::local_control::auth::CredentialGrant;
#[cfg(any(unix, test))]
use ::local_control::auth::{CredentialRequest, ScopedCredential};
use ::local_control::{
ActionKind, AuthToken, ControlEndpoint, ControlError, ControlResponse, ErrorCode,
ErrorResponseEnvelope, InstanceId, InstanceRecord, RegisteredInstance, RequestEnvelope,
ResponseEnvelope,
};
use axum::body::Bytes;
use axum::extract::State;
use axum::http::header::{AUTHORIZATION, HOST, ORIGIN};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::post;
use axum::{Json, Router};
pub use bridge::LocalControlBridge;
#[cfg(any(unix, test))]
use chrono::Duration;
use permissions::ensure_feature_enabled;
#[cfg(any(unix, test))]
use permissions::{ensure_action_allowed, ensure_protocol_version};
#[cfg(unix)]
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
use galaxy_core::channel::ChannelState;
use warpui::{Entity, ModelContext, ModelSpawner, SingletonEntity};
#[cfg(any(unix, test))]
const MAX_ACTIVE_CREDENTIALS: usize = 128;
/// App-owned authority shared by one instance's broker and HTTP listener.
///
/// Broker-issued bearer tokens map to grants only in this process-local state.
/// Knowing the endpoint from discovery is therefore insufficient to authenticate
/// an HTTP request.
#[derive(Clone)]
struct ControlServerState {
bridge_spawner: ModelSpawner<LocalControlBridge>,
instance_id: InstanceId,
expected_host: String,
credentials: Arc<Mutex<HashMap<String, CredentialGrant>>>,
}
/// Process-local publisher, credential broker, and HTTP server for one Warp instance.
///
/// Holding the runtime and registration keeps both listeners and the discovery
/// route alive. Dropping them stops request handling and removes the app's
/// published record and broker socket.
pub struct LocalControlServer {
_runtime: Option<tokio::runtime::Runtime>,
control_endpoint: Option<ControlEndpoint>,
registered_instance: Option<RegisteredInstance>,
}
impl Entity for LocalControlServer {
type Event = ();
}
impl SingletonEntity for LocalControlServer {}
impl LocalControlServer {
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
let mut server = Self {
_runtime: None,
control_endpoint: None,
registered_instance: None,
};
if let Err(error) = server.refresh_for_settings(ctx) {
log::warn!("Failed to refresh local-control server state: {error:#}");
}
ctx.subscribe_to_model(
&crate::settings::LocalControlSettings::handle(ctx),
|server, _, _, ctx| {
if let Err(error) = server.refresh_for_settings(ctx) {
log::warn!("Failed to refresh local-control server state: {error:#}");
}
},
);
server
}
/// Starts, refreshes, or removes local-control publication as settings change.
fn refresh_for_settings(&mut self, ctx: &mut ModelContext<Self>) -> Result<(), ControlError> {
if !permissions::warp_control_cli_enabled() {
self.stop(ctx);
return Ok(());
}
if !local_control_publication_supported() {
self.stop(ctx);
return Ok(());
}
if !crate::settings::LocalControlSettings::as_ref(ctx).is_enabled() {
self.stop(ctx);
return Ok(());
}
if self._runtime.is_some() {
return self.refresh_discovery_record(ctx);
}
self.start(ctx)
}
/// Stops both listeners and removes the discovery record and broker socket.
fn stop(&mut self, _ctx: &mut ModelContext<Self>) {
self.registered_instance = None;
self.control_endpoint = None;
self._runtime = None;
}
/// Binds both transports and publishes the routing record that connects them.
///
/// Startup first binds an ephemeral loopback HTTP port, publishes that port
/// plus the instance-derived broker filename, binds the broker socket, and
/// then serves credential issuance and typed control requests concurrently.
fn start(&mut self, ctx: &mut ModelContext<Self>) -> Result<(), ControlError> {
if self._runtime.is_some() {
return Err(ControlError::new(
ErrorCode::Internal,
"local-control server is already running",
));
}
ensure_feature_enabled()?;
if !local_control_publication_supported() {
return Err(ControlError::new(
ErrorCode::LocalControlDisabled,
"local control is disabled until this platform enforces discovery-record ACLs",
));
}
if !crate::settings::LocalControlSettings::as_ref(ctx).is_enabled() {
return Ok(());
}
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_io()
.build()
.map_err(|err| {
ControlError::with_details(
ErrorCode::Internal,
"failed to create local-control runtime",
err.to_string(),
)
})?;
let listener = runtime
.block_on(tokio::net::TcpListener::bind(SocketAddr::from((
[127, 0, 0, 1],
0,
))))
.map_err(|err| {
ControlError::with_details(
ErrorCode::Internal,
"failed to bind local-control listener",
err.to_string(),
)
})?;
let port = listener.local_addr().map_err(|err| {
ControlError::with_details(
ErrorCode::Internal,
"failed to read local-control listener address",
err.to_string(),
)
})?;
let control_endpoint = ControlEndpoint::localhost(port.port());
let record = discovery_record_for_settings(ctx, control_endpoint.clone());
let instance_id = record.instance_id.clone();
let bridge_spawner = LocalControlBridge::handle(ctx).update(ctx, |bridge, ctx| {
bridge.set_instance_id(instance_id.clone());
ctx.spawner()
});
let registered_instance = RegisteredInstance::register(record)?;
#[cfg(unix)]
let broker_listener = {
let runtime_guard = runtime.enter();
let listener = bind_credential_broker(registered_instance.record())?;
drop(runtime_guard);
listener
};
let state = ControlServerState {
bridge_spawner,
instance_id,
expected_host: format!("{}:{}", control_endpoint.host, control_endpoint.port),
credentials: Arc::default(),
};
let router = Router::new()
.route("/v1/control", post(handle_control_request))
.with_state(state.clone());
runtime.spawn(async move {
if let Err(err) = axum::serve(listener, router).await {
log::warn!("local-control listener stopped: {err:#}");
}
});
#[cfg(unix)]
runtime.spawn(run_credential_broker(broker_listener, state));
let endpoint_url = control_endpoint.url();
self._runtime = Some(runtime);
self.control_endpoint = Some(control_endpoint);
self.registered_instance = Some(registered_instance);
log::info!("local-control server started at {endpoint_url}");
Ok(())
}
fn refresh_discovery_record(
&mut self,
ctx: &mut ModelContext<Self>,
) -> Result<(), ControlError> {
let Some(control_endpoint) = self.control_endpoint.clone() else {
return Ok(());
};
let Some(registered_instance) = &mut self.registered_instance else {
return Ok(());
};
let mut record = discovery_record_for_settings(ctx, control_endpoint);
record.instance_id = registered_instance.record().instance_id.clone();
record.credential_broker = registered_instance.record().credential_broker.clone();
registered_instance.update(record)
}
}
/// Builds routing metadata without embedding any bearer credential or secret.
///
/// The endpoint and derived broker reference are published only while the
/// protected Scripting setting permits clients to use them.
fn discovery_record_for_settings(
ctx: &ModelContext<LocalControlServer>,
control_endpoint: ControlEndpoint,
) -> InstanceRecord {
let endpoint = crate::settings::LocalControlSettings::as_ref(ctx)
.is_enabled()
.then_some(control_endpoint);
InstanceRecord::for_current_process(
endpoint,
ChannelState::channel().to_string(),
ChannelState::app_id().to_string(),
ChannelState::app_version().map(str::to_owned),
ActionKind::implemented_metadata(),
)
}
/// Binds the instance's credential-bootstrap socket and restricts it to the owning user.
///
/// Any stale socket at the instance-specific path is removed before binding, and
/// the new socket is set to owner-only permissions before it accepts clients.
/// The path came from a validated instance-derived discovery reference, so a
/// record cannot redirect credential requests to an arbitrary socket.
#[cfg(unix)]
fn bind_credential_broker(
record: &InstanceRecord,
) -> Result<tokio::net::UnixListener, ControlError> {
let socket_path = record.broker_socket_path()?;
if socket_path.exists() {
std::fs::remove_file(&socket_path).map_err(|err| {
ControlError::with_details(
ErrorCode::Internal,
"failed to remove stale local-control credential broker socket",
err.to_string(),
)
})?;
}
let listener = tokio::net::UnixListener::bind(&socket_path).map_err(|err| {
ControlError::with_details(
ErrorCode::Internal,
"failed to bind owner-authenticated local-control credential broker",
err.to_string(),
)
})?;
std::fs::set_permissions(&socket_path, Permissions::from_mode(0o600)).map_err(|err| {
ControlError::with_details(
ErrorCode::Internal,
"failed to protect local-control credential broker socket",
err.to_string(),
)
})?;
Ok(listener)
}
#[cfg(unix)]
/// Accepts same-user credential requests independently from the HTTP listener.
async fn run_credential_broker(listener: tokio::net::UnixListener, state: ControlServerState) {
loop {
let Ok((stream, _)) = listener.accept().await else {
return;
};
let state = state.clone();
tokio::spawn(async move {
if let Err(err) = handle_credential_broker_connection(stream, state).await {
log::warn!("local-control credential broker connection failed: {err:#}");
}
});
}
}
#[cfg(unix)]
/// Authenticates the socket peer before decoding and evaluating its request.
///
/// This ordering makes the kernel-reported OS user, rather than any field in
/// caller-controlled JSON, the credential broker's client-identity boundary.
async fn handle_credential_broker_connection(
mut stream: tokio::net::UnixStream,
state: ControlServerState,
) -> Result<(), ControlError> {
let response = match ensure_same_user_peer(&stream) {
Ok(()) => {
let mut bytes = Vec::new();
stream.read_to_end(&mut bytes).await.map_err(|err| {
ControlError::with_details(
ErrorCode::InvalidRequest,
"failed to read local-control credential request",
err.to_string(),
)
})?;
match serde_json::from_slice::<CredentialRequest>(&bytes) {
Ok(request) => issue_credential(&state, request)
.await
.and_then(|credential| serialize_credential_broker_response(&credential)),
Err(err) => Err(ControlError::with_details(
ErrorCode::InvalidRequest,
"failed to decode local-control credential request",
err.to_string(),
)),
}
}
Err(error) => Err(error),
};
let bytes = match response {
Ok(bytes) => bytes,
Err(error) => serialize_credential_broker_response(&ErrorResponseEnvelope::new(error))?,
};
stream.write_all(&bytes).await.map_err(|err| {
ControlError::with_details(
ErrorCode::TransportUnavailable,
"failed to write local-control credential response",
err.to_string(),
)
})
}
#[cfg(unix)]
/// Requires the kernel-reported peer UID to match Warp's effective UID.
///
/// This excludes other OS users but does not distinguish trusted Warp code from
/// arbitrary processes already running as the same user.
fn ensure_same_user_peer(stream: &tokio::net::UnixStream) -> Result<(), ControlError> {
ensure_peer_uid(stream, unsafe { libc::geteuid() })
}
#[cfg(unix)]
/// Verifies a socket peer against an expected UID obtained outside request data.
fn ensure_peer_uid(stream: &tokio::net::UnixStream, expected_uid: u32) -> Result<(), ControlError> {
let peer = stream.peer_cred().map_err(|err| {
ControlError::with_details(
ErrorCode::UnauthorizedLocalClient,
"failed to identify local-control credential broker peer",
err.to_string(),
)
})?;
if peer.uid() != expected_uid {
return Err(ControlError::new(
ErrorCode::UnauthorizedLocalClient,
"local-control credential broker peer belongs to a different OS user",
));
}
Ok(())
}
#[cfg(unix)]
fn serialize_credential_broker_response(
response: &impl serde::Serialize,
) -> Result<Vec<u8>, ControlError> {
serde_json::to_vec(response).map_err(|err| {
ControlError::with_details(
ErrorCode::Internal,
"failed to serialize local-control credential response",
err.to_string(),
)
})
}
/// Evaluates current action policy and mints one short-lived exact-action grant.
///
/// The bearer secret and its grant are retained only in the running instance's
/// process-local map; neither is written back into the discovery registry.
#[cfg(any(unix, test))]
async fn issue_credential(
state: &ControlServerState,
request: CredentialRequest,
) -> Result<ScopedCredential, ControlError> {
ensure_feature_enabled()?;
ensure_protocol_version(request.protocol_version)?;
if !request.action.is_implemented() {
return Err(ControlError::new(
ErrorCode::UnsupportedAction,
format!(
"{} is not implemented by this local-control bridge",
request.action.as_str()
),
));
}
state
.bridge_spawner
.spawn({
let action = request.action;
move |_, ctx| ensure_action_allowed(action, ctx)
})
.await
.map_err(|_| {
ControlError::new(
ErrorCode::BridgeUnavailable,
"local-control app bridge is unavailable",
)
})??;
let auth_token = AuthToken::generate();
let grant = CredentialGrant::new(
state.instance_id.clone(),
request.action,
Duration::minutes(5),
);
let mut credentials = state.credentials.lock().map_err(|_| {
ControlError::new(
ErrorCode::Internal,
"local-control credential broker is unavailable",
)
})?;
insert_credential(
&mut credentials,
auth_token.secret().to_owned(),
grant.clone(),
);
Ok(ScopedCredential {
bearer_token: auth_token.secret().to_owned(),
grant,
})
}
/// Authenticates and hands one typed HTTP request to the app bridge.
///
/// Header hardening rejects browser-origin and wrong-endpoint requests. The
/// process-local credential lookup authenticates the transport, after which the
/// bridge revalidates current settings and exact-action authority before
/// resolving targets or dispatching a handler.
async fn handle_control_request(
State(state): State<ControlServerState>,
headers: HeaderMap,
payload: Bytes,
) -> Response {
if let Err(error) = validate_loopback_headers(&headers, &state.expected_host) {
return (
StatusCode::FORBIDDEN,
Json(ErrorResponseEnvelope::new(error)),
)
.into_response();
}
if let Err(error) = ensure_feature_enabled() {
return (
StatusCode::FORBIDDEN,
Json(ErrorResponseEnvelope::new(error)),
)
.into_response();
}
let auth_header = headers
.get(AUTHORIZATION)
.and_then(|value| value.to_str().ok());
let auth_token = match AuthToken::from_authorization_header(auth_header) {
Ok(token) => token,
Err(error) => {
return (
StatusCode::UNAUTHORIZED,
Json(ErrorResponseEnvelope::new(error)),
)
.into_response();
}
};
let grant = match state.credentials.lock() {
Ok(mut credentials) => lookup_credential(&mut credentials, &auth_token, &state.instance_id),
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponseEnvelope::new(ControlError::new(
ErrorCode::Internal,
"local-control credential broker is unavailable",
))),
)
.into_response();
}
};
let grant = match grant {
Ok(grant) => grant,
Err(error) => {
return (
StatusCode::UNAUTHORIZED,
Json(ErrorResponseEnvelope::new(error)),
)
.into_response();
}
};
let request = match serde_json::from_slice::<RequestEnvelope>(&payload) {
Ok(request) => request,
Err(err) => {
return (
StatusCode::BAD_REQUEST,
Json(ErrorResponseEnvelope::new(ControlError::with_details(
ErrorCode::InvalidRequest,
"failed to decode local-control request",
err.to_string(),
))),
)
.into_response();
}
};
let request_id = request.request_id;
let response = match state
.bridge_spawner
.spawn(move |bridge, ctx| bridge.handle_request(request, grant, ctx))
.await
{
Ok(response) => response,
Err(_) => ResponseEnvelope::error(
request_id,
ControlError::new(
ErrorCode::BridgeUnavailable,
"local-control app bridge is unavailable",
),
),
};
let status = match &response.response {
ControlResponse::Ok { .. } => StatusCode::OK,
ControlResponse::Error { .. } => StatusCode::BAD_REQUEST,
};
(status, Json(response)).into_response()
}
#[cfg(any(unix, test))]
fn insert_credential(
credentials: &mut HashMap<String, CredentialGrant>,
secret: String,
grant: CredentialGrant,
) {
credentials.retain(|_, grant| !grant.is_expired());
if credentials.len() >= MAX_ACTIVE_CREDENTIALS {
let oldest_secret = credentials
.iter()
.min_by_key(|(_, grant)| grant.issued_at)
.map(|(secret, _)| secret.clone());
if let Some(oldest_secret) = oldest_secret {
credentials.remove(&oldest_secret);
}
}
credentials.insert(secret, grant);
}
/// Resolves an unexpired bearer token issued by this exact running instance.
fn lookup_credential(
credentials: &mut HashMap<String, CredentialGrant>,
auth_token: &AuthToken,
instance_id: &InstanceId,
) -> Result<CredentialGrant, ControlError> {
if credentials
.get(auth_token.secret())
.is_some_and(CredentialGrant::is_expired)
{
credentials.remove(auth_token.secret());
}
let grant = credentials
.get(auth_token.secret())
.cloned()
.ok_or_else(|| {
ControlError::new(
ErrorCode::UnauthorizedLocalClient,
"local-control credential is invalid",
)
})?;
grant.verify_for_action(instance_id, grant.action)?;
Ok(grant)
}
fn local_control_publication_supported() -> bool {
cfg!(not(target_os = "windows"))
}
/// Performs browser-origin hardening for local-control endpoints.
///
/// These checks intentionally reject browser-style `Origin` requests and stale
/// endpoint selections, but they are not an authorization boundary. Scoped
/// bearer credentials and grant validation remain the authority for control
/// requests.
pub(crate) fn validate_loopback_headers(
headers: &HeaderMap,
expected_host: &str,
) -> Result<(), ControlError> {
if headers.contains_key(ORIGIN) {
return Err(ControlError::new(
ErrorCode::UnauthorizedLocalClient,
"browser-origin local-control requests are not allowed",
));
}
let host = headers
.get(HOST)
.and_then(|value| value.to_str().ok())
.ok_or_else(|| {
ControlError::new(
ErrorCode::UnauthorizedLocalClient,
"Host header is required for local-control requests",
)
})?;
if host != expected_host {
return Err(ControlError::new(
ErrorCode::UnauthorizedLocalClient,
"Host header does not match the selected local-control endpoint",
));
}
Ok(())
}
#[cfg(test)]
pub(crate) use bridge::validate_request_authority;
#[cfg(test)]
pub(crate) use permissions::{capabilities, ensure_settings_allow_action};
#[cfg(test)]
pub(crate) use resolver::{
require_active_window_id, resolve_index_from_ids, resolve_title_from_matches,
validate_action_params, validate_tab_create_target,
};
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;
+442
View File
@@ -0,0 +1,442 @@
use std::collections::HashMap;
use ::local_control::auth::{CredentialGrant, CredentialRequest};
use ::local_control::protocol::{
Action, ActionKind, PaneSelector, PaneTarget, TabSelector, TabTarget, TargetSelector,
WindowSelector, WindowTarget,
};
use ::local_control::{ErrorCode, InstanceId, RequestEnvelope};
use axum::body::Bytes;
use axum::extract::State;
use axum::http::header::{AUTHORIZATION, HOST, ORIGIN};
use axum::http::{HeaderMap, HeaderValue};
use chrono::Duration;
use settings::Setting as _;
use galaxy_core::features::FeatureFlag;
use warpui::SingletonEntity as _;
#[cfg(unix)]
use super::ensure_peer_uid;
use super::resolver::validate_action_target;
use super::{
capabilities, ensure_feature_enabled, ensure_protocol_version, ensure_settings_allow_action,
handle_control_request, insert_credential, issue_credential, lookup_credential,
require_active_window_id, resolve_index_from_ids, resolve_title_from_matches,
validate_action_params, validate_loopback_headers, validate_request_authority,
validate_tab_create_target, ControlServerState, LocalControlBridge, LocalControlServer,
MAX_ACTIVE_CREDENTIALS,
};
use crate::settings::{LocalControlMode, LocalControlModeSetting, LocalControlSettings};
fn settings_with_mode(mode: LocalControlMode) -> LocalControlSettings {
LocalControlSettings {
local_control_mode: LocalControlModeSetting::new(Some(mode)),
}
}
#[cfg(unix)]
#[tokio::test]
async fn credential_broker_rejects_peer_from_different_user() {
let (stream, _peer) = tokio::net::UnixStream::pair().expect("socket pair");
let actual_uid = stream.peer_cred().expect("peer credentials").uid();
let different_uid = if actual_uid == u32::MAX {
actual_uid - 1
} else {
actual_uid + 1
};
let err = ensure_peer_uid(&stream, different_uid).expect_err("different user is rejected");
assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient);
}
#[cfg(unix)]
#[tokio::test]
async fn credential_broker_accepts_peer_from_same_user() {
let (stream, _peer) = tokio::net::UnixStream::pair().expect("socket pair");
let actual_uid = stream.peer_cred().expect("peer credentials").uid();
ensure_peer_uid(&stream, actual_uid).expect("same user is accepted");
}
#[test]
fn protocol_version_helper_rejects_unsupported_versions() {
ensure_protocol_version(::local_control::PROTOCOL_VERSION)
.expect("current version is accepted");
let err = ensure_protocol_version(::local_control::PROTOCOL_VERSION + 1)
.expect_err("future protocol version is rejected");
assert_eq!(err.code, ErrorCode::ProtocolVersionUnsupported);
}
#[test]
fn tab_create_accepts_default_and_window_targets() {
validate_tab_create_target(&TargetSelector::default()).expect("default target is accepted");
validate_tab_create_target(&TargetSelector {
window: Some(WindowTarget::Id {
id: WindowSelector("window".to_owned()),
}),
tab: None,
pane: None,
session: None,
})
.expect("window id target is accepted");
validate_tab_create_target(&TargetSelector {
window: Some(WindowTarget::Index { index: 0 }),
tab: None,
pane: None,
session: None,
})
.expect("window index target is accepted");
validate_tab_create_target(&TargetSelector {
window: Some(WindowTarget::Title {
title: "window".to_owned(),
}),
tab: None,
pane: None,
session: None,
})
.expect("window title target is accepted");
}
#[test]
fn tab_create_rejects_lower_level_targets() {
let err = validate_tab_create_target(&TargetSelector {
window: None,
tab: Some(TabTarget::Id {
id: TabSelector("tab".to_owned()),
}),
pane: None,
session: None,
})
.expect_err("concrete tab target is rejected");
assert_eq!(err.code, ErrorCode::InvalidSelector);
let err = validate_tab_create_target(&TargetSelector {
window: None,
tab: None,
pane: Some(PaneTarget::Id {
id: PaneSelector("pane".to_owned()),
}),
session: None,
})
.expect_err("concrete pane target is rejected");
assert_eq!(err.code, ErrorCode::InvalidSelector);
}
#[test]
fn tab_create_rejects_unsupported_selector_forms() {
let err = validate_tab_create_target(&TargetSelector {
window: None,
tab: Some(TabTarget::Index { index: 0 }),
pane: None,
session: None,
})
.expect_err("indexed tab target is rejected");
assert_eq!(err.code, ErrorCode::InvalidSelector);
}
#[test]
fn surface_list_rejects_target_selectors() {
let error = validate_action_target(
ActionKind::SurfaceList,
&TargetSelector {
window: Some(WindowTarget::Active),
tab: None,
pane: None,
session: None,
},
)
.expect_err("surface.list is instance-wide");
assert_eq!(error.code, ErrorCode::InvalidSelector);
}
#[test]
fn capabilities_advertises_the_complete_catalog() {
assert_eq!(capabilities().len(), 84);
}
#[test]
fn loopback_headers_reject_origin_and_host_mismatch() {
let expected_host = "127.0.0.1:1234";
let mut headers = HeaderMap::new();
headers.insert(HOST, HeaderValue::from_static(expected_host));
validate_loopback_headers(&headers, expected_host).expect("matching host should be accepted");
headers.insert(ORIGIN, HeaderValue::from_static("https://example.com"));
let err =
validate_loopback_headers(&headers, expected_host).expect_err("origin should be rejected");
assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient);
headers.remove(ORIGIN);
headers.insert(HOST, HeaderValue::from_static("localhost:1234"));
let err = validate_loopback_headers(&headers, expected_host)
.expect_err("host mismatch should be rejected");
assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient);
let headers = HeaderMap::new();
let err = validate_loopback_headers(&headers, expected_host)
.expect_err("missing host should be rejected");
assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient);
}
#[test]
fn scripting_mode_controls_local_control() {
assert!(!settings_with_mode(LocalControlMode::Disabled).is_enabled());
assert!(settings_with_mode(LocalControlMode::Enabled).is_enabled());
}
#[test]
fn tab_create_requires_active_window() {
let active = warpui::WindowId::from_usize(1);
assert_eq!(
require_active_window_id(Some(active)).expect("active"),
active
);
let err = require_active_window_id(None).expect_err("missing active window");
assert_eq!(err.code, ErrorCode::MissingTarget);
}
#[test]
fn window_title_resolution_distinguishes_missing_and_ambiguous_targets() {
let missing = resolve_title_from_matches(&[], ActionKind::TabCreate)
.expect_err("zero-match title is missing");
assert_eq!(missing.code, ErrorCode::MissingTarget);
let matches = [
warpui::WindowId::from_usize(1),
warpui::WindowId::from_usize(2),
];
let ambiguous = resolve_title_from_matches(&matches, ActionKind::TabCreate)
.expect_err("multi-match title is ambiguous");
assert_eq!(ambiguous.code, ErrorCode::AmbiguousTarget);
}
#[test]
fn missing_window_index_returns_missing_target() {
let err = resolve_index_from_ids(std::iter::empty(), 0, ActionKind::TabCreate)
.expect_err("zero-match index is missing");
assert_eq!(err.code, ErrorCode::MissingTarget);
}
#[test]
fn feature_flag_disabled_denies_local_control() {
let _flag = FeatureFlag::WarpControlCli.override_enabled(false);
let err = ensure_feature_enabled().expect_err("feature flag disabled");
assert_eq!(err.code, ErrorCode::LocalControlDisabled);
}
#[test]
fn duplicate_server_start_is_rejected() {
warpui::App::test((), |mut app| async move {
let runtime = tokio::runtime::Builder::new_current_thread()
.build()
.expect("runtime");
let server = app.add_model(|_| LocalControlServer {
_runtime: Some(runtime),
control_endpoint: None,
registered_instance: None,
});
let err = server
.update(&mut app, |server, ctx| server.start(ctx))
.expect_err("duplicate start should fail");
assert_eq!(err.code, ErrorCode::Internal);
server
.update(&mut app, |server, _| server._runtime.take())
.expect("existing runtime should remain active")
.shutdown_background();
});
}
#[test]
fn scripting_disabled_denies_action() {
let settings = settings_with_mode(LocalControlMode::Disabled);
let err = ensure_settings_allow_action(&settings, ActionKind::TabCreate)
.expect_err("disabled scripting denies action");
assert_eq!(err.code, ErrorCode::LocalControlDisabled);
}
#[test]
fn scripting_enabled_allows_action() {
ensure_settings_allow_action(
&settings_with_mode(LocalControlMode::Enabled),
ActionKind::TabCreate,
)
.expect("enabled scripting allows action");
}
#[test]
fn tab_create_rejects_malformed_params() {
let err = validate_action_params(&Action {
kind: ActionKind::TabCreate,
params: serde_json::json!({ "unexpected": true }),
})
.expect_err("tab.create params must be empty");
assert_eq!(err.code, ErrorCode::InvalidParams);
validate_action_params(&Action {
kind: ActionKind::TabCreate,
params: serde_json::json!({}),
})
.expect("empty tab.create params are accepted");
}
#[test]
fn metadata_actions_reject_malformed_params() {
let err = validate_action_params(&Action {
kind: ActionKind::AppPing,
params: serde_json::json!({ "unexpected": true }),
})
.expect_err("app.ping params must be empty");
assert_eq!(err.code, ErrorCode::InvalidParams);
}
#[test]
fn bridge_checks_grant_before_action_params() {
let instance_id = InstanceId("inst_test".to_owned());
let grant = CredentialGrant::new(
instance_id.clone(),
ActionKind::AppPing,
Duration::minutes(5),
);
let err = validate_request_authority(
&instance_id,
&Action {
kind: ActionKind::AppVersion,
params: serde_json::json!({ "unexpected": true }),
},
&grant,
)
.expect_err("wrong-action grant is rejected before params");
assert_eq!(err.code, ErrorCode::InsufficientPermissions);
}
#[test]
fn credential_insertion_prunes_expired_and_caps_active_grants() {
let mut credentials = HashMap::new();
let instance_id = InstanceId("inst_test".to_owned());
insert_credential(
&mut credentials,
"expired".to_owned(),
CredentialGrant::new(
instance_id.clone(),
ActionKind::TabCreate,
Duration::minutes(-1),
),
);
insert_credential(
&mut credentials,
"active".to_owned(),
CredentialGrant::new(
instance_id.clone(),
ActionKind::TabCreate,
Duration::minutes(5),
),
);
assert!(!credentials.contains_key("expired"));
for index in 0..MAX_ACTIVE_CREDENTIALS {
insert_credential(
&mut credentials,
format!("active-{index}"),
CredentialGrant::new(
instance_id.clone(),
ActionKind::TabCreate,
Duration::minutes(5),
),
);
}
assert_eq!(credentials.len(), MAX_ACTIVE_CREDENTIALS);
assert!(credentials.contains_key(&format!("active-{}", MAX_ACTIVE_CREDENTIALS - 1)));
}
#[test]
fn expired_credential_is_rejected_and_pruned_before_request_decode() {
let mut credentials = HashMap::new();
let token = ::local_control::AuthToken::from_secret("expired");
credentials.insert(
token.secret().to_owned(),
CredentialGrant::new(
InstanceId("inst_test".to_owned()),
ActionKind::TabCreate,
Duration::minutes(-1),
),
);
let err = lookup_credential(
&mut credentials,
&token,
&InstanceId("inst_test".to_owned()),
)
.expect_err("expired grant is rejected");
assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient);
assert!(!credentials.contains_key(token.secret()));
}
#[test]
fn disabling_scripting_invalidates_existing_grant_and_prevents_new_grants() {
let _flag = FeatureFlag::WarpControlCli.override_enabled(true);
warpui::App::test((), |mut app| async move {
crate::test_util::settings::initialize_settings_for_tests(&mut app);
app.update(|ctx| {
LocalControlSettings::handle(ctx).update(ctx, |settings, ctx| {
settings
.local_control_mode
.set_value(LocalControlMode::Enabled, ctx)
})
})
.expect("local control should enable");
let instance_id = InstanceId("inst_test".to_owned());
let expected_host = "127.0.0.1:1234".to_owned();
let bridge = app.add_singleton_model(LocalControlBridge::new);
let state = bridge.update(&mut app, |bridge, ctx| {
bridge.set_instance_id(instance_id.clone());
ControlServerState {
bridge_spawner: ctx.spawner(),
instance_id: instance_id.clone(),
expected_host: expected_host.clone(),
credentials: Default::default(),
}
});
let credential = issue_credential(&state, CredentialRequest::new(ActionKind::AppPing))
.await
.expect("local-control credential should be issued");
app.update(|ctx| {
LocalControlSettings::handle(ctx).update(ctx, |settings, ctx| {
settings
.local_control_mode
.set_value(LocalControlMode::Disabled, ctx)
})
})
.expect("local control should disable");
let mut headers = HeaderMap::new();
headers.insert(
HOST,
HeaderValue::from_str(&expected_host).expect("valid host"),
);
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&credential.authorization_value()).expect("valid credential"),
);
let request = RequestEnvelope::new(Action::new(ActionKind::AppPing));
let response = handle_control_request(
State(state.clone()),
headers,
Bytes::from(serde_json::to_vec(&request).expect("request serializes")),
)
.await;
assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
let err = issue_credential(&state, CredentialRequest::new(ActionKind::AppPing))
.await
.expect_err("disabled scripting should prevent new grants");
assert_eq!(err.code, ErrorCode::LocalControlDisabled);
});
}
+60
View File
@@ -0,0 +1,60 @@
//! Permission checks for local control.
use ::local_control::{ActionKind, ControlError, ErrorCode, PROTOCOL_VERSION};
use warpui::{ModelContext, SingletonEntity};
use crate::features::FeatureFlag;
use crate::local_control::LocalControlBridge;
use crate::settings::LocalControlSettings;
pub(super) fn warp_control_cli_enabled() -> bool {
FeatureFlag::WarpControlCli.is_enabled()
}
pub(super) fn ensure_protocol_version(protocol_version: u32) -> Result<(), ControlError> {
if protocol_version == PROTOCOL_VERSION {
return Ok(());
}
Err(ControlError::new(
ErrorCode::ProtocolVersionUnsupported,
format!("unsupported protocol version {protocol_version}"),
))
}
pub(super) fn ensure_feature_enabled() -> Result<(), ControlError> {
if warp_control_cli_enabled() {
return Ok(());
}
Err(ControlError::new(
ErrorCode::LocalControlDisabled,
"Warp control CLI is disabled by feature flag",
))
}
#[cfg(test)]
pub(crate) fn capabilities() -> Vec<ActionKind> {
ActionKind::implemented_metadata()
.into_iter()
.map(|metadata| metadata.kind)
.collect()
}
pub(super) fn ensure_action_allowed(
action: ActionKind,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<(), ControlError> {
let settings = LocalControlSettings::as_ref(ctx);
ensure_settings_allow_action(settings, action)
}
pub(crate) fn ensure_settings_allow_action(
settings: &LocalControlSettings,
action: ActionKind,
) -> Result<(), ControlError> {
if !settings.is_enabled() {
return Err(ControlError::new(
ErrorCode::LocalControlDisabled,
format!("{} is disabled for local control", action.as_str()),
));
}
Ok(())
}
+521
View File
@@ -0,0 +1,521 @@
//! Target resolution and parameter validation for retained local-control actions.
use ::local_control::protocol::{
ActionNameParams, ActionParameterSpec, BindingNameParams, BooleanValueParams, ColorValueParams,
DirectionParams, EmptyParams, FileOpenParams, KeyParams, KeyValueParams, NamespaceParams,
PageQueryParams, PaneTarget, QueryParams, RenameParams, ResizeParams, SessionTarget,
TabActivateParams, TabCloseParams, TabCreateParams, TabTarget, TargetSelector, TextParams,
ThemeNameParams, WindowTarget,
};
use ::local_control::{ActionKind, ControlError, ErrorCode, TargetScope};
use warpui::{AppContext, ModelContext, TypedActionView, ViewHandle, WindowId};
use crate::local_control::handlers::metadata::action_metadata_for_name;
use crate::local_control::LocalControlBridge;
use crate::pane_group::{ActivationReason, PaneGroup, PaneGroupAction, PaneId};
use crate::workspace::{Workspace, WorkspaceAction};
pub(crate) fn validate_tab_create_target(target: &TargetSelector) -> Result<(), ControlError> {
if target.tab.is_some() || target.pane.is_some() || target.session.is_some() {
return Err(ControlError::new(
ErrorCode::InvalidSelector,
"tab.create accepts only a window selector",
));
}
Ok(())
}
pub(crate) fn validate_action_params(action: &::local_control::Action) -> Result<(), ControlError> {
if !action.kind.is_implemented() {
return Ok(());
}
match action.kind.metadata().parameter_spec {
ActionParameterSpec::None => parse_params::<EmptyParams>(action),
ActionParameterSpec::ActionName => {
let params = action.params_as::<ActionNameParams>()?;
action_metadata_for_name(&params.action).map(|_| ())
}
ActionParameterSpec::BindingName => parse_params::<BindingNameParams>(action),
ActionParameterSpec::BooleanValue => parse_params::<BooleanValueParams>(action),
ActionParameterSpec::ColorValue => parse_params::<ColorValueParams>(action),
ActionParameterSpec::Direction => parse_params::<DirectionParams>(action),
ActionParameterSpec::FileOpen => parse_params::<FileOpenParams>(action),
ActionParameterSpec::Key => parse_params::<KeyParams>(action),
ActionParameterSpec::KeyValue => parse_params::<KeyValueParams>(action),
ActionParameterSpec::Namespace => parse_params::<NamespaceParams>(action),
ActionParameterSpec::PageQuery => parse_params::<PageQueryParams>(action),
ActionParameterSpec::Query => parse_params::<QueryParams>(action),
ActionParameterSpec::Rename => parse_params::<RenameParams>(action),
ActionParameterSpec::Resize => parse_params::<ResizeParams>(action),
ActionParameterSpec::TabActivate => parse_params::<TabActivateParams>(action),
ActionParameterSpec::TabClose => parse_params::<TabCloseParams>(action),
ActionParameterSpec::TabCreate => parse_params::<TabCreateParams>(action),
ActionParameterSpec::Text => parse_params::<TextParams>(action),
ActionParameterSpec::ThemeName => parse_params::<ThemeNameParams>(action),
}
}
pub(crate) fn validate_action_target(
action: ActionKind,
target: &TargetSelector,
) -> Result<(), ControlError> {
let has_target = target.window.is_some()
|| target.tab.is_some()
|| target.pane.is_some()
|| target.session.is_some();
let rejects_all_targets = match action.metadata().target_scope {
TargetScope::Instance => action != ActionKind::AppFocus,
TargetScope::Appearance
| TargetScope::Settings
| TargetScope::Keybinding
| TargetScope::Action
| TargetScope::Capability => true,
TargetScope::Window
| TargetScope::Tab
| TargetScope::Pane
| TargetScope::Session
| TargetScope::Input
| TargetScope::Surface
| TargetScope::File => false,
};
if rejects_all_targets && has_target {
return Err(ControlError::new(
ErrorCode::InvalidSelector,
format!("{} does not accept target selectors", action.as_str()),
));
}
Ok(())
}
pub(super) fn target_window_id_for_target(
ctx: &mut ModelContext<LocalControlBridge>,
target: &TargetSelector,
action: ActionKind,
) -> Result<WindowId, ControlError> {
match target.window.as_ref() {
None | Some(WindowTarget::Active) => active_or_single_window_id(ctx, action),
Some(WindowTarget::Id { id }) => ctx
.window_ids()
.find(|window_id| window_id.to_string() == id.0)
.ok_or_else(|| {
ControlError::new(
ErrorCode::StaleTarget,
format!("{} cannot resolve the requested window id", action.as_str()),
)
}),
Some(WindowTarget::Index { index }) => {
resolve_index_from_ids(ctx.window_ids(), *index, action)
}
Some(WindowTarget::Title { .. }) => Err(ControlError::new(
ErrorCode::InvalidSelector,
format!(
"{} only supports active, opaque window id, and window index selectors",
action.as_str()
),
)),
}
}
#[cfg(test)]
pub(crate) fn require_active_window_id(
active_window: Option<WindowId>,
) -> Result<WindowId, ControlError> {
require_active_window_id_for_action(active_window, ActionKind::TabCreate)
}
pub(crate) fn require_active_window_id_for_action(
active_window: Option<WindowId>,
action: ActionKind,
) -> Result<WindowId, ControlError> {
active_window.ok_or_else(|| {
ControlError::new(
ErrorCode::MissingTarget,
format!("{} requires an active Warp window", action.as_str()),
)
})
}
fn active_or_single_window_id(
ctx: &mut ModelContext<LocalControlBridge>,
action: ActionKind,
) -> Result<WindowId, ControlError> {
if let Some(window_id) = ctx.windows().active_window() {
return Ok(window_id);
}
let window_ids = ctx.window_ids().collect::<Vec<_>>();
match window_ids.as_slice() {
[window_id] => Ok(*window_id),
[] => require_active_window_id_for_action(None, action),
_ => Err(ControlError::new(
ErrorCode::AmbiguousTarget,
format!(
"{} requires an explicit window selector when no Warp window is active",
action.as_str()
),
)),
}
}
pub(crate) fn resolve_index_from_ids(
ids: impl Iterator<Item = WindowId>,
index: u32,
action: ActionKind,
) -> Result<WindowId, ControlError> {
let mut ids = ids.collect::<Vec<_>>();
ids.sort_by_key(ToString::to_string);
ids.get(index as usize).copied().ok_or_else(|| {
ControlError::new(
ErrorCode::MissingTarget,
format!(
"{} cannot resolve the requested window index",
action.as_str()
),
)
})
}
#[cfg(test)]
pub(crate) fn resolve_title_from_matches(
matching: &[WindowId],
action: ActionKind,
) -> Result<WindowId, ControlError> {
match matching {
[window_id] => Ok(*window_id),
[] => Err(ControlError::new(
ErrorCode::MissingTarget,
format!(
"{} cannot resolve the requested window title",
action.as_str()
),
)),
_ => Err(ControlError::new(
ErrorCode::AmbiguousTarget,
format!("{} resolved multiple windows by title", action.as_str()),
)),
}
}
fn parse_params<T: serde::de::DeserializeOwned>(
action: &::local_control::Action,
) -> Result<(), ControlError> {
action.params_as::<T>().map(|_| ())
}
pub(crate) fn decode_params<T: serde::de::DeserializeOwned>(
params: &serde_json::Value,
) -> Result<T, ControlError> {
serde_json::from_value(params.clone()).map_err(|err| {
ControlError::with_details(
ErrorCode::InvalidParams,
"failed to decode action parameters",
err.to_string(),
)
})
}
pub(crate) fn reject_target_families(
action: ActionKind,
rejected: bool,
families: &str,
) -> Result<(), ControlError> {
if rejected {
return Err(ControlError::new(
ErrorCode::InvalidSelector,
format!("{} does not accept {families}", action.as_str()),
));
}
Ok(())
}
pub(crate) fn workspace_for_window(
window_id: WindowId,
action: ActionKind,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<ViewHandle<Workspace>, ControlError> {
ctx.views_of_type::<Workspace>(window_id)
.and_then(|workspaces| workspaces.into_iter().next())
.ok_or_else(|| {
ControlError::new(
ErrorCode::MissingTarget,
format!(
"{} requires a workspace in the target window",
action.as_str()
),
)
})
}
pub(crate) fn target_workspace(
action: ActionKind,
target: &TargetSelector,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<ViewHandle<Workspace>, ControlError> {
let window_id = target_window_id_for_target(ctx, target, action)?;
workspace_for_window(window_id, action, ctx)
}
pub(crate) fn target_pane_group(
action: ActionKind,
target: &TargetSelector,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<ViewHandle<PaneGroup>, ControlError> {
let workspace = target_workspace(action, target, ctx)?;
workspace.read(ctx, |workspace, ctx| {
let tab_index = tab_index_from_target(target, workspace, ctx)?;
workspace
.tab_views()
.nth(tab_index)
.cloned()
.ok_or_else(|| {
ControlError::new(
ErrorCode::StaleTarget,
format!("{} cannot resolve the requested tab", action.as_str()),
)
})
})
}
pub(crate) fn active_target_pane_group(
action: ActionKind,
target: &TargetSelector,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<ViewHandle<PaneGroup>, ControlError> {
reject_target_families(
action,
action != ActionKind::SessionActivate && target.session.is_some(),
"session selectors",
)?;
let workspace = target_workspace(action, target, ctx)?;
if target.tab.is_some() {
workspace.update(ctx, |workspace, ctx| {
let tab_index = tab_index_from_target(target, workspace, ctx)?;
workspace.handle_action(&WorkspaceAction::ActivateTab(tab_index), ctx);
Ok::<_, ControlError>(())
})?;
}
target_pane_group(action, target, ctx)
}
pub(crate) fn activate_target(
workspace: &ViewHandle<Workspace>,
action: ActionKind,
target: &TargetSelector,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<(), ControlError> {
if target.tab.is_some() {
workspace.update(ctx, |workspace, ctx| {
let tab_index = tab_index_from_target(target, workspace, ctx)?;
workspace.handle_action(&WorkspaceAction::ActivateTab(tab_index), ctx);
Ok::<_, ControlError>(())
})?;
}
if target.session.is_some() {
let pane_group = target_pane_group(action, target, ctx)?;
let pane_id = target_session_pane_id(action, target, &pane_group, ctx)?;
pane_group.update(ctx, |pane_group, ctx| {
pane_group.handle_action(
&PaneGroupAction::Activate(pane_id, ActivationReason::Click),
ctx,
);
});
} else if target.pane.is_some() {
let pane_group = target_pane_group(action, target, ctx)?;
focus_explicit_pane_target(action, target, &pane_group, ctx)?;
}
Ok(())
}
pub(crate) fn focus_explicit_pane_target(
action: ActionKind,
target: &TargetSelector,
pane_group: &ViewHandle<PaneGroup>,
ctx: &mut ModelContext<LocalControlBridge>,
) -> Result<(), ControlError> {
if target.pane.is_none() {
return Ok(());
}
let pane_id = target_pane_id(action, target, pane_group, ctx)?;
pane_group.update(ctx, |pane_group, ctx| {
pane_group.handle_action(
&PaneGroupAction::Activate(pane_id, ActivationReason::Click),
ctx,
);
});
Ok(())
}
pub(crate) fn target_pane_id(
action: ActionKind,
target: &TargetSelector,
pane_group: &ViewHandle<PaneGroup>,
ctx: &AppContext,
) -> Result<PaneId, ControlError> {
pane_group.read(ctx, |pane_group, ctx| match target.pane.as_ref() {
None | Some(PaneTarget::Active) => Ok(pane_group.focused_pane_id(ctx)),
Some(PaneTarget::Index { index }) => {
let pane_index = usize::try_from(*index).map_err(|err| {
ControlError::with_details(
ErrorCode::InvalidSelector,
"pane index is out of range",
err.to_string(),
)
})?;
pane_group.pane_id_from_index(pane_index).ok_or_else(|| {
ControlError::new(
ErrorCode::MissingTarget,
format!(
"{} cannot resolve the requested pane index",
action.as_str()
),
)
})
}
Some(PaneTarget::Id { id }) => pane_group
.visible_pane_ids()
.into_iter()
.find(|pane_id| pane_id.to_string() == id.0)
.ok_or_else(|| {
ControlError::new(
ErrorCode::StaleTarget,
format!("{} cannot resolve the requested pane id", action.as_str()),
)
}),
})
}
pub(crate) fn tab_index_from_target(
target: &TargetSelector,
workspace: &Workspace,
ctx: &AppContext,
) -> Result<usize, ControlError> {
match target.tab.as_ref() {
Some(TabTarget::Index { index }) => usize::try_from(*index)
.ok()
.filter(|index| *index < workspace.tab_count())
.ok_or_else(|| {
ControlError::new(
ErrorCode::MissingTarget,
"tab selector index did not match a visible tab",
)
}),
Some(TabTarget::Active) | None => Ok(workspace.active_tab_index()),
Some(TabTarget::Id { id }) => workspace
.tab_views()
.enumerate()
.find_map(|(index, pane_group)| (pane_group.id().to_string() == id.0).then_some(index))
.ok_or_else(|| {
ControlError::new(
ErrorCode::StaleTarget,
"tab selector id did not match a visible tab",
)
}),
Some(TabTarget::Title { title }) => {
let matching = workspace
.tab_views()
.enumerate()
.filter_map(|(index, pane_group)| {
(pane_group.as_ref(ctx).display_title(ctx) == *title).then_some(index)
})
.collect::<Vec<_>>();
match matching.as_slice() {
[index] => Ok(*index),
[] => Err(ControlError::new(
ErrorCode::MissingTarget,
"tab selector title did not match a visible tab",
)),
_ => Err(ControlError::new(
ErrorCode::AmbiguousTarget,
"tab selector title matched multiple visible tabs",
)),
}
}
}
}
pub(crate) fn input_target_pane_id(
action: ActionKind,
target: &TargetSelector,
pane_group: &ViewHandle<PaneGroup>,
ctx: &AppContext,
) -> Result<PaneId, ControlError> {
if target.session.is_some() {
return target_session_pane_id(action, target, pane_group, ctx);
}
if target.pane.is_some() {
return target_pane_id(action, target, pane_group, ctx);
}
pane_group
.read(ctx, |pane_group, ctx| {
pane_group.active_session_id(ctx).map(PaneId::from)
})
.ok_or_else(|| {
ControlError::new(
ErrorCode::MissingTarget,
format!("{} requires an active terminal session", action.as_str()),
)
})
}
pub(crate) fn target_session_pane_id(
action: ActionKind,
target: &TargetSelector,
pane_group: &ViewHandle<PaneGroup>,
ctx: &AppContext,
) -> Result<PaneId, ControlError> {
if target.session.is_none() && target.pane.is_some() {
let pane_id = target_pane_id(action, target, pane_group, ctx)?;
let has_terminal = pane_group.read(ctx, |pane_group, ctx| {
pane_group
.terminal_view_from_pane_id(pane_id, ctx)
.is_some()
});
if has_terminal {
return Ok(pane_id);
}
return Err(ControlError::new(
ErrorCode::MissingTarget,
format!("{} requires a terminal session target", action.as_str()),
));
}
let session_pane_id =
pane_group.read(ctx, |pane_group, ctx| match target.session.as_ref() {
None | Some(SessionTarget::Active) => pane_group
.active_session_id(ctx)
.map(PaneId::from)
.ok_or_else(|| {
ControlError::new(
ErrorCode::MissingTarget,
format!("{} requires an active terminal session", action.as_str()),
)
}),
Some(SessionTarget::Id { id }) => pane_group
.visible_pane_ids()
.into_iter()
.find(|pane_id| pane_id.to_string() == id.0)
.filter(|pane_id| {
pane_group
.terminal_view_from_pane_id(*pane_id, ctx)
.is_some()
})
.ok_or_else(|| {
ControlError::new(
ErrorCode::StaleTarget,
format!(
"{} cannot resolve the requested session id",
action.as_str()
),
)
}),
})?;
if target.pane.is_some() {
let pane_id = target_pane_id(action, target, pane_group, ctx)?;
if pane_id != session_pane_id {
return Err(ControlError::new(
ErrorCode::TargetStateConflict,
format!(
"{} pane and session selectors resolve different targets",
action.as_str()
),
));
}
}
Ok(session_pane_id)
}