RDM-Import, App-Designs, Auto-Update über Gitea, Logo; SFTP-Namensspalte repariert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 15:04:19 +02:00
co-authored by Claude Opus 5.5
parent a5eccc7764
commit 972fe0d316
16 changed files with 650 additions and 46 deletions
+124 -15
View File
@@ -140,14 +140,15 @@ async function reload() {
const b = $('#vaultBadge');
b.innerHTML = S.vault.encrypted ? `${ICONS.lock}<span>Vault verschlüsselt</span>` : `${ICONS.unlock}<span>Vault unverschlüsselt</span>`;
b.title = S.vault.encrypted ? 'Daten werden mit dem System-Schlüsselbund verschlüsselt.' : 'Kein System-Schlüsselbund verfügbar (z. B. gnome-keyring/kwallet installieren).';
applyAppTheme();
S.activeForwards = new Set(await call('fw:active'));
render();
}
// ============================================================ Navigation
$$('#sidebar .nav').forEach((n) => n.addEventListener('click', () => {
$$('#sidebar .nav[data-view]').forEach((n) => n.addEventListener('click', () => {
S.view = n.dataset.view;
$$('#sidebar .nav').forEach((x) => x.classList.toggle('active', x === n));
$$('#sidebar .nav[data-view]').forEach((x) => x.classList.toggle('active', x === n));
closeDrawer();
activateTab('home');
render();
@@ -456,7 +457,7 @@ function viewKeys(page) {
for (const k of list) {
const type = (k.publicKey || '').split(' ')[0] || 'key';
const used = S.vault.hosts.filter((x) => x.keyId === k.id).length;
const card = h(`<div class="card"><div class="avatar" style="background:#2a2f45;color:var(--orange)">${ICONS.key}</div><div class="meta"><div class="title">${esc(k.label)}</div><div class="sub">${esc(type)} · ${used} Host${used === 1 ? '' : 's'}</div></div><div class="actions"><button data-a="copy" title="Public Key kopieren">${ICONS.copy}</button><button data-a="edit" title="Bearbeiten">${ICONS.edit}</button></div></div>`);
const card = h(`<div class="card"><div class="avatar" style="background:var(--icon-bg);color:var(--orange)">${ICONS.key}</div><div class="meta"><div class="title">${esc(k.label)}</div><div class="sub">${esc(type)} · ${used} Host${used === 1 ? '' : 's'}</div></div><div class="actions"><button data-a="copy" title="Public Key kopieren">${ICONS.copy}</button><button data-a="edit" title="Bearbeiten">${ICONS.edit}</button></div></div>`);
card.onclick = (e) => {
const a = e.target.closest('[data-a]')?.dataset.a;
if (a === 'copy') { call('clipboard:write', k.publicKey || ''); toast('Public Key kopiert', 'ok'); }
@@ -519,7 +520,7 @@ function viewSnippets(page) {
if (!list.length) return c.append(emptyState('code', 'Keine Snippets', 'Speichere häufig genutzte Befehle und sende sie mit einem Klick an ein Terminal.', 'Neues Snippet', () => editSnippet()));
const l = h('<div class="list"></div>');
for (const s of list) {
const card = h(`<div class="card"><div class="avatar" style="background:#2a2f45;color:var(--green)">${ICONS.code}</div><div class="meta"><div class="title">${esc(s.label)}</div><div class="sub mono">${esc(s.command.split('\n')[0])}${s.command.includes('\n') ? ' …' : ''}</div></div><div class="actions"><button data-a="run" title="Im aktiven Terminal ausführen">${ICONS.play}</button><button data-a="copy" title="Kopieren">${ICONS.copy}</button><button data-a="del" title="Löschen">${ICONS.trash}</button></div></div>`);
const card = h(`<div class="card"><div class="avatar" style="background:var(--icon-bg);color:var(--green)">${ICONS.code}</div><div class="meta"><div class="title">${esc(s.label)}</div><div class="sub mono">${esc(s.command.split('\n')[0])}${s.command.includes('\n') ? ' …' : ''}</div></div><div class="actions"><button data-a="run" title="Im aktiven Terminal ausführen">${ICONS.play}</button><button data-a="copy" title="Kopieren">${ICONS.copy}</button><button data-a="del" title="Löschen">${ICONS.trash}</button></div></div>`);
card.onclick = async (e) => {
const a = e.target.closest('[data-a]')?.dataset.a;
if (a === 'run') runSnippet(s);
@@ -564,7 +565,7 @@ function viewForwards(page) {
const desc = f.type === 'dynamic' ? `SOCKS5 ${f.bindAddress || '127.0.0.1'}:${f.bindPort}`
: f.type === 'remote' ? `Remote ${f.bindPort} → ${f.destHost || '127.0.0.1'}:${f.destPort}`
: `Lokal ${f.bindAddress || '127.0.0.1'}:${f.bindPort} → ${f.destHost}:${f.destPort}`;
const card = h(`<div class="card"><div class="avatar" style="background:#2a2f45;color:${on ? 'var(--green)' : 'var(--muted)'}">${ICONS.fwd}</div><div class="meta"><div class="title">${esc(f.label || desc)} <span class="status-pill ${on ? 'on' : ''}">${on ? 'aktiv' : 'inaktiv'}</span></div><div class="sub">${esc(desc)} · via ${esc(hst ? hst.label || hst.address : '??')}</div></div>
const card = h(`<div class="card"><div class="avatar" style="background:var(--icon-bg);color:${on ? 'var(--green)' : 'var(--muted)'}">${ICONS.fwd}</div><div class="meta"><div class="title">${esc(f.label || desc)} <span class="status-pill ${on ? 'on' : ''}">${on ? 'aktiv' : 'inaktiv'}</span></div><div class="sub">${esc(desc)} · via ${esc(hst ? hst.label || hst.address : '??')}</div></div>
<button class="btn sm ${on ? '' : 'primary'}" data-a="toggle">${on ? ICONS.stop + 'Stoppen' : ICONS.play + 'Starten'}</button><div class="actions"><button data-a="del">${ICONS.trash}</button></div></div>`);
card.onclick = async (e) => {
const a = e.target.closest('[data-a]')?.dataset.a;
@@ -608,7 +609,7 @@ function viewKnown(page) {
if (!entries.length) return c.append(emptyState('shield', 'Keine bekannten Hosts', 'Host-Schlüssel werden bei der ersten Verbindung hier gespeichert.'));
const l = h('<div class="list"></div>');
for (const [id, v] of entries) {
const card = h(`<div class="card"><div class="avatar" style="background:#2a2f45;color:var(--green)">${ICONS.shield}</div><div class="meta"><div class="title">${esc(id)}</div><div class="sub mono">${esc(v.fingerprint)} · ${fmtDate(v.addedAt)}</div></div><div class="actions"><button title="Entfernen">${ICONS.trash}</button></div></div>`);
const card = h(`<div class="card"><div class="avatar" style="background:var(--icon-bg);color:var(--green)">${ICONS.shield}</div><div class="meta"><div class="title">${esc(id)}</div><div class="sub mono">${esc(v.fingerprint)} · ${fmtDate(v.addedAt)}</div></div><div class="actions"><button title="Entfernen">${ICONS.trash}</button></div></div>`);
$('.actions button', card).onclick = async () => { await call('vault:forgetHost', id); reload(); };
l.append(card);
}
@@ -636,7 +637,25 @@ async function viewSettings(page) {
const c = h('<div class="content settings-grid"></div>'); page.append(c);
const save = async (patch) => { await call('vault:settings', patch); S.vault.settings = { ...st, ...patch }; S.tabs.forEach((t) => t.applySettings?.()); };
// ---- App-Design
const designCard = h('<div class="settings-card"><h3>Design</h3><div class="theme-list design-list"></div><div class="field" style="margin:16px 0 0"><label>Akzentfarbe</label><div class="colors accent-colors"></div></div></div>');
for (const [id, t] of Object.entries(APP_THEMES)) {
const p = h(`<div class="theme-prev design-prev ${st.appTheme === id ? 'active' : ''}" style="background:${t.bg};color:${t.text}">
<div class="dp"><i style="background:${t.side}"></i><div><b style="background:${t.card}"><s style="background:${t.accent}"></s></b><b style="background:${t.card}"></b></div></div><div class="nm">${esc(t.name)}</div></div>`);
p.onclick = async () => { await save({ appTheme: id }); applyAppTheme(); render(); };
$('.design-list', designCard).append(p);
}
['', ...COLORS.slice(0, 7), '#21c7a8', '#cba6f7'].forEach((col) => {
const b = h(`<button type="button" title="${col || 'Standard des Designs'}" class="${(st.accent || '') === col ? 'active' : ''}" style="background:${col || 'conic-gradient(#6e7bff,#21c7a8,#cba6f7,#ffb454,#6e7bff)'}"></button>`);
b.onclick = async () => { await save({ accent: col }); applyAppTheme(); render(); };
$('.accent-colors', designCard).append(b);
});
const themeCard = h('<div class="settings-card"><h3>Terminal-Farbschema</h3><div class="theme-list"></div></div>');
const autoT = TERM_THEMES[(APP_THEMES[st.appTheme] || APP_THEMES.midnight).term];
const autoP = h(`<div class="theme-prev ${st.terminalTheme === 'auto' ? 'active' : ''}" style="background:${autoT.background};color:${autoT.foreground}"><div class="nm">Passend zum Design</div><span style="color:${autoT.green}">user@host</span>:<span style="color:${autoT.blue}">~</span>$ ls<br><span style="color:${autoT.blue}">folgt dem Design</span></div>`);
autoP.onclick = async () => { await save({ terminalTheme: 'auto' }); render(); };
$('.theme-list', themeCard).append(autoP);
for (const [id, t] of Object.entries(TERM_THEMES)) {
const p = h(`<div class="theme-prev ${st.terminalTheme === id ? 'active' : ''}" style="background:${t.background};color:${t.foreground}"><div class="nm">${esc(t.name)}</div><span style="color:${t.green}">user@host</span>:<span style="color:${t.blue}">~</span>$ ls<br><span style="color:${t.blue}">docs</span> <span style="color:${t.green}">run.sh</span> <span style="color:${t.red}">err</span></div>`);
p.onclick = async () => { await save({ terminalTheme: id }); render(); };
@@ -675,6 +694,31 @@ async function viewSettings(page) {
];
btns.forEach(([l, fn]) => { const b = h(`<button class="btn">${esc(l)}</button>`); b.onclick = fn; $('.row', dataCard).append(b); });
// ---- Import aus anderen Programmen
const importCard = h(`<div class="settings-card"><h3>Import</h3><p style="color:var(--muted);margin-top:0">Ordner und Hosts (RDP &amp; SSH) aus <b>Devolutions Remote Desktop Manager</b> übernehmen. In RDM: <i>Datei → Exportieren → Einträge exportieren</i> als <code>.rdm</code> (XML), JSON oder CSV. Passwörter werden von RDM nur unverschlüsselt exportiert, wenn du das beim Export aktivierst.</p></div>`);
const rdmBtn = h('<button class="btn primary">Aus Remote Desktop Manager importieren…</button>');
rdmBtn.onclick = importRdm;
importCard.append(rdmBtn);
// ---- Updates
const updCard = h(`<div class="settings-card"><h3>Updates</h3><p class="upd-info" style="color:var(--muted);margin-top:0">Installierte Version: …</p></div>`);
api.call('app:version').then((v) => { $('.upd-info', updCard).textContent = `Installierte Version: ${v}`; });
const updFields = h('<div></div>');
updFields.append(
field('Repository', 'updateUrl', st.updateUrl, { placeholder: 'https://git.mrblake.cc/MrBlake/MrTerm', hint: 'Gitea/Forgejo-Repository, dessen Releases die Installationspakete enthalten.' }),
field('Zugriffstoken (nur für private Repos)', 'updateToken', st.updateToken, { type: 'password', placeholder: 'optional' }),
check('Beim Start automatisch nach Updates suchen', 'updateAutoCheck', st.updateAutoCheck),
check('Vorabversionen (Pre-Releases) einbeziehen', 'updatePrerelease', st.updatePrerelease),
);
updFields.addEventListener('change', () => save(formValues(updFields)));
const chk = h(`<button class="btn">${ICONS.refresh}Jetzt nach Updates suchen</button>`);
chk.onclick = async () => {
chk.disabled = true;
try { await save(formValues(updFields)); const r = await call('update:check'); if (r.available) showUpdate(r); else toast(`MrTerm ist aktuell (${r.current}).`, 'ok'); } catch {}
chk.disabled = false;
};
updCard.append(updFields, chk);
const keysCard = h(`<div class="settings-card"><h3>Tastenkürzel</h3><div style="display:grid;grid-template-columns:auto 1fr;gap:8px 18px;color:var(--muted)">
<span><kbd>Strg</kbd>+<kbd>K</kbd> / <kbd>Strg</kbd>+<kbd>T</kbd></span><span>Quick Connect / Befehlspalette</span>
<span><kbd>Strg</kbd>+<kbd>W</kbd></span><span>Tab schließen</span>
@@ -683,9 +727,73 @@ async function viewSettings(page) {
<span><kbd>Strg</kbd>+<kbd>Shift</kbd>+<kbd>C/V</kbd></span><span>Kopieren / Einfügen im Terminal</span>
<span><kbd>Strg</kbd>+<kbd>Shift</kbd>+<kbd>F</kbd></span><span>Im Terminal suchen</span>
<span><kbd>Strg</kbd>+<kbd>+/−/0</kbd></span><span>Schriftgröße</span></div></div>`);
c.append(themeCard, termCard, rdpCard, dataCard, keysCard);
c.append(designCard, themeCard, termCard, rdpCard, importCard, dataCard, updCard, keysCard);
}
// ============================================================ Design
function termTheme() {
const id = settings().terminalTheme;
if (id === 'auto') return TERM_THEMES[(APP_THEMES[settings().appTheme] || APP_THEMES.midnight).term] || TERM_THEMES.mrterm;
return TERM_THEMES[id] || TERM_THEMES.mrterm;
}
function applyAppTheme() {
const st = settings();
const root = document.documentElement;
if (st.appTheme && st.appTheme !== 'midnight') root.dataset.theme = st.appTheme; else delete root.dataset.theme;
if (st.accent) root.style.setProperty('--accent', st.accent); else root.style.removeProperty('--accent');
S.tabs.forEach((t) => t.applySettings?.());
}
// ============================================================ RDM-Import
async function importRdm() {
const f = await call('import:pickFile', 'Remote Desktop Manager Export', ['rdm', 'xml', 'json', 'csv']);
if (!f) return;
let entries;
try { entries = parseRdmExport(f.name, f.content); } catch (e) { return toast('Import fehlgeschlagen: ' + e.message, 'error'); }
const hosts = entries.filter((e) => e.host);
if (!hosts.length) return toast('Keine RDP- oder SSH-Einträge in der Datei gefunden.', 'error');
const folders = new Set(entries.map((e) => e.folder.join(' / ')).filter(Boolean));
const rdpN = hosts.filter((e) => e.host.protocol === 'rdp').length;
const preview = hosts.slice(0, 8).map((e) => `<div class="card" style="padding:8px 10px;cursor:default">${avatar(e.host)}<div class="meta"><div class="title">${esc(e.host.label)}</div><div class="sub">${esc(e.folder.join(' / ') || 'Stammebene')} · ${esc(hostSub(e.host))}</div></div></div>`).join('');
const ok = await modal({
title: 'Import aus Remote Desktop Manager',
text: `${f.name}: ${hosts.length} Hosts (${rdpN} RDP, ${hosts.length - rdpN} SSH) in ${folders.size} Ordnern gefunden.`,
body: `<div class="list" style="max-height:300px;overflow:auto">${preview}</div>${hosts.length > 8 ? `<p style="margin:10px 0 0">… und ${hosts.length - 8} weitere</p>` : ''}`,
buttons: [{ label: 'Abbrechen', value: false, cls: 'ghost' }, { label: 'Importieren', value: true, cls: 'primary' }],
});
if (!ok) return;
const r = await call('vault:bulkImport', entries);
toast(`${r.hosts} Hosts importiert${r.skipped ? `, ${r.skipped} bereits vorhanden` : ''}`, 'ok');
await reload();
$('[data-view=hosts]').click();
}
// ============================================================ Updates
let updateInfo = null;
async function showUpdate(info) {
updateInfo = info;
const b = $('#updateBadge');
b.style.display = '';
b.querySelector('span').textContent = `Update ${info.version}`;
const r = await modal({
title: `MrTerm ${info.version} ist verfügbar`,
text: `Installiert: ${info.current}${info.asset ? ` · Paket: ${info.asset.name} (${fmtSize(info.asset.size)})` : ''}`,
body: `${info.notes ? `<div class="mono" style="white-space:pre-wrap;max-height:240px;overflow:auto;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:10px;margin-bottom:6px">${esc(info.notes)}</div>` : ''}
${info.kind === 'dev' ? '<p>Entwicklungsmodus: Bitte per <code>git pull</code> aktualisieren.</p>' : !info.asset ? '<p>Im Release ist kein passendes Paket für dieses System.</p>' : ''}
<div class="upd-prog" style="display:none"><div class="transfer" style="padding:6px 0"><span class="nm">Lade herunter …</span><span class="pct"></span><div class="bar"><i></i></div></div></div>`,
buttons: [{ label: 'Später', value: false, cls: 'ghost' }, { label: 'Release-Seite', value: 'web', cls: '' }, ...(info.asset && info.kind !== 'dev' ? [{ label: 'Jetzt installieren', value: true, cls: 'primary' }] : [])],
});
if (r === 'web') return api.call('shell:open', info.url);
if (r !== true) return;
toast('Update wird heruntergeladen …');
try { await call('update:download'); toast('Update wird installiert, MrTerm startet neu …', 'ok'); await call('update:install'); } catch {}
}
api.on('update:available', (info) => showUpdate(info));
api.on('update:progress', (done, total) => {
const b = $('#updateBadge span');
if (b) b.textContent = total ? `Update ${Math.round((done / total) * 100)}%` : 'Update …';
});
// ============================================================ Tabs
function addTab(session) {
S.tabs.push(session);
@@ -758,7 +866,7 @@ class TerminalSession {
const st = settings();
this.term = new Terminal({
fontFamily: st.fontFamily, fontSize: st.fontSize, cursorStyle: st.cursorStyle, cursorBlink: st.cursorBlink,
scrollback: st.scrollback, theme: TERM_THEMES[st.terminalTheme] || TERM_THEMES.mrterm, allowProposedApi: true, macOptionIsMeta: true,
scrollback: st.scrollback, theme: termTheme(), allowProposedApi: true, macOptionIsMeta: true,
});
this.fit = new FitAddon.FitAddon();
this.search = new SearchAddon.SearchAddon();
@@ -836,7 +944,7 @@ class TerminalSession {
onShow() { this.doFit(); this.term.focus(); }
applySettings() {
const st = settings();
Object.assign(this.term.options, { fontFamily: st.fontFamily, fontSize: st.fontSize, cursorStyle: st.cursorStyle, cursorBlink: st.cursorBlink, scrollback: st.scrollback, theme: TERM_THEMES[st.terminalTheme] || TERM_THEMES.mrterm });
Object.assign(this.term.options, { fontFamily: st.fontFamily, fontSize: st.fontSize, cursorStyle: st.cursorStyle, cursorBlink: st.cursorBlink, scrollback: st.scrollback, theme: termTheme() });
this.doFit();
}
@@ -956,7 +1064,7 @@ class FilePane {
const files = await this.list(dir);
this.cwd = dir; this.files = files; this.sel.clear();
$('.path', this.el).value = dir;
const roots = $('.roots', this.el); if (roots) roots.value = dir.slice(0, 3).toUpperCase();
const roots = $('.roots', this.el); if (roots) roots.value = dir.slice(0, 2).toUpperCase() + '\';
this.draw();
} catch (e) { toast(e.message, 'error'); }
}
@@ -969,8 +1077,8 @@ class FilePane {
const arrow = (k) => (this.sort.k === k ? (this.sort.dir > 0 ? ' ▲' : ' ▼') : '');
const rows = this.view = this.sorted();
box.innerHTML = `<table><thead><tr><th data-s="name">Name${arrow('name')}</th><th data-s="size" style="text-align:right">Größe${arrow('size')}</th><th data-s="mtime">Geändert${arrow('mtime')}</th></tr></thead><tbody>
<tr data-up="1"><td class="name"><span class="ico-dir">${ICONS.folder}</span><span>..</span></td><td></td><td></td></tr>
${rows.map((f, i) => `<tr data-i="${i}" draggable="true" class="${this.sel.has(f.name) ? 'sel' : ''}"><td class="name"><span class="${f.isDir ? 'ico-dir' : 'ico-file'}">${f.isDir ? ICONS.folder : ICONS.file}</span><span>${esc(f.name)}</span></td><td class="num">${f.isDir ? '' : fmtSize(f.size)}</td><td class="date">${fmtDate(f.mtime)}</td></tr>`).join('')}</tbody></table>`;
<tr data-up="1"><td class="name"><div><span class="ico-dir">${ICONS.folder}</span><span>..</span></div></td><td></td><td></td></tr>
${rows.map((f, i) => `<tr data-i="${i}" draggable="true" class="${this.sel.has(f.name) ? 'sel' : ''}"><td class="name"><div><span class="${f.isDir ? 'ico-dir' : 'ico-file'}">${f.isDir ? ICONS.folder : ICONS.file}</span><span>${esc(f.name)}</span></div></td><td class="num">${f.isDir ? '' : fmtSize(f.size)}</td><td class="date">${fmtDate(f.mtime)}</td></tr>`).join('')}</tbody></table>`;
$$('th', box).forEach((th) => (th.onclick = () => { const k = th.dataset.s; this.sort = { k, dir: this.sort.k === k ? -this.sort.dir : 1 }; this.draw(); }));
$('tr[data-up]', box).ondblclick = () => this.up();
$$('tr[data-i]', box).forEach((tr) => {
@@ -1113,11 +1221,11 @@ function openPalette() {
hosts.sort((a, b) => ((recent.indexOf(a.id) + 1 || 999) - (recent.indexOf(b.id) + 1 || 999)));
hosts.slice(0, 12).forEach((x) => {
items.push({ grp: 'Hosts', icon: avatar(x), label: x.label || x.address, sub: hostSub(x), run: () => connectHost(x) });
if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `<div class="avatar" style="background:#2a2f45;color:#6ea8ff">${ICONS.folder}</div>`, label: `SFTP: ${x.label || x.address}`, run: () => openSftp(x) });
if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--folder)">${ICONS.folder}</div>`, label: `SFTP: ${x.label || x.address}`, run: () => openSftp(x) });
});
S.vault.snippets.filter((s) => q && s.label.toLowerCase().includes(q)).slice(0, 5).forEach((s) => items.push({ grp: 'Snippets', icon: `<div class="avatar" style="background:#2a2f45;color:var(--green)">${ICONS.code}</div>`, label: s.label, sub: s.command, run: () => runSnippet(s) }));
S.vault.snippets.filter((s) => q && s.label.toLowerCase().includes(q)).slice(0, 5).forEach((s) => items.push({ grp: 'Snippets', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--green)">${ICONS.code}</div>`, label: s.label, sub: s.command, run: () => runSnippet(s) }));
[['Neuer Host', () => { activateTab('home'); editHost({}); }], ['Schlüssel generieren', generateKey], ['Einstellungen', () => $('[data-view=settings]').click()]]
.filter(([l]) => !q || l.toLowerCase().includes(q)).forEach(([l, run]) => items.push({ grp: 'Aktionen', icon: `<div class="avatar" style="background:#2a2f45;color:var(--accent-2)">${ICONS.plus}</div>`, label: l, run }));
.filter(([l]) => !q || l.toLowerCase().includes(q)).forEach(([l, run]) => items.push({ grp: 'Aktionen', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--accent-2)">${ICONS.plus}</div>`, label: l, run }));
idx = Math.min(idx, Math.max(0, items.length - 1));
let last = '';
res.innerHTML = '';
@@ -1182,3 +1290,4 @@ api.on('toast', (m, t) => toast(m, t));
api.on('win:state', (max) => { $('#winMax').innerHTML = max ? '<svg viewBox="0 0 12 12"><rect x="2" y="4" width="6" height="6"/><path d="M4 4V2h6v6H8"/></svg>' : '<svg viewBox="0 0 12 12"><rect x="2" y="2" width="8" height="8"/></svg>'; });
reload();
$('#updateBadge').onclick = () => updateInfo && showUpdate(updateInfo);