Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,6 @@
*.icns binary
*.woff binary
*.woff2 binary

# Preserve packaged upstream sources exactly, including their whitespace.
vendor/tachys/** whitespace=-blank-at-eol
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,9 @@ jobs:
- name: Check macOS GPU-only architecture
run: ./scripts/check-macos-gpu-only.sh

- name: Refuse new raw HTML sinks
run: ./scripts/check-ui-html-sinks.sh

- name: Test local build fabric
run: |
./scripts/tests/cargo-cache-build-tests.sh
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[workspace]
members = ["crates/*"]
exclude = ["crates/hypercolor-ui"]
exclude = ["crates/hypercolor-ui", "vendor/tachys"]
resolver = "3"

[patch.crates-io]
Expand Down
66 changes: 60 additions & 6 deletions crates/hypercolor-daemon/src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,13 +141,11 @@ impl PreparedDaemon {
self.options.macos_owner_snapshot,
self.options.service_status.take(),
)?;
let ui_dir = resolve_ui_dir(self.options.ui_dir.clone());
daemon_state.session_monitors = self.options.session_monitors.take();
for installer in extension_installers {
installer.install(&mut daemon_state)?;
}
install_extensions(&mut daemon_state, ui_dir.clone(), extension_installers)?;
Box::pin(daemon_state.start()).await?;

let ui_dir = resolve_ui_dir(self.options.ui_dir.clone());
let app_state = Arc::new(api::build_state(
&daemon_state,
macos_daemon_session_attestation.as_ref(),
Expand Down Expand Up @@ -195,6 +193,18 @@ impl PreparedDaemon {
}
}

fn install_extensions(
daemon: &mut DaemonState,
ui_dir: Option<PathBuf>,
extension_installers: &[&dyn DaemonExtensionInstaller],
) -> Result<()> {
daemon.ui_dir = ui_dir;
for installer in extension_installers {
installer.install(daemon)?;
}
Ok(())
}

pub trait DaemonExtensionInstaller: Send + Sync {
/// Install extension state, API routes, and lifecycle hooks before startup.
///
Expand Down Expand Up @@ -856,8 +866,9 @@ mod tests {
use hypercolor_types::config::{HypercolorConfig, LogLevel, RenderAccelerationMode};

use super::{
bind_api_listener, bind_api_listener_with_lease, default_env_filter,
notify_api_ready_extensions, resolve_log_level, serve_api_listeners_with_shutdown_timeout,
DaemonExtensionInstaller, bind_api_listener, bind_api_listener_with_lease,
default_env_filter, install_extensions, notify_api_ready_extensions, resolve_log_level,
serve_api_listeners_with_shutdown_timeout,
};
use crate::app_state::AppState;
use crate::extensions::DaemonLifecycleExtension;
Expand All @@ -884,6 +895,19 @@ mod tests {
calls: Arc<Mutex<Vec<&'static str>>>,
}

struct UiDirProbe(Arc<Mutex<Option<std::path::PathBuf>>>);

impl DaemonExtensionInstaller for UiDirProbe {
fn install(&self, daemon: &mut DaemonState) -> anyhow::Result<()> {
*self
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) =
daemon.ui_dir().map(std::path::Path::to_path_buf);
Ok(())
}
}

#[async_trait::async_trait]
impl DaemonLifecycleExtension for ApiReadyProbe {
fn name(&self) -> &'static str {
Expand Down Expand Up @@ -1018,4 +1042,34 @@ mod tests {
["first", "second"]
);
}

#[tokio::test]
async fn extension_installers_observe_the_same_resolved_ui_directory_as_the_router() {
let directory = tempfile::tempdir().expect("daemon test directory should be created");
let _data_dir = DataDirOverride::install(directory.path().join("data"));
let mut config = default_config();
config.effect_engine.compositor_acceleration_mode = RenderAccelerationMode::Cpu;
let config_manager = Arc::new(ConfigManager::from_config_unchecked(
directory.path().join("hypercolor.toml"),
config.clone(),
));
let mut daemon =
DaemonState::initialize(BootConfig::from_config_unchecked(config), config_manager)
.expect("daemon test state should initialize");
let observed = Arc::new(Mutex::new(None));
let probe = UiDirProbe(Arc::clone(&observed));
let ui_dir = directory.path().join("ui");

install_extensions(&mut daemon, Some(ui_dir.clone()), &[&probe])
.expect("extension installation should succeed");

assert_eq!(daemon.ui_dir(), Some(ui_dir.as_path()));
assert_eq!(
observed
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_deref(),
Some(ui_dir.as_path())
);
}
}
10 changes: 10 additions & 0 deletions crates/hypercolor-daemon/src/startup/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ pub(crate) async fn persist_scene_store_snapshot(
/// The domain graph is the primary transport-facing surface. Raw authorities
/// stay private when their pointer identity must remain fixed after assembly.
pub struct DaemonState {
/// Resolved directory served by the local UI router, when present.
/// Extensions may inspect the same path before their install/start hooks.
pub(crate) ui_dir: Option<PathBuf>,

/// Complete domain service graph shared by every transport.
pub domains: DomainContexts,

Expand Down Expand Up @@ -295,6 +299,12 @@ pub struct DaemonState {
}

impl DaemonState {
/// Resolved directory served by the local UI router.
#[must_use]
pub fn ui_dir(&self) -> Option<&std::path::Path> {
self.ui_dir.as_deref()
}

#[doc(hidden)]
#[must_use]
pub const fn input_manager(&self) -> &InputManager {
Expand Down
1 change: 1 addition & 0 deletions crates/hypercolor-daemon/src/startup/services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,7 @@ impl DaemonState {
info!("Device backends registered");

Ok(Self {
ui_dir: None,
domains,
config_manager,
extensions: ExtensionRegistry::default(),
Expand Down
5 changes: 3 additions & 2 deletions crates/hypercolor-ui/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions crates/hypercolor-ui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ gloo-net = { version = "0.7", features = ["http"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
futures-util = "0.3"
wasm-streams = "0.5"
bytes = "1.11"
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0", features = ["raw_value"] }
Expand All @@ -31,6 +33,7 @@ hypercolor-leptos-ext = { path = "../hypercolor-leptos-ext", default-features =
uuid = { version = "1.11", features = ["js"] } # enable WASM RNG for transitive uuid
leptos_icons = "0.7"
icondata = { version = "0.7", default-features = false, features = ["lucide"] }
tachys = "=0.2.18"
icondata_core = "0.1"
strum = { version = "0.28", features = ["derive"] }
leptoaster = { version = "0.2", features = ["csr"] }
Expand Down Expand Up @@ -112,3 +115,6 @@ opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"

[patch.crates-io]
tachys = { path = "../../vendor/tachys" }
6 changes: 0 additions & 6 deletions crates/hypercolor-ui/input.css
Original file line number Diff line number Diff line change
Expand Up @@ -1011,12 +1011,6 @@ input[type="range"].slider-silk::-moz-range-track {
contain-intrinsic-size: 300px 225px;
}

.vendor-mark-svg > svg {
display: block;
width: 100%;
height: 100%;
}

/* Accent edge glow — for active/important panels. Pairs with one of
the `.accent-*` classes below, which set `--glow-rgb` to the right
SilkCircuit palette value. Without an accent class the default is
Expand Down
24 changes: 24 additions & 0 deletions crates/hypercolor-ui/src/api/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ pub fn install_http_transport(
#[derive(Clone, PartialEq, Eq)]
struct DaemonTransport {
native_app: bool,
remote: bool,
base_url: Option<String>,
protected_control_credential: Option<String>,
}
Expand All @@ -78,6 +79,7 @@ impl Default for DaemonTransport {
fn default() -> Self {
Self {
native_app: false,
remote: false,
base_url: None,
protected_control_credential: fragment_dev_control_credential(),
}
Expand Down Expand Up @@ -115,6 +117,11 @@ fn fragment_dev_control_credential() -> Option<String> {

impl DaemonTransport {
fn resolve_url(&self, url: &str) -> Option<String> {
if self.remote {
return self.base_url.as_ref().and_then(|base| {
crate::remote_bridge::resolve_remote_api_url_from_base(base, url)
});
}
if !url.starts_with('/') {
return Some(url.to_owned());
}
Expand Down Expand Up @@ -348,6 +355,7 @@ pub fn save_api_key(api_key: &str) {
pub fn begin_native_daemon_verification() {
DAEMON_TRANSPORT.with_borrow_mut(|transport| {
transport.native_app = true;
transport.remote = false;
transport.base_url = None;
transport.protected_control_credential = None;
});
Expand All @@ -361,6 +369,7 @@ pub fn install_verified_daemon_connection(base_url: &str, credential: Option<&st
.filter(|credential| !credential.is_empty());
DAEMON_TRANSPORT.with_borrow_mut(|transport| {
transport.native_app = true;
transport.remote = false;
transport.base_url = (!base_url.is_empty()).then(|| base_url.to_owned());
transport.protected_control_credential = credential.map(str::to_owned);
});
Expand All @@ -370,6 +379,19 @@ pub fn install_verified_daemon_connection(base_url: &str, credential: Option<&st
pub fn clear_verified_daemon_connection() {
DAEMON_TRANSPORT.with_borrow_mut(|transport| {
transport.base_url = None;
transport.remote = false;
transport.protected_control_credential = None;
});
}

/// Install the host-provided Remote daemon route. Remote mode accepts only
/// `/api/v1` paths and never carries a local bearer credential.
#[cfg(target_arch = "wasm32")]
pub(crate) fn install_remote_daemon_connection(base_url: &str) {
DAEMON_TRANSPORT.with_borrow_mut(|transport| {
transport.native_app = false;
transport.remote = true;
transport.base_url = Some(base_url.trim_end_matches('/').to_owned());
transport.protected_control_credential = None;
});
}
Expand Down Expand Up @@ -890,6 +912,7 @@ mod tests {
fn native_transport_routes_relative_urls_and_preserves_absolute_urls() {
let transport = DaemonTransport {
native_app: true,
remote: false,
base_url: Some("http://127.0.0.1:9420".to_owned()),
protected_control_credential: None,
};
Expand Down Expand Up @@ -931,6 +954,7 @@ mod tests {
fn verified_credential_precedes_public_key_and_clears_without_persistence() {
let transport = DaemonTransport {
native_app: true,
remote: false,
base_url: None,
protected_control_credential: Some("protected".to_owned()),
};
Expand Down
13 changes: 7 additions & 6 deletions crates/hypercolor-ui/src/components/attachment_panel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use crate::async_helpers::spawn_identify;
use crate::channel_names;
use crate::components::attachment_editor;
use crate::components::component_picker::ComponentPicker;
use crate::components::device_card::topology_shape_svg;
use crate::components::device_card::{TopologyShape, topology_shape_kind};
use crate::icons::*;
use crate::layout_geometry;
use crate::layout_utils::channel_name_matches_slot_alias;
Expand Down Expand Up @@ -159,9 +159,9 @@ pub fn WiringPanel(
)
})
.cloned();
let zone_svg = zone_match.as_ref()
.map(|z| topology_shape_svg(&z.topology))
.unwrap_or_else(|| topology_shape_svg("strip"));
let zone_shape = zone_match.as_ref()
.map(|z| topology_shape_kind(&z.topology))
.unwrap_or_else(|| topology_shape_kind("strip"));
let zone_id = zone_match.as_ref().map(|z| z.id.clone());

// Channel name: localStorage → layout zone name → driver default
Expand Down Expand Up @@ -243,8 +243,9 @@ pub fn WiringPanel(
"color: rgba({accent}, 0.95); \
background: rgba({accent}, 0.08); \
box-shadow: inset 0 0 8px rgba({accent}, 0.12)"
)
inner_html=format!(r#"<svg viewBox="0 0 16 16" width="14" height="14">{zone_svg}</svg>"#) />
)>
<TopologyShape kind=zone_shape size=14 />
</div>


// Editable name
Expand Down
Loading
Loading