Tray: copy a network secret via ipc; use text buttons, not missing glyphs

Two fixes.

Secret copy: a new local control request returns a configured network's secret
in its tsn1 text form, so the tray's "Copy secret" button can put it on the
clipboard. The agent reads it from local state (same source as the CLI's
`network secret show`); it crosses only the owner/group socket and is wrapped in
a RedactedText type whose Debug prints "<redacted>", so it can never land in a
log. In the GUI the worker fetches it and hands it to the UI thread (which owns
the clipboard) through a one-shot slot kept out of the status snapshot, so it is
copied once and never stored in the cloned snapshot. Control protocol bumped
16 -> 17.

Icons: the button glyphs (⧉ ⟳ ✎ ✔ ✕) are not in egui's bundled fonts and showed
as tofu squares, so they are now plain text: copy / refresh / random / edit /
save / cancel. No icon font to embed.

Checks: fmt, clippy --workspace --all-targets -D warnings, and the tsunagi lib,
tsunagi-cli and wg-quic local_control tests pass on macOS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
UltradesuandClaude Opus 4.8 committed 2026-10-02 15:48:32 +01:00
1 parent 8f5ce21ce9
commit de56258eb1
6 files changed
+138 -12

No files matched your search

+39 -3
View File
@@ -33,6 +33,8 @@ pub(crate) enum Command {
Leave { network_id: String },
/// Change the hostname this device announces.
SetHostname(String),
/// Fetch a network's shared secret and place it on the clipboard.
CopySecret { network_id: String },
}
/// The latest the worker knows, read by the UI each frame.
@@ -57,6 +59,10 @@ pub(crate) struct AgentClient {
socket: PathBuf,
tx: mpsc::UnboundedSender<Command>,
shared: Arc<Mutex<Snapshot>>,
/// A secret fetched for the clipboard, taken by the UI thread which owns
/// the clipboard; kept out of [`Snapshot`] so it is copied once, not every
/// frame, and never travels in the cloned snapshot.
pending_copy: Arc<Mutex<Option<String>>>,
}
impl AgentClient {
@@ -68,8 +74,25 @@ impl AgentClient {
) -> Self {
let (tx, rx) = mpsc::unbounded_channel();
let shared = Arc::new(Mutex::new(Snapshot::default()));
runtime.spawn(worker(socket.clone(), rx, Arc::clone(&shared), ctx));
Self { socket, tx, shared }
let pending_copy = Arc::new(Mutex::new(None));
runtime.spawn(worker(
socket.clone(),
rx,
Arc::clone(&shared),
Arc::clone(&pending_copy),
ctx,
));
Self {
socket,
tx,
shared,
pending_copy,
}
}
/// Takes any secret the worker fetched for the clipboard.
pub(crate) fn take_pending_copy(&self) -> Option<String> {
lock(&self.pending_copy).take()
}
/// The control socket this client talks to.
@@ -100,6 +123,7 @@ async fn worker(
socket: PathBuf,
mut rx: mpsc::UnboundedReceiver<Command>,
shared: Arc<Mutex<Snapshot>>,
pending_copy: Arc<Mutex<Option<String>>>,
ctx: egui::Context,
) {
refresh(&socket, &shared, &ctx).await;
@@ -113,7 +137,7 @@ async fn worker(
}
command = rx.recv() => {
let Some(command) = command else { return };
run(&socket, command, &shared, &ctx).await;
run(&socket, command, &shared, &pending_copy, &ctx).await;
refresh(&socket, &shared, &ctx).await;
}
}
@@ -139,6 +163,7 @@ async fn run(
socket: &std::path::Path,
command: Command,
shared: &Arc<Mutex<Snapshot>>,
pending_copy: &Arc<Mutex<Option<String>>>,
ctx: &egui::Context,
) {
lock(shared).busy = true;
@@ -172,6 +197,17 @@ async fn run(
.await
.map(|accepted| format!("hostname: {accepted}"))
.map_err(|err| err.to_string()),
Command::CopySecret { network_id } => {
match ipc::network_secret(socket, &network_id).await {
// The secret goes only to the clipboard slot, never into the
// banner text or any log line.
Ok(secret) => {
*lock(pending_copy) = Some(secret);
Ok("secret copied to clipboard".to_string())
}
Err(err) => Err(err.to_string()),
}
}
};
{
+6
View File
@@ -76,6 +76,12 @@ impl eframe::App for App {
ui::draw(ctx, &self.agent, &mut self.state, &snapshot, &self.traffic);
self.show_devices_windows(ctx, &snapshot);
// A secret the worker fetched for the clipboard: copy it here, on the
// UI thread that owns the clipboard, exactly once.
if let Some(secret) = self.agent.take_pending_copy() {
ctx.copy_text(secret);
}
// Keep ticking so fresh status and tray actions are picked up.
ctx.request_repaint_after(Duration::from_millis(500));
}
+1 -1
View File
@@ -130,7 +130,7 @@ pub(crate) fn copy_label(ui: &mut egui::Ui, display: &str, value: &str) {
/// A tiny clipboard button that copies `value`.
pub(crate) fn copy_button(ui: &mut egui::Ui, value: &str) {
if ui
.small_button("⧉")
.small_button("copy")
.on_hover_text("copy to clipboard")
.clicked()
{
+15 -7
View File
@@ -64,7 +64,7 @@ pub(crate) fn draw(
ui.horizontal(|ui| {
ui.heading("tsunagi");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("⟳").on_hover_text("Refresh").clicked() {
if ui.button("refresh").clicked() {
agent.send(Command::Refresh);
}
if snapshot.busy {
@@ -148,7 +148,7 @@ fn draw_header(ui: &mut egui::Ui, agent: &AgentClient, state: &mut UiState, repo
match &mut state.host_edit {
None => {
ui.label(&report.hostname);
if ui.small_button("✎").on_hover_text("rename").clicked() {
if ui.small_button("edit").clicked() {
state.host_edit = Some(report.hostname.clone());
}
}
@@ -156,11 +156,11 @@ fn draw_header(ui: &mut egui::Ui, agent: &AgentClient, state: &mut UiState, repo
let response = ui.add(egui::TextEdit::singleline(buffer).desired_width(150.0));
let submit = (response.lost_focus()
&& ui.input(|i| i.key_pressed(egui::Key::Enter)))
|| ui.small_button("✔").clicked();
|| ui.small_button("save").clicked();
if submit {
agent.send(Command::SetHostname(buffer.trim().to_string()));
state.host_edit = None;
} else if ui.small_button("✕").clicked() {
} else if ui.small_button("cancel").clicked() {
state.host_edit = None;
}
}
@@ -186,9 +186,8 @@ fn draw_network(
ui.add_space(6.0);
egui::Frame::group(ui.style()).show(ui, |ui| {
ui.horizontal(|ui| {
// The name copies on click.
ui.strong(network.name.as_str());
format::copy_label(ui, "⧉", &network.name);
format::copy_button(ui, &network.name);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let leave =
egui::Button::new(egui::RichText::new("Leave").color(egui::Color32::WHITE))
@@ -255,6 +254,15 @@ fn draw_network(
if ui.button("Show devices").clicked() {
state.open_devices.insert(network.network_id.clone());
}
if ui
.button("Copy secret")
.on_hover_text("copy this network's secret to the clipboard")
.clicked()
{
agent.send(Command::CopySecret {
network_id: network.network_id.clone(),
});
}
});
});
});
@@ -280,7 +288,7 @@ fn draw_join(ui: &mut egui::Ui, agent: &AgentClient, join: &mut JoinForm) {
.password(true),
);
if ui
.button("⟳")
.button("random")
.on_hover_text("Generate a new random secret")
.clicked()
{