diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0a52eaf..aae913b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -139,6 +139,11 @@ jobs: CFBundleIconFileicon NSHighResolutionCapable LSMinimumSystemVersion11.0 + CFBundleURLTypes + + CFBundleURLNameVoid Deep Link + CFBundleURLSchemesvoid + PLIST mkdir -p dmg-stage @@ -193,6 +198,11 @@ jobs: CFBundleIconFileicon NSHighResolutionCapable LSMinimumSystemVersion11.0 + CFBundleURLTypes + + CFBundleURLNameVoid Deep Link + CFBundleURLSchemesvoid + PLIST mkdir -p dmg-stage diff --git a/assets/void.desktop b/assets/void.desktop new file mode 100644 index 0000000..f245ccd --- /dev/null +++ b/assets/void.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Name=Void Terminal +Comment=Infinite canvas terminal emulator +Exec=void %u +Icon=void +Terminal=false +Type=Application +Categories=System;TerminalEmulator; +MimeType=x-scheme-handler/void; diff --git a/installer/void.nsi b/installer/void.nsi index 3291fa7..497dfa4 100644 --- a/installer/void.nsi +++ b/installer/void.nsi @@ -61,6 +61,12 @@ Section "Install" CreateShortcut "$SMPROGRAMS\Void\Void.lnk" "$INSTDIR\${APP_EXE}" "" "$INSTDIR\void.ico" 0 CreateShortcut "$SMPROGRAMS\Void\Uninstall.lnk" "$INSTDIR\uninstall.exe" + ; Register void:// URL protocol handler for deep-link navigation + WriteRegStr HKCU "Software\Classes\void" "" "URL:Void Protocol" + WriteRegStr HKCU "Software\Classes\void" "URL Protocol" "" + WriteRegStr HKCU "Software\Classes\void\DefaultIcon" "" '"$INSTDIR\void.ico"' + WriteRegStr HKCU "Software\Classes\void\shell\open\command" "" '"$INSTDIR\${APP_EXE}" "%1"' + ; Registry — install path + Add/Remove Programs WriteRegStr HKCU "Software\Void" "InstallDir" "$INSTDIR" WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Void" \ @@ -98,6 +104,7 @@ Section "Uninstall" Delete "$SMPROGRAMS\Void\Uninstall.lnk" RMDir "$SMPROGRAMS\Void" + DeleteRegKey HKCU "Software\Classes\void" DeleteRegKey HKCU "Software\Void" DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Void" SectionEnd diff --git a/src/app.rs b/src/app.rs index 69653ba..cfd4d1a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,6 +4,9 @@ use egui::{Color32, Pos2, Vec2}; use crate::canvas::viewport::Viewport; use crate::command_palette::commands::Command; use crate::command_palette::CommandPalette; +use crate::deeplink::ipc::IpcServer; +use crate::deeplink::toast::Toast; +use crate::deeplink::DeepLink; use crate::sidebar::{Sidebar, SidebarResponse, SIDEBAR_BG, SIDEBAR_BORDER, SIDEBAR_PADDING_H}; use crate::state::workspace::Workspace; use crate::terminal::panel::PanelAction; @@ -35,13 +38,22 @@ pub struct VoidApp { brand_texture: egui::TextureHandle, sidebar: Sidebar, update_checker: UpdateChecker, + // Deep-link navigation + pending_deeplink: Option, + ipc_server: Option, + toast: Option, + navigate_dialog_open: bool, + navigate_buf: String, } impl VoidApp { - pub fn new(cc: &eframe::CreationContext<'_>) -> Self { + pub fn new(cc: &eframe::CreationContext<'_>, url_arg: Option) -> Self { let ctx = cc.egui_ctx.clone(); Self::setup_fonts(&ctx); + // Start IPC server for receiving deep-links from other instances + let ipc_server = IpcServer::start(ctx.clone()); + let brand_texture = { let png = include_bytes!("../assets/brand.png"); let img = image::load_from_memory(png) @@ -106,6 +118,11 @@ impl VoidApp { brand_texture, sidebar: Sidebar::default(), update_checker: UpdateChecker::new(cc.egui_ctx.clone()), + pending_deeplink: url_arg, + ipc_server, + toast: None, + navigate_dialog_open: false, + navigate_buf: String::new(), } } @@ -237,6 +254,29 @@ impl VoidApp { let is_fullscreen = ctx.input(|i| i.viewport().fullscreen.unwrap_or(false)); ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(!is_fullscreen)); } + Command::NavigateToLink => { + self.navigate_dialog_open = true; + self.navigate_buf.clear(); + } + Command::CopyLink => { + // Copy link to focused panel, or viewport position if none focused + let ws_id = self.ws().id; + let url = if let Some(p) = self.ws().panels.iter().find(|p| p.focused()) { + format!("void://open/{}/{}", ws_id, p.id()) + } else { + let center = self.viewport.visible_canvas_rect(screen_rect).center(); + let z = self.viewport.zoom; + format!( + "void://open/{ws_id}/@{:.0},{:.0},{:.2}", + center.x, center.y, z + ) + }; + if let Ok(mut clipboard) = arboard::Clipboard::new() { + let _ = clipboard.set_text(&url); + } + let time = ctx.input(|i| i.time); + self.toast = Some(Toast::new("Link copied to clipboard", 2.0, time)); + } } } @@ -269,6 +309,77 @@ impl VoidApp { ); } + /// Navigate to a deep-link target: workspace, panel, or canvas position. + fn navigate_to_deeplink(&mut self, link: DeepLink, canvas_rect: egui::Rect, time: f64) { + // Find workspace by UUID + let ws_id = match &link { + DeepLink::Workspace { workspace_id } + | DeepLink::Panel { workspace_id, .. } + | DeepLink::Position { workspace_id, .. } => workspace_id.clone(), + }; + + let ws_idx = self + .workspaces + .iter() + .position(|ws| ws.id.to_string() == ws_id); + + let Some(ws_idx) = ws_idx else { + self.toast = Some(Toast::new("Workspace not found", 3.0, time)); + return; + }; + + self.switch_workspace(ws_idx); + + match link { + DeepLink::Workspace { .. } => {} + DeepLink::Panel { panel_id, .. } => { + let panel_pos = self + .ws() + .panels + .iter() + .position(|p| p.id().to_string() == panel_id); + + if let Some(idx) = panel_pos { + let center = self.ws().panels[idx].rect().center(); + self.viewport.pan_to_center(center, canvas_rect); + self.ws_mut().bring_to_front(idx); + } else { + self.toast = Some(Toast::new("Panel not found", 3.0, time)); + } + } + DeepLink::Position { x, y, zoom, .. } => { + self.viewport.pan_to_center(Pos2::new(x, y), canvas_rect); + if let Some(z) = zoom { + self.viewport.zoom = z.clamp( + crate::canvas::config::ZOOM_MIN, + crate::canvas::config::ZOOM_MAX, + ); + } + } + } + } + + /// Process any pending deep-link URL (from CLI arg or IPC). + fn process_pending_deeplinks(&mut self, canvas_rect: egui::Rect, time: f64) { + // Check IPC server for incoming URL from another instance + if let Some(ref server) = self.ipc_server { + if let Some(url) = server.take_pending() { + self.pending_deeplink = Some(url); + } + } + + // Process pending deep-link + if let Some(url) = self.pending_deeplink.take() { + match crate::deeplink::parse(&url) { + Ok(link) => self.navigate_to_deeplink(link, canvas_rect, time), + Err(e) => { + log::warn!("Invalid deep-link URL: {e}"); + self.toast = Some(Toast::new(format!("Invalid link: {e}"), 3.0, time)); + } + } + } + } + fn handle_shortcuts(&mut self, ctx: &egui::Context) -> Option { if self.command_palette.open { return None; @@ -298,6 +409,10 @@ impl VoidApp { cmd = Some(Command::ZoomToFit); } else if i.key_pressed(egui::Key::F2) && !i.modifiers.ctrl { cmd = Some(Command::RenameTerminal); + } else if i.modifiers.ctrl && !i.modifiers.shift && i.key_pressed(egui::Key::L) { + cmd = Some(Command::NavigateToLink); + } else if i.modifiers.ctrl && i.modifiers.shift && i.key_pressed(egui::Key::L) { + cmd = Some(Command::CopyLink); } }); cmd @@ -351,13 +466,18 @@ impl eframe::App for VoidApp { self.execute_command(cmd, ctx, canvas_rect_for_commands); } + // Process pending deep-link navigation (from CLI arg or IPC) + let time = ctx.input(|i| i.time); + self.process_pending_deeplinks(canvas_rect_for_commands, time); + // Sync titles for p in &mut self.ws_mut().panels { p.sync_title(); } // Keyboard input to focused terminal - if !self.command_palette.open && self.renaming_panel.is_none() { + if !self.command_palette.open && self.renaming_panel.is_none() && !self.navigate_dialog_open + { for p in &mut self.ws_mut().panels { if p.focused() { p.handle_input(ctx); @@ -429,6 +549,61 @@ impl eframe::App for VoidApp { } } + // Navigate to Link dialog + if self.navigate_dialog_open { + let mut close = false; + let mut navigate_url: Option = None; + egui::Area::new(egui::Id::new("navigate_dialog")) + .order(egui::Order::Debug) + .fixed_pos(Pos2::new( + screen_rect.center().x - 200.0, + screen_rect.min.y + 120.0, + )) + .show(ctx, |ui| { + egui::Frame::default() + .fill(Color32::from_rgb(20, 20, 20)) + .stroke(egui::Stroke::new(0.5, Color32::from_rgb(40, 40, 40))) + .rounding(8.0) + .inner_margin(14.0) + .show(ui, |ui| { + ui.label( + egui::RichText::new("Navigate to Link") + .color(Color32::from_rgb(160, 160, 160)) + .size(12.0), + ); + ui.add_space(6.0); + let r = ui.add( + egui::TextEdit::singleline(&mut self.navigate_buf) + .desired_width(380.0) + .font(egui::FontId::monospace(12.0)) + .hint_text("void://open/..."), + ); + r.request_focus(); + ui.add_space(6.0); + ui.horizontal(|ui| { + if ui.button("Go").clicked() + || ui.input(|i| i.key_pressed(egui::Key::Enter)) + { + navigate_url = Some(self.navigate_buf.clone()); + close = true; + } + if ui.button("Cancel").clicked() + || ui.input(|i| i.key_pressed(egui::Key::Escape)) + { + close = true; + } + }); + }); + }); + if close { + self.navigate_dialog_open = false; + self.navigate_buf.clear(); + } + if let Some(url) = navigate_url { + self.pending_deeplink = Some(url); + } + } + // --- Sidebar --- if self.sidebar_visible { egui::SidePanel::left("sidebar") @@ -615,7 +790,7 @@ impl eframe::App for VoidApp { .show(ctx, |ui| { ctx.set_transform_layer(ui.layer_id(), transform); ui.set_clip_rect(clip); - ui.allocate_rect(clip, egui::Sense::hover()); + let canvas_bg_resp = ui.allocate_rect(clip, egui::Sense::click()); let mut order: Vec = (0..self.ws().panels.len()).collect(); order.sort_by_key(|&i| self.ws().panels[i].z_index()); @@ -737,6 +912,17 @@ impl eframe::App for VoidApp { self.renaming_panel = Some(self.ws().panels[*idx].id()); self.rename_buf = self.ws().panels[*idx].title().to_string(); } + PanelAction::CopyLink => { + let ws_id = self.ws().id; + let panel_id = self.ws().panels[*idx].id(); + let url = format!("void://open/{ws_id}/{panel_id}"); + if let Ok(mut clipboard) = arboard::Clipboard::new() { + let _ = clipboard.set_text(&url); + } + let time = ctx.input(|i| i.time); + self.toast = + Some(Toast::new("Link copied to clipboard", 2.0, time)); + } } } } @@ -772,6 +958,33 @@ impl eframe::App for VoidApp { } } + // Canvas context menu — Copy Link with coordinates + zoom + // Placed in the content layer (Order::Middle) so it receives right-clicks + // that the background layer (Order::Background) would miss. + canvas_bg_resp.context_menu(|ui| { + if ui.button("Copy Link to Position").clicked() { + let canvas_pos = ui + .input(|i| i.pointer.hover_pos()) + .map(|pos| self.viewport.screen_to_canvas(pos, canvas_rect)) + .unwrap_or_else(|| { + self.viewport.visible_canvas_rect(canvas_rect).center() + }); + let ws_id = self.ws().id; + let z = self.viewport.zoom; + let url = format!( + "void://open/{ws_id}/@{:.0},{:.0},{:.2}", + canvas_pos.x, canvas_pos.y, z + ); + if let Ok(mut clipboard) = arboard::Clipboard::new() { + let _ = clipboard.set_text(&url); + } + let time = ui.input(|i| i.time); + self.toast = + Some(Toast::new("Position link copied to clipboard", 2.0, time)); + ui.close_menu(); + } + }); + // Draw snap guides let painter = ui.painter(); let guide_stroke = @@ -825,5 +1038,24 @@ impl eframe::App for VoidApp { } }); } + + // --- Toast notification overlay --- + if let Some(ref toast) = self.toast { + let time = ctx.input(|i| i.time); + egui::Area::new(egui::Id::new("toast_overlay")) + .order(egui::Order::Debug) + .fixed_pos(canvas_rect.center_bottom() - Vec2::new(0.0, 60.0)) + .interactable(false) + .show(ctx, |ui| { + if !toast.show(ui, canvas_rect, time) { + // will be cleaned up below + } + }); + if toast.is_expired(time) { + self.toast = None; + } else { + ctx.request_repaint(); + } + } } } diff --git a/src/command_palette/commands.rs b/src/command_palette/commands.rs index fc5bc12..53fcd74 100644 --- a/src/command_palette/commands.rs +++ b/src/command_palette/commands.rs @@ -16,6 +16,8 @@ pub enum Command { FocusNext, FocusPrev, ToggleFullscreen, + NavigateToLink, + CopyLink, } /// A registered command with display info. @@ -92,4 +94,14 @@ pub const COMMANDS: &[CommandEntry] = &[ label: "Toggle Fullscreen", shortcut: "F11", }, + CommandEntry { + command: Command::NavigateToLink, + label: "Navigate to Link", + shortcut: "Ctrl+L", + }, + CommandEntry { + command: Command::CopyLink, + label: "Copy Link to Focused Panel", + shortcut: "Ctrl+Shift+L", + }, ]; diff --git a/src/deeplink/ipc.rs b/src/deeplink/ipc.rs new file mode 100644 index 0000000..3ddc050 --- /dev/null +++ b/src/deeplink/ipc.rs @@ -0,0 +1,122 @@ +// Single-instance IPC via TCP loopback + lockfile discovery. +// +// The first Void instance binds a TCP listener on 127.0.0.1 (OS-assigned port) +// and writes the port to a lockfile. Subsequent launches read the lockfile, +// send the void:// URL to the running instance, and exit. + +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::thread; + +/// Pending deep-link URL received from another instance. +pub type PendingUrl = Arc>>; + +/// IPC server that listens for incoming void:// URLs from secondary instances. +pub struct IpcServer { + pending: PendingUrl, + lock_path: PathBuf, + // Keep the listener alive so the port stays bound. + _listener: TcpListener, +} + +impl IpcServer { + /// Start the IPC server. Binds a random port and writes it to the lockfile. + /// Returns `None` if the data directory cannot be determined. + pub fn start(ctx: egui::Context) -> Option { + let lock_path = lock_file_path()?; + + let listener = TcpListener::bind("127.0.0.1:0").ok()?; + let port = listener.local_addr().ok()?.port(); + + // Write port to lockfile + if let Some(parent) = lock_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(&lock_path, port.to_string()); + + let pending: PendingUrl = Arc::new(Mutex::new(None)); + let pending_clone = Arc::clone(&pending); + + let accept_listener = listener.try_clone().expect("failed to clone TcpListener"); + thread::spawn(move || { + for stream in accept_listener.incoming().flatten() { + if let Some(url) = read_url(stream) { + if let Ok(mut guard) = pending_clone.lock() { + *guard = Some(url); + } + ctx.request_repaint(); + } + } + }); + + Some(Self { + pending, + lock_path, + _listener: listener, + }) + } + + /// Take the pending URL if one has arrived. + pub fn take_pending(&self) -> Option { + self.pending.lock().ok()?.take() + } +} + +impl Drop for IpcServer { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.lock_path); + } +} + +/// Try to send a URL to a running Void instance. Returns `true` if successful. +pub fn try_send_to_running(url: &str) -> bool { + let Some(lock_path) = lock_file_path() else { + return false; + }; + let Ok(port_str) = std::fs::read_to_string(&lock_path) else { + return false; + }; + let Ok(port) = port_str.trim().parse::() else { + // Corrupt lockfile — remove it + let _ = std::fs::remove_file(&lock_path); + return false; + }; + + // Try to connect with a short timeout + let addr = format!("127.0.0.1:{port}"); + let Ok(mut stream) = + TcpStream::connect_timeout(&addr.parse().unwrap(), std::time::Duration::from_secs(2)) + else { + // Stale lockfile — server not running + let _ = std::fs::remove_file(&lock_path); + return false; + }; + + // Send the URL followed by a newline + let _ = stream.set_write_timeout(Some(std::time::Duration::from_secs(2))); + if writeln!(stream, "{url}").is_err() { + return false; + } + let _ = stream.flush(); + true +} + +fn read_url(stream: TcpStream) -> Option { + let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(5))); + let mut reader = BufReader::new(stream); + let mut line = String::new(); + reader.read_line(&mut line).ok()?; + let trimmed = line.trim().to_string(); + if trimmed.starts_with("void://") { + Some(trimmed) + } else { + None + } +} + +fn lock_file_path() -> Option { + let dirs = directories::ProjectDirs::from("", "", "void")?; + Some(dirs.data_dir().join("void.lock")) +} diff --git a/src/deeplink/mod.rs b/src/deeplink/mod.rs new file mode 100644 index 0000000..f35be80 --- /dev/null +++ b/src/deeplink/mod.rs @@ -0,0 +1,248 @@ +// Deep-link URL parsing and navigation for void:// protocol +// +// URL format: +// void://open/ → switch to workspace +// void://open// → focus panel + center viewport +// void://open//@,[,] → navigate to canvas coordinates + +pub mod ipc; +pub mod register; +pub mod toast; + +use std::fmt; + +/// Parsed deep-link target. +#[derive(Debug, Clone, PartialEq)] +pub enum DeepLink { + Workspace { + workspace_id: String, + }, + Panel { + workspace_id: String, + panel_id: String, + }, + Position { + workspace_id: String, + x: f32, + y: f32, + zoom: Option, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum DeepLinkError { + InvalidScheme, + MissingAction, + MissingWorkspaceId, + InvalidWorkspaceId, + InvalidPanelId, + InvalidCoordinates, +} + +impl fmt::Display for DeepLinkError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidScheme => write!(f, "URL must start with void://"), + Self::MissingAction => write!(f, "missing action (expected void://open/...)"), + Self::MissingWorkspaceId => write!(f, "missing workspace ID"), + Self::InvalidWorkspaceId => write!(f, "invalid workspace UUID"), + Self::InvalidPanelId => write!(f, "invalid panel UUID"), + Self::InvalidCoordinates => { + write!(f, "invalid coordinates (expected @x,y or @x,y,zoom)") + } + } + } +} + +/// Parse a `void://` deep-link URL into a navigation target. +pub fn parse(url: &str) -> Result { + // Strip scheme + let rest = url + .strip_prefix("void://") + .ok_or(DeepLinkError::InvalidScheme)?; + + // Strip action + let rest = rest + .strip_prefix("open/") + .ok_or(DeepLinkError::MissingAction)?; + + // Split into segments (filter empty for trailing slashes) + let segments: Vec<&str> = rest.split('/').filter(|s| !s.is_empty()).collect(); + + if segments.is_empty() { + return Err(DeepLinkError::MissingWorkspaceId); + } + + let workspace_id = segments[0]; + // Validate workspace UUID + uuid::Uuid::parse_str(workspace_id).map_err(|_| DeepLinkError::InvalidWorkspaceId)?; + let workspace_id = workspace_id.to_string(); + + if segments.len() == 1 { + return Ok(DeepLink::Workspace { workspace_id }); + } + + let second = segments[1]; + + // Check if it's a coordinate segment (@x,y or @x,y,zoom) + if let Some(coords) = second.strip_prefix('@') { + let parts: Vec<&str> = coords.split(',').collect(); + if parts.len() < 2 || parts.len() > 3 { + return Err(DeepLinkError::InvalidCoordinates); + } + let x: f32 = parts[0] + .parse() + .map_err(|_| DeepLinkError::InvalidCoordinates)?; + let y: f32 = parts[1] + .parse() + .map_err(|_| DeepLinkError::InvalidCoordinates)?; + let zoom: Option = if parts.len() == 3 { + Some( + parts[2] + .parse() + .map_err(|_| DeepLinkError::InvalidCoordinates)?, + ) + } else { + None + }; + return Ok(DeepLink::Position { + workspace_id, + x, + y, + zoom, + }); + } + + // Otherwise it's a panel ID + uuid::Uuid::parse_str(second).map_err(|_| DeepLinkError::InvalidPanelId)?; + Ok(DeepLink::Panel { + workspace_id, + panel_id: second.to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const WS_ID: &str = "550e8400-e29b-41d4-a716-446655440000"; + const PANEL_ID: &str = "6ba7b810-9dad-11d1-80b4-00c04fd430c8"; + + #[test] + fn parse_workspace_url() { + let url = format!("void://open/{WS_ID}"); + assert_eq!( + parse(&url).unwrap(), + DeepLink::Workspace { + workspace_id: WS_ID.to_string() + } + ); + } + + #[test] + fn parse_workspace_with_trailing_slash() { + let url = format!("void://open/{WS_ID}/"); + assert_eq!( + parse(&url).unwrap(), + DeepLink::Workspace { + workspace_id: WS_ID.to_string() + } + ); + } + + #[test] + fn parse_panel_url() { + let url = format!("void://open/{WS_ID}/{PANEL_ID}"); + assert_eq!( + parse(&url).unwrap(), + DeepLink::Panel { + workspace_id: WS_ID.to_string(), + panel_id: PANEL_ID.to_string(), + } + ); + } + + #[test] + fn parse_position_without_zoom() { + let url = format!("void://open/{WS_ID}/@500.5,300"); + assert_eq!( + parse(&url).unwrap(), + DeepLink::Position { + workspace_id: WS_ID.to_string(), + x: 500.5, + y: 300.0, + zoom: None, + } + ); + } + + #[test] + fn parse_position_with_zoom() { + let url = format!("void://open/{WS_ID}/@100,200,1.5"); + assert_eq!( + parse(&url).unwrap(), + DeepLink::Position { + workspace_id: WS_ID.to_string(), + x: 100.0, + y: 200.0, + zoom: Some(1.5), + } + ); + } + + #[test] + fn reject_invalid_scheme() { + assert_eq!( + parse("http://open/abc").unwrap_err(), + DeepLinkError::InvalidScheme + ); + } + + #[test] + fn reject_missing_action() { + assert_eq!( + parse("void://foo/bar").unwrap_err(), + DeepLinkError::MissingAction + ); + } + + #[test] + fn reject_missing_workspace() { + assert_eq!( + parse("void://open/").unwrap_err(), + DeepLinkError::MissingWorkspaceId + ); + } + + #[test] + fn reject_invalid_workspace_uuid() { + assert_eq!( + parse("void://open/not-a-uuid").unwrap_err(), + DeepLinkError::InvalidWorkspaceId + ); + } + + #[test] + fn reject_invalid_panel_uuid() { + let url = format!("void://open/{WS_ID}/not-a-uuid"); + assert_eq!(parse(&url).unwrap_err(), DeepLinkError::InvalidPanelId); + } + + #[test] + fn reject_invalid_coordinates() { + let url = format!("void://open/{WS_ID}/@abc,def"); + assert_eq!(parse(&url).unwrap_err(), DeepLinkError::InvalidCoordinates); + } + + #[test] + fn reject_coordinates_too_few_parts() { + let url = format!("void://open/{WS_ID}/@100"); + assert_eq!(parse(&url).unwrap_err(), DeepLinkError::InvalidCoordinates); + } + + #[test] + fn reject_coordinates_too_many_parts() { + let url = format!("void://open/{WS_ID}/@1,2,3,4"); + assert_eq!(parse(&url).unwrap_err(), DeepLinkError::InvalidCoordinates); + } +} diff --git a/src/deeplink/register.rs b/src/deeplink/register.rs new file mode 100644 index 0000000..d961dc8 --- /dev/null +++ b/src/deeplink/register.rs @@ -0,0 +1,167 @@ +// Auto-register the void:// protocol handler on first launch. +// Checks if already registered before doing anything. + +/// Register the void:// URL scheme handler if not already present. +pub fn ensure_registered() { + #[cfg(target_os = "windows")] + register_windows(); + + #[cfg(target_os = "linux")] + register_linux(); + + #[cfg(target_os = "macos")] + register_macos(); +} + +#[cfg(target_os = "windows")] +fn register_windows() { + #[cfg(target_os = "windows")] + use std::os::windows::process::CommandExt; + use std::process::Command; + + // Check if already registered + let check = Command::new("reg") + .args([ + "query", + "HKCU\\Software\\Classes\\void\\shell\\open\\command", + ]) + .creation_flags(0x08000000) // CREATE_NO_WINDOW + .output(); + if check.is_ok_and(|o| o.status.success()) { + return; + } + + let exe = match std::env::current_exe() { + Ok(p) => p.to_string_lossy().to_string(), + Err(_) => return, + }; + + let command_value = format!("\"{}\" \"%1\"", exe); + + let entries: &[(&str, &str, &str)] = &[ + ("HKCU\\Software\\Classes\\void", "", "URL:Void Protocol"), + ("HKCU\\Software\\Classes\\void", "URL Protocol", ""), + ( + "HKCU\\Software\\Classes\\void\\shell\\open\\command", + "", + &command_value, + ), + ]; + + for (key, name, value) in entries { + let mut args = vec!["add", key]; + if name.is_empty() { + args.extend(["/ve", "/d", value, "/f"]); + } else { + args.extend(["/v", name, "/d", value, "/f"]); + } + let _ = Command::new("reg") + .args(&args) + .creation_flags(0x08000000) + .output(); + } + + log::info!("Registered void:// protocol handler (Windows)"); +} + +#[cfg(target_os = "linux")] +fn register_linux() { + use std::process::Command; + + let Some(app_dir) = dirs_path("applications") else { + return; + }; + let desktop_path = app_dir.join("void-terminal.desktop"); + + // Already registered + if desktop_path.exists() { + return; + } + + let exe = match std::env::current_exe() { + Ok(p) => p.to_string_lossy().to_string(), + Err(_) => return, + }; + + let desktop_content = format!( + "[Desktop Entry]\n\ + Name=Void Terminal\n\ + Comment=Infinite canvas terminal emulator\n\ + Exec={} %u\n\ + Icon=void\n\ + Terminal=false\n\ + Type=Application\n\ + Categories=System;TerminalEmulator;\n\ + MimeType=x-scheme-handler/void;\n", + exe + ); + + let _ = std::fs::create_dir_all(&app_dir); + if std::fs::write(&desktop_path, desktop_content).is_err() { + return; + } + + let _ = Command::new("xdg-mime") + .args(["default", "void-terminal.desktop", "x-scheme-handler/void"]) + .output(); + + let _ = Command::new("update-desktop-database") + .arg(&app_dir) + .output(); + + log::info!("Registered void:// protocol handler (Linux)"); +} + +#[cfg(target_os = "linux")] +fn dirs_path(subdir: &str) -> Option { + let home = std::env::var("HOME").ok()?; + let xdg = std::env::var("XDG_DATA_HOME").unwrap_or_else(|_| format!("{}/.local/share", home)); + Some(std::path::PathBuf::from(xdg).join(subdir)) +} + +#[cfg(target_os = "macos")] +fn register_macos() { + use std::process::Command; + + let exe = match std::env::current_exe() { + Ok(p) => p, + Err(_) => return, + }; + + // Walk up from the binary to find the .app bundle + let mut app_bundle = None; + let mut path = exe.as_path(); + for _ in 0..4 { + if let Some(parent) = path.parent() { + if parent.extension().is_some_and(|ext| ext == "app") { + app_bundle = Some(parent.to_path_buf()); + break; + } + path = parent; + } + } + + let Some(bundle) = app_bundle else { + return; // Not running from .app bundle (dev build) + }; + + // Check if already registered by querying LaunchServices + let check = Command::new("defaults") + .args([ + "read", + "com.apple.LaunchServices/com.apple.launchservices.secure", + ]) + .output(); + if check.is_ok_and(|o| String::from_utf8_lossy(&o.stdout).contains("x-scheme-handler/void")) { + return; + } + + let lsregister = "/System/Library/Frameworks/CoreServices.framework\ + /Frameworks/LaunchServices.framework/Support/lsregister"; + let _ = Command::new(lsregister) + .args(["-R", "-f"]) + .arg(&bundle) + .output(); + + log::info!("Registered void:// protocol handler (macOS)"); +} diff --git a/src/deeplink/toast.rs b/src/deeplink/toast.rs new file mode 100644 index 0000000..1baa33c --- /dev/null +++ b/src/deeplink/toast.rs @@ -0,0 +1,63 @@ +// Lightweight toast notifications for deep-link navigation feedback. + +use egui::{Align2, Color32, FontId, Rect, Ui}; + +pub struct Toast { + pub message: String, + pub expires_at: f64, +} + +impl Toast { + pub fn new(message: impl Into, duration_secs: f64, current_time: f64) -> Self { + Self { + message: message.into(), + expires_at: current_time + duration_secs, + } + } + + pub fn is_expired(&self, current_time: f64) -> bool { + current_time >= self.expires_at + } + + /// Render the toast as a bottom-center overlay. Returns `true` if still visible. + pub fn show(&self, ui: &mut Ui, canvas_rect: Rect, current_time: f64) -> bool { + if self.is_expired(current_time) { + return false; + } + + let remaining = self.expires_at - current_time; + let alpha = if remaining < 0.5 { + (remaining / 0.5) as f32 + } else { + 1.0 + }; + + let painter = ui.painter(); + let font = FontId::proportional(14.0); + let text_color = Color32::from_white_alpha((230.0 * alpha) as u8); + let bg_color = Color32::from_rgba_unmultiplied(30, 30, 30, (200.0 * alpha) as u8); + + let galley = painter.layout_no_wrap(self.message.clone(), font, Color32::WHITE); + let text_size = galley.size(); + let padding = egui::vec2(16.0, 10.0); + let toast_size = text_size + padding * 2.0; + + let center_x = canvas_rect.center().x; + let bottom_y = canvas_rect.max.y - 40.0; + let toast_rect = Rect::from_center_size( + egui::pos2(center_x, bottom_y - toast_size.y / 2.0), + toast_size, + ); + + painter.rect_filled(toast_rect, 8.0, bg_color); + painter.text( + toast_rect.center(), + Align2::CENTER_CENTER, + &self.message, + FontId::proportional(14.0), + text_color, + ); + + true + } +} diff --git a/src/main.rs b/src/main.rs index bb28d24..620e859 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod app; mod canvas; mod command_palette; +mod deeplink; mod panel; mod shortcuts; mod sidebar; @@ -19,6 +20,20 @@ fn main() -> Result<()> { env_logger::init(); log::info!("Starting Void terminal..."); + // Register void:// protocol handler on this system (idempotent, silent) + deeplink::register::ensure_registered(); + + // Check for void:// deep-link URL passed as CLI argument + let url_arg = std::env::args().nth(1).filter(|a| a.starts_with("void://")); + + // If another instance is already running, send the URL to it and exit + if let Some(ref url) = url_arg { + if deeplink::ipc::try_send_to_running(url) { + log::info!("Sent deep-link to running instance: {url}"); + return Ok(()); + } + } + let icon = { let png = include_bytes!("../assets/icon.png"); let img = image::load_from_memory(png) @@ -45,7 +60,7 @@ fn main() -> Result<()> { eframe::run_native( "Void", options, - Box::new(|cc| Ok(Box::new(app::VoidApp::new(cc)))), + Box::new(move |cc| Ok(Box::new(app::VoidApp::new(cc, url_arg)))), ) .map_err(|e| anyhow::anyhow!("eframe error: {}", e))?; diff --git a/src/state/persistence.rs b/src/state/persistence.rs index 2cd9819..c1cb8c8 100644 --- a/src/state/persistence.rs +++ b/src/state/persistence.rs @@ -29,6 +29,8 @@ pub struct WorkspaceState { /// Serializable snapshot of a single terminal panel (layout only, no PTY). #[derive(Serialize, Deserialize)] pub struct PanelState { + #[serde(default)] + pub id: Option, pub title: String, pub position: [f32; 2], pub size: [f32; 2], diff --git a/src/terminal/panel.rs b/src/terminal/panel.rs index 898061e..903579a 100644 --- a/src/terminal/panel.rs +++ b/src/terminal/panel.rs @@ -67,6 +67,27 @@ pub const VOID_SHORTCUTS: &[(Modifiers, Key)] = &[ }, Key::T, ), + // Deep-link navigation shortcuts + ( + Modifiers { + alt: false, + ctrl: true, + shift: false, + mac_cmd: false, + command: false, + }, + Key::L, + ), + ( + Modifiers { + alt: false, + ctrl: true, + shift: true, + mac_cmd: false, + command: false, + }, + Key::L, + ), ]; pub struct TerminalPanel { @@ -107,6 +128,7 @@ pub struct TerminalPanel { pub enum PanelAction { Close, Rename, + CopyLink, } #[derive(Default)] @@ -264,6 +286,12 @@ impl TerminalPanel { let color = Color32::from_rgb(state.color[0], state.color[1], state.color[2]); let mut panel = Self::new_with_terminal(ctx, position, size, color, cwd); + // Restore persisted panel ID if available (stable deep-link targets) + if let Some(ref id_str) = state.id { + if let Ok(id) = uuid::Uuid::parse_str(id_str) { + panel.id = id; + } + } panel.z_index = state.z_index; panel.focused = state.focused; panel @@ -272,6 +300,7 @@ impl TerminalPanel { /// Snapshot the panel layout for persistence (no PTY state). pub fn to_saved(&self) -> crate::state::persistence::PanelState { crate::state::persistence::PanelState { + id: Some(self.id.to_string()), title: self.title.clone(), position: [self.position.x, self.position.y], size: [self.size.x, self.size.y], @@ -1286,6 +1315,11 @@ impl TerminalPanel { ix.action = Some(PanelAction::Close); ui.close_menu(); } + ui.separator(); + if ui.button("Copy Link").clicked() { + ix.action = Some(PanelAction::CopyLink); + ui.close_menu(); + } }); ix