// Mobile-connector "Mobile App" console (page_id `app`) — the plugin's single // page: relay connection status, the device list, the pairing dialog, and — // for admins — the settings dialog (the plugin's config lives here, not in the // generic plugin-detail form; see `Plugin::config_in_detail_page`). // // Self-scoped per caller: an admin sees every device and may reassign/revoke // any of them; anyone else sees only their own devices, can pair a new one // (it auto-binds to them) and revoke their own. Default-exports the element // class; the host registers it. import { html, nothing } from 'lit'; import { MobileBase, jf, ago, deviceLabel, t } from './common.js'; const P = 'plugin.mobile-connector'; // Relay presets offered in the settings dialog. The official relay is not in // service yet — shown disabled (the value is still recognised if configured // by hand). A "custom" choice free-forms the wss:// URL. const RELAY_OFFICIAL = 'wss://relay.skaldagent.net/v1/ws'; const RELAY_TEST = 'wss://relay-test.skaldagent.net/v1/ws'; export default class MobileAppPage extends MobileBase { static get properties() { return { _status: { state: true }, // { running, connected, relay_url, last_error } | null _devices: { state: true }, // [] | null (loading) _isAdmin: { state: true }, _users: { state: true }, // admin: [{id, username, display_name}] _pick: { state: true }, // admin: { [pubkey]: user_id } reassign selections _error: { state: true }, _pair: { state: true }, // dialog state | null _cfg: { state: true }, // dialog state | null }; } constructor() { super(); this._status = null; this._devices = null; this._isAdmin = false; this._users = []; this._pick = {}; this._error = null; this._pair = null; this._cfg = null; this._poll = null; this._pairPoll = null; this._pairTimer = null; this._knownPubkeys = new Set(); } connectedCallback() { super.connectedCallback(); this._init(); this._poll = setInterval(() => this._load(true), 5000); } disconnectedCallback() { super.disconnectedCallback(); if (this._poll) { clearInterval(this._poll); this._poll = null; } this._stopPairWatch(); } async _init() { try { const me = await jf('/api/auth/me'); this._isAdmin = me?.role_id === 'admin'; } catch { this._isAdmin = false; } await this._load(); } async _load(quiet = false) { if (!quiet) this._error = null; try { this._status = await jf(`${this.api}/status`); } catch (e) { if (!quiet) this._error = e.message; this._status = { running: false, connected: false, relay_url: null, last_error: null }; } if (!this._status.running) { this._devices = []; return; } try { const d = await jf(`${this.api}/devices`); this._devices = d.devices || []; if (this._isAdmin && !this._users.length) { try { this._users = await jf('/api/users'); } catch { /* the reassign dropdown stays empty */ } } this._detectPairing(); } catch (e) { if (!quiet) this._error = e.message; if (this._devices === null) this._devices = []; } } // ── Pairing dialog ───────────────────────────────────────────────────────── _detectPairing() { // While the dialog is open, a pubkey we have never seen means the phone // just scanned the QR — switch the dialog to its success state. if (!this._pair || !this._pair.session || this._pair.paired) { this._knownPubkeys = new Set((this._devices || []).map(d => d.pubkey)); return; } const fresh = (this._devices || []).find(d => !this._knownPubkeys.has(d.pubkey)); if (fresh) { this._pair = { ...this._pair, paired: true }; this._stopPairWatch(); } } _startPairWatch() { this._stopPairWatch(); this._pairPoll = setInterval(() => this._load(true), 2000); const tick = () => { if (!this._pair?.session) return this._stopPairWatch(); const remain = Math.max(0, Math.round((this._pair.session.expires_at - Date.now()) / 1000)); this._pair = { ...this._pair, remain }; if (remain <= 0 && this._pairTimer) { clearInterval(this._pairTimer); this._pairTimer = null; } }; tick(); this._pairTimer = setInterval(tick, 1000); } _stopPairWatch() { if (this._pairPoll) { clearInterval(this._pairPoll); this._pairPoll = null; } if (this._pairTimer) { clearInterval(this._pairTimer); this._pairTimer = null; } } async _openPairing() { this._pair = { session: null, remain: 0, busy: true, error: null, paired: false }; this._knownPubkeys = new Set((this._devices || []).map(d => d.pubkey)); try { const session = await jf(`${this.api}/pairing`, { method: 'POST', body: JSON.stringify({}) }); this._pair = { ...this._pair, session, busy: false }; this._startPairWatch(); } catch (e) { this._pair = { ...this._pair, busy: false, error: e.message }; } } async _closePairing() { const had = this._pair?.session && !this._pair.paired; this._stopPairWatch(); this._pair = null; // Best-effort close of the window we opened (a consumed/expired one is // already gone server-side; a paired one belongs to the new device). if (had) { try { await jf(`${this.api}/pairing`, { method: 'DELETE' }); } catch { /* ignore */ } } } // ── Device actions ───────────────────────────────────────────────────────── _userName(id) { const u = this._users.find(x => x.id === id); return u ? (u.display_name || u.username) : id; } async _bind(pubkey) { const user_id = this._pick[pubkey]; if (!user_id) return; try { await jf(`${this.api}/devices/bind`, { method: 'POST', body: JSON.stringify({ pubkey, user_id }) }); await this._load(); } catch (e) { this._error = e.message; } } async _revoke(pubkey) { if (!confirm(t(`${P}.devices.revoke_confirm`))) return; try { await jf(`${this.api}/devices/revoke`, { method: 'POST', body: JSON.stringify({ pubkey }) }); await this._load(); } catch (e) { this._error = e.message; } } // ── Settings dialog (admin) ──────────────────────────────────────────────── async _openConfig() { this._cfg = { loading: true, error: null, ok: false, draft: null, relayChoice: 'test', customUrl: '', enabled: true }; try { const all = await jf('/api/plugins'); const p = (all ?? []).find(x => x.id === 'mobile-connector'); if (!p) throw new Error(t(`${P}.cfg.not_found`)); const c = p.config || {}; const url = c.relay_url || ''; const relayChoice = url === RELAY_OFFICIAL ? 'official' : (url === RELAY_TEST || !url) ? 'test' : 'custom'; this._cfg = { ...this._cfg, loading: false, enabled: !!p.enabled, relayChoice, customUrl: relayChoice === 'custom' ? url : '', draft: { relay_url: url, pairing_ttl: c.pairing_ttl ?? 300, require_device_confirmation: c.require_device_confirmation !== false, notify_delay_secs: c.notify_delay_secs ?? 20, }, }; } catch (e) { this._cfg = { ...this._cfg, loading: false, error: e.message }; } } _patchCfg(key, value) { this._cfg = { ...this._cfg, draft: { ...this._cfg.draft, [key]: value }, ok: false }; } async _saveConfig() { const { draft, relayChoice, customUrl, enabled } = this._cfg; const relay_url = relayChoice === 'custom' ? (customUrl || '').trim() : relayChoice === 'official' ? RELAY_OFFICIAL : RELAY_TEST; if (relayChoice === 'custom' && !/^wss?:\/\/.+/.test(relay_url)) { this._cfg = { ...this._cfg, error: t(`${P}.cfg.bad_url`), ok: false }; return; } this._cfg = { ...this._cfg, busy: true, error: null, ok: false }; try { await jf('/api/plugins/mobile-connector', { method: 'PUT', body: JSON.stringify({ enabled, config: { ...draft, relay_url } }), }); this._cfg = { ...this._cfg, busy: false, ok: true, draft: { ...draft, relay_url } }; // The plugin reloads on save; the status poll picks up the reconnection. setTimeout(() => { if (this._cfg?.ok) this._cfg = null; this._load(true); }, 900); } catch (e) { this._cfg = { ...this._cfg, busy: false, error: e.message }; } } // ── Render ───────────────────────────────────────────────────────────────── render() { return html`
${t(`${P}.devices.empty`)}
${this._status?.connected ? html`${t(`${P}.devices.empty_hint`)}
` : nothing}