From 6758b5f73d0d30cc9060cf96d8233bbebe709497 Mon Sep 17 00:00:00 2001 From: Ryan Date: Tue, 4 Nov 2025 20:58:34 -0500 Subject: [PATCH] release(v1.8.3): feat(mobile+ci): harden Capacitor switcher & make release-on-version robust --- .github/workflows/release-on-version.yml | 28 +- CHANGELOG.md | 9 + public/js/mobile/switcher.js | 648 +++++++++++++---------- 3 files changed, 393 insertions(+), 292 deletions(-) diff --git a/.github/workflows/release-on-version.yml b/.github/workflows/release-on-version.yml index 9b4a360..0995889 100644 --- a/.github/workflows/release-on-version.yml +++ b/.github/workflows/release-on-version.yml @@ -17,26 +17,39 @@ jobs: release: runs-on: ubuntu-latest concurrency: - group: release-${{ github.ref }}-${{ github.sha }} - cancel-in-progress: false + # Cancel older runs for the same branch/ref so only the latest proceeds + group: release-${{ github.ref }} + cancel-in-progress: true steps: - - name: Checkout + - name: Checkout correct ref uses: actions/checkout@v4 with: fetch-depth: 0 + # For workflow_run, use the triggering workflow's head_sha; else use the current SHA + ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} - name: Ensure tags available run: | git fetch --tags --force --prune --quiet + - name: Show recent tags (debug) + run: git tag --list "v*" --sort=-v:refname | head -n 20 + - name: Read version from version.js id: ver shell: bash run: | set -euo pipefail - VER=$(grep -Eo "APP_VERSION\s*=\s*['\"]v[^'\"]+['\"]" public/js/version.js | sed -E "s/.*['\"](v[^'\"]+)['\"].*/\1/") - if [[ -z "$VER" ]]; then + echo "version.js at commit: $(git rev-parse --short HEAD)" + sed -n '1,80p' public/js/version.js || true + + VER=$( + grep -Eo "APP_VERSION[^\\n]*['\"]v[0-9][^'\"]+['\"]" public/js/version.js \ + | sed -E "s/.*['\"](v[^'\"]+)['\"].*/\1/" \ + | tail -n1 + ) + if [[ -z "${VER:-}" ]]; then echo "Could not parse APP_VERSION from version.js" >&2 exit 1 fi @@ -69,7 +82,7 @@ jobs: shell: bash run: | set -euo pipefail - VER="${{ steps.ver.outputs.version }}" # e.g. v1.6.12 + VER="${{ steps.ver.outputs.version }}" # e.g. v1.8.2 ZIP="FileRise-${VER}.zip" # Clean staging copy (exclude dotfiles you don’t want) @@ -195,7 +208,8 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.ver.outputs.version }} - target_commitish: ${{ github.sha }} + # Point the tag at the same commit we checked out (handles workflow_run case) + target_commitish: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} name: ${{ steps.ver.outputs.version }} body_path: RELEASE_BODY.md generate_release_notes: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 0815adf..81db902 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## Changees 11/4/2025 (v1.8.3) + +release(v1.8.3): feat(mobile+ci): harden Capacitor switcher & make release-on-version robust + +- switcher.js: allow running inside Capacitor; remove innerHTML usage; build nodes safely; normalize/strip creds from URLs; add withParam() for ?frapp=1; drop inline handlers; clamp rename length; minor UX polish. +- CI: cancel superseded runs per ref; checkout triggering commit (workflow_run head_sha); improve APP_VERSION parsing; point tag to checked-out commit; add recent-tag debug. + +--- + ## Changes 11/4/2025 (v1.8.2) release(v1.8.2): media progress tracking + watched badges; PWA scaffolding; mobile switcher (closes #37) diff --git a/public/js/mobile/switcher.js b/public/js/mobile/switcher.js index 447df3c..3283bb0 100644 --- a/public/js/mobile/switcher.js +++ b/public/js/mobile/switcher.js @@ -1,287 +1,365 @@ (function(){ - const isCap = !!window.Capacitor || /Capacitor/i.test(navigator.userAgent); - if (!isCap) return; - if ((location.origin || '').startsWith('capacitor://')) return; - - const Plugins = (window.Capacitor && window.Capacitor.Plugins) || {}; - const Pref = Plugins.Preferences ? { - get: ({key}) => Plugins.Preferences.get({key}), - set: ({key,value}) => Plugins.Preferences.set({key,value}), - remove:({key}) => Plugins.Preferences.remove({key}) - } : { - get: async ({key}) => ({ value: localStorage.getItem(key) || null }), - set: async ({key,value}) => localStorage.setItem(key, value), - remove: async ({key}) => localStorage.removeItem(key) - }; - const Http = (Plugins.Http || Plugins.CapacitorHttp) || null; - - const K_INST='fr_instances_v1', K_ACTIVE='fr_active_v1', K_STATUS='fr_status_v1'; - - const $ = s => document.querySelector(s); - const el = (t,a={},html='') => { const n=document.createElement(t); for (const k in a) n.setAttribute(k,a[k]); n.innerHTML=html; return n; }; - const normalize = u => { if(!u) return ''; let v=u.trim(); if(!/^https?:\/\//i.test(v)) v='https://'+v; return v.replace(/\/+$/,''); }; - const host = u => { try{ return new URL(normalize(u)).hostname }catch{ return '' } }; - const originOf = u => { try{ return new URL(normalize(u)).origin }catch{ return '' } }; - const faviconUrl = u => { try{ const x=new URL(normalize(u)); return x.origin+'/favicon.ico' }catch{ return '' } }; - const initialsIcon = (hn='FR') => { - const t=(hn||'FR').replace(/^www\./,'').slice(0,2).toUpperCase(); - const svg=` - - ${t}`; - return 'data:image/svg+xml;utf8,'+encodeURIComponent(svg); - }; - - async function getStatusCache(){ const raw=(await Pref.get({key:K_STATUS})).value; try{ return raw?JSON.parse(raw):{} }catch{ return {}; } } - async function writeStatus(origin, ok){ const cache=await getStatusCache(); cache[origin]={ ok, ts: Date.now() }; await Pref.set({key:K_STATUS, value:JSON.stringify(cache)}); } - - async function verifyFileRise(u, timeout=5000){ - if (!u || !Http) return {ok:false}; - const base = normalize(u), origin = originOf(base); - const tryJson = async (url, validate) => { - try{ - const r = await Http.get({ url, connectTimeout:timeout, readTimeout:timeout, headers:{'Accept':'application/json','Cache-Control':'no-cache'} }); - if (r && r.data) { - const j = (typeof r.data === 'string') ? JSON.parse(r.data) : r.data; - return !!validate(j); - } - }catch(_){} - return false; - }; - if (await tryJson(origin + '/siteConfig.json', j => j && (j.appTitle || j.headerTitle || j.auth || j.oidc || j.basicAuth))) return {ok:true, origin}; - if (await tryJson(origin + '/api/ping.php', j => j && (j.ok===true || j.status==='ok' || j.pong || j.app==='FileRise'))) return {ok:true, origin}; - if (await tryJson(origin + '/api/version.php', j => j && (j.version || j.app==='FileRise'))) return {ok:true, origin}; - try{ - const r = await Http.get({ url: origin+'/', connectTimeout:timeout, readTimeout:timeout, headers:{'Cache-Control':'no-cache'} }); - if (typeof r.data === 'string' && /FileRise/i.test(r.data)) return {ok:true, origin}; - }catch(_){} - return {ok:false, origin}; - } - - async function probeReachable(u, timeout=3000){ - try{ - const base = new URL(normalize(u)).origin, ico=base+'/favicon.ico'; - if (Http){ - try{ const r=await Http.get({ url: ico, connectTimeout:timeout, readTimeout:timeout, headers:{'Cache-Control':'no-cache'} }); - if (r && typeof r.status==='number' && r.status<500) return true; }catch(e){} - try{ const r2=await Http.get({ url: base+'/', connectTimeout:timeout, readTimeout:timeout, headers:{'Cache-Control':'no-cache'} }); - if (r2 && typeof r2.status==='number' && r2.status<500) return true; }catch(e){} - return false; - } - return await new Promise(res=>{ - const img=new Image(), t=setTimeout(()=>done(false), timeout); - function done(ok){ clearTimeout(t); img.onload=img.onerror=null; res(ok); } - img.onload=()=>done(true); img.onerror=()=>done(false); - img.src = ico + (ico.includes('?')?'&':'?') + '__fr=' + Date.now(); - }); - }catch{ return false; } - } - - async function loadInstances(){ const raw=(await Pref.get({key:K_INST})).value; try{ return raw?JSON.parse(raw):[] }catch{ return [] } } - async function saveInstances(list){ await Pref.set({key:K_INST, value:JSON.stringify(list)}); } - async function getActive(){ return (await Pref.get({key:K_ACTIVE})).value } - async function setActive(id){ await Pref.set({key:K_ACTIVE, value:id||''}) } - - // ---- Styles (slide-up sheet + disabled buttons + safe-area) ---- - if (!$('#frx-mobile-style')) { - const css = ` - .frx-fab { position:fixed; right:16px; bottom:calc(env(safe-area-inset-bottom,0px) + 18px); width:52px; height:52px; border-radius:26px; - background: linear-gradient(180deg,#64B5F6,#2196F3 65%,#1976D2); color:#fff; display:grid; place-items:center; - box-shadow:0 10px 22px rgba(33,150,243,.38); z-index:2147483647; cursor:pointer; user-select:none; } - .frx-fab:active { transform: translateY(1px) scale(.98); } - .frx-fab svg { width:26px; height:26px; fill:white } - .frx-scrim{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:2147483645;opacity:0;visibility:hidden;transition:opacity .24s ease} - .frx-scrim.show{opacity:1;visibility:visible} - .frx-sheet{position:fixed;left:0;right:0;bottom:0;background:#0f172a;color:#e5e7eb; - border-top-left-radius:16px;border-top-right-radius:16px;box-shadow:0 -10px 30px rgba(0,0,0,.3); - z-index:2147483646;transform:translateY(100%);opacity:0;visibility:hidden; - transition:transform .28s cubic-bezier(.2,.8,.2,1), opacity .28s ease; will-change:transform} - .frx-sheet.show{transform:translateY(0);opacity:1;visibility:visible} - .frx-sheet .hdr{display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-bottom:1px solid rgba(255,255,255,.08)} - .frx-title{display:flex;align-items:center;gap:10px;font-weight:800} - .frx-title img{width:22px;height:22px} - .frx-list{max-height:60vh;overflow:auto;padding:8px 12px} - .frx-chip{border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:12px;margin:8px 4px;background:rgba(255,255,255,.04)} - .frx-chip.active{outline:3px solid rgba(33,150,243,.35); border-color:#2196F3} - .frx-top{display:flex;gap:10px;align-items:center;justify-content:space-between;margin-bottom:10px} - .frx-left{display:flex;gap:10px;align-items:center} - .frx-ico{width:20px;height:20px;border-radius:6px;overflow:hidden;background:#fff;display:grid;place-items:center} - .frx-ico img{width:100%;height:100%;object-fit:cover;display:block} - .frx-name{font-weight:800} - .frx-host{font-size:12px;opacity:.8;margin-top:2px} - .frx-status{display:flex;align-items:center;gap:6px;font-size:12px;opacity:.9} - .frx-dot{width:10px;height:10px;border-radius:50%;} - .frx-dot.on{background:#10B981;box-shadow:0 0 0 3px rgba(16,185,129,.18)} - .frx-dot.off{background:#ef4444;box-shadow:0 0 0 3px rgba(239,68,68,.18)} - .frx-actions{display:flex;gap:8px;flex-wrap:wrap} - .frx-btn{appearance:none;border:0;border-radius:10px;padding:10px 12px;font-weight:700;cursor:pointer;transition:.15s ease opacity, .15s ease filter} - .frx-btn[disabled]{opacity:.5;cursor:not-allowed;filter:grayscale(20%)} - .frx-primary{background:linear-gradient(180deg,#64B5F6,#2196F3);color:#fff} - .frx-ghost{background:transparent;color:#cbd5e1;border:1px solid rgba(255,255,255,.12)} - .frx-danger{background:transparent;color:#f44336;border:1px solid rgba(244,67,54,.45)} - .frx-row{display:flex;gap:8px;align-items:center} - .frx-field{display:grid;gap:6px;margin:8px 4px} - .frx-input{width:100%;padding:12px;border-radius:10px;border:1px solid rgba(255,255,255,.12);background:transparent;color:inherit} - .frx-footer{display:flex;justify-content:flex-end;gap:8px;padding:10px 12px;border-top:1px solid rgba(255,255,255,.08)} - @media (pointer:coarse) { .frx-fab { width:58px; height:58px; border-radius:29px; } } - `; - document.head.appendChild(el('style',{id:'frx-mobile-style'}, css)); - } - - // DOM - const scrim = el('div',{class:'frx-scrim', id:'frx-scrim'}); - const sheet = el('div',{class:'frx-sheet', id:'frx-sheet'}, ` -
-
- FileRiseFileRise Switcher -
-
- - -
-
-
-
-
- - -
-
- - `); - const fab = el('div',{class:'frx-fab', id:'frx-fab', title:'Switch server'}, ``); - document.body.appendChild(scrim); document.body.appendChild(sheet); document.body.appendChild(fab); - - function show(){ scrim.classList.add('show'); sheet.classList.add('show'); fab.style.display='none'; } - function hide(){ scrim.classList.remove('show'); sheet.classList.remove('show'); fab.style.display='grid'; } - $('#frx-close').addEventListener('click', hide); - $('#frx-add-cancel').addEventListener('click', hide); - $('#frx-home').addEventListener('click', ()=>{ try{ location.href='capacitor://localhost/index.html'; }catch{} }); - scrim.addEventListener('click', hide); - document.addEventListener('keydown', e=>{ if(e.key==='Escape') hide(); }); - - function chipNode(item, isActive){ - const hv=host(item.url); - const node = el('div',{class:'frx-chip'+(isActive?' active':''), 'data-id':item.id}); - const top = el('div',{class:'frx-top'}); - const left = el('div',{class:'frx-left'}); - const ico = el('div',{class:'frx-ico'}); - const img = new Image(); - img.alt=''; img.src=item.favicon||faviconUrl(item.url)||initialsIcon(hv); - img.onerror=()=>{ img.onerror=null; img.src=initialsIcon(hv); }; - ico.appendChild(img); - const txt = el('div',{}, `
${item.name || hv}
${hv}
`); - left.appendChild(ico); left.appendChild(txt); - const status = el('div',{class:'frx-status'}, `Checking…`); - top.appendChild(left); top.appendChild(status); - const actions = el('div',{class:'frx-actions'}); - const bOpen = el('button',{class:'frx-btn frx-primary', 'data-act':'open', disabled:true}, 'Open'); - const bRen = el('button',{class:'frx-btn frx-ghost', 'data-act':'rename'}, 'Rename'); - const bDel = el('button',{class:'frx-btn frx-danger', 'data-act':'remove'}, 'Remove'); - actions.appendChild(bOpen); actions.appendChild(bRen); actions.appendChild(bDel); - node.appendChild(top); node.appendChild(actions); - return node; - } - - async function renderList(){ - const listEl=$('#frx-list'); listEl.innerHTML=''; - const list=await loadInstances(); const active=await getActive(); - const cache=await getStatusCache(); - - list.sort((a,b)=>(b.lastUsed||0)-(a.lastUsed||0)).forEach(item=>{ - const chip = chipNode(item, item.id===active); - const o = originOf(item.url), cached = cache[o]; - const dot = chip.querySelector(`#frx-dot-${item.id}`), lbl = chip.querySelector(`#frx-lbl-${item.id}`); - const openBtn = chip.querySelector('[data-act="open"]'); - - if (cached){ - dot.classList.add(cached.ok ? 'on':'off'); - lbl.textContent = cached.ok ? 'Online' : 'Offline'; - openBtn.disabled = !cached.ok; - } else { - lbl.textContent = 'Unknown'; - openBtn.disabled = true; - } - - chip.addEventListener('click', async (e)=>{ - const act = e.target?.dataset?.act; - if (!act) return; - - if (act==='open'){ - if (openBtn.disabled) return; - await setActive(item.id); - const url=normalize(item.url), withFlag=url+(url.includes('?')?'&':'?')+'frapp=1'; - window.location.replace(withFlag); - } else if (act==='rename'){ - const nn=prompt('New display name:', item.name || host(item.url)); - if (nn!=null){ - const L=await loadInstances(); const it=L.find(x=>x.id===item.id); - if (it){ it.name=nn.trim(); it.lastUsed=Date.now(); await saveInstances(L); renderList(); } - } - } else if (act==='remove'){ - if (!confirm('Remove this server?')) return; - let L=await loadInstances(); L=L.filter(x=>x.id!==item.id); await saveInstances(L); - const a=await getActive(); if (a===item.id) await setActive(L[0]?.id||''); renderList(); - } - }); - - listEl.appendChild(chip); - - // Live refresh (best effort) - (async ()=>{ - const ok = await probeReachable(item.url, 2500); - const d = document.getElementById(`frx-dot-${item.id}`); - const l = document.getElementById(`frx-lbl-${item.id}`); - const b = chip.querySelector('[data-act="open"]'); - if (d && l && b){ - d.classList.remove('on','off'); - d.classList.add(ok?'on':'off'); - l.textContent = ok ? 'Online' : 'Offline'; - b.disabled = !ok; - } - const o2 = originOf(item.url); if (o2) writeStatus(o2, ok); - })(); - }); - } - - $('#frx-add-save').addEventListener('click', async ()=>{ - const name = $('#frx-name').value.trim(); - const url = $('#frx-url').value.trim(); - if (!url) { alert('Enter a valid URL'); return; } - - // Verify: must be FileRise - const vf = await verifyFileRise(url); - if (!vf.ok) { alert('That address does not look like a FileRise server.'); return; } - - let L = await loadInstances(); - const h = host(url); - const dupe = L.find(i => host(i.url)===h); - const inst = dupe || { id:'i'+Math.random().toString(36).slice(2)+Date.now().toString(36) }; - inst.name = name || inst.name || h; - inst.url = normalize(url); - inst.favicon = faviconUrl(url); - inst.lastUsed = Date.now(); - if (!dupe) L.push(inst); - await saveInstances(L); - await setActive(inst.id); - - if (vf.origin) await writeStatus(vf.origin, true); - - window.location.replace(inst.url + (inst.url.includes('?')?'&':'?') + 'frapp=1'); + const isCap = !!window.Capacitor || /Capacitor/i.test(navigator.userAgent); + if (!isCap) return; + // NOTE: allow running inside Capacitor (origin "capacitor://localhost") + + const Plugins = (window.Capacitor && window.Capacitor.Plugins) || {}; + const Pref = Plugins.Preferences ? { + get: ({key}) => Plugins.Preferences.get({key}), + set: ({key,value}) => Plugins.Preferences.set({key,value}), + remove:({key}) => Plugins.Preferences.remove({key}) + } : { + get: async ({key}) => ({ value: localStorage.getItem(key) || null }), + set: async ({key,value}) => localStorage.setItem(key, value), + remove: async ({key}) => localStorage.removeItem(key) + }; + const Http = (Plugins.Http || Plugins.CapacitorHttp) || null; + + const K_INST='fr_instances_v1', K_ACTIVE='fr_active_v1', K_STATUS='fr_status_v1'; + + const $ = s => document.querySelector(s); + + // Safe element builder: attributes only, children as nodes/strings (no innerHTML) + const el = (tag, attrs = {}, children = []) => { + const n = document.createElement(tag); + for (const k in attrs) n.setAttribute(k, attrs[k]); + (Array.isArray(children) ? children : [children]).forEach(c => { + if (c == null) return; + n.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); }); - - fab.addEventListener('click', async ()=>{ await renderList(); show(); }); - - - // Ensure zoom gestures work if the host page tried to disable them - (function ensureZoomable(){ - let m = document.querySelector('meta[name=viewport]'); - const desired = 'width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=yes, minimum-scale=1, maximum-scale=5'; - if (!m){ m = document.createElement('meta'); m.setAttribute('name','viewport'); document.head.appendChild(m); } - const c = m.getAttribute('content') || ''; - if (/user-scalable=no|maximum-scale=1/.test(c)) m.setAttribute('content', desired); - })(); - })(); \ No newline at end of file + return n; + }; + + // Normalize to http(s), strip creds, collapse trailing slashes + const normalize = (u) => { + if (!u) return ''; + let v = u.trim(); + if (!/^https?:\/\//i.test(v)) v = 'https://' + v; + try { + const url = new URL(v); + if (!/^https?:$/.test(url.protocol)) return ''; + url.username = ''; + url.password = ''; + url.pathname = url.pathname.replace(/\/+$/,''); + return url.toString(); + } catch { return ''; } + }; + + // Append/overwrite a query param safely on a normalized URL + const withParam = (base, k, v) => { + try { + const u = new URL(normalize(base)); + u.searchParams.set(k, v); + return u.toString(); + } catch { return ''; } + }; + + const host = u => { + try { return new URL(normalize(u)).hostname; } catch { return ''; } + }; + const originOf = u => { + try { return new URL(normalize(u)).origin; } catch { return ''; } + }; + const faviconUrl = u => { + try { const x = new URL(normalize(u)); return x.origin + '/favicon.ico'; } catch { return ''; } + }; + const initialsIcon = (hn='FR') => { + const t=(hn||'FR').replace(/^www\./,'').slice(0,2).toUpperCase(); + const svg=` + + ${t}`; + return 'data:image/svg+xml;utf8,'+encodeURIComponent(svg); + }; + + async function getStatusCache(){ + const raw=(await Pref.get({key:K_STATUS})).value; + try { return raw ? JSON.parse(raw) : {}; } catch { return {}; } + } + async function writeStatus(origin, ok){ + const cache=await getStatusCache(); + cache[origin]={ ok, ts: Date.now() }; + await Pref.set({key:K_STATUS, value:JSON.stringify(cache)}); + } + + async function verifyFileRise(u, timeout=5000){ + if (!u || !Http) return {ok:false}; + const base = normalize(u), org = originOf(base); + const tryJson = async (url, validate) => { + try{ + const r = await Http.get({ url, connectTimeout:timeout, readTimeout:timeout, headers:{'Accept':'application/json','Cache-Control':'no-cache'} }); + if (r && r.data) { + const j = (typeof r.data === 'string') ? JSON.parse(r.data) : r.data; + return !!validate(j); + } + }catch(_){} + return false; + }; + if (await tryJson(org + '/siteConfig.json', j => j && (j.appTitle || j.headerTitle || j.auth || j.oidc || j.basicAuth))) return {ok:true, origin:org}; + if (await tryJson(org + '/api/ping.php', j => j && (j.ok===true || j.status==='ok' || j.pong || j.app==='FileRise'))) return {ok:true, origin:org}; + if (await tryJson(org + '/api/version.php', j => j && (j.version || j.app==='FileRise'))) return {ok:true, origin:org}; + try{ + const r = await Http.get({ url: org+'/', connectTimeout:timeout, readTimeout:timeout, headers:{'Cache-Control':'no-cache'} }); + if (typeof r.data === 'string' && /FileRise/i.test(r.data)) return {ok:true, origin:org}; + }catch(_){} + return {ok:false, origin:org}; + } + + async function probeReachable(u, timeout=3000){ + try{ + const base = new URL(normalize(u)).origin, ico=base+'/favicon.ico'; + if (Http){ + try{ const r=await Http.get({ url: ico, connectTimeout:timeout, readTimeout:timeout, headers:{'Cache-Control':'no-cache'} }); + if (r && typeof r.status==='number' && r.status<500) return true; }catch(e){} + try{ const r2=await Http.get({ url: base+'/', connectTimeout:timeout, readTimeout:timeout, headers:{'Cache-Control':'no-cache'} }); + if (r2 && typeof r2.status==='number' && r2.status<500) return true; }catch(e){} + return false; + } + return await new Promise(res=>{ + const img=new Image(), t=setTimeout(()=>done(false), timeout); + function done(ok){ clearTimeout(t); img.onload=img.onerror=null; res(ok); } + img.onload=()=>done(true); img.onerror=()=>done(false); + img.src = ico + (ico.includes('?')?'&':'?') + '__fr=' + Date.now(); + }); + }catch{ return false; } + } + + async function loadInstances(){ + const raw=(await Pref.get({key:K_INST})).value; + try { return raw ? JSON.parse(raw) : []; } catch { return []; } + } + async function saveInstances(list){ + await Pref.set({key:K_INST, value:JSON.stringify(list)}); + } + async function getActive(){ return (await Pref.get({key:K_ACTIVE})).value } + async function setActive(id){ await Pref.set({key:K_ACTIVE, value:id||''}) } + + // ---- Styles (slide-up sheet + disabled buttons + safe-area) ---- + if (!$('#frx-mobile-style')) { + const css = ` + .frx-fab { position:fixed; right:16px; bottom:calc(env(safe-area-inset-bottom,0px) + 18px); width:52px; height:52px; border-radius:26px; + background: linear-gradient(180deg,#64B5F6,#2196F3 65%,#1976D2); color:#fff; display:grid; place-items:center; + box-shadow:0 10px 22px rgba(33,150,243,.38); z-index:2147483647; cursor:pointer; user-select:none; } + .frx-fab:active { transform: translateY(1px) scale(.98); } + .frx-fab svg { width:26px; height:26px; fill:white } + .frx-scrim{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:2147483645;opacity:0;visibility:hidden;transition:opacity .24s ease} + .frx-scrim.show{opacity:1;visibility:visible} + .frx-sheet{position:fixed;left:0;right:0;bottom:0;background:#0f172a;color:#e5e7eb; + border-top-left-radius:16px;border-top-right-radius:16px;box-shadow:0 -10px 30px rgba(0,0,0,.3); + z-index:2147483646;transform:translateY(100%);opacity:0;visibility:hidden; + transition:transform .28s cubic-bezier(.2,.8,.2,1), opacity .28s ease; will-change:transform} + .frx-sheet.show{transform:translateY(0);opacity:1;visibility:visible} + .frx-sheet .hdr{display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-bottom:1px solid rgba(255,255,255,.08)} + .frx-title{display:flex;align-items:center;gap:10px;font-weight:800} + .frx-title img{width:22px;height:22px} + .frx-list{max-height:60vh;overflow:auto;padding:8px 12px} + .frx-chip{border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:12px;margin:8px 4px;background:rgba(255,255,255,.04)} + .frx-chip.active{outline:3px solid rgba(33,150,243,.35); border-color:#2196F3} + .frx-top{display:flex;gap:10px;align-items:center;justify-content:space-between;margin-bottom:10px} + .frx-left{display:flex;gap:10px;align-items:center} + .frx-ico{width:20px;height:20px;border-radius:6px;overflow:hidden;background:#fff;display:grid;place-items:center} + .frx-ico img{width:100%;height:100%;object-fit:cover;display:block} + .frx-name{font-weight:800} + .frx-host{font-size:12px;opacity:.8;margin-top:2px} + .frx-status{display:flex;align-items:center;gap:6px;font-size:12px;opacity:.9} + .frx-dot{width:10px;height:10px;border-radius:50%;} + .frx-dot.on{background:#10B981;box-shadow:0 0 0 3px rgba(16,185,129,.18)} + .frx-dot.off{background:#ef4444;box-shadow:0 0 0 3px rgba(239,68,68,.18)} + .frx-actions{display:flex;gap:8px;flex-wrap:wrap} + .frx-btn{appearance:none;border:0;border-radius:10px;padding:10px 12px;font-weight:700;cursor:pointer;transition:.15s ease opacity, .15s ease filter} + .frx-btn[disabled]{opacity:.5;cursor:not-allowed;filter:grayscale(20%)} + .frx-primary{background:linear-gradient(180deg,#64B5F6,#2196F3);color:#fff} + .frx-ghost{background:transparent;color:#cbd5e1;border:1px solid rgba(255,255,255,.12)} + .frx-danger{background:transparent;color:#f44336;border:1px solid rgba(244,67,54,.45)} + .frx-row{display:flex;gap:8px;align-items:center} + .frx-field{display:grid;gap:6px;margin:8px 4px} + .frx-input{width:100%;padding:12px;border-radius:10px;border:1px solid rgba(255,255,255,.12);background:transparent;color:inherit} + .frx-footer{display:flex;justify-content:flex-end;gap:8px;padding:10px 12px;border-top:1px solid rgba(255,255,255,.08)} + @media (pointer:coarse) { .frx-fab { width:58px; height:58px; border-radius:29px; } } + `; + document.head.appendChild(el('style',{id:'frx-mobile-style'}, css)); + } + + // ---- DOM skeleton (no innerHTML) ---- + const scrim = el('div',{class:'frx-scrim', id:'frx-scrim'}); + const sheet = el('div',{class:'frx-sheet', id:'frx-sheet'}); + const hdr = el('div',{class:'hdr'}); + const title = el('div',{class:'frx-title'}); + const logo = el('img',{src:'/assets/logo.svg', alt:'FileRise'}); + // inline handler via property, not attribute + logo.onerror = function(){ this.style.display='none'; }; + title.append(logo, el('span',{},'FileRise Switcher')); + const hdrBtns = el('div',{class:'frx-row'},[ + el('button',{class:'frx-btn frx-ghost', id:'frx-home'},'Home'), + el('button',{class:'frx-btn frx-ghost', id:'frx-close'},'Close') + ]); + hdr.append(title, hdrBtns); + + const list = el('div',{class:'frx-list', id:'frx-list'}); + const formWrap = el('div',{style:'padding:10px 12px'},[ + el('div',{class:'frx-field'},[ + el('input',{class:'frx-input', id:'frx-name', placeholder:'Display name (optional)'}), + el('input',{class:'frx-input', id:'frx-url', placeholder:'https://files.example.com'}) + ]) + ]); + const footer = el('div',{class:'frx-footer'},[ + el('button',{class:'frx-btn frx-ghost', id:'frx-add-cancel'},'Close'), + el('button',{class:'frx-btn frx-primary', id:'frx-add-save'},'+ Add server') + ]); + sheet.append(hdr, list, formWrap, footer); + + const fab = el('div',{class:'frx-fab', id:'frx-fab', title:'Switch server'},[ + el('svg',{viewBox:'0 0 24 24'},[ el('path',{d:'M7 7h10v2H7V7zm0 4h10v2H7v-2zm0 4h10v2H7v-2z'}) ]) + ]); + + document.body.appendChild(scrim); + document.body.appendChild(sheet); + document.body.appendChild(fab); + + function show(){ scrim.classList.add('show'); sheet.classList.add('show'); fab.style.display='none'; } + function hide(){ scrim.classList.remove('show'); sheet.classList.remove('show'); fab.style.display='grid'; } + $('#frx-close').addEventListener('click', hide); + $('#frx-add-cancel').addEventListener('click', hide); + $('#frx-home').addEventListener('click', ()=>{ try{ location.href='capacitor://localhost/index.html'; }catch{} }); + scrim.addEventListener('click', hide); + document.addEventListener('keydown', e=>{ if(e.key==='Escape') hide(); }); + + function chipNode(item, isActive){ + const hv = host(item.url); + const node = el('div',{class:'frx-chip'+(isActive?' active':''), 'data-id':item.id}); + + const top = el('div',{class:'frx-top'}); + const left = el('div',{class:'frx-left'}); + + const ico = el('div',{class:'frx-ico'}); + const img = new Image(); + img.alt=''; img.src=item.favicon||faviconUrl(item.url)||initialsIcon(hv); + img.onerror=()=>{ img.onerror=null; img.src=initialsIcon(hv); }; + ico.appendChild(img); + + const txt = el('div',{},[ + el('div',{class:'frx-name'}, (item.name || hv)), + el('div',{class:'frx-host'}, hv) + ]); + + left.appendChild(ico); + left.appendChild(txt); + + const dot = el('span',{class:'frx-dot', id:`frx-dot-${item.id}`}); + const lbl = el('span',{id:`frx-lbl-${item.id}`}, 'Checking…'); + const status = el('div',{class:'frx-status'}, [dot, lbl]); + + top.appendChild(left); + top.appendChild(status); + + const actions = el('div',{class:'frx-actions'}); + const bOpen = el('button',{class:'frx-btn frx-primary', 'data-act':'open', disabled:true}, 'Open'); + const bRen = el('button',{class:'frx-btn frx-ghost', 'data-act':'rename'}, 'Rename'); + const bDel = el('button',{class:'frx-btn frx-danger', 'data-act':'remove'}, 'Remove'); + actions.appendChild(bOpen); actions.appendChild(bRen); actions.appendChild(bDel); + + node.appendChild(top); + node.appendChild(actions); + return node; + } + + async function renderList(){ + const listEl=$('#frx-list'); listEl.textContent=''; + const list=await loadInstances(); const active=await getActive(); + const cache=await getStatusCache(); + + list.sort((a,b)=>(b.lastUsed||0)-(a.lastUsed||0)).forEach(item=>{ + const chip = chipNode(item, item.id===active); + const o = originOf(item.url), cached = cache[o]; + const dot = chip.querySelector(`#frx-dot-${item.id}`); + const lbl = chip.querySelector(`#frx-lbl-${item.id}`); + const openBtn = chip.querySelector('[data-act="open"]'); + + if (cached){ + dot.classList.add(cached.ok ? 'on':'off'); + lbl.textContent = cached.ok ? 'Online' : 'Offline'; + openBtn.disabled = !cached.ok; + } else { + lbl.textContent = 'Unknown'; + openBtn.disabled = true; + } + + chip.addEventListener('click', async (e)=>{ + const act = e.target?.dataset?.act; + if (!act) return; + + if (act==='open'){ + if (openBtn.disabled) return; + await setActive(item.id); + const dest = withParam(item.url, 'frapp', '1'); + if (dest) window.location.replace(dest); + } else if (act==='rename'){ + const nn=prompt('New display name:', item.name || host(item.url)); + if (nn!=null){ + const L=await loadInstances(); const it=L.find(x=>x.id===item.id); + if (it){ it.name=nn.trim().slice(0,120); it.lastUsed=Date.now(); await saveInstances(L); renderList(); } + } + } else if (act==='remove'){ + if (!confirm('Remove this server?')) return; + let L=await loadInstances(); L=L.filter(x=>x.id!==item.id); await saveInstances(L); + const a=await getActive(); if (a===item.id) await setActive(L[0]?.id||''); renderList(); + } + }); + + listEl.appendChild(chip); + + // Live refresh (best effort) + (async ()=>{ + const ok = await probeReachable(item.url, 2500); + const d = document.getElementById(`frx-dot-${item.id}`); + const l = document.getElementById(`frx-lbl-${item.id}`); + const b = chip.querySelector('[data-act="open"]'); + if (d && l && b){ + d.classList.remove('on','off'); + d.classList.add(ok?'on':'off'); + l.textContent = ok ? 'Online' : 'Offline'; + b.disabled = !ok; + } + const o2 = originOf(item.url); if (o2) writeStatus(o2, ok); + })(); + }); + } + + $('#frx-add-save').addEventListener('click', async ()=>{ + const name = $('#frx-name').value.trim(); + const url = $('#frx-url').value.trim(); + if (!url) { alert('Enter a valid URL'); return; } + + // Verify: must be FileRise + const vf = await verifyFileRise(url); + if (!vf.ok) { alert('That address does not look like a FileRise server.'); return; } + + let L = await loadInstances(); + const h = host(url); + const dupe = L.find(i => host(i.url)===h); + const inst = dupe || { id:'i'+Math.random().toString(36).slice(2)+Date.now().toString(36) }; + inst.name = name || inst.name || h; + inst.url = normalize(url); + inst.favicon = faviconUrl(url); + inst.lastUsed = Date.now(); + if (!dupe) L.push(inst); + await saveInstances(L); + await setActive(inst.id); + + if (vf.origin) await writeStatus(vf.origin, true); + + const dest = withParam(inst.url, 'frapp', '1'); + if (dest) window.location.replace(dest); + }); + + fab.addEventListener('click', async ()=>{ await renderList(); show(); }); + + // Ensure zoom gestures work if the host page tried to disable them + (function ensureZoomable(){ + let m = document.querySelector('meta[name=viewport]'); + const desired = 'width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=yes, minimum-scale=1, maximum-scale=5'; + if (!m){ m = document.createElement('meta'); m.setAttribute('name','viewport'); document.head.appendChild(m); } + const c = m.getAttribute('content') || ''; + if (/user-scalable=no|maximum-scale=1/.test(c)) m.setAttribute('content', desired); + })(); +})(); \ No newline at end of file