diff --git a/cmd/nimbo-gui/brandserver_test.go b/cmd/nimbo-gui/brandserver_test.go new file mode 100644 index 0000000..5774147 --- /dev/null +++ b/cmd/nimbo-gui/brandserver_test.go @@ -0,0 +1,35 @@ +package main + +import ( + "testing" + + "github.com/otherworld/nimbo/internal/brand" + "github.com/otherworld/nimbo/internal/policy" +) + +// A partner build's baked-in server lock applies with no licence or registry +// policy; an admin policy's own address wins; the stock build changes nothing +// (Deck d1010). +func TestWithBrandServer(t *testing.T) { + acme := brand.Brand{ServerURL: "https://cloud.acme.example", LockServer: true} + + p := withBrandServer(policy.Policy{AllowSignOut: true}, acme) + if !p.LockServer || p.ServerURL != "https://cloud.acme.example" || !p.AllowSignOut { + t.Errorf("partner lock not applied: %+v", p) + } + + p = withBrandServer(policy.Policy{ServerURL: "https://admin.example"}, acme) + if p.ServerURL != "https://admin.example" || !p.LockServer { + t.Errorf("admin address should win, lock should hold: %+v", p) + } + + p = withBrandServer(policy.Policy{AllowSignOut: true}, brand.Brand{ServerURL: "https://preset.example"}) + if p.ServerURL != "https://preset.example" || p.LockServer { + t.Errorf("a preset without lockServer must not lock: %+v", p) + } + + in := policy.Policy{AllowSignOut: true} + if p := withBrandServer(in, brand.Brand{Name: "OtherSync"}); p != in { + t.Errorf("stock brand changed the policy: %+v", p) + } +} diff --git a/cmd/nimbo-gui/frontend/dist/assets/index-B4DLXM4K.js b/cmd/nimbo-gui/frontend/dist/assets/index-CEyEhSMa.js similarity index 94% rename from cmd/nimbo-gui/frontend/dist/assets/index-B4DLXM4K.js rename to cmd/nimbo-gui/frontend/dist/assets/index-CEyEhSMa.js index e92b0a3..1b95c35 100644 --- a/cmd/nimbo-gui/frontend/dist/assets/index-B4DLXM4K.js +++ b/cmd/nimbo-gui/frontend/dist/assets/index-CEyEhSMa.js @@ -36,6 +36,6 @@ Expires: ${H(It).expires??``} SHA-256: ${H(It).fingerprint??``}`),s.disabled=H(Ft)}),U(`click`,s,()=>Lt(H(It)?.fingerprint??``)),G(e,t)},a=e=>{var t=Pg(),n=I(t,!0);O(t),z(()=>K(n,H(It).message)),G(e,t)};q(n,e=>{H(It).result===`ok`?e(r):H(It).result===`untrusted`?e(i,1):e(a,-1)}),G(e,t)},h=e=>{var t=x_(),n=I(t,!0),r=R(n),i=e=>{var t=Br();t.nodeValue=` (certificate pinned)`,G(e,t)};q(r,e=>{H(wt).localPinned&&e(i)}),O(t),z(e=>K(n,e),[()=>Bt(H(B))]),G(e,t)};q(p,e=>{H(It)?e(m):H(wt).localAddress&&e(h,1)}),O(t),z(e=>{K(i,`Sync directly with your server when you're on the same network; ${H(n)??``} uses the internet address whenever the local one isn't reachable. Needs HTTPS on that address — a self-signed certificate is fine. Leave empty if your server isn't on your network. Your local address must serve the same site, including the /push/ws notification socket.`),o.disabled=H(Ft),s.disabled=e,K(c,H(Ft)?`Testing…`:`Test`)},[()=>H(Ft)||!H(Nt).trim()]),U(`input`,o,()=>{P(It,null),P(Pt,``)}),U(`keydown`,o,e=>{e.key===`Enter`&&H(Nt).trim()&&Lt()}),Ti(o,()=>H(Nt),e=>P(Nt,e)),U(`click`,s,()=>Lt()),G(e,t)},_=M(()=>H(wt).server.toLowerCase().startsWith(`https://`));q(h,e=>{H(_)&&e(g)}),z(()=>{K(o,H(wt).user),K(c,H(wt).server)}),G(e,t)},f=e=>{var t=w_(),n=R(I(t));O(t),U(`click`,n,()=>vl()),G(e,t)};q(u,e=>{H(wt).signedIn?e(d):e(f,-1)});var p=R(u,2),m=e=>{var t=A_();J(R(L(t),2),17,()=>H(Ot),Yr,(e,t)=>{var n=k_(),r=I(n),i=I(r),a=I(i,!0);O(i);var o=R(i,2),s=I(o,!0);O(o);var c=R(o,2),l=e=>{G(e,T_())};q(c,e=>{H(t).active&&e(l)}),O(r);var u=R(r,2),d=I(u),f=e=>{var n=E_();U(`click`,n,()=>Rd(H(t).id)),G(e,n)},p=e=>{var n=D_(),r=I(n,!0);O(n),z(()=>{Z(n,`title`,H(t).status),K(r,H(t).status)}),G(e,n)};q(d,e=>{H(t).status===`Sign in again`?e(f):H(t).status&&!H(t).active&&e(p,1)});var m=R(d,2),h=e=>{var n=O_(),r=L(n),i=R(r,2);z(()=>r.disabled=H(kt)),U(`click`,r,()=>Vt(H(t).id)),U(`click`,i,()=>Ht(H(t).id)),G(e,n)};q(m,e=>{H(t).active||e(h)}),O(u),O(n),z(()=>{K(a,H(t).user),Z(o,`title`,H(t).server),K(s,H(t).server)}),G(e,n)}),G(e,t)};q(p,e=>{H(Ot).length>1&&e(m)});var h=R(p,2),g=I(h);O(h);var v=R(h,4),y=R(I(v),4),b=I(y);X(b);var x=R(b),S=e=>{G(e,Jg())};q(x,e=>{H(Ue)===`base`&&e(S)}),O(y),O(v);var C=R(v,2),w=R(I(C),2),ee=I(w);ee.value=ee.__value=`system`;var te=R(ee);te.value=te.__value=`light`;var ne=R(te);ne.value=ne.__value=`dark`,O(w),O(C);var re=R(C,2),ie=I(re);X(ie),k(),O(re);var ae=R(re,4),oe=e=>{var t=j_(),r=I(t);X(r);var i=R(r);O(t),z(()=>{xi(r,H(Wt)),K(i,` Start ${H(n)??``} when I log in`)}),U(`change`,r,Gt),G(e,t)},se=e=>{G(e,M_())};q(ae,e=>{H(Ut)?e(oe):e(se,-1)});var ce=R(ae,2),le=e=>{var t=N_(),r=L(t),i=I(r);X(i);var a=R(i);O(r);var o=R(r,2),s=I(o);O(o),z(()=>{xi(i,H(qt)),K(a,` Add the ${H(n)??``} entries to the classic Explorer right-click menu`),K(s,`Windows 11's new right-click menu shows ${H(n)??``} either way; this covers the classic one (Show more options).`)}),U(`change`,i,Jt),G(e,t)};q(ce,e=>{H(Kt)&&e(le)});var T=R(ce,2),ue=e=>{var t=L_(),r=L(t),i=I(r);X(i);var a=R(i);O(r);var o=R(r,2);J(o,17,()=>H(Ot),e=>e.id,(e,t)=>{var n=P_(),r=I(n),i=R(I(r)),a=e=>{var n=Br();z(e=>K(n,` for ${H(t).user??``} on ${e??``}`),[()=>Xt(H(t).server)]),G(e,n)};q(i,e=>{H(Ot).length>1&&e(a)}),O(r);var o=R(r,2);X(o),O(n),z(()=>{Z(r,`for`,`navname-`+H(t).id),Z(o,`id`,`navname-`+H(t).id),Z(o,`placeholder`,H(t).sidebarAuto),bi(o,H(t).sidebarName)}),U(`change`,o,e=>Yt(H(t).id,e.currentTarget.value)),G(e,n)});var s=R(o,2),c=e=>{G(e,F_())};q(s,e=>{H(Ot).length&&e(c)});var l=R(s,2),u=e=>{G(e,I_())};q(l,e=>{H(Zt)&&e(u)}),z(()=>{xi(i,H($t)),i.disabled=H(en),K(a,` Show ${H(n)??``} in the Explorer sidebar (points at your default sync location)`)}),U(`change`,i,tn),G(e,t)};q(T,e=>{H(Qt)&&e(ue)});var de=R(T,2),fe=R(I(de)),pe=e=>{var t=R_();U(`click`,t,()=>Iu(c(`troubleshooting`))),G(e,t)};q(fe,e=>{H(o)&&e(pe)}),O(de);var me=R(de,2),he=I(me);X(he),k(),O(me);var ge=R(me,4),_e=e=>{var t=H_(),r=R(I(t),2),i=I(r,!0),a=R(i),o=e=>{var t=Br();z(()=>K(t,`· Nextcloud ${H(B).serverVersion??``}`)),G(e,t)};q(a,e=>{H(B).serverVersion&&e(o)}),O(r);var s=R(r,4),c=I(s,!0);O(s);var l=R(s,4),u=I(l),d=e=>{var t=z_(),r=L(t),i=I(r);O(r);var a=R(r,2);z(()=>K(i,`not set up on this server, so ${H(n)??``} checks for changes every 30 seconds instead. It needs the Client Push app on the server (restart ${H(n)??``} once it is)`)),U(`click`,a,()=>Iu(`https://github.com/nextcloud/notify_push#readme`)),G(e,t)},f=e=>{var t=B_(),n=R(L(t)),r=e=>{var t=Br();z(()=>K(t,`· up ${H(B).pushUptime??``}`)),G(e,t)};q(n,e=>{H(B).pushUptime&&e(r)}),G(e,t)},p=e=>{G(e,V_())};q(u,e=>{H(B).pushAvailable?H(B).pushConnected?e(f,1):e(p,-1):e(d)}),O(l);var m=R(l,4),h=I(m,!0),g=R(h),_=e=>{var t=Br();z(()=>K(t,`· ${H(B).lastStatus??``}`)),G(e,t)};q(g,e=>{H(B).lastStatus&&e(_)}),O(m),O(t),z(()=>{K(i,H(B).serverURL||`—`),K(c,H(B).account||`—`),K(h,H(B).lastSync)}),G(e,t)};q(ge,e=>{H(B)&&e(_e)});var ve=R(ge,2),ye=I(ve),be=R(ye,2),xe=R(be,2),Se=e=>{var t=U_(),n=I(t,!0);O(t),z(()=>K(n,H(vr))),G(e,t)};q(xe,e=>{H(vr)&&e(Se)}),O(ve);var Ce=R(ve,4),we=I(Ce),Te=I(we);O(we);var Ee=R(we,2),De=e=>{var t=K_(),n=L(t),r=R(n,2),i=e=>{var t=W_(),n=I(t,!0);O(t),z(()=>K(n,H(br))),G(e,t)};q(r,e=>{H(br)&&e(i)});var a=R(r,2),o=e=>{var t=G_();z(()=>t.disabled=H(wr)),U(`click`,t,Mr),G(e,t)};q(a,e=>{H(Cr)&&e(o)}),z(()=>n.disabled=H(wr)),U(`click`,n,jr),G(e,t)};q(Ee,e=>{H(Tr)&&e(De)}),O(Ce);var Oe=R(Ce,2),ke=e=>{var t=q_(),n=L(t),r=I(n);X(r),k(),O(n),k(2),z(()=>xi(r,H(Er))),U(`change`,r,Or),G(e,t)};q(Oe,e=>{H(Tr)&&e(ke)});var Ae=R(Oe,2),je=e=>{var t=J_(),n=I(t),r=R(I(n),6),i=I(r),a=R(i,2);O(r),O(n),O(t),U(`click`,i,kr),U(`click`,a,()=>P(Dr,!1)),G(e,t)};q(Ae,e=>{H(Dr)&&e(je)});var E=R(Ae,2),Me=e=>{var t=Y_(),n=I(t,!0);O(t),z(()=>K(n,H(Sr))),G(e,t)};q(E,e=>{H(Sr)&&e(Me)});var D=R(E,4),Ne=e=>{var t=X_(),n=L(t),r=R(I(n));O(n);var i=R(n,2),a=I(i);O(i),z(()=>{K(r,` — ${H(ur).customer??``}${H(ur).seats?` · ${H(ur).seats} seats`:``}${H(ur).expires?` · expires ${H(ur).expires}`:` · perpetual`}.`),a.disabled=H(fr)}),U(`click`,a,gr),G(e,t)},Pe=e=>{var t=Q_(),r=L(t),a=I(r),o=R(a,3),s=R(o,2),c=e=>{var t=Z_(),n=R(L(t)),r=I(n);O(n),z(()=>K(r,`Installed licence: ${H(ur).err??``}.`)),G(e,t)};q(s,e=>{H(ur).hasLicence&&H(ur).err&&e(c)}),O(r);var l=R(r,2),u=I(l);X(u);var d=R(u,2);O(l),z(()=>{K(a,`${H(n)??``} is `),d.disabled=H(fr)}),U(`click`,o,()=>Iu(H(i)+`/business.html`)),U(`keydown`,u,e=>e.key===`Enter`&&hr()),Ti(u,()=>H(dr),e=>P(dr,e)),U(`click`,d,hr),G(e,t)};q(D,e=>{H(ur).licensed?e(Ne):e(Pe,-1)});var Fe=R(D,2),Ie=e=>{var t=$_(),n=I(t,!0);O(t),z(()=>K(n,H(pr))),G(e,t)};q(Fe,e=>{H(pr)&&e(Ie)});var Le=R(Fe,2),Re=I(Le),ze=R(Re),Be=I(ze,!0);O(ze);var Ve=R(ze,2),He=e=>{var t=ev();U(`click`,R(L(t)),()=>Iu(H(o))),G(e,t)};q(Ve,e=>{H(o)&&e(He)});var We=R(Ve,2),Ge=I(We,!0);O(We),O(Le),z((e,t)=>{xi(ie,H(nn)),xi(he,H(Xn)),be.disabled=H(_r),K(Te,`${H(n)??``} ${H(lr)??``}`),K(Re,`© ${e??``} ${H(r)??``} · `),K(Be,t),K(Ge,H(a))},[()=>new Date().getFullYear(),()=>s(H(i))]),U(`click`,g,()=>vl()),U(`change`,b,qe),Ti(b,()=>H(_),e=>P(_,e)),U(`change`,w,fn),mi(w,()=>H(dn),e=>P(dn,e)),U(`change`,ie,rn),U(`change`,he,Zn),U(`click`,ye,()=>ju()),U(`click`,be,yr),U(`click`,ze,()=>Iu(H(i))),U(`click`,We,()=>Iu(`mailto:`+H(a))),G(e,t)};q(Zr,e=>{H(f)===`folders`?e(Qr):H(f)===`sync`?e($r,1):H(f)===`exclusions`?e(ei,2):H(f)===`appearance`?e(ti,3):e(ni,-1)}),O(Kr),O(Nr),z(()=>{Ir=Y(Fr,1,`svelte-1v8xn86`,null,Ir,{active:H(f)===`folders`}),W=Y(Lr,1,`svelte-1v8xn86`,null,W,{active:H(f)===`sync`}),zr=Y(Rr,1,`svelte-1v8xn86`,null,zr,{active:H(f)===`exclusions`}),Ur=Y(Hr,1,`svelte-1v8xn86`,null,Ur,{active:H(f)===`appearance`}),Gr=Y(Wr,1,`svelte-1v8xn86`,null,Gr,{active:H(f)===`general`})}),U(`click`,Fr,()=>P(f,`folders`)),U(`click`,Lr,()=>P(f,`sync`)),U(`click`,Rr,()=>P(f,`exclusions`)),U(`click`,Hr,()=>P(f,`appearance`)),U(`click`,Wr,()=>P(f,`general`)),G(e,Nr),Ye()}jr([`click`,`change`,`keydown`,`input`]);var iv=W(` `),av=W(`Downloads first: `,1),ov=W(``),sv=W(`
`),cv=W(`

