Four tasks: RemoteClientKind enum + storage migration + browse stopgap; web PUT/test endpoints + URL-keyed password merge; web UI editor section; desktop editor section + test thread. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
30 KiB
Kind-Aware Remote Editor — Implementation Plan (Phase 2)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: An in-UI editor (both UIs) to add/edit/remove/test federation remotes of both kinds (catacomb + PeerTube), applied live, with PeerTube remotes manageable now and browsable in phase 3.
Architecture: A RemoteClientKind enum wraps RemoteClient/PeerTubeClient; the live remotes lists hold it (web behind a RwLock for live-apply). Dedicated /api/remotes/* endpoints (extended GET, new PUT whole-list-replace, new POST /test) manage them, with URL-keyed write-only password merge on the web. Both editors gain a kind selector + conditional username field. Existing catacomb browse is untouched; PeerTube browse is a phase-3 stopgap.
Tech Stack: Rust, axum, eframe/egui, reqwest (blocking), serde/serde_json; the embedded web_ui/index.html SPA.
Global Constraints
- Inherits
docs/superpowers/specs/2026-07-10-federation-remote-editor-design.mdfor unchanged editor mechanics; deltas indocs/superpowers/specs/2026-07-10-federation-editor-phase2-kind-aware-design.md. - Web config source of truth is
state.config(Mutex<Config>, accessed via.lock_recover()); save viacfg.save(&state.config_path); on save error don't swap the live list; drop the config lock before taking the remotes lock. - Web passwords masked/write-only:
GETnever returns plaintext; blank on save keeps the stored secret, matched by URL. Desktop shows passwords in the clear. - After any edit, both UIs refetch and clear the current remote selection.
- The web SPA is one embedded file; a
cargo builddoes not catch JS syntax errors — after editingweb_ui/index.html, runawk '/<script>/{f=1;next}/<\/script>/{f=0}f' src/web_ui/index.html > /tmp/spa.js && node --check /tmp/spa.js. - New UI must use the existing theme CSS variables; no CDN assets (offline-first).
- Commits SSH-signed:
export SSH_AUTH_SOCK=/tmp/luna-ssh-agent.sock. End messages withCo-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>.
Task 1: RemoteClientKind enum + storage migration + browse dispatch
Atomic: introduce the enum and switch both live lists to it (web behind RwLock), fixing every read site and adding the PeerTube browse stopgap. Compiles with catacomb browse unchanged.
Files:
- Modify:
src/remote.rs— addRemoteClientKind. - Modify:
src/web.rs—WebState.remotestype, construction,get_remotes,get_remote_library. - Modify:
src/app.rs—App.remotestype, construction,start_remote_fetch,remotes_screenname access.
Interfaces:
-
Consumes:
crate::remote::RemoteClient,crate::peertube::PeerTubeClient,crate::config::{RemoteSection, RemoteKind}. -
Produces:
pub enum RemoteClientKind { Catacomb(RemoteClient), Peertube(PeerTubeClient) }withfrom_section(&RemoteSection) -> Self,name(&self) -> &str,kind(&self) -> RemoteKind. -
Step 1: Write the failing unit test
Append to src/remote.rs's #[cfg(test)] mod tests:
#[test]
fn client_kind_from_section_dispatches() {
use crate::config::{RemoteKind, RemoteSection};
let cat = RemoteSection {
name: "c".into(), url: "http://p:8081".into(),
kind: RemoteKind::Catacomb, username: None, password: None,
};
let pt = RemoteSection {
name: "p".into(), url: "https://framatube.org".into(),
kind: RemoteKind::Peertube, username: None, password: None,
};
let a = RemoteClientKind::from_section(&cat);
let b = RemoteClientKind::from_section(&pt);
assert_eq!(a.kind(), RemoteKind::Catacomb);
assert_eq!(a.name(), "c");
assert_eq!(b.kind(), RemoteKind::Peertube);
assert_eq!(b.name(), "p");
}
- Step 2: Run to verify it fails
Run: cargo test --release client_kind_from_section 2>&1 | grep -E "cannot find|test result"
Expected: FAIL — RemoteClientKind not found.
- Step 3: Add the enum
Append to src/remote.rs (module scope, after RemoteClient's impl):
/// A live federation client of either kind. Held by both front-ends' remotes
/// lists so catacomb peers and PeerTube targets coexist.
pub enum RemoteClientKind {
Catacomb(RemoteClient),
Peertube(crate::peertube::PeerTubeClient),
}
impl RemoteClientKind {
pub fn from_section(cfg: &crate::config::RemoteSection) -> Self {
match cfg.kind {
crate::config::RemoteKind::Catacomb => Self::Catacomb(RemoteClient::new(cfg)),
crate::config::RemoteKind::Peertube => {
Self::Peertube(crate::peertube::PeerTubeClient::new(cfg))
}
}
}
pub fn name(&self) -> &str {
match self {
Self::Catacomb(c) => &c.name,
Self::Peertube(p) => &p.name,
}
}
pub fn kind(&self) -> crate::config::RemoteKind {
match self {
Self::Catacomb(_) => crate::config::RemoteKind::Catacomb,
Self::Peertube(_) => crate::config::RemoteKind::Peertube,
}
}
}
- Step 4: Run to verify it passes
Run: cargo test --release client_kind_from_section 2>&1 | grep "test result"
Expected: test result: ok. 1 passed. (Crate won't fully build yet if the test build touches web/app — if so, proceed to Steps 5–7 and re-run at Step 8.)
- Step 5: Migrate
WebState.remotestoRwLock<Vec<Arc<RemoteClientKind>>>
In src/web.rs:
Field (line ~154):
pub remotes: std::sync::RwLock<Vec<std::sync::Arc<crate::remote::RemoteClientKind>>>,
Construction (line ~3214):
let remotes: Vec<std::sync::Arc<crate::remote::RemoteClientKind>> = config
.remotes
.iter()
.map(|r| std::sync::Arc::new(crate::remote::RemoteClientKind::from_section(r)))
.collect();
And in the WebState { … } literal change remotes, to remotes: std::sync::RwLock::new(remotes),.
get_remotes (line ~2250):
async fn get_remotes(State(state): State<Arc<WebState>>) -> impl IntoResponse {
let list: Vec<_> = state
.remotes
.read()
.unwrap()
.iter()
.enumerate()
.map(|(i, r)| {
let (url, has_password) = match r.as_ref() {
crate::remote::RemoteClientKind::Catacomb(c) => (c.base_url().to_string(), c.has_password()),
crate::remote::RemoteClientKind::Peertube(p) => (p.base_url().to_string(), p.has_password()),
};
let kind = match r.kind() {
crate::config::RemoteKind::Catacomb => "catacomb",
crate::config::RemoteKind::Peertube => "peertube",
};
serde_json::json!({ "id": i, "name": r.name(), "url": url, "kind": kind, "has_password": has_password })
})
.collect();
Json(list)
}
get_remote_library (line ~2264) — dispatch + stopgap:
async fn get_remote_library(
State(state): State<Arc<WebState>>,
Path(id): Path<usize>,
) -> Response {
let remote = state.remotes.read().unwrap().get(id).cloned();
let Some(remote) = remote else {
return (StatusCode::NOT_FOUND, "no such remote").into_response();
};
match remote.as_ref() {
crate::remote::RemoteClientKind::Catacomb(_) => {
match tokio::task::spawn_blocking(move || match remote.as_ref() {
crate::remote::RemoteClientKind::Catacomb(c) => c.library_json(),
_ => unreachable!(),
})
.await
{
Ok(Ok(v)) => Json(v).into_response(),
Ok(Err(e)) => (StatusCode::BAD_GATEWAY, format!("remote error: {e}")).into_response(),
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "remote task failed").into_response(),
}
}
crate::remote::RemoteClientKind::Peertube(_) => {
(StatusCode::NOT_IMPLEMENTED, "PeerTube browsing arrives in a later update").into_response()
}
}
}
This needs RemoteClient::has_password() and PeerTubeClient::has_password() — add each (Step 6).
- Step 6: Add
has_password/base_urlaccessors
src/remote.rs, in impl RemoteClient (public accessor near base_url):
pub fn has_password(&self) -> bool {
self.password.is_some()
}
src/peertube.rs, in impl PeerTubeClient:
pub fn base_url(&self) -> &str {
&self.api_base
}
pub fn has_password(&self) -> bool {
self.password.is_some()
}
(Remove the #[allow(dead_code)] on PeerTubeClient/impl from phase 1 now that it's constructed — or leave it; either compiles. Prefer removing the struct-level allow so genuine future dead code still warns.)
- Step 7: Migrate
App.remotes+ desktop use-sites
In src/app.rs:
Field (line ~264):
remotes: Vec<std::sync::Arc<crate::remote::RemoteClientKind>>,
Construction (line ~549):
let remotes: Vec<std::sync::Arc<crate::remote::RemoteClientKind>> = config.remotes.iter()
.map(|r| std::sync::Arc::new(crate::remote::RemoteClientKind::from_section(r)))
.collect();
remotes_screen name access (line ~2570): r.name → r.name().
start_remote_fetch (line ~2523) — dispatch + stopgap:
fn start_remote_fetch(&mut self, idx: usize) {
let Some(client) = self.remotes.get(idx).cloned() else { return };
self.remote_selected = Some(idx);
self.remote_library = None;
match client.as_ref() {
crate::remote::RemoteClientKind::Catacomb(_) => {
self.remote_status = format!("Connecting to {}…", client.name());
let (tx, rx) = std::sync::mpsc::channel();
self.remote_rx = Some(rx);
let repaint_ctx = self.egui_ctx.clone();
std::thread::spawn(move || {
let res = match client.as_ref() {
crate::remote::RemoteClientKind::Catacomb(c) => c.library(),
_ => unreachable!(),
};
let _ = tx.send(res);
repaint_ctx.request_repaint();
});
}
crate::remote::RemoteClientKind::Peertube(_) => {
self.remote_status =
"PeerTube browsing arrives in a later update".to_string();
}
}
}
- Step 8: Build both front-ends + full tests
Run: cargo build --release 2>&1 | grep -E "^error" | head
Expected: no output.
Run: cargo test --release 2>&1 | grep "test result"
Expected: all ok, 0 failed (+1 unit vs phase 1).
- Step 9: Commit
export SSH_AUTH_SOCK=/tmp/luna-ssh-agent.sock
git add src/remote.rs src/peertube.rs src/web.rs src/app.rs
git commit -m "feat(remote): RemoteClientKind enum + kind-aware live remotes list
Both front-ends hold Vec<Arc<RemoteClientKind>> (web behind RwLock). Catacomb
browse unchanged; PeerTube remotes report a phase-3 stopgap when browsed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
Task 2: Web editor backend — merge + PUT + test endpoints
Files:
- Modify:
src/web.rs—merge_remote_passwords;put_remotes;test_remote; routes. - Modify:
tests/api.rs— integration coverage.
Interfaces:
-
Consumes: Task 1
RemoteClientKind,RemoteClient/PeerTubeClientreachability calls. -
Produces:
PUT /api/remotes,POST /api/remotes/test; puremerge_remote_passwords(inputs: &[RemoteInput], existing: &[RemoteSection]) -> Vec<RemoteSection>. -
Step 1: Write the failing merge unit test
Add to src/web.rs in a #[cfg(test)] mod tests block (create with use super::*; if none):
#[test]
fn merge_keeps_blank_password_by_url() {
use crate::config::{RemoteKind, RemoteSection};
let existing = vec![RemoteSection {
name: "old".into(), url: "http://p:8081".into(),
kind: RemoteKind::Catacomb, username: None, password: Some("SECRET".into()),
}];
let inputs = vec![
RemoteInput { name: "renamed".into(), url: "http://p:8081".into(),
kind: RemoteKind::Catacomb, username: None, password: None }, // blank → keep
RemoteInput { name: "new".into(), url: "http://q:8081".into(),
kind: RemoteKind::Catacomb, username: None, password: Some("TYPED".into()) },
];
let out = merge_remote_passwords(&inputs, &existing);
assert_eq!(out[0].password.as_deref(), Some("SECRET")); // preserved by URL
assert_eq!(out[0].name, "renamed");
assert_eq!(out[1].password.as_deref(), Some("TYPED"));
}
- Step 2: Run to verify it fails
Run: cargo test --release merge_keeps_blank 2>&1 | grep -E "cannot find|test result"
Expected: FAIL — RemoteInput/merge_remote_passwords not found.
- Step 3: Implement the input type + merge + endpoints
Add to src/web.rs (module scope):
#[derive(serde::Deserialize)]
pub struct RemoteInput {
pub name: String,
pub url: String,
#[serde(default)]
pub kind: crate::config::RemoteKind,
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub password: Option<String>,
}
/// Resolve write-only passwords: keep the typed password if non-empty, else
/// adopt the stored password of the existing remote with the same URL, else
/// None. Trims URLs so whitespace can't defeat the match.
pub fn merge_remote_passwords(
inputs: &[RemoteInput],
existing: &[crate::config::RemoteSection],
) -> Vec<crate::config::RemoteSection> {
inputs
.iter()
.map(|i| {
let url = i.url.trim().to_string();
let password = match i.password.as_deref().map(str::trim).filter(|p| !p.is_empty()) {
Some(p) => Some(p.to_string()),
None => existing
.iter()
.find(|e| e.url.trim() == url)
.and_then(|e| e.password.clone()),
};
let username = i.username.as_deref().map(str::trim).filter(|u| !u.is_empty()).map(String::from);
crate::config::RemoteSection {
name: i.name.trim().to_string(),
url,
kind: i.kind.clone(),
username,
password,
}
})
.collect()
}
/// `PUT /api/remotes` — replace the whole peer list (live-apply).
async fn put_remotes(
State(state): State<Arc<WebState>>,
Json(body): Json<Vec<RemoteInput>>,
) -> impl IntoResponse {
let merged = {
let mut cfg = state.config.lock_recover();
let merged = merge_remote_passwords(&body, &cfg.remotes);
cfg.remotes = merged.clone();
if let Err(e) = cfg.save(&state.config_path) {
return (StatusCode::INTERNAL_SERVER_ERROR, format!("save failed: {e}")).into_response();
}
merged
}; // config lock dropped here
let rebuilt: Vec<std::sync::Arc<crate::remote::RemoteClientKind>> = merged
.iter()
.map(|r| std::sync::Arc::new(crate::remote::RemoteClientKind::from_section(r)))
.collect();
*state.remotes.write().unwrap() = rebuilt;
Json(serde_json::json!({ "ok": true })).into_response()
}
/// `POST /api/remotes/test` — reachability check for a (possibly unsaved) peer.
async fn test_remote(
State(state): State<Arc<WebState>>,
Json(body): Json<RemoteInput>,
) -> impl IntoResponse {
// Resolve a blank password from the stored remote with the same URL.
let section = {
let cfg = state.config.lock_recover();
merge_remote_passwords(std::slice::from_ref(&body), &cfg.remotes)
.into_iter()
.next()
.unwrap()
};
let result = tokio::task::spawn_blocking(move || {
match crate::remote::RemoteClientKind::from_section(§ion) {
crate::remote::RemoteClientKind::Catacomb(c) => c.library_json().map(|_| None::<usize>),
crate::remote::RemoteClientKind::Peertube(p) => p.list_channels().map(|ch| Some(ch.len())),
}
})
.await;
match result {
Ok(Ok(channels)) => Json(serde_json::json!({ "ok": true, "channels": channels })).into_response(),
Ok(Err(e)) => Json(serde_json::json!({ "ok": false, "error": e })).into_response(),
Err(_) => Json(serde_json::json!({ "ok": false, "error": "test task failed" })).into_response(),
}
}
Add the routes (near the existing /api/remotes route, line ~3364):
.route("/api/remotes", get(get_remotes).put(put_remotes))
.route("/api/remotes/test", post(test_remote))
(Keep the existing .route("/api/remotes/:id/library", get(get_remote_library)).)
- Step 4: Run the merge test to verify it passes
Run: cargo test --release merge_keeps_blank 2>&1 | grep "test result"
Expected: test result: ok. 1 passed.
- Step 5: Add integration coverage
In tests/api.rs, add a test that (following the file's existing spawn-server helper) PUT /api/remotes with a catacomb + a peertube entry, then GET /api/remotes and asserts both appear with the right kind and has_password. Model it on the nearest existing settings/endpoint test in that file (reuse its server-spawn + curl helpers; skip if curl absent, as the file already does). Assert the JSON contains "kind":"peertube" and "kind":"catacomb" and that no plaintext password is present in the GET body.
- Step 6: Build + full tests
Run: cargo build --release 2>&1 | grep -E "^error" | head → no output.
Run: cargo test --release 2>&1 | grep "test result" → all ok.
- Step 7: Commit
export SSH_AUTH_SOCK=/tmp/luna-ssh-agent.sock
git add src/web.rs tests/api.rs
git commit -m "feat(web): PUT /api/remotes + test endpoint, kind-aware, live-apply
URL-keyed write-only password merge; whole-list replace rebuilds the live
RwLock client list; POST /api/remotes/test checks reachability per kind.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
Task 3: Web UI editor section
Files:
- Modify:
src/web_ui/index.html— a "Federation peers" section in the Settings modal + its JS.
Interfaces:
-
Consumes:
GET/PUT /api/remotes,POST /api/remotes/test(Tasks 1–2). -
Step 1: Add the section markup
Inside the Settings modal (find the settings form container in src/web_ui/index.html), add a section using existing theme vars:
<div class="settings-section" id="remotesSection">
<h3>Federation peers</h3>
<p class="muted">Browse other Catacomb instances, or PeerTube channels, from this one.</p>
<div id="remotesRows"></div>
<button type="button" class="btn" id="addRemoteBtn">+ Add peer</button>
<button type="button" class="btn btn-primary" id="saveRemotesBtn">Save peers</button>
<span id="remotesStatus" class="muted"></span>
</div>
(Match the surrounding markup's class names; settings-section/btn/muted are placeholders — use whatever the modal already uses.)
- Step 2: Add the JS
In the <script> block, add (adapt fetch/api() helper to the file's existing one):
let remotesModel = []; // {name,url,kind,username,has_password,password?}
async function loadRemotes() {
const r = await api('/api/remotes');
remotesModel = (await r.json()).map(x => ({
name: x.name, url: x.url, kind: x.kind || 'catacomb',
username: x.username || '', has_password: !!x.has_password, password: ''
}));
renderRemotes();
}
function renderRemotes() {
const box = document.getElementById('remotesRows');
box.innerHTML = '';
remotesModel.forEach((rm, i) => {
const row = document.createElement('div');
row.className = 'remote-row';
row.innerHTML = `
<select data-i="${i}" class="rm-kind">
<option value="catacomb"${rm.kind==='catacomb'?' selected':''}>Catacomb</option>
<option value="peertube"${rm.kind==='peertube'?' selected':''}>PeerTube</option>
</select>
<input data-i="${i}" class="rm-name" placeholder="name" value="${escapeHtml(rm.name)}">
<input data-i="${i}" class="rm-url" placeholder="url" value="${escapeHtml(rm.url)}">
<input data-i="${i}" class="rm-user" placeholder="username" value="${escapeHtml(rm.username)}"
style="display:${rm.kind==='peertube'?'inline-block':'none'}">
<input data-i="${i}" class="rm-pass" type="password"
placeholder="${rm.has_password?'password set — leave blank to keep':'password'}">
<button type="button" data-i="${i}" class="rm-test">Test</button>
<button type="button" data-i="${i}" class="rm-del">✕</button>
<span class="rm-result muted" data-i="${i}"></span>`;
box.appendChild(row);
});
box.querySelectorAll('.rm-kind').forEach(el => el.onchange = e => {
remotesModel[+e.target.dataset.i].kind = e.target.value; renderRemotes();
});
const bind = (cls, field) => box.querySelectorAll('.'+cls).forEach(el =>
el.oninput = e => { remotesModel[+e.target.dataset.i][field] = e.target.value; });
bind('rm-name','name'); bind('rm-url','url'); bind('rm-user','username'); bind('rm-pass','password');
box.querySelectorAll('.rm-del').forEach(el => el.onclick = e => {
remotesModel.splice(+e.target.dataset.i, 1); renderRemotes();
});
box.querySelectorAll('.rm-test').forEach(el => el.onclick = async e => {
const i = +e.target.dataset.i, rm = remotesModel[i];
const out = box.querySelector('.rm-result[data-i="'+i+'"]');
out.textContent = 'testing…';
const r = await api('/api/remotes/test', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ url: rm.url, kind: rm.kind, username: rm.username, password: rm.password || null }) });
const j = await r.json();
out.textContent = j.ok ? ('✓ ok' + (j.channels!=null?` (${j.channels} channels)`:'')) : ('✗ ' + (j.error||'failed'));
});
}
document.getElementById('addRemoteBtn').onclick = () => {
remotesModel.push({ name:'', url:'', kind:'catacomb', username:'', has_password:false, password:'' });
renderRemotes();
};
document.getElementById('saveRemotesBtn').onclick = async () => {
const payload = remotesModel.map(rm => ({
name: rm.name, url: rm.url, kind: rm.kind,
username: rm.kind==='peertube' ? (rm.username||null) : null,
password: rm.password ? rm.password : null
}));
const r = await api('/api/remotes', { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
document.getElementById('remotesStatus').textContent = r.ok ? 'Saved.' : 'Save failed.';
if (r.ok) { await loadRemotes(); if (typeof loadRemoteSwitcher==='function') loadRemoteSwitcher(); }
};
Call loadRemotes() when the Settings modal opens (add to the existing settings-open handler). escapeHtml and api() should already exist in the file; if escapeHtml doesn't, add a small helper.
- Step 3: Syntax-check the SPA JS
Run: awk '/<script>/{f=1;next}/<\/script>/{f=0}f' src/web_ui/index.html > /tmp/spa.js && node --check /tmp/spa.js && echo "JS OK"
Expected: JS OK.
- Step 4: Build + visual check
Run: cargo build --release 2>&1 | grep -E "^error" | head → no output.
Then run the server against a scratch dir and confirm in a browser (or the headless-chromium harness per HANDOFF gotcha #6) that the Settings modal shows the section, Add/Test/Save work, the username field toggles with kind, and a saved catacomb peer still appears in the remote switcher.
- Step 5: Commit
export SSH_AUTH_SOCK=/tmp/luna-ssh-agent.sock
git add src/web_ui/index.html
git commit -m "feat(web-ui): federation peers editor (add/edit/remove/test, kind-aware)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
Task 4: Desktop editor section + test thread
Files:
- Modify:
src/app.rs— a "Federation peers" section in the Settings screen; a test-connection thread + result channel.
Interfaces:
-
Consumes: Task 1
RemoteClientKind;RemoteSectionediting onself.config.remotes. -
Step 1: Add a test-result channel field
In the App struct (near remote_rx), add:
/// Test-connection result for the settings editor: (row index, message).
remote_test_rx: Option<Receiver<(usize, String)>>,
Initialise remote_test_rx: None in the constructor.
- Step 2: Render the editor section in the Settings screen
In settings_screen (find the settings UI in src/app.rs), add a section:
ui.separator();
ui.heading("Federation peers");
ui.label(egui::RichText::new(
"Browse other Catacomb instances, or PeerTube channels, from this one.")
.weak().small());
let mut remove: Option<usize> = None;
let mut test: Option<usize> = None;
for (i, r) in self.config.remotes.iter_mut().enumerate() {
ui.horizontal(|ui| {
egui::ComboBox::from_id_source(("rm-kind", i))
.selected_text(match r.kind {
crate::config::RemoteKind::Catacomb => "Catacomb",
crate::config::RemoteKind::Peertube => "PeerTube",
})
.show_ui(ui, |ui| {
ui.selectable_value(&mut r.kind, crate::config::RemoteKind::Catacomb, "Catacomb");
ui.selectable_value(&mut r.kind, crate::config::RemoteKind::Peertube, "PeerTube");
});
ui.add(egui::TextEdit::singleline(&mut r.name).hint_text("name").desired_width(90.0));
ui.add(egui::TextEdit::singleline(&mut r.url).hint_text("url").desired_width(180.0));
if r.kind == crate::config::RemoteKind::Peertube {
let mut user = r.username.clone().unwrap_or_default();
if ui.add(egui::TextEdit::singleline(&mut user).hint_text("username").desired_width(90.0)).changed() {
r.username = if user.is_empty() { None } else { Some(user) };
}
}
let mut pass = r.password.clone().unwrap_or_default();
if ui.add(egui::TextEdit::singleline(&mut pass).password(true).hint_text("password").desired_width(90.0)).changed() {
r.password = if pass.is_empty() { None } else { Some(pass) };
}
if ui.button("Test").clicked() { test = Some(i); }
if ui.button("✕").clicked() { remove = Some(i); }
});
}
if ui.button("+ Add peer").clicked() {
self.config.remotes.push(crate::config::RemoteSection {
name: String::new(), url: String::new(),
kind: crate::config::RemoteKind::Catacomb, username: None, password: None,
});
}
if let Some(i) = remove { self.config.remotes.remove(i); }
if let Some(i) = test { self.start_remote_test(i); }
if !self.remote_status.is_empty() {
ui.label(egui::RichText::new(&self.remote_status).weak().small());
}
(Place this before the settings "Save" button. The existing Save path already calls self.config.save(...); extend that handler — Step 4.)
- Step 3: Add the test thread
Add to impl App:
/// Reachability-test the remote at `self.config.remotes[idx]` on a thread.
fn start_remote_test(&mut self, idx: usize) {
let Some(section) = self.config.remotes.get(idx).cloned() else { return };
self.remote_status = format!("Testing {}…", section.name);
let (tx, rx) = std::sync::mpsc::channel();
self.remote_test_rx = Some(rx);
let ctx = self.egui_ctx.clone();
std::thread::spawn(move || {
let msg = match crate::remote::RemoteClientKind::from_section(§ion) {
crate::remote::RemoteClientKind::Catacomb(c) => match c.library_json() {
Ok(_) => "✓ reachable".to_string(),
Err(e) => format!("✗ {e}"),
},
crate::remote::RemoteClientKind::Peertube(p) => match p.list_channels() {
Ok(ch) => format!("✓ {} channels", ch.len()),
Err(e) => format!("✗ {e}"),
},
};
let _ = tx.send((idx, msg));
ctx.request_repaint();
});
}
- Step 4: Drain the test result + rebuild remotes on save
In update() (near the remote_rx drain), add:
if let Some((_, msg)) = self.remote_test_rx.as_ref().and_then(|rx| rx.try_recv().ok()) {
self.remote_status = msg;
self.remote_test_rx = None;
ctx.request_repaint();
}
In the settings Save handler (where self.config.save(&self.config_path) runs), after a successful save add:
self.remotes = self.config.remotes.iter()
.map(|r| std::sync::Arc::new(crate::remote::RemoteClientKind::from_section(r)))
.collect();
self.remote_selected = None;
self.remote_library = None;
- Step 5: Build + full tests
Run: cargo build --release 2>&1 | grep -E "^error" | head → no output.
Run: cargo test --release 2>&1 | grep "test result" → all ok.
- Step 6: Manual verify (desktop)
Launch the GUI (XWayland recipe from HANDOFF), open Settings, add a catacomb peer and a PeerTube remote (https://framatube.org/c/<channel>), Test each (expect ✓ + channel count for PeerTube), Save; confirm the Remotes screen lists both, catacomb browse works, and clicking the PeerTube remote shows the phase-3 stopgap message.
- Step 7: Commit
export SSH_AUTH_SOCK=/tmp/luna-ssh-agent.sock
git add src/app.rs
git commit -m "feat(desktop): federation peers editor (add/edit/remove/test, kind-aware)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
Self-Review
Spec coverage:
- Multi-kind storage (
RemoteClientKindenum, both lists, webRwLock) → Task 1. ✓ - Browse dispatch + PeerTube stopgap → Task 1 Steps 5/7. ✓
- Editor UI (kind selector + conditional username, both UIs) → Tasks 3/4. ✓
- Web API deltas (
GETkind/has_password,PUT,POST /test) → Tasks 1/2. ✓ - URL-keyed write-only password merge → Task 2
merge_remote_passwords+ test. ✓ - Test-connection kind-branched → Task 2 (web) + Task 4 (desktop). ✓
- Live-apply rebuild both fronts → Task 2
put_remotes, Task 4 Step 4. ✓ - Testing:
from_sectionunit, merge unit,tests/api.rsPUT/GET round-trip, manual → Tasks 1/2/3/4. ✓
Placeholder scan: UI class-name/api()/escapeHtml/settings-container references are explicitly "match the file's existing helper/markup" — they name the exact integration point, not vague work. All logic steps carry concrete code.
Type consistency: RemoteClientKind::{from_section,name,kind}, RemoteInput { name,url,kind,username,password }, merge_remote_passwords(&[RemoteInput], &[RemoteSection]) -> Vec<RemoteSection>, and the has_password/base_url accessors are named/typed identically where defined (Tasks 1/2) and used (Tasks 2/3/4). RemoteKind is Clone (config phase 1) so i.kind.clone() in merge is valid. ✓