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
924 changes: 763 additions & 161 deletions src-tauri/Cargo.lock

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ name = "codex-helper-bridge"
path = "src/bridge_cli.rs"

[build-dependencies]
png = "0.17"
serde_json = "1"
tauri-build = { version = "2.0.0", features = [] }

Expand All @@ -30,6 +31,8 @@ rusqlite = { version = "0.32", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2.0.0", features = ["tray-icon"] }
tauri-plugin-dialog = "2"
tauri-plugin-opener = "2"
tempfile = "3"
tokio = { version = "1", features = ["macros", "process", "rt-multi-thread", "time"] }
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
Expand Down
182 changes: 119 additions & 63 deletions src-tauri/build.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
use std::env;
use std::fs;
use std::fs::File;
use std::io::{BufReader, BufWriter};
use std::path::{Path, PathBuf};
use std::process::Command;

fn main() {
assert_app_icon();
Expand Down Expand Up @@ -120,13 +121,11 @@ fn assert_app_icon() {
/// Edit `tray.png` (any size, black + transparent). Cargo embeds the generated
/// 44×44 asset via `include_image!` so icon changes rebuild reliably.
fn prepare_tray_icon() {
const MENU_BAR_PX: &str = "44";
const MENU_BAR_PX: u32 = 44;

let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));
let icons_dir = manifest_dir.join("icons");
let tray_source = icons_dir.join("tray.png");
let tray_flat = icons_dir.join("tray-flat.png");
let tray_scaled = icons_dir.join("tray-menu-scaled.png");
let tray_menu = icons_dir.join("tray-menu.png");

if !tray_source.is_file() {
Expand All @@ -142,74 +141,131 @@ fn prepare_tray_icon() {

println!("cargo:rerun-if-changed={}", tray_source.display());

// Interlaced PNG (Adam7) breaks Tauri tray embedding; flatten before resize.
deinterlace_png(&tray_source, &tray_flat);

let fit = Command::new("sips")
.arg("-Z")
.arg(MENU_BAR_PX)
.arg(&tray_flat)
.arg("--out")
.arg(&tray_scaled)
.status()
.unwrap_or_else(|error| panic!("failed to run sips for tray icon fit: {error}"));
if !fit.success() {
let source = read_rgba_png(&tray_source);
let menu = fit_and_pad_rgba(&source, MENU_BAR_PX);
write_rgba_png(&tray_menu, &menu);

println!("cargo:rerun-if-changed={}", tray_menu.display());
}

struct RgbaImage {
width: u32,
height: u32,
pixels: Vec<u8>,
}

fn read_rgba_png(path: &Path) -> RgbaImage {
let file = File::open(path)
.unwrap_or_else(|error| panic!("failed to open {}: {error}", path.display()));
let mut decoder = png::Decoder::new(BufReader::new(file));
decoder.set_transformations(
png::Transformations::normalize_to_color8() | png::Transformations::ALPHA,
);
let mut reader = decoder
.read_info()
.unwrap_or_else(|error| panic!("failed to read PNG metadata {}: {error}", path.display()));
let mut buffer = vec![0; reader.output_buffer_size()];
let info = reader
.next_frame(&mut buffer)
.unwrap_or_else(|error| panic!("failed to decode PNG {}: {error}", path.display()));
if info.color_type != png::ColorType::Rgba || info.bit_depth != png::BitDepth::Eight {
panic!(
"failed to fit tray icon {} within {MENU_BAR_PX}px",
tray_flat.display()
"tray icon {} must decode to 8-bit RGBA, got {:?} {:?}",
path.display(),
info.color_type,
info.bit_depth
);
}
buffer.truncate(info.buffer_size());
RgbaImage {
width: info.width,
height: info.height,
pixels: buffer,
}
}

let pad = Command::new("sips")
.arg("--padToHeightWidth")
.arg(MENU_BAR_PX)
.arg(MENU_BAR_PX)
.arg(&tray_scaled)
.arg("--out")
.arg(&tray_menu)
.status()
.unwrap_or_else(|error| panic!("failed to run sips for tray icon pad: {error}"));
let _ = fs::remove_file(&tray_flat);
let _ = fs::remove_file(&tray_scaled);
if !pad.success() {
panic!(
"failed to pad tray icon {} to {MENU_BAR_PX}x{MENU_BAR_PX}",
tray_scaled.display()
);
fn write_rgba_png(path: &Path, image: &RgbaImage) {
let file = File::create(path)
.unwrap_or_else(|error| panic!("failed to create {}: {error}", path.display()));
let mut encoder = png::Encoder::new(BufWriter::new(file), image.width, image.height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder
.write_header()
.unwrap_or_else(|error| panic!("failed to write PNG header {}: {error}", path.display()));
writer
.write_image_data(&image.pixels)
.unwrap_or_else(|error| panic!("failed to write PNG data {}: {error}", path.display()));
}

fn fit_and_pad_rgba(source: &RgbaImage, canvas_size: u32) -> RgbaImage {
let scale =
(canvas_size as f64 / source.width as f64).min(canvas_size as f64 / source.height as f64);
let scaled_width = ((source.width as f64 * scale).round() as u32).clamp(1, canvas_size);
let scaled_height = ((source.height as f64 * scale).round() as u32).clamp(1, canvas_size);
let offset_x = (canvas_size - scaled_width) / 2;
let offset_y = (canvas_size - scaled_height) / 2;
let mut pixels = vec![0; (canvas_size * canvas_size * 4) as usize];

for y in 0..scaled_height {
for x in 0..scaled_width {
let source_x = (x as f64 + 0.5) / scale - 0.5;
let source_y = (y as f64 + 0.5) / scale - 0.5;
let pixel = sample_rgba(source, source_x, source_y);
let dest = (((y + offset_y) * canvas_size + x + offset_x) * 4) as usize;
pixels[dest..dest + 4].copy_from_slice(&pixel);
}
}

println!("cargo:rerun-if-changed={}", tray_menu.display());
RgbaImage {
width: canvas_size,
height: canvas_size,
pixels,
}
}

fn deinterlace_png(source: &Path, dest: &Path) {
let tiff = dest.with_extension("tif");
let to_tiff = Command::new("sips")
.arg("-s")
.arg("format")
.arg("tiff")
.arg(source)
.arg("--out")
.arg(&tiff)
.status()
.unwrap_or_else(|error| panic!("failed to run sips for tray deinterlace: {error}"));
if !to_tiff.success() {
panic!(
"failed to deinterlace tray icon {} via TIFF",
source.display()
);
fn sample_rgba(source: &RgbaImage, x: f64, y: f64) -> [u8; 4] {
let x = x.clamp(0.0, (source.width - 1) as f64);
let y = y.clamp(0.0, (source.height - 1) as f64);
let x0 = x.floor() as u32;
let y0 = y.floor() as u32;
let x1 = (x0 + 1).min(source.width - 1);
let y1 = (y0 + 1).min(source.height - 1);
let tx = x - x0 as f64;
let ty = y - y0 as f64;

let top = mix_pixel(source.pixel(x0, y0), source.pixel(x1, y0), tx);
let bottom = mix_pixel(source.pixel(x0, y1), source.pixel(x1, y1), tx);
mix_pixel(top, bottom, ty)
}

fn mix_pixel(a: [u8; 4], b: [u8; 4], t: f64) -> [u8; 4] {
let alpha_a = a[3] as f64 / 255.0;
let alpha_b = b[3] as f64 / 255.0;
let alpha = alpha_a + (alpha_b - alpha_a) * t;
if alpha <= f64::EPSILON {
return [0, 0, 0, 0];
}

let to_png = Command::new("sips")
.arg("-s")
.arg("format")
.arg("png")
.arg(&tiff)
.arg("--out")
.arg(dest)
.status()
.unwrap_or_else(|error| panic!("failed to run sips for tray PNG export: {error}"));
let _ = fs::remove_file(&tiff);
if !to_png.success() {
panic!("failed to write deinterlaced tray icon {}", dest.display());
let mut out = [0; 4];
for channel in 0..3 {
let premultiplied_a = a[channel] as f64 * alpha_a;
let premultiplied_b = b[channel] as f64 * alpha_b;
let premultiplied = premultiplied_a + (premultiplied_b - premultiplied_a) * t;
out[channel] = (premultiplied / alpha).round().clamp(0.0, 255.0) as u8;
}
out[3] = (alpha * 255.0).round().clamp(0.0, 255.0) as u8;
out
}

impl RgbaImage {
fn pixel(&self, x: u32, y: u32) -> [u8; 4] {
let index = ((y * self.width + x) * 4) as usize;
[
self.pixels[index],
self.pixels[index + 1],
self.pixels[index + 2],
self.pixels[index + 3],
]
}
}
13 changes: 6 additions & 7 deletions src-tauri/src/routes.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use std::sync::Arc;

use serde_json::{json, Value};
use tauri_plugin_opener::{open_path, open_url, reveal_item_in_dir};

use crate::cdp::{list_targets, pick_codex_page_target, CdpTarget};
use crate::logging::DiagnosticLogger;
Expand Down Expand Up @@ -145,12 +146,9 @@ fn read_latest_log_response(logger: &DiagnosticLogger) -> Value {

fn reveal_path_response(path: &std::path::Path) -> Value {
let result = if path.is_dir() {
std::process::Command::new("open").arg(path).spawn()
open_path(path, None::<&str>)
} else {
std::process::Command::new("open")
.arg("-R")
.arg(path)
.spawn()
reveal_item_in_dir(path)
};
match result {
Ok(_) => json!({ "status": "ok", "path": path.to_string_lossy() }),
Expand All @@ -177,7 +175,8 @@ async fn open_devtools_response(debug_port: u16) -> Value {
Ok(url) => url,
Err(error) => return json!({ "status": "failed", "message": error.to_string() }),
};
match std::process::Command::new("open").arg(&url).spawn() {
let target_id = target_id.trim();
match open_url(&url, None::<&str>) {
Ok(_) => json!({ "status": "ok", "targetId": target_id, "url": url }),
Err(error) => json!({ "status": "failed", "message": error.to_string() }),
}
Expand All @@ -188,7 +187,7 @@ fn open_external_local_url_response(payload: &Value) -> Value {
Ok(url) => url,
Err(message) => return json!({ "status": "failed", "message": message }),
};
match std::process::Command::new("open").arg(&url).spawn() {
match open_url(&url, None::<&str>) {
Ok(_) => json!({ "status": "ok", "url": url }),
Err(error) => json!({ "status": "failed", "message": error.to_string() }),
}
Expand Down
30 changes: 14 additions & 16 deletions src-tauri/src/zed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ use std::process::Command;

use serde::Serialize;
use serde_json::{json, Value};
use tauri_plugin_opener::open_url;

#[derive(Debug)]
pub enum ZedRemoteError {
Validation(&'static str),
StateRead(std::io::Error),
StateParse(serde_json::Error),
Launch(std::io::Error),
Launch(String),
}

impl std::fmt::Display for ZedRemoteError {
Expand All @@ -21,7 +22,7 @@ impl std::fmt::Display for ZedRemoteError {
Self::Validation(message) => f.write_str(message),
Self::StateRead(_) => f.write_str("Cannot read Codex remote connection state"),
Self::StateParse(_) => f.write_str("Cannot parse Codex remote connection state"),
Self::Launch(error) => write!(f, "Failed to launch Zed: {error}"),
Self::Launch(message) => write!(f, "Failed to launch Zed: {message}"),
}
}
}
Expand All @@ -31,7 +32,7 @@ impl std::error::Error for ZedRemoteError {
match self {
Self::StateRead(error) => Some(error),
Self::StateParse(error) => Some(error),
Self::Launch(error) => Some(error),
Self::Launch(_) => None,
Self::Validation(_) => None,
}
}
Expand Down Expand Up @@ -295,26 +296,23 @@ fn percent_encode_segment(segment: &str) -> String {
}

fn launch_zed_url(url: &str) -> Result<(), ZedRemoteError> {
let app_path = find_zed_app_path();
let cli_path = find_zed_cli_path();
if cfg!(target_os = "macos") {
if let Some(app_path) = app_path {
Command::new("open")
.arg("-a")
.arg(app_path)
.arg(url)
.spawn()
.map_err(ZedRemoteError::Launch)?;
return Ok(());
}
}
if !cli_path.is_empty() {
Command::new(cli_path)
.arg(url)
.spawn()
.map_err(ZedRemoteError::Launch)?;
.map_err(|error| ZedRemoteError::Launch(error.to_string()))?;
return Ok(());
}
let app_path = find_zed_app_path();
if cfg!(target_os = "macos") {
if let Some(app_path) = app_path {
let app = app_path.to_string_lossy().into_owned();
open_url(url, Some(app.as_str()))
.map_err(|error| ZedRemoteError::Launch(error.to_string()))?;
return Ok(());
}
}
Err(ZedRemoteError::Validation(
"Zed is not installed or not available on PATH",
))
Expand Down
8 changes: 3 additions & 5 deletions src/routes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { spawn } from "node:child_process";
import {
appendFileSync,
existsSync,
Expand All @@ -11,12 +10,13 @@ import { homedir } from "node:os";
import { join } from "node:path";
import { listTargets, pickCodexPageTarget } from "./cdp";
import {
PortForwardManager,
discoverRemoteListeningPorts,
discoveryRequestFromPayload,
PortForwardManager,
requestFromPayload,
} from "./ports";
import { invokeRustBridge, isRustBridgePath } from "./rust-bridge";
import { launchSystemOpen } from "./system-open";

import {
fallbackOpenRequestResponse,
Expand Down Expand Up @@ -241,9 +241,7 @@ function readLatestLogContents(): string {

function openPath(path: string, reveal = false): JsonValue {
try {
const args = reveal ? ["-R", path] : [path];
const child = spawn("open", args, { stdio: "ignore", detached: true });
child.unref();
launchSystemOpen(path, { reveal });
return { status: "ok", path };
} catch (error) {
return {
Expand Down
Loading
Loading