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:
1 parent
8f5ce21ce9
commit
de56258eb1
6 files changed
+138
-12
No files matched your search
@@ -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()),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user