feat(fpagent): show scanner availability and gate capture

Consume fpagent's fprint-device-status {available, device, captureAgentId}
in the /fingerprint wizard. Availability is tracked tri-state so an agent
that does not report it leaves capture working as before; the header tag
shows offline / no-scanner / ready, the capture panel warns, and capture is
disabled until a supported reader is attached.

Adds a vm-sandbox test for the wizard logic and the OpenSpec change
fpagent-device-availability, archived with the fingerprint-wizard spec.
This commit is contained in:
2026-10-02 15:12:16 +01:00
parent 35d895574f
commit 548e33390c
11 changed files with 458 additions and 9 deletions
+60 -6
View File
@@ -20,7 +20,9 @@ Vue.config.ignoredElements = [/^cds-/];
session: null,
tick: Date.now(),
socket: null,
agent: { connected: false, version: '', captureAgentId: '' },
// available is tri-state: null = not yet reported (old agent), true/false
// = the last fprint-device-status. Unknown is not blocked.
agent: { connected: false, version: '', captureAgentId: '', available: null, device: '' },
domain: '',
reference: '',
otpLookup: '',
@@ -68,6 +70,27 @@ Vue.config.ignoredElements = [/^cds-/];
if (!this.session || !this.session.domainsCheckedAt) return '';
return new Date(this.session.domainsCheckedAt).toLocaleTimeString();
},
// Warning shown in the capture panel when the agent cannot capture.
scannerNotice: function () {
if (!this.agent.connected) {
return {
title: 'Capture agent offline',
subtitle: 'The fpagent service is not reachable on this machine. Captures are disabled until it reconnects.',
};
}
if (this.agent.available === false) {
return {
title: 'Scanner not connected',
subtitle: 'Attach the fingerprint reader. Captures are disabled until the agent detects it.',
};
}
return null;
},
// Capture is allowed when the agent is connected and no status has said
// the scanner is missing. Unknown availability (old agent) stays allowed.
captureReady: function () {
return this.agent.connected && this.agent.available !== false;
},
},
created: function () {
@@ -139,18 +162,38 @@ Vue.config.ignoredElements = [/^cds-/];
renderAgentTag: function () {
var el = document.getElementById('agent-status');
if (!el) return;
el.textContent = this.agent.connected ? 'Agent connected' : 'Agent offline';
el.setAttribute('type', this.agent.connected ? 'green' : 'red');
el.setAttribute('title', this.agent.version ? 'fpagent ' + this.agent.version : '');
var label, type, title = this.agent.version ? 'fpagent ' + this.agent.version : '';
if (!this.agent.connected) {
label = 'Agent offline'; type = 'red';
} else if (this.agent.available === false) {
label = 'Scanner not connected'; type = 'red';
} else if (this.agent.available === true) {
label = 'Agent ready'; type = 'green';
} else {
// Connected but no status yet (or an agent that does not report it).
label = 'Agent connected'; type = 'green';
}
if (this.agent.available === true && this.agent.device) {
title += ' · ' + this.agent.device;
}
el.textContent = label;
el.setAttribute('type', type);
el.setAttribute('title', title);
},
connectAgent: function () {
var self = this;
this.socket = io(this.session.agentUrl, { reconnectionDelayMax: 5000 });
var offline = function () {
self.agent.connected = false;
self.agent.available = null;
self.agent.device = '';
self.renderAgentTag();
};
this.socket.on('connect', function () { self.agent.connected = true; self.renderAgentTag(); });
this.socket.on('disconnect', function () { self.agent.connected = false; self.renderAgentTag(); });
this.socket.on('connect_error', function () { self.agent.connected = false; self.renderAgentTag(); });
this.socket.on('disconnect', offline);
this.socket.on('connect_error', offline);
this.socket.on('agent-hello', function (data) {
self.agent.version = (data && data.version) || '';
@@ -158,6 +201,12 @@ Vue.config.ignoredElements = [/^cds-/];
self.renderAgentTag();
});
this.socket.on('fprint-device-status', function (data) {
self.agent.available = !!(data && data.available);
self.agent.device = (data && data.device) || '';
self.renderAgentTag();
});
this.socket.on('fprint-enroll-status', function (data) {
if (!self.capturing) return;
self.fingers[self.capturing].stage = (data && data.stage) || 0;
@@ -213,6 +262,11 @@ Vue.config.ignoredElements = [/^cds-/];
capture: function (finger) {
if (this.capturing || !this.socket || !this.domain || !this.reference) return;
if (!this.captureReady) {
var notice = this.scannerNotice;
if (notice) this.showNotif('warning', notice.title, notice.subtitle);
return;
}
// Preserve a previous good capture so a cancelled/failed recapture
// can restore it instead of destroying it.
var old = this.fingers[finger];