Files appear instantly but download only when you open them. - Anything you add or change syncs back to your server as usual.

`),lv=W(`

`),uv=W(`
`),dv=W(`

Loading…

`),fv=W(`

No folders here.

`),pv=W(`
`),mv=W(``),hv=W(`
`),gv=W(`
  • `),_v=W(`

    `),vv=W(`
    `),yv=W(`
    ↔
    📁
    Local Folder
    `,1),bv=W(`

    Loading account…

    `),xv=W(`
    `);function Sv(e,t){Je(t,!0);let n=[],r=N(null),i=N(``),a=N(0),o=N(`everything`),s=N(!1),c=N(``);(async()=>{P(r,await lu(),!0),P(i,H(r).defaultDir,!0),P(a,H(r).freeBytes,!0),!H(r).onDemandSupport&&H(o)===`ondemand`&&P(o,`everything`)})();function l(e){if(!e||e<0)return`—`;let t=[`B`,`KB`,`MB`,`GB`,`TB`],n=e,r=0;for(;n>=1024&&r0))} ${t[r]}`}async function u(){let e=await Vu(H(i));e&&(P(i,e,!0),P(a,await tu(e),!0))}let d=N(null),f=N(!1),p=N(0),m=!1,h=null;async function g(){P(s,!0),P(c,``),H(o)===`ondemand`&&(P(f,!0),m=!1,P(p,0),h=setInterval(async()=>{P(p,(await Yl())?.adoptScanDirs||0,!0)},1500));let e=H(o)===`everything`&&H(x).length?`everything:`+JSON.stringify(H(x)):H(o),n=await Ll(H(i),e);if(h&&=(clearInterval(h),null),P(f,!1),P(s,!1),m){m=!1;return}if(n){if(H(o)===`ondemand`&&n.startsWith(`{`))try{let e=JSON.parse(n);if(e.error){P(c,e.error,!0);return}P(d,e,!0);return}catch{}P(c,n,!0);return}H(o)===`choose`&&await Mu(),t.done()}async function _(){m=!0,P(f,!1),await Dd(`ondemand-cancel`)}async function v(){P(d,null),P(s,!0);let e=await Dd(`ondemand-adopt`);if(P(s,!1),e){P(c,e,!0);return}t.done()}async function y(){P(d,null),await Dd(`ondemand-cancel`)}function b(){t.done()}let x=N(F([])),S=N(!1),C=N(``),w=N(F([])),ee=N(!1);async function te(e){P(C,e.replace(/^\/+|\/+$/g,``),!0),P(ee,!0),P(w,(await jl(H(C))??[]).filter(e=>e.isDir),!0),P(ee,!1)}function ne(){P(S,!0),te(``)}function re(){H(C)&&te(H(C).split(`/`).slice(0,-1).join(`/`))}function ie(e){P(x,H(x).includes(e)?H(x).filter(t=>t!==e):[...H(x),e],!0)}let ae=M(()=>H(x).length<=3?H(x).join(`, `):`${H(x).slice(0,3).join(`, `)} and ${H(x).length-3} more`);var oe=xv(),se=I(oe),ce=e=>{var t=yv(),m=L(t),h=I(m),oe=I(h),se=I(oe,!0);O(oe);var ce=R(oe,2),le=I(ce,!0);O(ce);var T=R(ce,2),ue=I(T,!0);O(T),O(h);var de=R(h,4),fe=R(I(de),4),pe=I(fe,!0);O(fe);var me=R(fe,2),he=I(me);O(me);var ge=R(me,2);O(de),O(m);var _e=R(m,2),ve=I(_e);let ye;var be=I(ve);X(be),be.value=be.__value=`everything`;var xe=R(be,2),Se=R(I(xe)),Ce=e=>{var t=iv(),n=I(t);O(t),z(e=>K(n,`(${e??``})`),[()=>l(H(r).accountBytes)]),G(e,t)};q(Se,e=>{H(r).accountBytes>0&&e(Ce)}),O(xe),O(ve);var we=R(ve,2),Te=e=>{var t=sv(),n=I(t),r=e=>{var t=av(),n=L(t),r=R(I(n)),i=I(r,!0);O(r),O(n);var a=R(n,2);z(e=>{Z(n,`title`,e),K(i,H(ae)),a.disabled=H(s)},[()=>H(x).join(`, `)]),U(`click`,a,ne),G(e,t)},i=e=>{var t=ov();z(()=>t.disabled=H(s)),U(`click`,t,ne),G(e,t)};q(n,e=>{H(x).length?e(r):e(i,-1)}),O(t),G(e,t)};q(we,e=>{H(o)===`everything`&&e(Te)});var Ee=R(we,2);let De;var Oe=I(Ee);X(Oe),Oe.value=Oe.__value=`choose`,k(2),O(Ee);var ke=R(Ee,2);let Ae;var je=I(ke);X(je),je.value=je.__value=`ondemand`,k(2),O(ke);var E=R(ke,2),Me=e=>{G(e,cv())};q(E,e=>{H(o)===`ondemand`&&e(Me)}),O(_e);var D=R(_e,2),Ne=e=>{var t=lv(),n=I(t,!0);O(t),z(()=>K(n,H(c))),G(e,t)};q(D,e=>{H(c)&&e(Ne)});var Pe=R(D,2),Fe=I(Pe),Ie=R(Fe,2),Le=I(Ie,!0);O(Ie),O(Pe);var Re=R(Pe,2),ze=e=>{var t=uv(),n=I(t),r=R(I(n),2),i=I(r);O(r);var a=R(r,2),o=I(a),s=I(o,!0);O(o),k(),O(a);var c=R(a,2),l=I(c);O(c),O(n),O(t),z(()=>{K(i,`${Jp.name??``} is comparing everything in this folder with your server before setting up. On a large account this can take several minutes. Nothing is changed until you confirm.`),K(s,H(p))}),U(`click`,l,_),G(e,t)};q(Re,e=>{H(f)&&e(ze)});var Be=R(Re,2),Ve=e=>{var t=hv(),n=I(t),r=R(I(n),4),i=I(r),a=R(i,2),o=I(a);O(a),O(r);var s=R(r,2),c=I(s),l=e=>{G(e,dv())},u=e=>{G(e,fv())};q(c,e=>{H(ee)?e(l):H(w).length===0&&e(u,1)}),J(R(c,2),17,()=>H(w),e=>e.path,(e,t)=>{var n=pv(),r=I(n);X(r);var i=R(r,2),a=I(i);O(i),O(n),z(e=>{xi(r,e),Z(i,`title`,`Open ${H(t).name??``}`),K(a,`📁 ${H(t).name??``}`)},[()=>H(x).includes(H(t).path)]),U(`change`,r,()=>ie(H(t).path)),U(`click`,i,()=>te(H(t).path)),G(e,n)}),O(s);var d=R(s,2),f=I(d),p=I(f);O(f);var m=R(f,2),h=e=>{var t=mv();U(`click`,t,()=>P(x,[],!0)),G(e,t)};q(m,e=>{H(x).length&&e(h)});var g=R(m,2);O(d),O(n),O(t),z(()=>{i.disabled=!H(C),Z(a,`title`,`/`+H(C)),K(o,`/${H(C)??``}`),K(p,`${H(x).length??``} chosen`)}),U(`click`,i,re),U(`click`,g,()=>P(S,!1)),G(e,t)};q(Be,e=>{H(S)&&e(Ve)});var He=R(Be,2),Ue=e=>{var t=vv(),n=I(t),r=R(I(n),2),a=R(I(r)),o=I(a,!0);O(a);var s=R(a);O(r);var c=R(r,2),u=I(c),f=e=>{var t=gv(),n=I(t),r=I(n,!0);O(n);var i=R(n);O(t),z(()=>{K(r,H(d).keep),K(i,` already ${H(d).keep===1?`matches`:`match`} your server — kept on this PC.`)}),G(e,t)};q(u,e=>{H(d).keep&&e(f)});var p=R(u,2),m=e=>{var t=gv(),n=I(t),r=I(n,!0);O(n);var i=R(n);O(t),z(()=>{K(r,H(d).replace),K(i,` ${H(d).replace===1?`is an online-only leftover`:`are online-only leftovers`} from another app — replaced with ${H(d).replace===1?`a ${Jp.name} placeholder`:`${Jp.name} placeholders`}.`)}),G(e,t)};q(p,e=>{H(d).replace&&e(m)});var h=R(p,2),g=e=>{var t=gv(),n=I(t),r=I(n,!0);O(n);var i=R(n);O(t),z(()=>{K(r,H(d).upload),K(i,` ${H(d).upload===1?`isn’t`:`aren’t`} on your server yet — uploaded.`)}),G(e,t)};q(h,e=>{H(d).upload&&e(g)});var _=R(h,2),b=e=>{var t=gv(),n=I(t),r=I(n,!0);O(n);var i=R(n);O(t),z(()=>{K(r,H(d).conflict),K(i,` ${H(d).conflict===1?`differs`:`differ`} from your server — both versions kept.`)}),G(e,t)};q(_,e=>{H(d).conflict&&e(b)}),O(c);var x=R(c,2),S=e=>{var t=_v(),n=I(t);O(t),z(e=>K(n,`Upload size: ${e??``}`),[()=>l(H(d).uploadBytes)]),G(e,t)};q(x,e=>{H(d).uploadBytes&&e(S)});var C=R(x,2),w=I(C),ee=R(w,2);O(C),O(n),O(t),z(()=>{K(o,H(i)),K(s,`. ${Jp.name??``} can keep them where they are instead of downloading everything again.`)}),U(`click`,w,y),U(`click`,ee,v),G(e,t)};q(He,e=>{H(d)&&e(Ue)}),z((e,t,n)=>{K(se,e),K(le,H(r).user),K(ue,t),Z(fe,`title`,H(i)),K(pe,H(i)),K(he,`${n??``} free`),ge.disabled=H(s),ye=Y(ve,1,`opt svelte-1wxc8fs`,null,ye,{sel:H(o)===`everything`}),be.disabled=H(s),De=Y(Ee,1,`opt svelte-1wxc8fs`,null,De,{sel:H(o)===`choose`}),Oe.disabled=H(s),Ae=Y(ke,1,`opt svelte-1wxc8fs`,null,Ae,{sel:H(o)===`ondemand`,off:!H(r).onDemandSupport}),je.disabled=H(s)||!H(r).onDemandSupport,Fe.disabled=H(s),Ie.disabled=H(s)||!H(i),K(Le,H(s)?`Setting up…`:`Connect`)},[()=>(H(r).user||`?`).charAt(0).toUpperCase(),()=>H(r).server.replace(/^https?:\/\//,``),()=>l(H(a))]),U(`click`,ge,u),Di(n,[],be,()=>H(o),e=>P(o,e)),Di(n,[],Oe,()=>H(o),e=>P(o,e)),Di(n,[],je,()=>H(o),e=>P(o,e)),U(`click`,Fe,b),U(`click`,Ie,g),G(e,t)},le=e=>{G(e,bv())};q(se,e=>{H(r)?e(ce):e(le,-1)}),O(oe),G(e,oe),Ye()}jr([`click`,`change`]);var Cv=W(`

    Sign in again

    `,1),wv=W(`

    Sign in to Nextcloud

    `,1),Tv=W(``),Ev=W(`

    `),Dv=W(``),Ov=W(`
    `);function kv(e,t){Je(t,!0);let n=`Enter your Nextcloud server address to begin.`,r=N(``),i=N(n),a=N(!1),o=N(`signin`),s=N(null),c=N(!1),l=M(()=>!!H(s)&&!H(c));async function u(){let e=await Bd();P(s,e?.id?e:null,!0),P(c,!1),H(a)||(P(r,H(s)?H(s).server:``,!0),P(i,H(s)?``:n,!0))}u(),ns(`login:target`,u),ns(`login:error`,e=>{H(a)&&(P(i,`Sign-in failed: `+e.data),P(a,!1))}),ns(`login:done`,async e=>{if(P(a,!1),e.data===!0){P(i,`Signed in again.`),await Il();return}await $l(),P(o,`setup`)});async function d(){if(!H(r).trim())return;P(a,!0),P(i,`Connecting…`);let e=await Tl(H(r));if(e.startsWith(`error:`)){P(i,e,!0),P(a,!1);return}P(i,`Finish signing in in the browser that just opened. This window steps aside until you have.`)}function f(){P(a,!1),P(i,H(l)?``:n,!0)}function p(){zd(),P(c,!0),P(r,``),P(i,n)}var m=Vr(),h=L(m),g=e=>{Sv(e,{done:()=>Il()})},_=e=>{var t=Ov(),n=I(t),o=e=>{var t=Cv(),n=R(L(t),2),r=I(n,!0);O(n);var i=R(n,2),o=I(i);O(i);var c=R(i,2);z(()=>{K(r,H(s).address),K(o,`${Jp.name??``} can't sign in to this account any more, for example because its access was + Anything you add or change syncs back to your server as usual.

    `),lv=W(`

    `),uv=W(`
    `),dv=W(`

    Loading…

    `),fv=W(`

    No folders here.

    `),pv=W(`
    `),mv=W(``),hv=W(`
    `),gv=W(`
  • `),_v=W(`

    `),vv=W(`
    `),yv=W(`
    ↔
    📁
    Local Folder
    `,1),bv=W(`

    Loading account…

    `),xv=W(`
    `);function Sv(e,t){Je(t,!0);let n=[],r=N(null),i=N(``),a=N(0),o=N(`everything`),s=N(!1),c=N(``);(async()=>{P(r,await lu(),!0),P(i,H(r).defaultDir,!0),P(a,H(r).freeBytes,!0),!H(r).onDemandSupport&&H(o)===`ondemand`&&P(o,`everything`)})();function l(e){if(!e||e<0)return`—`;let t=[`B`,`KB`,`MB`,`GB`,`TB`],n=e,r=0;for(;n>=1024&&r0))} ${t[r]}`}async function u(){let e=await Vu(H(i));e&&(P(i,e,!0),P(a,await tu(e),!0))}let d=N(null),f=N(!1),p=N(0),m=!1,h=null;async function g(){P(s,!0),P(c,``),H(o)===`ondemand`&&(P(f,!0),m=!1,P(p,0),h=setInterval(async()=>{P(p,(await Yl())?.adoptScanDirs||0,!0)},1500));let e=H(o)===`everything`&&H(x).length?`everything:`+JSON.stringify(H(x)):H(o),n=await Ll(H(i),e);if(h&&=(clearInterval(h),null),P(f,!1),P(s,!1),m){m=!1;return}if(n){if(H(o)===`ondemand`&&n.startsWith(`{`))try{let e=JSON.parse(n);if(e.error){P(c,e.error,!0);return}P(d,e,!0);return}catch{}P(c,n,!0);return}H(o)===`choose`&&await Mu(),t.done()}async function _(){m=!0,P(f,!1),await Dd(`ondemand-cancel`)}async function v(){P(d,null),P(s,!0);let e=await Dd(`ondemand-adopt`);if(P(s,!1),e){P(c,e,!0);return}t.done()}async function y(){P(d,null),await Dd(`ondemand-cancel`)}function b(){t.done()}let x=N(F([])),S=N(!1),C=N(``),w=N(F([])),ee=N(!1);async function te(e){P(C,e.replace(/^\/+|\/+$/g,``),!0),P(ee,!0),P(w,(await jl(H(C))??[]).filter(e=>e.isDir),!0),P(ee,!1)}function ne(){P(S,!0),te(``)}function re(){H(C)&&te(H(C).split(`/`).slice(0,-1).join(`/`))}function ie(e){P(x,H(x).includes(e)?H(x).filter(t=>t!==e):[...H(x),e],!0)}let ae=M(()=>H(x).length<=3?H(x).join(`, `):`${H(x).slice(0,3).join(`, `)} and ${H(x).length-3} more`);var oe=xv(),se=I(oe),ce=e=>{var t=yv(),m=L(t),h=I(m),oe=I(h),se=I(oe,!0);O(oe);var ce=R(oe,2),le=I(ce,!0);O(ce);var T=R(ce,2),ue=I(T,!0);O(T),O(h);var de=R(h,4),fe=R(I(de),4),pe=I(fe,!0);O(fe);var me=R(fe,2),he=I(me);O(me);var ge=R(me,2);O(de),O(m);var _e=R(m,2),ve=I(_e);let ye;var be=I(ve);X(be),be.value=be.__value=`everything`;var xe=R(be,2),Se=R(I(xe)),Ce=e=>{var t=iv(),n=I(t);O(t),z(e=>K(n,`(${e??``})`),[()=>l(H(r).accountBytes)]),G(e,t)};q(Se,e=>{H(r).accountBytes>0&&e(Ce)}),O(xe),O(ve);var we=R(ve,2),Te=e=>{var t=sv(),n=I(t),r=e=>{var t=av(),n=L(t),r=R(I(n)),i=I(r,!0);O(r),O(n);var a=R(n,2);z(e=>{Z(n,`title`,e),K(i,H(ae)),a.disabled=H(s)},[()=>H(x).join(`, `)]),U(`click`,a,ne),G(e,t)},i=e=>{var t=ov();z(()=>t.disabled=H(s)),U(`click`,t,ne),G(e,t)};q(n,e=>{H(x).length?e(r):e(i,-1)}),O(t),G(e,t)};q(we,e=>{H(o)===`everything`&&e(Te)});var Ee=R(we,2);let De;var Oe=I(Ee);X(Oe),Oe.value=Oe.__value=`choose`,k(2),O(Ee);var ke=R(Ee,2);let Ae;var je=I(ke);X(je),je.value=je.__value=`ondemand`,k(2),O(ke);var E=R(ke,2),Me=e=>{G(e,cv())};q(E,e=>{H(o)===`ondemand`&&e(Me)}),O(_e);var D=R(_e,2),Ne=e=>{var t=lv(),n=I(t,!0);O(t),z(()=>K(n,H(c))),G(e,t)};q(D,e=>{H(c)&&e(Ne)});var Pe=R(D,2),Fe=I(Pe),Ie=R(Fe,2),Le=I(Ie,!0);O(Ie),O(Pe);var Re=R(Pe,2),ze=e=>{var t=uv(),n=I(t),r=R(I(n),2),i=I(r);O(r);var a=R(r,2),o=I(a),s=I(o,!0);O(o),k(),O(a);var c=R(a,2),l=I(c);O(c),O(n),O(t),z(()=>{K(i,`${Jp.name??``} is comparing everything in this folder with your server before setting up. On a large account this can take several minutes. Nothing is changed until you confirm.`),K(s,H(p))}),U(`click`,l,_),G(e,t)};q(Re,e=>{H(f)&&e(ze)});var Be=R(Re,2),Ve=e=>{var t=hv(),n=I(t),r=R(I(n),4),i=I(r),a=R(i,2),o=I(a);O(a),O(r);var s=R(r,2),c=I(s),l=e=>{G(e,dv())},u=e=>{G(e,fv())};q(c,e=>{H(ee)?e(l):H(w).length===0&&e(u,1)}),J(R(c,2),17,()=>H(w),e=>e.path,(e,t)=>{var n=pv(),r=I(n);X(r);var i=R(r,2),a=I(i);O(i),O(n),z(e=>{xi(r,e),Z(i,`title`,`Open ${H(t).name??``}`),K(a,`📁 ${H(t).name??``}`)},[()=>H(x).includes(H(t).path)]),U(`change`,r,()=>ie(H(t).path)),U(`click`,i,()=>te(H(t).path)),G(e,n)}),O(s);var d=R(s,2),f=I(d),p=I(f);O(f);var m=R(f,2),h=e=>{var t=mv();U(`click`,t,()=>P(x,[],!0)),G(e,t)};q(m,e=>{H(x).length&&e(h)});var g=R(m,2);O(d),O(n),O(t),z(()=>{i.disabled=!H(C),Z(a,`title`,`/`+H(C)),K(o,`/${H(C)??``}`),K(p,`${H(x).length??``} chosen`)}),U(`click`,i,re),U(`click`,g,()=>P(S,!1)),G(e,t)};q(Be,e=>{H(S)&&e(Ve)});var He=R(Be,2),Ue=e=>{var t=vv(),n=I(t),r=R(I(n),2),a=R(I(r)),o=I(a,!0);O(a);var s=R(a);O(r);var c=R(r,2),u=I(c),f=e=>{var t=gv(),n=I(t),r=I(n,!0);O(n);var i=R(n);O(t),z(()=>{K(r,H(d).keep),K(i,` already ${H(d).keep===1?`matches`:`match`} your server — kept on this PC.`)}),G(e,t)};q(u,e=>{H(d).keep&&e(f)});var p=R(u,2),m=e=>{var t=gv(),n=I(t),r=I(n,!0);O(n);var i=R(n);O(t),z(()=>{K(r,H(d).replace),K(i,` ${H(d).replace===1?`is an online-only leftover`:`are online-only leftovers`} from another app — replaced with ${H(d).replace===1?`a ${Jp.name} placeholder`:`${Jp.name} placeholders`}.`)}),G(e,t)};q(p,e=>{H(d).replace&&e(m)});var h=R(p,2),g=e=>{var t=gv(),n=I(t),r=I(n,!0);O(n);var i=R(n);O(t),z(()=>{K(r,H(d).upload),K(i,` ${H(d).upload===1?`isn’t`:`aren’t`} on your server yet — uploaded.`)}),G(e,t)};q(h,e=>{H(d).upload&&e(g)});var _=R(h,2),b=e=>{var t=gv(),n=I(t),r=I(n,!0);O(n);var i=R(n);O(t),z(()=>{K(r,H(d).conflict),K(i,` ${H(d).conflict===1?`differs`:`differ`} from your server — both versions kept.`)}),G(e,t)};q(_,e=>{H(d).conflict&&e(b)}),O(c);var x=R(c,2),S=e=>{var t=_v(),n=I(t);O(t),z(e=>K(n,`Upload size: ${e??``}`),[()=>l(H(d).uploadBytes)]),G(e,t)};q(x,e=>{H(d).uploadBytes&&e(S)});var C=R(x,2),w=I(C),ee=R(w,2);O(C),O(n),O(t),z(()=>{K(o,H(i)),K(s,`. ${Jp.name??``} can keep them where they are instead of downloading everything again.`)}),U(`click`,w,y),U(`click`,ee,v),G(e,t)};q(He,e=>{H(d)&&e(Ue)}),z((e,t,n)=>{K(se,e),K(le,H(r).user),K(ue,t),Z(fe,`title`,H(i)),K(pe,H(i)),K(he,`${n??``} free`),ge.disabled=H(s),ye=Y(ve,1,`opt svelte-1wxc8fs`,null,ye,{sel:H(o)===`everything`}),be.disabled=H(s),De=Y(Ee,1,`opt svelte-1wxc8fs`,null,De,{sel:H(o)===`choose`}),Oe.disabled=H(s),Ae=Y(ke,1,`opt svelte-1wxc8fs`,null,Ae,{sel:H(o)===`ondemand`,off:!H(r).onDemandSupport}),je.disabled=H(s)||!H(r).onDemandSupport,Fe.disabled=H(s),Ie.disabled=H(s)||!H(i),K(Le,H(s)?`Setting up…`:`Connect`)},[()=>(H(r).user||`?`).charAt(0).toUpperCase(),()=>H(r).server.replace(/^https?:\/\//,``),()=>l(H(a))]),U(`click`,ge,u),Di(n,[],be,()=>H(o),e=>P(o,e)),Di(n,[],Oe,()=>H(o),e=>P(o,e)),Di(n,[],je,()=>H(o),e=>P(o,e)),U(`click`,Fe,b),U(`click`,Ie,g),G(e,t)},le=e=>{G(e,bv())};q(se,e=>{H(r)?e(ce):e(le,-1)}),O(oe),G(e,oe),Ye()}jr([`click`,`change`]);var Cv=W(`

    Sign in again

    `,1),wv=W(`

    Sign in to Nextcloud

    `,1),Tv=W(``),Ev=W(`

    `),Dv=W(``),Ov=W(``);function kv(e,t){Je(t,!0);let n=`Enter your Nextcloud server address to begin.`,r=N(``),i=N(n),a=N(!1),o=N(`signin`),s=N(null),c=N(!1),l=M(()=>!!H(s)&&!H(c)),u=N(``),d=N(!1);async function f(){let e=await Bd();P(s,e?.id?e:null,!0),P(c,!1),H(a)||(P(r,H(s)?H(s).server:H(u),!0),P(i,H(s)?``:n,!0))}f(),(async()=>{let e=await Uu();e?.serverUrl&&(P(u,e.serverUrl,!0),P(d,e.lockServer,!0),!H(a)&&!H(s)&&(H(d)||!H(r))&&P(r,H(u),!0))})(),ns(`login:target`,f),ns(`login:error`,e=>{H(a)&&(P(i,`Sign-in failed: `+e.data),P(a,!1))}),ns(`login:done`,async e=>{if(P(a,!1),e.data===!0){P(i,`Signed in again.`),await Il();return}await $l(),P(o,`setup`)});async function p(){if(!H(r).trim())return;P(a,!0),P(i,`Connecting…`);let e=await Tl(H(r));if(e.startsWith(`error:`)){P(i,`Sign-in failed: `+e.slice(6).trim()),P(a,!1);return}P(i,`Finish signing in in the browser that just opened. This window steps aside until you have.`)}function m(){P(a,!1),P(i,H(l)?``:n,!0)}function h(){zd(),P(c,!0),P(r,H(u),!0),P(i,n)}var g=Vr(),_=L(g),v=e=>{Sv(e,{done:()=>Il()})},y=e=>{var t=Ov(),n=I(t),o=e=>{var t=Cv(),n=R(L(t),2),r=I(n,!0);O(n);var i=R(n,2),o=I(i);O(i);var c=R(i,2);z(()=>{K(r,H(s).address),K(o,`${Jp.name??``} can't sign in to this account any more, for example because its access was removed in Nextcloud. Sign in again and it carries on where it left off: your folders and - settings are kept, and nothing needs setting up again.`),c.disabled=H(a)}),U(`click`,c,d),G(e,t)},c=e=>{var t=wv(),n=R(L(t),2);X(n);var i=R(n,2);z(()=>{n.disabled=H(a),i.disabled=H(a)}),U(`keydown`,n,e=>e.key===`Enter`&&d()),Ti(n,()=>H(r),e=>P(r,e)),U(`click`,i,d),G(e,t)};q(n,e=>{H(l)&&H(s)?e(o):e(c,-1)});var u=R(n,2),m=e=>{var t=Tv();U(`click`,t,f),G(e,t)};q(u,e=>{H(a)&&e(m)});var h=R(u,2),g=e=>{var t=Ev(),n=I(t,!0);O(t),z(()=>K(n,H(i))),G(e,t)};q(h,e=>{H(i)&&e(g)});var _=R(h,2),v=e=>{var t=Dv();U(`click`,t,p),G(e,t)};q(_,e=>{H(l)&&!H(a)&&e(v)}),O(t),G(e,t)};q(h,e=>{H(o)===`setup`?e(g):e(_,-1)}),G(e,m),Ye()}jr([`click`,`keydown`]);var Av=W(`

    `),jv=W(`

    Not shared yet.

    `),Mv=W(`
    `),Nv=W(`
    `),Pv=W(``),Fv=W(`
    Sharing

    Shares

    Create public link

    Share with a user

    `);function Iv(e,t){Je(t,!0);let n=N(``),r=N(F([])),i=N(``),a=N(!1),o=N(``),s=N(``),c=N(``),l=N(!1),u=N(``),d=N(!1);async function f(){P(n,await jd(),!0),P(r,await Ad()??[],!0)}f(),ns(`share:target`,()=>{P(i,``),P(o,``),f()});let p=e=>e.replace(/\/+$/,``).split(`/`).pop()||e,m=e=>(e&2)!=0,h=e=>e===3;async function g(e){P(i,``),P(a,!0);let t=await e();return P(a,!1),t?(P(i,t,!0),!1):(await f(),!0)}async function _(){await g(()=>Vl(H(s).trim(),H(c).trim(),H(l)))&&(P(s,``),P(c,``),P(l,!1))}async function v(){await g(()=>Hl(H(u),H(d)))&&(P(u,``),P(d,!1))}let y=e=>g(()=>Gl(e));async function b(e,t){await Bl(e),P(o,t,!0),setTimeout(()=>{H(o)===t&&P(o,``)},1500)}let x=e=>Iu(e);var S=Fv(),C=I(S),w=R(I(C),2),ee=I(w);O(w);var te=R(w,2),ne=I(te);O(te),O(C);var re=R(C,2),ie=I(re),ae=e=>{var t=Av(),n=I(t,!0);O(t),z(()=>K(n,H(i))),G(e,t)};q(ie,e=>{H(i)&&e(ae)});var oe=R(ie,4),se=e=>{G(e,jv())},ce=e=>{var t=Vr();J(L(t),17,()=>H(r),Yr,(e,t)=>{var n=Pv(),r=I(n),i=I(r),s=I(i),c=e=>{G(e,Br(`🔗 Public link`))},l=M(()=>h(H(t).type)),u=e=>{var n=Br();z(()=>K(n,`👤 ${H(t).shareWith??``}`)),G(e,n)};q(s,e=>{H(l)?e(c):e(u,-1)}),O(i);var d=R(i,2),f=I(d,!0);O(d);var p=R(d,2);O(r);var g=R(r,2),_=e=>{var n=Mv(),r=I(n);X(r);var i=R(r,2),a=I(i,!0);O(i);var s=R(i,2);O(n),z(()=>{bi(r,H(t).url),K(a,H(o)===H(t).id?`Copied!`:`Copy`)}),U(`click`,i,()=>b(H(t).url,H(t).id)),U(`click`,s,()=>x(H(t).url)),G(e,n)},v=M(()=>h(H(t).type)&&H(t).url);q(g,e=>{H(v)&&e(_)});var S=R(g,2),C=e=>{var n=Nv(),r=I(n);O(n),z(e=>K(r,`Expires ${e??``}`),[()=>H(t).expiration.slice(0,10)]),G(e,n)};q(S,e=>{H(t).expiration&&e(C)}),O(n),z(e=>{K(f,e),p.disabled=H(a)},[()=>m(H(t).permissions)?`Can edit`:`View only`]),U(`click`,p,()=>y(H(t).id)),G(e,n)}),G(e,t)};q(oe,e=>{H(r).length===0?e(se):e(ce,-1)});var le=R(oe,4),T=I(le),ue=R(I(T));X(ue),O(T);var de=R(T,2),fe=R(I(de));X(fe),O(de);var pe=R(de,2),me=I(pe);X(me),k(),O(pe);var he=R(pe,2);O(le);var ge=R(le,4),_e=I(ge),ve=R(I(_e));X(ve),O(_e);var ye=R(_e,2),be=I(ye);X(be),k(),O(ye);var xe=R(ye,2);O(ge),O(re),O(S),z(e=>{Z(w,`title`,H(n)),K(ee,`📄 ${e??``}`),K(ne,`/${H(n)??``}`),he.disabled=H(a),xe.disabled=H(a)},[()=>p(H(n))]),Ti(ue,()=>H(s),e=>P(s,e)),Ti(fe,()=>H(c),e=>P(c,e)),Oi(me,()=>H(l),e=>P(l,e)),U(`click`,he,_),U(`keydown`,ve,e=>e.key===`Enter`&&v()),Ti(ve,()=>H(u),e=>P(u,e)),Oi(be,()=>H(d),e=>P(d,e)),U(`click`,xe,v),G(e,S),Ye()}jr([`click`,`keydown`]);var Lv=W(`

    `),Rv=W(`

    Loading…

    `),zv=W(`

    No previous versions. Nextcloud keeps versions as a file changes over time.

    `),Bv=W(`
    `),Vv=W(`

    Restoring makes that version the current file (the present copy is kept as a version too).

    `,1),Hv=W(`
    Version history
    `);function Uv(e,t){Je(t,!0);let n=N(``),r=N(F([])),i=N(``),a=N(!1),o=N(!0);async function s(){P(o,!0),P(n,await ef(),!0),P(r,await $d()??[],!0),P(o,!1)}s(),ns(`versions:target`,()=>{P(i,``),s()});let c=e=>e.replace(/\/+$/,``).split(`/`).pop()||e;function l(e){if(!e||e<0)return`—`;let t=[`B`,`KB`,`MB`,`GB`,`TB`],n=0;for(;e>=1024&&n0?e.toFixed(1):Math.round(e)} ${t[n]}`}async function u(e){P(i,``),P(a,!0);let t=await rd(e);if(P(a,!1),t){P(i,t,!0);return}await s()}var d=Hv(),f=I(d),p=R(I(f),2),m=I(p);O(p);var h=R(p,2),g=I(h);O(h),O(f);var _=R(f,2),v=I(_),y=e=>{var t=Lv(),n=I(t,!0);O(t),z(()=>K(n,H(i))),G(e,t)};q(v,e=>{H(i)&&e(y)});var b=R(v,2),x=e=>{G(e,Rv())},S=e=>{G(e,zv())},C=e=>{var t=Vv();J(R(L(t),2),17,()=>H(r),Yr,(e,t)=>{var n=Bv(),r=I(n),i=I(r),o=I(i,!0);O(i);var s=R(i,2),c=I(s,!0);O(s),O(r);var d=R(r,2);O(n),z(e=>{K(o,H(t).modified),K(c,e),d.disabled=H(a)},[()=>l(H(t).size)]),U(`click`,d,()=>u(H(t).href)),G(e,n)}),G(e,t)};q(b,e=>{H(o)?e(x):H(r).length===0?e(S,1):e(C,-1)}),O(_),O(d),z(e=>{Z(p,`title`,H(n)),K(m,`📄 ${e??``}`),K(g,`/${H(n)??``}`)},[()=>c(H(n))]),G(e,d),Ye()}jr([`click`]);var Wv=W(`
    Logs
     
    `);function Gv(e,t){Je(t,!0);let n=N(``),r=N(``),i=N(!1),a=N(!1),o;async function s(e=!0){P(n,await Gd(),!0),P(r,await bu(),!0),P(i,await Zd(),!0),e&&requestAnimationFrame(()=>{o&&(o.scrollTop=o.scrollHeight)})}s();async function c(){await Bl(H(n)),P(a,!0),setTimeout(()=>P(a,!1),1500)}function l(){P(i,!H(i)),kd(H(i))}var u=Wv(),d=I(u),f=R(I(d),2),p=I(f,!0);O(f);var m=R(f,2),h=I(m),g=R(h,2),_=I(g,!0);O(g);var v=R(g,2),y=R(v,2),b=I(y);X(b),k(),O(y),O(m),O(d);var x=R(d,2),S=I(x,!0);O(x),Ni(x,e=>o=e,()=>o),O(u),z(()=>{Z(f,`title`,H(r)),K(p,H(r)),K(_,H(a)?`Copied!`:`Copy`),xi(b,H(i)),K(S,H(n)||`(no log yet)`)}),U(`click`,h,()=>s()),U(`click`,g,c),U(`click`,v,()=>Au()),U(`change`,b,l),G(e,u),Ye()}jr([`click`,`change`]),He();var Kv=W(``),qv=W(`🗂`),Jv=W(`
    Loading from your server…
    `);function Yv(e,t){Je(t,!1);let n=window.location.hash.slice(1),r=n.startsWith(`appsplash/`)?n.slice(10):``,i=r.indexOf(`?`),a=decodeURIComponent(i>=0?r.slice(0,i):r)||`App`,o=i>=0&&new URLSearchParams(r.slice(i)).get(`icon`)||``;Pi();var s=Jv(),c=I(s),l=I(c),u=e=>{var t=Kv();z(()=>Z(t,`src`,o)),G(e,t)},d=e=>{G(e,qv())};q(l,e=>{o?e(u):e(d,-1)}),O(c);var f=R(c,2),p=I(f,!0);O(f),k(4),O(s),z(()=>K(p,a)),G(e,s),Ye()}var Xv=W(`

    `),Zv=W(``),Qv=W(``),$v=W(`
    `);function ey(e,t){Je(t,!0);let n=N(``),r=N(null),i=N(null);Dn(()=>{let e=Yp.current;e&&(P(n,e.value,!0),queueMicrotask(()=>{e.kind===`text`&&H(i)?(H(i).focus(),H(i).select()):H(r)?.focus()}))});function a(){let e=Yp.current;e&&Qp(e.kind===`ask`?!0:e.kind===`text`?H(n):void 0)}function o(){let e=Yp.current;e&&Qp(e.kind===`ask`?!1:e.kind===`text`?null:void 0)}function s(e){Yp.current&&(e.key===`Escape`?(e.preventDefault(),o()):e.key===`Enter`&&Yp.current.kind===`text`&&(e.preventDefault(),a()))}var c=Vr();Ar(`keydown`,on,s);var l=L(c),u=e=>{let t=M(()=>Yp.current);var s=$v(),c=I(s),l=I(c),u=I(l,!0);O(l);var d=R(l,2),f=e=>{var n=Xv(),r=I(n,!0);O(n),z(()=>K(r,H(t).message)),G(e,n)};q(d,e=>{H(t).message&&e(f)});var p=R(d,2),m=e=>{var t=Zv();X(t),Ni(t,e=>P(i,e),()=>H(i)),Ti(t,()=>H(n),e=>P(n,e)),G(e,t)};q(p,e=>{H(t).kind===`text`&&e(m)});var h=R(p,2),g=I(h),_=e=>{var n=Qv(),r=I(n,!0);O(n),z(()=>K(r,H(t).cancel)),U(`click`,n,o),G(e,n)};q(g,e=>{H(t).kind!==`tell`&&e(_)});var v=R(g,2);let y;var b=I(v,!0);O(v),Ni(v,e=>P(r,e),()=>H(r)),O(h),O(c),O(s),z(()=>{K(u,H(t).title),y=Y(v,1,`primary svelte-11qie5r`,null,y,{danger:H(t).danger}),K(b,H(t).ok)}),U(`click`,v,a),G(e,s)};q(l,e=>{Yp.current&&e(u)}),G(e,c),Ye()}jr([`click`]);var ty=W(` `,1);function ny(e,t){Je(t,!0);let n=N(F(window.location.hash.replace(`#`,``)));window.addEventListener(`hashchange`,()=>{P(n,window.location.hash.replace(`#`,``),!0)});let r=window.matchMedia(`(prefers-color-scheme: dark)`),i=`system`,a=``;function o(){let e;e=i===`dark`?!0:i===`light`?!1:a===`dark`?!0:a===`light`?!1:r.matches,e?document.documentElement.setAttribute(`data-theme`,`dark`):document.documentElement.removeAttribute(`data-theme`)}r.addEventListener(`change`,o),ns(`theme`,e=>{i=e.data||`system`,o()}),(async()=>{i=await du(),o()})();let s=0;async function c(){let e=++s;for(let t=0;t<12&&e===s;t++){let e=await wu();if(e){a=e,o();return}await new Promise(e=>setTimeout(e,1e3))}}c();function l(e,t){let n=e.replace(`#`,``);return n.length===6?`#`+[0,2,4].map(e=>Math.max(0,Math.round(parseInt(n.slice(e,e+2),16)*t))).map(e=>e.toString(16).padStart(2,`0`)).join(``):e}function u(e){let t=document.documentElement;e&&/^#[0-9a-fA-F]{6}$/.test(e)?(t.style.setProperty(`--accent`,e),t.style.setProperty(`--accent-dark`,l(e,.88))):(t.style.removeProperty(`--accent`),t.style.removeProperty(`--accent-dark`))}ns(`accent`,e=>{u(e.data||``),c()}),(async()=>{for(let e=0;e<12;e++){try{let e=await qd();if(e&&/^#[0-9a-fA-F]{6}$/.test(e)){u(e);return}}catch{}await new Promise(e=>setTimeout(e,1e3))}})();var d=ty(),f=L(d),p=e=>{Yv(e,{})},m=M(()=>H(n).startsWith(`appsplash`)),h=e=>{yh(e,{})},g=e=>{rv(e,{})},_=M(()=>H(n).startsWith(`settings`)),v=e=>{kv(e,{})},y=e=>{Iv(e,{})},b=e=>{Uv(e,{})},x=e=>{Gv(e,{})},S=e=>{qp(e,{})};q(f,e=>{H(m)?e(p):H(n)===`status`?e(h,1):H(_)?e(g,2):H(n)===`login`?e(v,3):H(n)===`share`?e(y,4):H(n)===`versions`?e(b,5):H(n)===`logs`?e(x,6):e(S,-1)}),ey(R(f,2),{}),G(e,d),Ye()}Hr(ny,{target:document.getElementById(`app`)}); \ No newline at end of file + settings are kept, and nothing needs setting up again.`),c.disabled=H(a)}),U(`click`,c,p),G(e,t)},c=e=>{var t=wv(),n=R(L(t),2);X(n);var i=R(n,2);z(()=>{n.disabled=H(a)||H(d),Z(n,`title`,H(d)?`This server is set for you`:``),i.disabled=H(a)}),U(`keydown`,n,e=>e.key===`Enter`&&p()),Ti(n,()=>H(r),e=>P(r,e)),U(`click`,i,p),G(e,t)};q(n,e=>{H(l)&&H(s)?e(o):e(c,-1)});var u=R(n,2),f=e=>{var t=Tv();U(`click`,t,m),G(e,t)};q(u,e=>{H(a)&&e(f)});var g=R(u,2),_=e=>{var t=Ev(),n=I(t,!0);O(t),z(()=>K(n,H(i))),G(e,t)};q(g,e=>{H(i)&&e(_)});var v=R(g,2),y=e=>{var t=Dv();U(`click`,t,h),G(e,t)};q(v,e=>{H(l)&&!H(a)&&e(y)}),O(t),G(e,t)};q(_,e=>{H(o)===`setup`?e(v):e(y,-1)}),G(e,g),Ye()}jr([`click`,`keydown`]);var Av=W(`

    `),jv=W(`

    Not shared yet.

    `),Mv=W(`
    `),Nv=W(`
    `),Pv=W(``),Fv=W(`
    Sharing

    Shares

    Create public link

    Share with a user

    `);function Iv(e,t){Je(t,!0);let n=N(``),r=N(F([])),i=N(``),a=N(!1),o=N(``),s=N(``),c=N(``),l=N(!1),u=N(``),d=N(!1);async function f(){P(n,await jd(),!0),P(r,await Ad()??[],!0)}f(),ns(`share:target`,()=>{P(i,``),P(o,``),f()});let p=e=>e.replace(/\/+$/,``).split(`/`).pop()||e,m=e=>(e&2)!=0,h=e=>e===3;async function g(e){P(i,``),P(a,!0);let t=await e();return P(a,!1),t?(P(i,t,!0),!1):(await f(),!0)}async function _(){await g(()=>Vl(H(s).trim(),H(c).trim(),H(l)))&&(P(s,``),P(c,``),P(l,!1))}async function v(){await g(()=>Hl(H(u),H(d)))&&(P(u,``),P(d,!1))}let y=e=>g(()=>Gl(e));async function b(e,t){await Bl(e),P(o,t,!0),setTimeout(()=>{H(o)===t&&P(o,``)},1500)}let x=e=>Iu(e);var S=Fv(),C=I(S),w=R(I(C),2),ee=I(w);O(w);var te=R(w,2),ne=I(te);O(te),O(C);var re=R(C,2),ie=I(re),ae=e=>{var t=Av(),n=I(t,!0);O(t),z(()=>K(n,H(i))),G(e,t)};q(ie,e=>{H(i)&&e(ae)});var oe=R(ie,4),se=e=>{G(e,jv())},ce=e=>{var t=Vr();J(L(t),17,()=>H(r),Yr,(e,t)=>{var n=Pv(),r=I(n),i=I(r),s=I(i),c=e=>{G(e,Br(`🔗 Public link`))},l=M(()=>h(H(t).type)),u=e=>{var n=Br();z(()=>K(n,`👤 ${H(t).shareWith??``}`)),G(e,n)};q(s,e=>{H(l)?e(c):e(u,-1)}),O(i);var d=R(i,2),f=I(d,!0);O(d);var p=R(d,2);O(r);var g=R(r,2),_=e=>{var n=Mv(),r=I(n);X(r);var i=R(r,2),a=I(i,!0);O(i);var s=R(i,2);O(n),z(()=>{bi(r,H(t).url),K(a,H(o)===H(t).id?`Copied!`:`Copy`)}),U(`click`,i,()=>b(H(t).url,H(t).id)),U(`click`,s,()=>x(H(t).url)),G(e,n)},v=M(()=>h(H(t).type)&&H(t).url);q(g,e=>{H(v)&&e(_)});var S=R(g,2),C=e=>{var n=Nv(),r=I(n);O(n),z(e=>K(r,`Expires ${e??``}`),[()=>H(t).expiration.slice(0,10)]),G(e,n)};q(S,e=>{H(t).expiration&&e(C)}),O(n),z(e=>{K(f,e),p.disabled=H(a)},[()=>m(H(t).permissions)?`Can edit`:`View only`]),U(`click`,p,()=>y(H(t).id)),G(e,n)}),G(e,t)};q(oe,e=>{H(r).length===0?e(se):e(ce,-1)});var le=R(oe,4),T=I(le),ue=R(I(T));X(ue),O(T);var de=R(T,2),fe=R(I(de));X(fe),O(de);var pe=R(de,2),me=I(pe);X(me),k(),O(pe);var he=R(pe,2);O(le);var ge=R(le,4),_e=I(ge),ve=R(I(_e));X(ve),O(_e);var ye=R(_e,2),be=I(ye);X(be),k(),O(ye);var xe=R(ye,2);O(ge),O(re),O(S),z(e=>{Z(w,`title`,H(n)),K(ee,`📄 ${e??``}`),K(ne,`/${H(n)??``}`),he.disabled=H(a),xe.disabled=H(a)},[()=>p(H(n))]),Ti(ue,()=>H(s),e=>P(s,e)),Ti(fe,()=>H(c),e=>P(c,e)),Oi(me,()=>H(l),e=>P(l,e)),U(`click`,he,_),U(`keydown`,ve,e=>e.key===`Enter`&&v()),Ti(ve,()=>H(u),e=>P(u,e)),Oi(be,()=>H(d),e=>P(d,e)),U(`click`,xe,v),G(e,S),Ye()}jr([`click`,`keydown`]);var Lv=W(`

    `),Rv=W(`

    Loading…

    `),zv=W(`

    No previous versions. Nextcloud keeps versions as a file changes over time.

    `),Bv=W(`
    `),Vv=W(`

    Restoring makes that version the current file (the present copy is kept as a version too).

    `,1),Hv=W(`
    Version history
    `);function Uv(e,t){Je(t,!0);let n=N(``),r=N(F([])),i=N(``),a=N(!1),o=N(!0);async function s(){P(o,!0),P(n,await ef(),!0),P(r,await $d()??[],!0),P(o,!1)}s(),ns(`versions:target`,()=>{P(i,``),s()});let c=e=>e.replace(/\/+$/,``).split(`/`).pop()||e;function l(e){if(!e||e<0)return`—`;let t=[`B`,`KB`,`MB`,`GB`,`TB`],n=0;for(;e>=1024&&n0?e.toFixed(1):Math.round(e)} ${t[n]}`}async function u(e){P(i,``),P(a,!0);let t=await rd(e);if(P(a,!1),t){P(i,t,!0);return}await s()}var d=Hv(),f=I(d),p=R(I(f),2),m=I(p);O(p);var h=R(p,2),g=I(h);O(h),O(f);var _=R(f,2),v=I(_),y=e=>{var t=Lv(),n=I(t,!0);O(t),z(()=>K(n,H(i))),G(e,t)};q(v,e=>{H(i)&&e(y)});var b=R(v,2),x=e=>{G(e,Rv())},S=e=>{G(e,zv())},C=e=>{var t=Vv();J(R(L(t),2),17,()=>H(r),Yr,(e,t)=>{var n=Bv(),r=I(n),i=I(r),o=I(i,!0);O(i);var s=R(i,2),c=I(s,!0);O(s),O(r);var d=R(r,2);O(n),z(e=>{K(o,H(t).modified),K(c,e),d.disabled=H(a)},[()=>l(H(t).size)]),U(`click`,d,()=>u(H(t).href)),G(e,n)}),G(e,t)};q(b,e=>{H(o)?e(x):H(r).length===0?e(S,1):e(C,-1)}),O(_),O(d),z(e=>{Z(p,`title`,H(n)),K(m,`📄 ${e??``}`),K(g,`/${H(n)??``}`)},[()=>c(H(n))]),G(e,d),Ye()}jr([`click`]);var Wv=W(`
    Logs
     
    `);function Gv(e,t){Je(t,!0);let n=N(``),r=N(``),i=N(!1),a=N(!1),o;async function s(e=!0){P(n,await Gd(),!0),P(r,await bu(),!0),P(i,await Zd(),!0),e&&requestAnimationFrame(()=>{o&&(o.scrollTop=o.scrollHeight)})}s();async function c(){await Bl(H(n)),P(a,!0),setTimeout(()=>P(a,!1),1500)}function l(){P(i,!H(i)),kd(H(i))}var u=Wv(),d=I(u),f=R(I(d),2),p=I(f,!0);O(f);var m=R(f,2),h=I(m),g=R(h,2),_=I(g,!0);O(g);var v=R(g,2),y=R(v,2),b=I(y);X(b),k(),O(y),O(m),O(d);var x=R(d,2),S=I(x,!0);O(x),Ni(x,e=>o=e,()=>o),O(u),z(()=>{Z(f,`title`,H(r)),K(p,H(r)),K(_,H(a)?`Copied!`:`Copy`),xi(b,H(i)),K(S,H(n)||`(no log yet)`)}),U(`click`,h,()=>s()),U(`click`,g,c),U(`click`,v,()=>Au()),U(`change`,b,l),G(e,u),Ye()}jr([`click`,`change`]),He();var Kv=W(``),qv=W(`🗂`),Jv=W(`
    Loading from your server…
    `);function Yv(e,t){Je(t,!1);let n=window.location.hash.slice(1),r=n.startsWith(`appsplash/`)?n.slice(10):``,i=r.indexOf(`?`),a=decodeURIComponent(i>=0?r.slice(0,i):r)||`App`,o=i>=0&&new URLSearchParams(r.slice(i)).get(`icon`)||``;Pi();var s=Jv(),c=I(s),l=I(c),u=e=>{var t=Kv();z(()=>Z(t,`src`,o)),G(e,t)},d=e=>{G(e,qv())};q(l,e=>{o?e(u):e(d,-1)}),O(c);var f=R(c,2),p=I(f,!0);O(f),k(4),O(s),z(()=>K(p,a)),G(e,s),Ye()}var Xv=W(`

    `),Zv=W(``),Qv=W(``),$v=W(`
    `);function ey(e,t){Je(t,!0);let n=N(``),r=N(null),i=N(null);Dn(()=>{let e=Yp.current;e&&(P(n,e.value,!0),queueMicrotask(()=>{e.kind===`text`&&H(i)?(H(i).focus(),H(i).select()):H(r)?.focus()}))});function a(){let e=Yp.current;e&&Qp(e.kind===`ask`?!0:e.kind===`text`?H(n):void 0)}function o(){let e=Yp.current;e&&Qp(e.kind===`ask`?!1:e.kind===`text`?null:void 0)}function s(e){Yp.current&&(e.key===`Escape`?(e.preventDefault(),o()):e.key===`Enter`&&Yp.current.kind===`text`&&(e.preventDefault(),a()))}var c=Vr();Ar(`keydown`,on,s);var l=L(c),u=e=>{let t=M(()=>Yp.current);var s=$v(),c=I(s),l=I(c),u=I(l,!0);O(l);var d=R(l,2),f=e=>{var n=Xv(),r=I(n,!0);O(n),z(()=>K(r,H(t).message)),G(e,n)};q(d,e=>{H(t).message&&e(f)});var p=R(d,2),m=e=>{var t=Zv();X(t),Ni(t,e=>P(i,e),()=>H(i)),Ti(t,()=>H(n),e=>P(n,e)),G(e,t)};q(p,e=>{H(t).kind===`text`&&e(m)});var h=R(p,2),g=I(h),_=e=>{var n=Qv(),r=I(n,!0);O(n),z(()=>K(r,H(t).cancel)),U(`click`,n,o),G(e,n)};q(g,e=>{H(t).kind!==`tell`&&e(_)});var v=R(g,2);let y;var b=I(v,!0);O(v),Ni(v,e=>P(r,e),()=>H(r)),O(h),O(c),O(s),z(()=>{K(u,H(t).title),y=Y(v,1,`primary svelte-11qie5r`,null,y,{danger:H(t).danger}),K(b,H(t).ok)}),U(`click`,v,a),G(e,s)};q(l,e=>{Yp.current&&e(u)}),G(e,c),Ye()}jr([`click`]);var ty=W(` `,1);function ny(e,t){Je(t,!0);let n=N(F(window.location.hash.replace(`#`,``)));window.addEventListener(`hashchange`,()=>{P(n,window.location.hash.replace(`#`,``),!0)});let r=window.matchMedia(`(prefers-color-scheme: dark)`),i=`system`,a=``;function o(){let e;e=i===`dark`?!0:i===`light`?!1:a===`dark`?!0:a===`light`?!1:r.matches,e?document.documentElement.setAttribute(`data-theme`,`dark`):document.documentElement.removeAttribute(`data-theme`)}r.addEventListener(`change`,o),ns(`theme`,e=>{i=e.data||`system`,o()}),(async()=>{i=await du(),o()})();let s=0;async function c(){let e=++s;for(let t=0;t<12&&e===s;t++){let e=await wu();if(e){a=e,o();return}await new Promise(e=>setTimeout(e,1e3))}}c();function l(e,t){let n=e.replace(`#`,``);return n.length===6?`#`+[0,2,4].map(e=>Math.max(0,Math.round(parseInt(n.slice(e,e+2),16)*t))).map(e=>e.toString(16).padStart(2,`0`)).join(``):e}function u(e){let t=document.documentElement;e&&/^#[0-9a-fA-F]{6}$/.test(e)?(t.style.setProperty(`--accent`,e),t.style.setProperty(`--accent-dark`,l(e,.88))):(t.style.removeProperty(`--accent`),t.style.removeProperty(`--accent-dark`))}ns(`accent`,e=>{u(e.data||``),c()}),(async()=>{for(let e=0;e<12;e++){try{let e=await qd();if(e&&/^#[0-9a-fA-F]{6}$/.test(e)){u(e);return}}catch{}await new Promise(e=>setTimeout(e,1e3))}})();var d=ty(),f=L(d),p=e=>{Yv(e,{})},m=M(()=>H(n).startsWith(`appsplash`)),h=e=>{yh(e,{})},g=e=>{rv(e,{})},_=M(()=>H(n).startsWith(`settings`)),v=e=>{kv(e,{})},y=e=>{Iv(e,{})},b=e=>{Uv(e,{})},x=e=>{Gv(e,{})},S=e=>{qp(e,{})};q(f,e=>{H(m)?e(p):H(n)===`status`?e(h,1):H(_)?e(g,2):H(n)===`login`?e(v,3):H(n)===`share`?e(y,4):H(n)===`versions`?e(b,5):H(n)===`logs`?e(x,6):e(S,-1)}),ey(R(f,2),{}),G(e,d),Ye()}Hr(ny,{target:document.getElementById(`app`)}); \ No newline at end of file diff --git a/cmd/nimbo-gui/frontend/dist/index.html b/cmd/nimbo-gui/frontend/dist/index.html index d4544d4..c757bd4 100644 --- a/cmd/nimbo-gui/frontend/dist/index.html +++ b/cmd/nimbo-gui/frontend/dist/index.html @@ -6,7 +6,7 @@ Wails + Svelte + TS - + diff --git a/cmd/nimbo-gui/frontend/src/Login.svelte b/cmd/nimbo-gui/frontend/src/Login.svelte index 23cb077..34862e7 100644 --- a/cmd/nimbo-gui/frontend/src/Login.svelte +++ b/cmd/nimbo-gui/frontend/src/Login.svelte @@ -18,16 +18,29 @@ let different = $state(false); const again = $derived(!!target && !different); + // A server preset (admin policy, or a partner build's brand.json) fills the + // address in; a locked one also stops it being changed, since sign-in goes + // to that server whatever is typed (Deck d1010). + let preset = $state(""); + let locked = $state(false); + async function loadTarget() { const t = await App.SignInTarget(); target = t?.id ? t : null; different = false; if (!busy) { - server = target ? target.server : ""; + server = target ? target.server : preset; msg = target ? "" : startMsg; } } loadTarget(); + (async () => { + const p = await App.PolicyInfo(); + if (!p?.serverUrl) return; + preset = p.serverUrl; + locked = p.lockServer; + if (!busy && !target && (locked || !server)) server = preset; + })(); Events.On("login:target", loadTarget); Events.On("login:error", (e: any) => { if (!busy) return; msg = "Sign-in failed: " + e.data; busy = false; }); @@ -48,7 +61,7 @@ busy = true; msg = "Connecting…"; const url = await App.BeginLogin(server); - if (url.startsWith("error:")) { msg = url; busy = false; return; } + if (url.startsWith("error:")) { msg = "Sign-in failed: " + url.slice(6).trim(); busy = false; return; } // The window minimises itself meanwhile (it covered the browser's Grant // access button) and comes back for setup or an error. msg = "Finish signing in in the browser that just opened. This window steps aside until you have."; @@ -65,7 +78,7 @@ function signInSomeoneElse() { App.SignInSomeoneElse(); // the grant may now be for any account different = true; - server = ""; + server = preset; msg = startMsg; } @@ -83,7 +96,8 @@ {:else}

    Sign in to Nextcloud

    - e.key === "Enter" && login()} /> {/if} diff --git a/cmd/nimbo-gui/service.go b/cmd/nimbo-gui/service.go index d8cb119..587eac5 100644 --- a/cmd/nimbo-gui/service.go +++ b/cmd/nimbo-gui/service.go @@ -778,14 +778,29 @@ func (a *App) refreshLicence() { if info.Allows(license.TierBusiness) { pol = policy.Load() // admin policy is a business-tier capability } + pol = withBrandServer(pol, brand.Current) a.licMu.Lock() a.lic = info a.pol = pol a.licMu.Unlock() } +// withBrandServer adds a partner build's baked-in server preset and lock to +// the policy (Deck d1010). It needs no licence: the partner paid for the build. +// An admin policy's own server address wins over the brand's; a lock from +// either side holds. +func withBrandServer(p policy.Policy, b brand.Brand) policy.Policy { + if p.ServerURL == "" { + p.ServerURL = b.ServerURL + } + if b.LockServer && p.ServerURL != "" { + p.LockServer = true + } + return p +} + // policyNow returns the effective admin policy (empty/permissive unless a -// business licence is active). +// business licence is active), plus a partner build's server lock. func (a *App) policyNow() policy.Policy { a.licMu.Lock() defer a.licMu.Unlock() diff --git a/internal/account/login.go b/internal/account/login.go index de49704..6ee0ffe 100644 --- a/internal/account/login.go +++ b/internal/account/login.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "log/slog" "net/http" "net/url" "strings" @@ -65,12 +66,14 @@ func InitLogin(ctx context.Context, serverURL string) (*Flow, error) { hc := &http.Client{Timeout: 30 * time.Second} resp, err := hc.Do(req) if err != nil { - return nil, fmt.Errorf("start login flow: %w", err) + slog.Warn("start login flow failed", "server", serverURL, "err", err) + return nil, explainLoginStart(serverURL, fmt.Errorf("start login flow: %w", err)) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<12)) - return nil, fmt.Errorf("start login flow: server returned %s: %s", resp.Status, strings.TrimSpace(string(body))) + slog.Warn("start login flow refused", "server", serverURL, "status", resp.Status, "body", strings.TrimSpace(string(body))) + return nil, explainLoginStatus(serverURL, resp.StatusCode, resp.Status, strings.TrimSpace(string(body))) } var out struct { @@ -81,10 +84,12 @@ func InitLogin(ctx context.Context, serverURL string) (*Flow, error) { Login string `json:"login"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - return nil, fmt.Errorf("decode login flow response: %w", err) + slog.Warn("start login flow: not a login-flow answer", "server", serverURL, "err", err) + return nil, notNextcloud(serverURL, fmt.Errorf("decode login flow response: %w", err)) } if out.Login == "" || out.Poll.Token == "" || out.Poll.Endpoint == "" { - return nil, fmt.Errorf("start login flow: incomplete response from server") + slog.Warn("start login flow: incomplete answer", "server", serverURL) + return nil, notNextcloud(serverURL, errors.New("start login flow: incomplete response from server")) } return &Flow{ diff --git a/internal/account/login_test.go b/internal/account/login_test.go index 027c309..9cb203a 100644 --- a/internal/account/login_test.go +++ b/internal/account/login_test.go @@ -3,6 +3,7 @@ package account import ( "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "os" @@ -37,7 +38,7 @@ func TestInitLoginDefaultsToHTTPS(t *testing.T) { defer srv.Close() host := strings.TrimPrefix(srv.URL, "http://") _, err := InitLogin(context.Background(), host) - if err == nil || !strings.Contains(err.Error(), "HTTPS client") { + if err == nil || !strings.Contains(errors.Unwrap(err).Error(), "HTTPS client") { t.Fatalf("want https-vs-http transport error proving scheme default, got: %v", err) } } diff --git a/internal/account/loginerr.go b/internal/account/loginerr.go new file mode 100644 index 0000000..6f815d8 --- /dev/null +++ b/internal/account/loginerr.go @@ -0,0 +1,98 @@ +package account + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "net" + "net/url" + "strings" + "syscall" +) + +// loginError is what InitLogin returns when it can't start a sign-in: a short +// sentence a user can act on, wrapping the transport or server error so logs +// and errors.Is keep the detail. The sign-in screens show Error() as it is, and +// the raw Go error ("start login flow: Post …: context deadline exceeded") +// told nobody anything (Deck d907). +type loginError struct { + msg string + err error +} + +func (e *loginError) Error() string { return e.msg } +func (e *loginError) Unwrap() error { return e.err } + +// hostOf names the server the way the user typed it, without the scheme. +func hostOf(serverURL string) string { + if u, err := url.Parse(serverURL); err == nil && u.Host != "" { + return u.Host + strings.TrimRight(u.Path, "/") + } + return serverURL +} + +// wsaConnRefused is WSAECONNREFUSED: Windows reports a refused connection with +// its own code, which syscall.ECONNREFUSED doesn't match there. +const wsaConnRefused = syscall.Errno(10061) + +// explainLoginStart turns a failed request to start the login flow into a +// plain message. A cancelled attempt is passed through untouched: nothing went +// wrong that the user needs telling about. +func explainLoginStart(serverURL string, err error) error { + if errors.Is(err, context.Canceled) { + return err + } + host := hostOf(serverURL) + var ( + certErr *tls.CertificateVerificationError + unknownCA x509.UnknownAuthorityError + badHost x509.HostnameError + invalidCert x509.CertificateInvalidError + dnsErr *net.DNSError + netErr net.Error + ) + var msg string + switch { + case errors.As(err, &certErr), errors.As(err, &unknownCA), errors.As(err, &badHost), errors.As(err, &invalidCert): + msg = fmt.Sprintf("Can't connect securely to %s: its certificate isn't trusted. An expired or self-signed certificate is the usual cause.", host) + case strings.Contains(err.Error(), "server gave HTTP response to HTTPS client"): + msg = fmt.Sprintf("%s doesn't answer on https. If your server really has no https, type the address starting with http://", host) + case errors.As(err, &dnsErr): + msg = fmt.Sprintf("Can't find %s. Check the address, and that this device is online.", host) + case errors.Is(err, syscall.ECONNREFUSED), errors.Is(err, wsaConnRefused): + msg = fmt.Sprintf("%s turned the connection down. Check the address (and the port, if it has one), then try again.", host) + case errors.Is(err, context.DeadlineExceeded), errors.As(err, &netErr) && netErr.Timeout(): + msg = fmt.Sprintf("%s didn't answer in time. Check your connection and the address, then try again.", host) + default: + msg = fmt.Sprintf("Can't reach %s. Check your connection and the address, then try again.", host) + } + return &loginError{msg: msg, err: err} +} + +// explainLoginStatus is the plain message for a server that answered the +// login-flow request with something other than 200. The body (often an HTML +// error page) only goes in the wrapped error. +func explainLoginStatus(serverURL string, status int, statusText, body string) error { + host := hostOf(serverURL) + raw := fmt.Errorf("start login flow: server returned %s: %s", statusText, body) + var msg string + switch { + case status == 404: + msg = fmt.Sprintf("No Nextcloud sign-in found at %s. Check the address; if Nextcloud isn't at the top of the site, include its folder, e.g. example.com/nextcloud.", host) + case status >= 500: + msg = fmt.Sprintf("%s had a problem starting the sign-in (%s). Try again in a moment.", host, statusText) + default: + msg = fmt.Sprintf("%s wouldn't start a sign-in (%s).", host, statusText) + } + return &loginError{msg: msg, err: raw} +} + +// notNextcloud is the plain message for a 200 answer that isn't a login flow. +func notNextcloud(serverURL string, err error) error { + return &loginError{ + msg: fmt.Sprintf("%s answered, but not like a Nextcloud server. Check the address.", hostOf(serverURL)), + err: err, + } +} diff --git a/internal/account/loginerr_test.go b/internal/account/loginerr_test.go new file mode 100644 index 0000000..f97c4e1 --- /dev/null +++ b/internal/account/loginerr_test.go @@ -0,0 +1,100 @@ +package account + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "syscall" + "testing" +) + +// Each common way a sign-in can fail to start reads as a short plain sentence +// naming the server, never the raw Go error, and still unwraps to the original +// so logs and errors.Is keep the detail (Deck d907). +func TestExplainLoginStart(t *testing.T) { + wrap := func(err error) error { + return &url.Error{Op: "Post", URL: "https://cloud.example.com/index.php/login/v2", Err: err} + } + op := func(err error) error { return wrap(&net.OpError{Op: "dial", Net: "tcp", Err: err}) } + cases := []struct { + name string + err error + want string + }{ + {"timeout", wrap(context.DeadlineExceeded), "didn't answer in time"}, + {"dns", op(&net.DNSError{Err: "no such host", Name: "cloud.example.com", IsNotFound: true}), "Can't find cloud.example.com"}, + {"refused", op(os.NewSyscallError("connect", syscall.ECONNREFUSED)), "turned the connection down"}, + {"refused-windows", op(os.NewSyscallError("connectex", syscall.Errno(10061))), "turned the connection down"}, + {"unknown-ca", wrap(&tls.CertificateVerificationError{Err: x509.UnknownAuthorityError{}}), "certificate"}, + {"hostname", wrap(x509.HostnameError{Host: "cloud.example.com"}), "certificate"}, + {"plain-http", wrap(errors.New("http: server gave HTTP response to HTTPS client")), "http://"}, + {"other-net", op(errors.New("network is unreachable")), "Can't reach cloud.example.com"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := explainLoginStart("https://cloud.example.com", c.err) + if !strings.Contains(got.Error(), c.want) { + t.Errorf("message %q, want it to contain %q", got.Error(), c.want) + } + if strings.Contains(got.Error(), "login/v2") || strings.Contains(got.Error(), "Post ") { + t.Errorf("message %q leaks the raw error", got.Error()) + } + if !errors.Is(got, c.err) { + t.Errorf("message does not unwrap to the original error") + } + }) + } +} + +// A caller cancelling the attempt is not a failure to explain. +func TestExplainLoginStartKeepsCancel(t *testing.T) { + err := &url.Error{Op: "Post", URL: "https://x/index.php/login/v2", Err: context.Canceled} + if got := explainLoginStart("https://x", err); got != err { + t.Errorf("got %v, want the original cancel error", got) + } +} + +// A server that isn't Nextcloud (or the wrong path) answers 404: say so plainly +// instead of dumping its HTML error page. +func TestInitLoginNotFoundIsPlain(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "Not here", http.StatusNotFound) + })) + defer srv.Close() + _, err := InitLogin(context.Background(), srv.URL) + if err == nil { + t.Fatal("want an error") + } + if !strings.Contains(err.Error(), "No Nextcloud sign-in") || strings.Contains(err.Error(), "") { + t.Errorf("got %q", err.Error()) + } +} + +func TestInitLoginServerErrorIsPlain(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "stack trace…", http.StatusServiceUnavailable) + })) + defer srv.Close() + _, err := InitLogin(context.Background(), srv.URL) + if err == nil || !strings.Contains(err.Error(), "503") || strings.Contains(err.Error(), "stack trace") { + t.Errorf("got %v", err) + } +} + +func TestInitLoginNotNextcloudIsPlain(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("welcome to my blog")) + })) + defer srv.Close() + _, err := InitLogin(context.Background(), srv.URL) + if err == nil || !strings.Contains(err.Error(), "not like a Nextcloud server") { + t.Errorf("got %v", err) + } +} diff --git a/internal/brand/brand.go b/internal/brand/brand.go index ab3fc88..d1a3133 100644 --- a/internal/brand/brand.go +++ b/internal/brand/brand.go @@ -11,6 +11,7 @@ import ( "encoding/json" "fmt" "log/slog" + "net/url" "strings" ) @@ -31,6 +32,12 @@ type Brand struct { APIBase string `json:"apiBase"` // GitHub releases API root for the update check AccentHex string `json:"accentHex"` // brand accent (tray badge, UI accent fallback) AppID string `json:"appId"` // MSIX Application Id (AUMID) + self-update task name + the config/data dir name (slugged) + // ServerURL presets the Nextcloud server the sign-in window offers, and + // LockServer pins sign-in to it, the same lock the HKLM admin policy gives + // but baked into a partner build, so it needs no licence key or registry + // step on a client's PC (Deck d1010). Both are empty in the stock build. + ServerURL string `json:"serverUrl,omitempty"` + LockServer bool `json:"lockServer,omitempty"` } // Current is the active brand for this build. @@ -66,14 +73,37 @@ func (b Brand) Validate() error { return nil } +// ValidateServer checks the server preset: an https address with a host, and +// a lock only together with one. build-partner.ps1 refuses a brand that fails +// it, so a broken one should never ship. +func (b Brand) ValidateServer() error { + if b.ServerURL == "" { + if b.LockServer { + return fmt.Errorf("brand.json: lockServer is set but serverUrl is empty") + } + return nil + } + u, err := url.Parse(b.ServerURL) + if err != nil || u.Scheme != "https" || u.Host == "" { + return fmt.Errorf("brand.json: serverUrl %q must be an https:// address", b.ServerURL) + } + return nil +} + // sanitize drops a former name that fails Validate, so such a build runs as a // brand that was never renamed: its own Start-menu folder and labels, no -// rename notice. +// rename notice. A server preset that fails ValidateServer is dropped with its +// lock: a lock to an unusable address would leave nobody able to sign in. func sanitize(b Brand) Brand { if err := b.Validate(); err != nil { slog.Error("ignoring formerName", "err", err) b.FormerName = "" } + if err := b.ValidateServer(); err != nil { + slog.Error("ignoring serverUrl and lockServer", "err", err) + b.ServerURL, b.LockServer = "", false + } + b.ServerURL = strings.TrimRight(b.ServerURL, "/") return b } diff --git a/internal/brand/brand_test.go b/internal/brand/brand_test.go index 7779dee..e26df95 100644 --- a/internal/brand/brand_test.go +++ b/internal/brand/brand_test.go @@ -104,6 +104,38 @@ func TestValidateFormerName(t *testing.T) { } } +func TestValidateServer(t *testing.T) { + for _, c := range []struct { + b Brand + ok bool + }{ + {Brand{}, true}, + {Brand{ServerURL: "https://cloud.acme.example"}, true}, + {Brand{ServerURL: "https://acme.example/nextcloud", LockServer: true}, true}, + {Brand{LockServer: true}, false}, + {Brand{ServerURL: "http://cloud.acme.example", LockServer: true}, false}, + {Brand{ServerURL: "cloud.acme.example"}, false}, + } { + if err := c.b.ValidateServer(); (err == nil) != c.ok { + t.Errorf("%+v: ValidateServer() = %v, want ok=%v", c.b, err, c.ok) + } + } +} + +// A lock to an unusable address is dropped with its address, not kept; a good +// preset loses only a trailing slash. +func TestSanitizeServer(t *testing.T) { + if b := sanitize(Brand{Name: "Acme", ServerURL: "http://x.example", LockServer: true}); b.ServerURL != "" || b.LockServer { + t.Errorf("bad preset kept: %+v", b) + } + if b := sanitize(Brand{Name: "Acme", ServerURL: "https://x.example/", LockServer: true}); b.ServerURL != "https://x.example" || !b.LockServer { + t.Errorf("good preset changed: %+v", b) + } + if s := stockBrand(); s.ServerURL != "" || s.LockServer { + t.Errorf("stock brand must not preset a server: %+v", s) + } +} + // A brand that fails the check runs as never renamed rather than wearing the // stock build's former name. func TestSanitizeDropsBadFormerName(t *testing.T) { diff --git a/packaging/whitelabel/README.md b/packaging/whitelabel/README.md index 1a7d797..a2499a4 100644 --- a/packaging/whitelabel/README.md +++ b/packaging/whitelabel/README.md @@ -45,6 +45,8 @@ user-visible identity value: | `feedUrl`, `apiBase` | the in-app update check + "Update now" target — point these at the partner's OWN release feed | | `accentHex` | brand accent — **drives the app icon** (tile + cloud, auto-derived), plus the tray badge and UI accent fallback | | `appId` | the MSIX Application Id used in the AUMID and the self-update task name — must match the manifest's `` | +| `serverUrl` | optional: the partner's Nextcloud (`https://…`, a subfolder is fine). The sign-in window fills it in | +| `lockServer` | optional, needs `serverUrl`: sign-in always goes to `serverUrl` and the address can't be changed. The same lock as the `ServerURL`/`LockServer` admin policy, but baked in, so it needs no business licence or registry step on each PC (for a partner's clients). If an admin policy on the PC also sets a server, the policy's address wins. `build-partner.ps1` refuses a lock without an https `serverUrl` | | `formerName` | **Stock only: leave it out for partners.** The stock build's name before the rename to OtherSync ("Nimbo"). It keeps what existing stock installs made under the old name (the "Nimbo Apps" Start-menu folder, the `%LOCALAPPDATA%\Nimbo` sidebar icon folder, "Nimbo - " sidebar labels) and triggers the one-time "Nimbo is now OtherSync" notice. A partner brand carrying it would share those with stock and tell its users "Nimbo is now ". `build-partner.ps1` refuses a partner `brand.json` that sets it, unless `partner.json` has `"renamedFrom"` equal to it (a partner that really renamed its own product) | ## Steps to build a branded copy diff --git a/packaging/whitelabel/build-partner.ps1 b/packaging/whitelabel/build-partner.ps1 index cce2226..d6dcaf1 100644 --- a/packaging/whitelabel/build-partner.ps1 +++ b/packaging/whitelabel/build-partner.ps1 @@ -46,6 +46,15 @@ $formerName = (Get-Content $brandSrc -Raw | ConvertFrom-Json).formerName if ($formerName -and $formerName -ne $cfg.renamedFrom) { throw "brand.json sets formerName '$formerName', which is the stock build's. Remove it from the partner's brand.json, or, if this partner really renamed its product from '$formerName', add `"renamedFrom`": `"$formerName`" to partner.json." } +# serverUrl / lockServer: the app drops a preset it can't use (and its lock), +# which would ship a partner build that signs in anywhere. Refuse it here. +$brandCfg = Get-Content $brandSrc -Raw | ConvertFrom-Json +if ($brandCfg.lockServer -and -not $brandCfg.serverUrl) { + throw "brand.json sets lockServer but no serverUrl." +} +if ($brandCfg.serverUrl -and $brandCfg.serverUrl -notmatch '^https://[^/\s]+') { + throw "brand.json serverUrl '$($brandCfg.serverUrl)' must be an https:// address." +} # Make Go + windres reachable even if not on PATH. function Ensure-Tool($exe, $candidates) {