Refactor: remove desktop/Tauri bundle, add i18n, CI/CD pipeline
Nightly Build / build (push) Failing after 6s
Nightly Build / build (push) Failing after 6s
- Remove desktop (Tauri) bundle: docs/desktop.md, icons/, tauri.conf.json, src/desktop/mod.rs, gen/schemas/ - Remove build.rs (no longer needed) - Add i18n system (crates/core-api, plugin-mobile-connector, web) - Refactor config system (src/config.rs, boot_format.rs) - Add mobile connector features (app, router, device pairing) - Plugin system improvements (skald-core) - Update dependencies (Cargo.lock, Cargo.toml) - CI/CD: Gitea Actions workflows (nightly + release), package.sh, verify-version.sh, builds.skaldagent.net config
This commit is contained in:
@@ -6,10 +6,23 @@
|
||||
// `Plugin::web_pages` contract): they talk only to `/api/plugin/<id>/…` and,
|
||||
// for the user directory used by the reassign dropdown, the host `/api/users`
|
||||
// (the fragment runs with the logged-in admin's full session privileges).
|
||||
//
|
||||
// i18n: the plugin ships its own dictionary (`./i18n.js`) and registers it into
|
||||
// the host's shared strings via `addStrings` (imported from the app root by the
|
||||
// absolute `/lib/i18n.js` specifier — the same module the host app uses, so
|
||||
// `t()` and `locale-changed` are shared). `MobileBase` mixes in `I18nMixin` so
|
||||
// every fragment re-renders on a language switch. Register once, at module load.
|
||||
import { LitElement } from 'lit';
|
||||
import { t, addStrings, I18nMixin } from '/lib/i18n.js';
|
||||
import STRINGS from './i18n.js';
|
||||
|
||||
addStrings(STRINGS);
|
||||
|
||||
export { t };
|
||||
|
||||
/// JSON fetch that throws the server's error text on non-2xx and tolerates an
|
||||
/// empty (204) body.
|
||||
/// empty (204) body. The server's error text is already localized (the backend
|
||||
/// resolves the caller's locale), so it is safe to surface directly.
|
||||
export async function jf(url, opts = {}) {
|
||||
const res = await fetch(url, {
|
||||
headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
|
||||
@@ -25,27 +38,27 @@ export async function jf(url, opts = {}) {
|
||||
}
|
||||
|
||||
/// Base for the console fragments: renders into light DOM (so Bootstrap classes
|
||||
/// and the app's theme CSS variables apply) and exposes the plugin's API root
|
||||
/// from the host-set `plugin-id` attribute.
|
||||
export class MobileBase extends LitElement {
|
||||
/// and the app's theme CSS variables apply), re-renders on locale change, and
|
||||
/// exposes the plugin's API root from the host-set `plugin-id` attribute.
|
||||
export class MobileBase extends I18nMixin(LitElement) {
|
||||
createRenderRoot() { return this; }
|
||||
get api() { return `/api/plugin/${this.getAttribute('plugin-id') || 'mobile-connector'}`; }
|
||||
}
|
||||
|
||||
/// Human-friendly "time ago" for a Unix-ms timestamp (or "—" when absent).
|
||||
/// Human-friendly, localized "time ago" for a Unix-ms timestamp (or "—" when absent).
|
||||
export function ago(ms) {
|
||||
if (!ms) return '—';
|
||||
if (!ms) return t('plugin.mobile-connector.time.never');
|
||||
const s = Math.max(0, Math.floor((Date.now() - ms) / 1000));
|
||||
if (s < 60) return `${s}s ago`;
|
||||
if (s < 60) return t('plugin.mobile-connector.time.ago_s', { n: s });
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
if (m < 60) return t('plugin.mobile-connector.time.ago_m', { n: m });
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
if (h < 24) return t('plugin.mobile-connector.time.ago_h', { n: h });
|
||||
return t('plugin.mobile-connector.time.ago_d', { n: Math.floor(h / 24) });
|
||||
}
|
||||
|
||||
/// Best-effort device label from the `device_info` JSON a phone sends on hello.
|
||||
export function deviceLabel(d) {
|
||||
const info = d.device_info || {};
|
||||
return info.name || info.model || info.device || d.platform || 'Unknown device';
|
||||
return info.name || info.model || info.device || d.platform || t('plugin.mobile-connector.devices.unknown');
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
// from the host `/api/users` (the fragment runs with the admin's session).
|
||||
// Default-exports the element class; the host registers it.
|
||||
import { html, nothing } from 'lit';
|
||||
import { MobileBase, jf, ago, deviceLabel } from './common.js';
|
||||
import { MobileBase, jf, ago, deviceLabel, t } from './common.js';
|
||||
|
||||
const P = 'plugin.mobile-connector';
|
||||
|
||||
export default class MobileDevicesPage extends MobileBase {
|
||||
static get properties() {
|
||||
@@ -67,7 +69,7 @@ export default class MobileDevicesPage extends MobileBase {
|
||||
}
|
||||
|
||||
async _revoke(pubkey) {
|
||||
if (!confirm('Revoke this device? It loses access immediately.')) return;
|
||||
if (!confirm(t(`${P}.devices.revoke_confirm`))) return;
|
||||
try {
|
||||
await jf(`${this.api}/devices/revoke`, { method: 'POST', body: JSON.stringify({ pubkey }) });
|
||||
await this._load();
|
||||
@@ -79,13 +81,13 @@ export default class MobileDevicesPage extends MobileBase {
|
||||
return html`
|
||||
<div class="um-page">
|
||||
<div class="um-header d-flex justify-content-between align-items-center">
|
||||
<h2 class="um-title"><i class="bi bi-phone me-2"></i>Mobile devices</h2>
|
||||
<h2 class="um-title"><i class="bi bi-phone me-2"></i>${t(`${P}.devices.title`)}</h2>
|
||||
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._load()}>
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Refresh</button>
|
||||
<i class="bi bi-arrow-repeat me-1"></i>${t(`${P}.devices.refresh`)}</button>
|
||||
</div>
|
||||
<div style="padding:0 1.25rem 1.5rem">
|
||||
${this._error ? html`<div class="alert alert-danger py-2" style="font-size:.85rem">${this._error}</div>` : nothing}
|
||||
${loading ? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> Loading…</div>` : this._renderList()}
|
||||
${loading ? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> ${t(`${P}.devices.loading`)}</div>` : this._renderList()}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -94,15 +96,15 @@ export default class MobileDevicesPage extends MobileBase {
|
||||
const rows = this._devices || [];
|
||||
if (!rows.length) {
|
||||
return html`<div class="um-empty" style="padding:1rem">
|
||||
<i class="bi bi-phone"></i><p>No paired devices yet.</p>
|
||||
<p style="font-size:.8rem;opacity:.7">Use the <em>Pair a device</em> page to add one.</p>
|
||||
<i class="bi bi-phone"></i><p>${t(`${P}.devices.empty`)}</p>
|
||||
<p style="font-size:.8rem;opacity:.7">${t(`${P}.devices.empty_hint`)}</p>
|
||||
</div>`;
|
||||
}
|
||||
return html`
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle" style="font-size:.88rem">
|
||||
<thead><tr>
|
||||
<th>Device</th><th>State</th><th>Bound to</th><th>Last seen</th><th class="text-end">Actions</th>
|
||||
<th>${t(`${P}.devices.col_device`)}</th><th>${t(`${P}.devices.col_state`)}</th><th>${t(`${P}.devices.col_bound`)}</th><th>${t(`${P}.devices.col_last_seen`)}</th><th class="text-end">${t(`${P}.devices.col_actions`)}</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows.map(d => this._renderRow(d))}</tbody>
|
||||
</table>
|
||||
@@ -119,7 +121,7 @@ export default class MobileDevicesPage extends MobileBase {
|
||||
${d.pubkey.slice(0, 16)}…</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge ${authorized ? 'text-bg-success' : 'text-bg-secondary'}">${d.state}</span>
|
||||
<span class="badge ${authorized ? 'text-bg-success' : 'text-bg-secondary'}">${t(`${P}.devices.state_${d.state}`)}</span>
|
||||
</td>
|
||||
<td>${d.bound_user ? this._userName(d.bound_user) : html`<span class="text-body-secondary">—</span>`}</td>
|
||||
<td class="text-body-secondary">${ago(d.last_seen)}</td>
|
||||
@@ -128,12 +130,12 @@ export default class MobileDevicesPage extends MobileBase {
|
||||
<select class="form-select form-select-sm" style="width:auto"
|
||||
.value=${this._pick[d.pubkey] || d.bound_user || ''}
|
||||
@change=${(e) => { this._pick = { ...this._pick, [d.pubkey]: e.target.value }; }}>
|
||||
<option value="">Assign to…</option>
|
||||
<option value="">${t(`${P}.devices.assign_to`)}</option>
|
||||
${this._users.map(u => html`<option value=${u.id}>${u.display_name || u.username}</option>`)}
|
||||
</select>
|
||||
<button class="btn btn-sm btn-primary"
|
||||
?disabled=${!this._pick[d.pubkey] || this._pick[d.pubkey] === d.bound_user}
|
||||
@click=${() => this._bind(d.pubkey)}>Bind</button>
|
||||
@click=${() => this._bind(d.pubkey)}>${t(`${P}.devices.bind`)}</button>
|
||||
<button class="btn btn-sm btn-outline-danger" @click=${() => this._revoke(d.pubkey)}>
|
||||
<i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// Frontend translations for the mobile-connector page fragments.
|
||||
//
|
||||
// Served at `/api/plugin/mobile-connector/web/i18n.js` and imported by
|
||||
// `common.js`, which registers it into the host's shared dictionaries via
|
||||
// `addStrings` (see `web/lib/i18n.js`). Keys are namespaced `plugin.mobile-
|
||||
// connector.*` so they never collide with core keys. These are the *frontend*
|
||||
// UI strings; the plugin's backend error strings live in `../i18n/*.json` and
|
||||
// reach the browser already translated as HTTP response text.
|
||||
const P = 'plugin.mobile-connector';
|
||||
|
||||
export default {
|
||||
en: {
|
||||
[`${P}.pairing.title`]: 'Pair a device',
|
||||
[`${P}.pairing.intro`]: 'Open a pairing window, then scan the QR code with the Skald mobile app. The device is linked to you and works immediately — you can reassign it to another user from the Mobile devices page.',
|
||||
[`${P}.pairing.open`]: 'Open pairing window',
|
||||
[`${P}.pairing.opening`]: 'Opening…',
|
||||
[`${P}.pairing.qr_alt`]: 'Pairing QR',
|
||||
[`${P}.pairing.expired`]: 'Window expired',
|
||||
[`${P}.pairing.scan_within`]: 'Scan within {n}s',
|
||||
[`${P}.pairing.new_code`]: 'New code',
|
||||
[`${P}.pairing.close`]: 'Close',
|
||||
|
||||
[`${P}.devices.title`]: 'Mobile devices',
|
||||
[`${P}.devices.refresh`]: 'Refresh',
|
||||
[`${P}.devices.loading`]: 'Loading…',
|
||||
[`${P}.devices.empty`]: 'No paired devices yet.',
|
||||
[`${P}.devices.empty_hint`]: 'Use the Pair a device page to add one.',
|
||||
[`${P}.devices.col_device`]: 'Device',
|
||||
[`${P}.devices.col_state`]: 'State',
|
||||
[`${P}.devices.col_bound`]: 'Bound to',
|
||||
[`${P}.devices.col_last_seen`]: 'Last seen',
|
||||
[`${P}.devices.col_actions`]: 'Actions',
|
||||
[`${P}.devices.state_authorized`]: 'authorized',
|
||||
[`${P}.devices.state_pending`]: 'pending',
|
||||
[`${P}.devices.assign_to`]: 'Assign to…',
|
||||
[`${P}.devices.bind`]: 'Bind',
|
||||
[`${P}.devices.revoke_confirm`]: 'Revoke this device? It loses access immediately.',
|
||||
[`${P}.devices.unknown`]: 'Unknown device',
|
||||
|
||||
[`${P}.time.never`]: '—',
|
||||
[`${P}.time.ago_s`]: '{n}s ago',
|
||||
[`${P}.time.ago_m`]: '{n}m ago',
|
||||
[`${P}.time.ago_h`]: '{n}h ago',
|
||||
[`${P}.time.ago_d`]: '{n}d ago',
|
||||
},
|
||||
|
||||
it: {
|
||||
[`${P}.pairing.title`]: 'Associa un dispositivo',
|
||||
[`${P}.pairing.intro`]: 'Apri una finestra di associazione, poi scansiona il codice QR con l’app Skald sul telefono. Il dispositivo viene collegato a te e funziona subito — puoi riassegnarlo a un altro utente dalla pagina Dispositivi mobili.',
|
||||
[`${P}.pairing.open`]: 'Apri finestra di associazione',
|
||||
[`${P}.pairing.opening`]: 'Apertura…',
|
||||
[`${P}.pairing.qr_alt`]: 'QR di associazione',
|
||||
[`${P}.pairing.expired`]: 'Finestra scaduta',
|
||||
[`${P}.pairing.scan_within`]: 'Scansiona entro {n}s',
|
||||
[`${P}.pairing.new_code`]: 'Nuovo codice',
|
||||
[`${P}.pairing.close`]: 'Chiudi',
|
||||
|
||||
[`${P}.devices.title`]: 'Dispositivi mobili',
|
||||
[`${P}.devices.refresh`]: 'Aggiorna',
|
||||
[`${P}.devices.loading`]: 'Caricamento…',
|
||||
[`${P}.devices.empty`]: 'Nessun dispositivo associato.',
|
||||
[`${P}.devices.empty_hint`]: 'Usa la pagina Associa un dispositivo per aggiungerne uno.',
|
||||
[`${P}.devices.col_device`]: 'Dispositivo',
|
||||
[`${P}.devices.col_state`]: 'Stato',
|
||||
[`${P}.devices.col_bound`]: 'Assegnato a',
|
||||
[`${P}.devices.col_last_seen`]: 'Ultimo accesso',
|
||||
[`${P}.devices.col_actions`]: 'Azioni',
|
||||
[`${P}.devices.state_authorized`]: 'autorizzato',
|
||||
[`${P}.devices.state_pending`]: 'in attesa',
|
||||
[`${P}.devices.assign_to`]: 'Assegna a…',
|
||||
[`${P}.devices.bind`]: 'Associa',
|
||||
[`${P}.devices.revoke_confirm`]: 'Revocare questo dispositivo? Perderà l’accesso immediatamente.',
|
||||
[`${P}.devices.unknown`]: 'Dispositivo sconosciuto',
|
||||
|
||||
[`${P}.time.never`]: '—',
|
||||
[`${P}.time.ago_s`]: '{n}s fa',
|
||||
[`${P}.time.ago_m`]: '{n}m fa',
|
||||
[`${P}.time.ago_h`]: '{n}h fa',
|
||||
[`${P}.time.ago_d`]: '{n}g fa',
|
||||
},
|
||||
|
||||
fr: {
|
||||
[`${P}.pairing.title`]: 'Associer un appareil',
|
||||
[`${P}.pairing.intro`]: 'Ouvrez une fenêtre d’association, puis scannez le QR code avec l’app mobile Skald. L’appareil est lié à vous et fonctionne immédiatement — vous pouvez le réassigner à un autre utilisateur depuis la page Appareils mobiles.',
|
||||
[`${P}.pairing.open`]: 'Ouvrir la fenêtre d’association',
|
||||
[`${P}.pairing.opening`]: 'Ouverture…',
|
||||
[`${P}.pairing.qr_alt`]: 'QR d’association',
|
||||
[`${P}.pairing.expired`]: 'Fenêtre expirée',
|
||||
[`${P}.pairing.scan_within`]: 'Scannez sous {n}s',
|
||||
[`${P}.pairing.new_code`]: 'Nouveau code',
|
||||
[`${P}.pairing.close`]: 'Fermer',
|
||||
|
||||
[`${P}.devices.title`]: 'Appareils mobiles',
|
||||
[`${P}.devices.refresh`]: 'Actualiser',
|
||||
[`${P}.devices.loading`]: 'Chargement…',
|
||||
[`${P}.devices.empty`]: 'Aucun appareil associé.',
|
||||
[`${P}.devices.empty_hint`]: 'Utilisez la page Associer un appareil pour en ajouter un.',
|
||||
[`${P}.devices.col_device`]: 'Appareil',
|
||||
[`${P}.devices.col_state`]: 'État',
|
||||
[`${P}.devices.col_bound`]: 'Assigné à',
|
||||
[`${P}.devices.col_last_seen`]: 'Vu la dernière fois',
|
||||
[`${P}.devices.col_actions`]: 'Actions',
|
||||
[`${P}.devices.state_authorized`]: 'autorisé',
|
||||
[`${P}.devices.state_pending`]: 'en attente',
|
||||
[`${P}.devices.assign_to`]: 'Assigner à…',
|
||||
[`${P}.devices.bind`]: 'Associer',
|
||||
[`${P}.devices.revoke_confirm`]: 'Révoquer cet appareil ? Il perd l’accès immédiatement.',
|
||||
[`${P}.devices.unknown`]: 'Appareil inconnu',
|
||||
|
||||
[`${P}.time.never`]: '—',
|
||||
[`${P}.time.ago_s`]: 'il y a {n}s',
|
||||
[`${P}.time.ago_m`]: 'il y a {n}m',
|
||||
[`${P}.time.ago_h`]: 'il y a {n}h',
|
||||
[`${P}.time.ago_d`]: 'il y a {n}j',
|
||||
},
|
||||
};
|
||||
@@ -6,7 +6,9 @@
|
||||
// is usable on the phone immediately and can be reassigned later from the
|
||||
// Devices page. Default-exports the element class; the host registers it.
|
||||
import { html, nothing } from 'lit';
|
||||
import { MobileBase, jf } from './common.js';
|
||||
import { MobileBase, jf, t } from './common.js';
|
||||
|
||||
const P = 'plugin.mobile-connector';
|
||||
|
||||
export default class MobilePairingPage extends MobileBase {
|
||||
static get properties() {
|
||||
@@ -73,36 +75,34 @@ export default class MobilePairingPage extends MobileBase {
|
||||
return html`
|
||||
<div class="um-page">
|
||||
<div class="um-header">
|
||||
<h2 class="um-title"><i class="bi bi-qr-code me-2"></i>Pair a device</h2>
|
||||
<h2 class="um-title"><i class="bi bi-qr-code me-2"></i>${t(`${P}.pairing.title`)}</h2>
|
||||
</div>
|
||||
<div style="padding:0 1.25rem 1.5rem; max-width:640px">
|
||||
${this._error ? html`<div class="alert alert-danger py-2" style="font-size:.85rem">${this._error}</div>` : nothing}
|
||||
|
||||
${!this._session ? html`
|
||||
<p class="text-body-secondary" style="font-size:.9rem">
|
||||
Open a pairing window, then scan the QR code with the Skald mobile app.
|
||||
The device is linked to <strong>you</strong> and works immediately — you can
|
||||
reassign it to another user from the <em>Mobile devices</em> page.
|
||||
${t(`${P}.pairing.intro`)}
|
||||
</p>
|
||||
<button class="btn btn-primary" ?disabled=${this._busy} @click=${() => this._open()}>
|
||||
<i class="bi bi-qr-code-scan me-1"></i>${this._busy ? 'Opening…' : 'Open pairing window'}
|
||||
<i class="bi bi-qr-code-scan me-1"></i>${this._busy ? t(`${P}.pairing.opening`) : t(`${P}.pairing.open`)}
|
||||
</button>
|
||||
` : html`
|
||||
<div class="d-flex flex-column align-items-center gap-3 p-3"
|
||||
style="border:1px solid var(--border-color,#ddd); border-radius:var(--radius-md,12px)">
|
||||
<img src=${this._session.url} alt="Pairing QR" width="256" height="256"
|
||||
<img src=${this._session.url} alt=${t(`${P}.pairing.qr_alt`)} width="256" height="256"
|
||||
style="image-rendering:pixelated; ${expired ? 'opacity:.25' : ''}" />
|
||||
${expired
|
||||
? html`<div class="text-danger" style="font-size:.9rem"><i class="bi bi-clock-history me-1"></i>Window expired</div>`
|
||||
? html`<div class="text-danger" style="font-size:.9rem"><i class="bi bi-clock-history me-1"></i>${t(`${P}.pairing.expired`)}</div>`
|
||||
: html`<div class="text-body-secondary" style="font-size:.9rem">
|
||||
Scan within <strong>${this._remain}s</strong>
|
||||
${t(`${P}.pairing.scan_within`, { n: this._remain })}
|
||||
</div>`}
|
||||
<div class="d-flex gap-2">
|
||||
${expired
|
||||
? html`<button class="btn btn-primary btn-sm" @click=${() => this._open()}>
|
||||
<i class="bi bi-arrow-repeat me-1"></i>New code</button>`
|
||||
<i class="bi bi-arrow-repeat me-1"></i>${t(`${P}.pairing.new_code`)}</button>`
|
||||
: html`<button class="btn btn-outline-secondary btn-sm" @click=${() => this._stop()}>
|
||||
<i class="bi bi-x-lg me-1"></i>Close</button>`}
|
||||
<i class="bi bi-x-lg me-1"></i>${t(`${P}.pairing.close`)}</button>`}
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
Reference in New Issue
Block a user