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

+11
View File
@@ -1448,6 +1448,17 @@ impl tsunagi::ipc::ReportSource for AgentControl {
})
}
fn network_secret(&self, network_id: String) -> tsunagi::BoxFuture<'_, Result<String, String>> {
Box::pin(async move {
let networks = stored_networks(&self.paths);
let network = networks
.iter()
.find(|network| network.network_id.to_string() == network_id)
.ok_or_else(|| format!("no configured network with id {network_id}"))?;
Ok(network.secret.encode().as_str().to_owned())
})
}
fn join(
&self,
name: String,
+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()
{
+66 -1
View File
@@ -73,6 +73,31 @@ use crate::error::{Error, Result};
/// Largest accepted local control message.
pub const MAX_MESSAGE_LEN: usize = 1024 * 1024;
/// A string the control protocol must carry but that must never appear in a
/// log: it serialises transparently (the text crosses the socket intact) but
/// its `Debug` is redacted. Used for a network secret handed back to a local
/// client that asked to copy it.
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RedactedText(String);
impl RedactedText {
/// Wraps a string.
pub fn new(text: impl Into<String>) -> Self {
Self(text.into())
}
/// Unwraps to the plain string.
pub fn into_string(self) -> String {
self.0
}
}
impl std::fmt::Debug for RedactedText {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("<redacted>")
}
}
/// Where the control socket for a state directory lives.
///
/// A Unix socket path is limited to around 100 bytes, which a state directory
@@ -195,6 +220,13 @@ pub enum Request {
#[serde(default)]
broadcast: Option<bool>,
},
/// Ask for a configured network's shared secret, in its `tsn1…` text form,
/// so a local GUI or script can copy it to re-share. The reply travels only
/// over the owner/group socket and the secret is never logged.
Secret {
/// Which network, by the id text `status` prints.
network_id: String,
},
}
impl std::fmt::Debug for Request {
@@ -215,6 +247,7 @@ impl std::fmt::Debug for Request {
write!(f, "Dns {{ enable: {enable}, port: {port:?} }}")
}
Request::Join { name, .. } => write!(f, "Join {{ name: {name}, secret: <redacted> }}"),
Request::Secret { network_id } => write!(f, "Secret {{ {network_id} }}"),
}
}
}
@@ -239,6 +272,8 @@ pub enum Response {
///
/// `None` means it is not serving at all.
Dns(Option<DnsReport>),
/// A network's shared secret, redacted in `Debug`.
Secret(RedactedText),
/// The request could not be served.
Error(String),
}
@@ -579,6 +614,17 @@ pub trait ReportSource: Send + Sync + 'static {
) -> BoxFuture<'_, std::result::Result<Option<DnsReport>, String>> {
Box::pin(async move { Err("this agent cannot serve DNS".to_string()) })
}
/// Returns a configured network's shared secret in its `tsn1…` text form.
///
/// Defaulted to a refusal, like the others. An implementor reads it from
/// local state; it must never be logged.
fn network_secret(
&self,
_network_id: String,
) -> BoxFuture<'_, std::result::Result<String, String>> {
Box::pin(async move { Err("this agent cannot read secrets".to_string()) })
}
}
impl<F> ReportSource for F
@@ -609,7 +655,7 @@ pub const EXCHANGE_TIMEOUT: Duration = Duration::from_secs(5);
///
/// Bump it whenever [`Request`], [`Response`] or anything they contain
/// changes shape.
pub const CONTROL_PROTOCOL: u32 = u32::from_be_bytes([b'T', b'S', b'N', 16]);
pub const CONTROL_PROTOCOL: u32 = u32::from_be_bytes([b'T', b'S', b'N', 17]);
/// Reads one request off an accepted stream, answers it, writes the response.
///
@@ -667,6 +713,10 @@ where
Ok(report) => Response::Dns(report),
Err(reason) => Response::Error(reason),
},
Request::Secret { network_id } => match source.network_secret(network_id).await {
Ok(secret) => Response::Secret(RedactedText::new(secret)),
Err(reason) => Response::Error(reason),
},
};
write_message(&mut stream, &response).await
}
@@ -821,6 +871,21 @@ pub async fn set_dns(
}
}
/// Asks a running agent for a configured network's shared secret, in its
/// `tsn1…` text form, so a local client can copy it. The secret is never
/// logged by this path.
pub async fn network_secret(path: impl AsRef<Path>, network_id: &str) -> Result<String> {
let path = path.as_ref();
let request = Request::Secret {
network_id: network_id.to_string(),
};
match exchange(path, &request, EXCHANGE_TIMEOUT).await? {
Response::Secret(secret) => Ok(secret.into_string()),
Response::Error(reason) => Err(Error::Storage(reason)),
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
}
}
pub(crate) async fn write_message<S, T>(stream: &mut S, value: &T) -> Result<()>
where
S: AsyncWrite + Unpin,