fix(desktop): preserve instance URL queries and host matching (#472)
* fix(desktop): preserve instance URL queries and correct host matching * chore(desktop): reduce unrelated rustfmt churn * test(desktop): replace personal host fixtures with example.com
This commit is contained in:
@@ -232,6 +232,8 @@
|
|||||||
"openchamber": "./bin/cli.js",
|
"openchamber": "./bin/cli.js",
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/lang-cpp": "^6.0.3",
|
||||||
|
"@codemirror/lang-go": "^6.0.1",
|
||||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||||
"@ibm/plex": "^6.4.1",
|
"@ibm/plex": "^6.4.1",
|
||||||
|
|||||||
@@ -1082,9 +1082,37 @@ fn normalize_host_url(raw: &str) -> Option<String> {
|
|||||||
normalized.push(':');
|
normalized.push(':');
|
||||||
normalized.push_str(&port.to_string());
|
normalized.push_str(&port.to_string());
|
||||||
}
|
}
|
||||||
|
let path = parsed.path();
|
||||||
|
if path.is_empty() {
|
||||||
|
normalized.push('/');
|
||||||
|
} else {
|
||||||
|
normalized.push_str(path);
|
||||||
|
}
|
||||||
|
if let Some(query) = parsed.query() {
|
||||||
|
normalized.push('?');
|
||||||
|
normalized.push_str(query);
|
||||||
|
}
|
||||||
Some(normalized)
|
Some(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sanitize_host_url_for_storage(raw: &str) -> Option<String> {
|
||||||
|
normalize_host_url(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_health_url(base_url: &str) -> Option<String> {
|
||||||
|
let normalized = normalize_host_url(base_url)?;
|
||||||
|
let mut parsed = url::Url::parse(&normalized).ok()?;
|
||||||
|
let current_path = parsed.path();
|
||||||
|
let trimmed_path = current_path.trim_end_matches('/');
|
||||||
|
let health_path = if trimmed_path.is_empty() {
|
||||||
|
"/health".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{trimmed_path}/health")
|
||||||
|
};
|
||||||
|
parsed.set_path(&health_path);
|
||||||
|
Some(parsed.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
fn settings_file_path() -> PathBuf {
|
fn settings_file_path() -> PathBuf {
|
||||||
if let Ok(dir) = env::var("OPENCHAMBER_DATA_DIR") {
|
if let Ok(dir) = env::var("OPENCHAMBER_DATA_DIR") {
|
||||||
if !dir.trim().is_empty() {
|
if !dir.trim().is_empty() {
|
||||||
@@ -1134,7 +1162,10 @@ fn write_desktop_local_port_to_disk(port: u16) -> Result<()> {
|
|||||||
|
|
||||||
|
|
||||||
fn read_desktop_hosts_config_from_disk() -> DesktopHostsConfig {
|
fn read_desktop_hosts_config_from_disk() -> DesktopHostsConfig {
|
||||||
let path = settings_file_path();
|
read_desktop_hosts_config_from_path(&settings_file_path())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_desktop_hosts_config_from_path(path: &Path) -> DesktopHostsConfig {
|
||||||
let raw = fs::read_to_string(path).ok();
|
let raw = fs::read_to_string(path).ok();
|
||||||
let parsed = raw
|
let parsed = raw
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -1158,7 +1189,7 @@ fn read_desktop_hosts_config_from_disk() -> DesktopHostsConfig {
|
|||||||
if host.id.trim().is_empty() || host.id == LOCAL_HOST_ID {
|
if host.id.trim().is_empty() || host.id == LOCAL_HOST_ID {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(url) = normalize_host_url(&host.url) {
|
if let Some(url) = sanitize_host_url_for_storage(&host.url) {
|
||||||
hosts.push(DesktopHost {
|
hosts.push(DesktopHost {
|
||||||
id: host.id,
|
id: host.id,
|
||||||
label: if host.label.trim().is_empty() {
|
label: if host.label.trim().is_empty() {
|
||||||
@@ -1215,7 +1246,10 @@ fn write_desktop_window_state_to_disk(state: &DesktopWindowState) -> Result<()>
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn write_desktop_hosts_config_to_disk(config: &DesktopHostsConfig) -> Result<()> {
|
fn write_desktop_hosts_config_to_disk(config: &DesktopHostsConfig) -> Result<()> {
|
||||||
let path = settings_file_path();
|
write_desktop_hosts_config_to_path(&settings_file_path(), config)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_desktop_hosts_config_to_path(path: &Path, config: &DesktopHostsConfig) -> Result<()> {
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent)?;
|
fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
@@ -1238,7 +1272,7 @@ fn write_desktop_hosts_config_to_disk(config: &DesktopHostsConfig) -> Result<()>
|
|||||||
if id.is_empty() || id == LOCAL_HOST_ID {
|
if id.is_empty() || id == LOCAL_HOST_ID {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let url = normalize_host_url(&h.url)?;
|
let url = sanitize_host_url_for_storage(&h.url)?;
|
||||||
Some(DesktopHost {
|
Some(DesktopHost {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
label: if h.label.trim().is_empty() {
|
label: if h.label.trim().is_empty() {
|
||||||
@@ -1281,8 +1315,7 @@ struct HostProbeResult {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn desktop_host_probe(url: String) -> Result<HostProbeResult, String> {
|
async fn desktop_host_probe(url: String) -> Result<HostProbeResult, String> {
|
||||||
let normalized = normalize_host_url(&url).ok_or_else(|| "Invalid URL".to_string())?;
|
let health = build_health_url(&url).ok_or_else(|| "Invalid URL".to_string())?;
|
||||||
let health = format!("{}/health", normalized.trim_end_matches('/'));
|
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.no_proxy()
|
.no_proxy()
|
||||||
.timeout(Duration::from_secs(2))
|
.timeout(Duration::from_secs(2))
|
||||||
@@ -1782,21 +1815,7 @@ fn resolve_web_dist_dir(app: &tauri::AppHandle) -> Result<PathBuf> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_server_url(input: &str) -> Option<String> {
|
fn normalize_server_url(input: &str) -> Option<String> {
|
||||||
let trimmed = input.trim();
|
normalize_host_url(input)
|
||||||
if trimmed.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
match url::Url::parse(trimmed) {
|
|
||||||
Ok(url) => {
|
|
||||||
if url.scheme() == "http" || url.scheme() == "https" {
|
|
||||||
Some(trimmed.trim_end_matches('/').to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -2690,3 +2709,55 @@ fn main() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
fn unique_settings_path(test_name: &str) -> PathBuf {
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("clock drift")
|
||||||
|
.as_nanos();
|
||||||
|
std::env::temp_dir().join(format!("openchamber-{test_name}-{nanos}-settings.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_host_url_for_storage_keeps_query_params() {
|
||||||
|
let input = "https://example.com?coder_session_token=xxxxxx";
|
||||||
|
let sanitized = sanitize_host_url_for_storage(input).expect("sanitized url");
|
||||||
|
assert_eq!(sanitized, "https://example.com/?coder_session_token=xxxxxx");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_host_url_for_storage_strips_fragment_and_keeps_query() {
|
||||||
|
let input = "https://example.com/workspace?coder_session_token=xxxxxx#ignored";
|
||||||
|
let sanitized = sanitize_host_url_for_storage(input).expect("sanitized url");
|
||||||
|
assert_eq!(sanitized, "https://example.com/workspace?coder_session_token=xxxxxx");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_and_read_hosts_config_preserves_query_params() {
|
||||||
|
let path = unique_settings_path("desktop-hosts-query");
|
||||||
|
let config = DesktopHostsConfig {
|
||||||
|
hosts: vec![DesktopHost {
|
||||||
|
id: "remote-1".to_string(),
|
||||||
|
label: "Remote".to_string(),
|
||||||
|
url: "https://example.com?coder_session_token=xxxxxx".to_string(),
|
||||||
|
}],
|
||||||
|
default_host_id: Some("remote-1".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
write_desktop_hosts_config_to_path(&path, &config).expect("write config");
|
||||||
|
let read_back = read_desktop_hosts_config_from_path(&path);
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
|
assert_eq!(read_back.hosts.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
read_back.hosts[0].url,
|
||||||
|
"https://example.com/?coder_session_token=xxxxxx"
|
||||||
|
);
|
||||||
|
assert_eq!(read_back.default_host_id.as_deref(), Some("remote-1"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ import {
|
|||||||
desktopHostsGet,
|
desktopHostsGet,
|
||||||
desktopHostsSet,
|
desktopHostsSet,
|
||||||
desktopOpenNewWindowAtUrl,
|
desktopOpenNewWindowAtUrl,
|
||||||
|
locationMatchesHost,
|
||||||
|
normalizeHostUrl,
|
||||||
|
redactSensitiveUrl,
|
||||||
type DesktopHost,
|
type DesktopHost,
|
||||||
type HostProbeResult,
|
type HostProbeResult,
|
||||||
} from '@/lib/desktopHosts';
|
} from '@/lib/desktopHosts';
|
||||||
@@ -50,33 +53,21 @@ type HostStatus = {
|
|||||||
latencyMs: number;
|
latencyMs: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeHostUrl = (raw: string): string | null => {
|
const toNavigationUrl = (rawUrl: string): string => {
|
||||||
const trimmed = raw.trim();
|
const normalized = normalizeHostUrl(rawUrl);
|
||||||
if (!trimmed) return null;
|
if (!normalized) {
|
||||||
try {
|
return rawUrl.trim();
|
||||||
const url = new URL(trimmed);
|
|
||||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return url.origin;
|
|
||||||
} catch {
|
|
||||||
// Tauri/WebKit edge: accept origin without trailing slash.
|
|
||||||
try {
|
|
||||||
const url = new URL(trimmed.endsWith('/') ? trimmed : `${trimmed}/`);
|
|
||||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return url.origin;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const toNavigationUrl = (origin: string): string => {
|
try {
|
||||||
const trimmed = origin.trim();
|
const url = new URL(normalized);
|
||||||
if (!trimmed) return trimmed;
|
if (!url.pathname.endsWith('/')) {
|
||||||
return trimmed.endsWith('/') ? trimmed : `${trimmed}/`;
|
url.pathname = `${url.pathname}/`;
|
||||||
|
}
|
||||||
|
return url.toString();
|
||||||
|
} catch {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getLocalOrigin = (): string => {
|
const getLocalOrigin = (): string => {
|
||||||
@@ -119,18 +110,17 @@ const buildLocalHost = (): DesktopHost => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const resolveCurrentHost = (hosts: DesktopHost[]) => {
|
const resolveCurrentHost = (hosts: DesktopHost[]) => {
|
||||||
const currentOrigin = typeof window === 'undefined' ? '' : window.location.origin;
|
const currentHref = typeof window === 'undefined' ? '' : window.location.href;
|
||||||
const localOrigin = getLocalOrigin();
|
const localOrigin = getLocalOrigin();
|
||||||
const normalizedCurrent = normalizeHostUrl(currentOrigin) || currentOrigin;
|
|
||||||
const normalizedLocal = normalizeHostUrl(localOrigin) || localOrigin;
|
const normalizedLocal = normalizeHostUrl(localOrigin) || localOrigin;
|
||||||
|
const normalizedCurrent = normalizeHostUrl(currentHref) || currentHref;
|
||||||
|
|
||||||
if (normalizedCurrent && normalizedLocal && normalizedCurrent === normalizedLocal) {
|
if (currentHref && locationMatchesHost(currentHref, localOrigin)) {
|
||||||
return { id: LOCAL_HOST_ID, label: 'Local', url: normalizedLocal };
|
return { id: LOCAL_HOST_ID, label: 'Local', url: normalizedLocal };
|
||||||
}
|
}
|
||||||
|
|
||||||
const match = hosts.find((h) => {
|
const match = hosts.find((h) => {
|
||||||
const normalized = normalizeHostUrl(h.url);
|
return currentHref ? locationMatchesHost(currentHref, h.url) : false;
|
||||||
return normalized && normalized === normalizedCurrent;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (match) {
|
if (match) {
|
||||||
@@ -139,7 +129,7 @@ const resolveCurrentHost = (hosts: DesktopHost[]) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: 'custom',
|
id: 'custom',
|
||||||
label: normalizedCurrent || 'Instance',
|
label: redactSensitiveUrl(normalizedCurrent || 'Instance'),
|
||||||
url: normalizedCurrent,
|
url: normalizedCurrent,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -279,7 +269,7 @@ export function DesktopHostSwitcherDialog({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
if (probe.status === 'unreachable') {
|
if (probe.status === 'unreachable') {
|
||||||
toast.error(`Instance "${host.label}" is unreachable`);
|
toast.error(`Instance "${redactSensitiveUrl(host.label)}" is unreachable`);
|
||||||
setSwitchingHostId(null);
|
setSwitchingHostId(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -321,7 +311,7 @@ export function DesktopHostSwitcherDialog({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const label = (editLabel || url).trim();
|
const label = (editLabel || redactSensitiveUrl(url)).trim();
|
||||||
const nextHosts = configHosts.map((h) => (h.id === editingId ? { ...h, label, url } : h));
|
const nextHosts = configHosts.map((h) => (h.id === editingId ? { ...h, label, url } : h));
|
||||||
await persist(nextHosts, defaultHostId);
|
await persist(nextHosts, defaultHostId);
|
||||||
cancelEdit();
|
cancelEdit();
|
||||||
@@ -333,7 +323,7 @@ export function DesktopHostSwitcherDialog({
|
|||||||
setError('Invalid URL (must be http/https)');
|
setError('Invalid URL (must be http/https)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const label = (newLabel || url).trim();
|
const label = (newLabel || redactSensitiveUrl(url)).trim();
|
||||||
const id = makeId();
|
const id = makeId();
|
||||||
|
|
||||||
const nextHosts = [{ id, label, url }, ...configHosts];
|
const nextHosts = [{ id, label, url }, ...configHosts];
|
||||||
@@ -381,10 +371,10 @@ export function DesktopHostSwitcherDialog({
|
|||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="min-w-0 flex items-center gap-2">
|
<div className="min-w-0 flex items-center gap-2">
|
||||||
<span className="typography-ui-header font-semibold text-foreground">Current</span>
|
<span className="typography-ui-header font-semibold text-foreground">Current</span>
|
||||||
<span className="max-w-[9rem] truncate typography-ui-label text-muted-foreground">{current.label}</span>
|
<span className="max-w-[9rem] truncate typography-ui-label text-muted-foreground">{redactSensitiveUrl(current.label)}</span>
|
||||||
<span className="text-muted-foreground">•</span>
|
<span className="text-muted-foreground">•</span>
|
||||||
<span className="typography-ui-header font-semibold text-foreground">Default</span>
|
<span className="typography-ui-header font-semibold text-foreground">Default</span>
|
||||||
<span className="max-w-[9rem] truncate typography-ui-label text-muted-foreground">{currentDefaultLabel}</span>
|
<span className="max-w-[9rem] truncate typography-ui-label text-muted-foreground">{redactSensitiveUrl(currentDefaultLabel)}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -417,9 +407,9 @@ export function DesktopHostSwitcherDialog({
|
|||||||
<div className="flex items-center justify-between gap-2 flex-shrink-0">
|
<div className="flex items-center justify-between gap-2 flex-shrink-0">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className="typography-meta text-muted-foreground">Current:</span>
|
<span className="typography-meta text-muted-foreground">Current:</span>
|
||||||
<span className="typography-ui-label text-foreground truncate">{current.label}</span>
|
<span className="typography-ui-label text-foreground truncate">{redactSensitiveUrl(current.label)}</span>
|
||||||
<span className="typography-meta text-muted-foreground">Current default:</span>
|
<span className="typography-meta text-muted-foreground">Current default:</span>
|
||||||
<span className="typography-ui-label text-foreground truncate">{currentDefaultLabel}</span>
|
<span className="typography-ui-label text-foreground truncate">{redactSensitiveUrl(currentDefaultLabel)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
@@ -456,6 +446,8 @@ export function DesktopHostSwitcherDialog({
|
|||||||
const status = statusById[host.id] || null;
|
const status = statusById[host.id] || null;
|
||||||
const isEditing = editingId === host.id;
|
const isEditing = editingId === host.id;
|
||||||
const effectiveUrl = isLocal ? getLocalOrigin() : (normalizeHostUrl(host.url) || host.url);
|
const effectiveUrl = isLocal ? getLocalOrigin() : (normalizeHostUrl(host.url) || host.url);
|
||||||
|
const displayLabel = redactSensitiveUrl(host.label);
|
||||||
|
const displayUrl = redactSensitiveUrl(effectiveUrl);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -473,13 +465,13 @@ export function DesktopHostSwitcherDialog({
|
|||||||
)}
|
)}
|
||||||
onClick={() => void handleSwitch(host)}
|
onClick={() => void handleSwitch(host)}
|
||||||
disabled={switchingHostId === host.id}
|
disabled={switchingHostId === host.id}
|
||||||
aria-label={`Switch to ${host.label}`}
|
aria-label={`Switch to ${displayLabel}`}
|
||||||
>
|
>
|
||||||
<span className={cn('h-2 w-2 rounded-full flex-shrink-0', statusDotClass(status?.status ?? null))} />
|
<span className={cn('h-2 w-2 rounded-full flex-shrink-0', statusDotClass(status?.status ?? null))} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className={cn('typography-ui-label truncate', isActive ? 'text-foreground' : 'text-foreground')}>
|
<span className={cn('typography-ui-label truncate', isActive ? 'text-foreground' : 'text-foreground')}>
|
||||||
{host.label}
|
{displayLabel}
|
||||||
</span>
|
</span>
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<span className="typography-micro text-muted-foreground">Current</span>
|
<span className="typography-micro text-muted-foreground">Current</span>
|
||||||
@@ -493,7 +485,7 @@ export function DesktopHostSwitcherDialog({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="typography-micro text-muted-foreground truncate font-mono">
|
<div className="typography-micro text-muted-foreground truncate font-mono">
|
||||||
{effectiveUrl}
|
{displayUrl}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -734,7 +726,7 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost
|
|||||||
const all = [local, ...(cfg.hosts || [])];
|
const all = [local, ...(cfg.hosts || [])];
|
||||||
const current = resolveCurrentHost(all);
|
const current = resolveCurrentHost(all);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setLabel(current.label || 'Instance');
|
setLabel(redactSensitiveUrl(current.label || 'Instance'));
|
||||||
const normalized = normalizeHostUrl(current.url);
|
const normalized = normalizeHostUrl(current.url);
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
setStatus(null);
|
setStatus(null);
|
||||||
@@ -767,9 +759,7 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost
|
|||||||
|
|
||||||
const isCurrentlyLocal = (() => {
|
const isCurrentlyLocal = (() => {
|
||||||
try {
|
try {
|
||||||
const current = normalizeHostUrl(window.location.origin);
|
return locationMatchesHost(window.location.href, getLocalOrigin());
|
||||||
const local = normalizeHostUrl(getLocalOrigin());
|
|
||||||
return Boolean(current && local && current === local);
|
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -790,6 +780,7 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost
|
|||||||
: label === 'Local'
|
: label === 'Local'
|
||||||
? fallbackLabel
|
? fallbackLabel
|
||||||
: label;
|
: label;
|
||||||
|
const safeEffectiveLabel = redactSensitiveUrl(effectiveLabel);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -804,7 +795,7 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost
|
|||||||
>
|
>
|
||||||
<RiServerLine className="h-5 w-5" />
|
<RiServerLine className="h-5 w-5" />
|
||||||
<span className="hidden sm:inline typography-ui-label font-medium text-muted-foreground truncate max-w-[11rem]">
|
<span className="hidden sm:inline typography-ui-label font-medium text-muted-foreground truncate max-w-[11rem]">
|
||||||
{effectiveLabel}
|
{safeEffectiveLabel}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ import { DesktopHostSwitcherDialog } from '@/components/desktop/DesktopHostSwitc
|
|||||||
import { OpenInAppButton } from '@/components/desktop/OpenInAppButton';
|
import { OpenInAppButton } from '@/components/desktop/OpenInAppButton';
|
||||||
import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell, isVSCodeRuntime } from '@/lib/desktop';
|
import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell, isVSCodeRuntime } from '@/lib/desktop';
|
||||||
import { sessionEvents } from '@/lib/sessionEvents';
|
import { sessionEvents } from '@/lib/sessionEvents';
|
||||||
import { desktopHostsGet } from '@/lib/desktopHosts';
|
import { desktopHostsGet, locationMatchesHost, redactSensitiveUrl } from '@/lib/desktopHosts';
|
||||||
import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
|
import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
|
||||||
import { GridLoader } from '@/components/ui/grid-loader';
|
import { GridLoader } from '@/components/ui/grid-loader';
|
||||||
import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP } from '@/lib/projectMeta';
|
import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP } from '@/lib/projectMeta';
|
||||||
@@ -539,47 +539,22 @@ export const Header: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeHostUrl = (raw: string): string | null => {
|
|
||||||
const trimmed = raw.trim();
|
|
||||||
if (!trimmed) return null;
|
|
||||||
try {
|
|
||||||
const url = new URL(trimmed);
|
|
||||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return url.origin;
|
|
||||||
} catch {
|
|
||||||
try {
|
|
||||||
const url = new URL(trimmed.endsWith('/') ? trimmed : `${trimmed}/`);
|
|
||||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return url.origin;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cfg = await desktopHostsGet();
|
const cfg = await desktopHostsGet();
|
||||||
const currentOrigin = window.location.origin;
|
const currentHref = window.location.href;
|
||||||
const localOrigin = window.__OPENCHAMBER_LOCAL_ORIGIN__ || currentOrigin;
|
const localOrigin = window.__OPENCHAMBER_LOCAL_ORIGIN__ || window.location.origin;
|
||||||
const normalizedCurrent = normalizeHostUrl(currentOrigin) || currentOrigin;
|
|
||||||
const normalizedLocal = normalizeHostUrl(localOrigin) || localOrigin;
|
|
||||||
|
|
||||||
if (normalizedCurrent && normalizedLocal && normalizedCurrent === normalizedLocal) {
|
if (locationMatchesHost(currentHref, localOrigin)) {
|
||||||
setCurrentInstanceLabel('Local');
|
setCurrentInstanceLabel('Local');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const match = cfg.hosts.find((host) => {
|
const match = cfg.hosts.find((host) => {
|
||||||
const normalized = normalizeHostUrl(host.url);
|
return locationMatchesHost(currentHref, host.url);
|
||||||
return normalized && normalized === normalizedCurrent;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (match?.label?.trim()) {
|
if (match?.label?.trim()) {
|
||||||
setCurrentInstanceLabel(match.label.trim());
|
setCurrentInstanceLabel(redactSensitiveUrl(match.label.trim()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1794,7 +1769,9 @@ export const Header: React.FC = () => {
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
{isDesktopApp ? `Current instance: ${currentInstanceLabel}` : 'Services'} ({shortcutLabel('toggle_services_menu')}; next tab {shortcutLabel('cycle_services_tab')})
|
{isDesktopApp
|
||||||
|
? `Current instance: ${currentInstanceLabel}`
|
||||||
|
: 'Services'} ({shortcutLabel('toggle_services_menu')}; next tab {shortcutLabel('cycle_services_tab')})
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -24,6 +24,71 @@ export type HostProbeResult = {
|
|||||||
latencyMs: number;
|
latencyMs: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SENSITIVE_QUERY_KEY = /token|auth|secret|api/i;
|
||||||
|
|
||||||
|
export const normalizeHostUrl = (raw: string): string | null => {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
try {
|
||||||
|
const url = new URL(trimmed);
|
||||||
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return trimmed.split('#')[0] || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const redactSensitiveUrl = (raw: string): string => {
|
||||||
|
const normalized = normalizeHostUrl(raw);
|
||||||
|
if (!normalized) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(normalized);
|
||||||
|
const keys = Array.from(new Set(Array.from(url.searchParams.keys())));
|
||||||
|
for (const key of keys) {
|
||||||
|
if (SENSITIVE_QUERY_KEY.test(key)) {
|
||||||
|
url.searchParams.set(key, '[REDACTED]');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return url.toString();
|
||||||
|
} catch {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const locationMatchesHost = (locationHref: string, hostUrl: string): boolean => {
|
||||||
|
const normalizedCurrent = normalizeHostUrl(locationHref);
|
||||||
|
const normalizedHost = normalizeHostUrl(hostUrl);
|
||||||
|
if (!normalizedCurrent || !normalizedHost) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const current = new URL(normalizedCurrent);
|
||||||
|
const host = new URL(normalizedHost);
|
||||||
|
if (current.origin !== host.origin) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (host.search && current.search !== host.search) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostPath = host.pathname.length > 1 ? host.pathname.replace(/\/+$/, '') : host.pathname;
|
||||||
|
const currentPath = current.pathname.length > 1 ? current.pathname.replace(/\/+$/, '') : current.pathname;
|
||||||
|
if (hostPath === '/') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return currentPath === hostPath || currentPath.startsWith(`${hostPath}/`);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const isRecord = (value: unknown): value is Record<string, unknown> => {
|
const isRecord = (value: unknown): value is Record<string, unknown> => {
|
||||||
return typeof value === 'object' && value !== null;
|
return typeof value === 'object' && value !== null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
"start": "node bin/cli.js serve"
|
"start": "node bin/cli.js serve"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/lang-cpp": "^6.0.3",
|
||||||
|
"@codemirror/lang-go": "^6.0.1",
|
||||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||||
"@ibm/plex": "^6.4.1",
|
"@ibm/plex": "^6.4.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user