fix(mesh): setup modal re-fires on EVERY plug — dismissals key on plug time

detected_device_info now carries plugged_at (the /dev node's creation
time; udev recreates it per plug). Dismissals store (path -> plugged_at),
so swapping sticks or a fast unplug/replug between status polls can no
longer be suppressed by an old 'Not Now' (v1 path-only dismissals are
abandoned wholesale).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-22 19:08:18 -04:00
parent 197e351880
commit cad68eb941
2 changed files with 42 additions and 14 deletions

View File

@ -572,6 +572,12 @@ pub struct DetectedDeviceInfo {
pub pid: Option<String>,
pub product: Option<String>,
pub manufacturer: Option<String>,
/// Unix epoch seconds of the /dev node's creation — udev recreates the
/// node on every plug, so this changes on each replug. The UI keys its
/// "Not Now" dismissals on (path, plugged_at): swapping a stick (or
/// unplug/replug faster than a status poll) invalidates old dismissals
/// and the setup modal fires again, per the hot-swap UX (2026-07-22).
pub plugged_at: Option<u64>,
}
/// Like `detect_serial_devices`, but with USB metadata per port.
@ -579,12 +585,19 @@ pub async fn detect_serial_devices_info() -> Vec<DetectedDeviceInfo> {
let mut out = Vec::new();
for path in detect_serial_devices().await {
let usb = usb_info_for_tty(&path).await;
let plugged_at = tokio::fs::metadata(&path)
.await
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs());
out.push(DetectedDeviceInfo {
path,
vid: usb.0,
pid: usb.1,
product: usb.2,
manufacturer: usb.3,
plugged_at,
});
}
out

View File

@ -34,6 +34,8 @@ export interface MeshStatus {
pid?: string | null
product?: string | null
manufacturer?: string | null
/** Epoch seconds the /dev node appeared — changes on every replug. */
plugged_at?: number | null
}>
/** Hot-swap "keep as is": false = archipelago never writes config to the radio. */
manage_radio?: boolean
@ -288,10 +290,20 @@ export const useMeshStore = defineStore('mesh', () => {
// Dismissals are cleared the moment the port disappears (unplug), so the
// modal re-appears on EVERY plug-in — including swapping a different stick
// into the same /dev path — per the hot-swap UX (2026-07-22).
const DETECT_DISMISS_KEY = 'archipelago.mesh.detect-dismissed.v1'
const dismissedDetectedPaths = ref<Set<string>>(new Set(
JSON.parse(localStorage.getItem(DETECT_DISMISS_KEY) || '[]') as string[]
))
// v2: dismissals key on (path → plugged_at). udev recreates the /dev node
// on every plug, so plugged_at changes on each replug — an old "Not Now"
// can never suppress a NEWLY plugged stick, even when the swap happens
// faster than a status poll (which absence-pruning alone missed) or when
// the same /dev path is reused. v1 (a bare path set) is intentionally
// abandoned: stale one-time dismissals from before 2026-07-22 shouldn't
// suppress anything either.
const DETECT_DISMISS_KEY = 'archipelago.mesh.detect-dismissed.v2'
const dismissedDetected = ref<Record<string, number>>(
JSON.parse(localStorage.getItem(DETECT_DISMISS_KEY) || '{}') as Record<string, number>
)
function pluggedAt(s: MeshStatus, path: string): number {
return s.detected_device_info?.find(d => d.path === path)?.plugged_at ?? 0
}
// Consecutive polls each candidate port has been present-but-not-connected.
// The modal waits for 2 sightings so it doesn't flash during the couple of
// seconds an ordinary reconnect (same radio, transient blip) needs.
@ -300,15 +312,15 @@ export const useMeshStore = defineStore('mesh', () => {
const s = status.value
if (!s) return []
return (s.detected_devices || []).filter(p =>
!dismissedDetectedPaths.value.has(p) &&
dismissedDetected.value[p] !== pluggedAt(s, p) &&
// The port the live session occupies is not a candidate…
!(s.device_connected && s.device_path === p) &&
// …and a port only qualifies once it has survived the blip debounce.
(detectSightings.value[p] ?? 0) >= 2
)
})
/** Called after every status fetch: advance sighting counters and clear
* dismissals/counters for ports that vanished (unplug replug reshows). */
/** Called after every status fetch: advance sighting counters and drop
* dismissals for ports that vanished (belt-and-braces with plugged_at). */
function trackDetectedDevices(s: MeshStatus) {
const present = new Set(s.detected_devices || [])
const next: Record<string, number> = {}
@ -319,21 +331,24 @@ export const useMeshStore = defineStore('mesh', () => {
}
detectSightings.value = next
let dirty = false
for (const p of [...dismissedDetectedPaths.value]) {
for (const p of Object.keys(dismissedDetected.value)) {
if (!present.has(p)) {
dismissedDetectedPaths.value.delete(p)
delete dismissedDetected.value[p]
dirty = true
}
}
if (dirty) {
dismissedDetectedPaths.value = new Set(dismissedDetectedPaths.value)
localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify([...dismissedDetectedPaths.value]))
dismissedDetected.value = { ...dismissedDetected.value }
localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify(dismissedDetected.value))
}
}
function dismissDetectedDevice(path: string) {
dismissedDetectedPaths.value.add(path)
dismissedDetectedPaths.value = new Set(dismissedDetectedPaths.value)
localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify([...dismissedDetectedPaths.value]))
const s = status.value
dismissedDetected.value = {
...dismissedDetected.value,
[path]: s ? pluggedAt(s, path) : 0,
}
localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify(dismissedDetected.value))
}
/** Read-only firmware/config probe of a detected port (hot-swap modal). */
async function probeDevice(path: string): Promise<MeshDeviceProbe> {