diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index 43b01a6a8..c3e15b765 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -5631,7 +5631,7 @@ 0% { opacity: 1; } 100% { opacity: 0; } } - `,C.head.appendChild(B)}a&&r>0&&setTimeout(()=>{u.classList.add("foliate-arrow-fadeout"),setTimeout(()=>{u.parentNode&&u.parentNode.removeChild(u)},1e3)},r)}return c.append(u),c}static copyImage([e],t={}){let{src:A}=t,s=Ii("image"),{left:n,top:a,height:r,width:o}=e;return s.setAttribute("href",A),s.setAttribute("x",n),s.setAttribute("y",a),s.setAttribute("height",r),s.setAttribute("width",o),s}};Dc=new WeakMap,Od=new WeakMap,Vo=new WeakMap,Rs=new WeakMap,Wd=new WeakMap,ka=new WeakMap;var tU=i=>{let e=0,t=A=>{if(A.id=e++,A.subitems)for(let s of A.subitems)t(s)};for(let A of i)t(A);return i},ek=i=>i.flatMap(e=>e.subitems?.length?[e,ek(e.subitems)].flat():e),Vd=class{async init({toc:e,ids:t,splitHref:A,getFragment:s}){tU(e);let n=ek(e),a=new Map;for(let[o,c]of n.entries()){let[g,l]=await A(c?.href)??[],I={fragment:l,item:c};a.has(g)?a.get(g).items.push(I):a.set(g,{prev:n[o-1],items:[I]})}let r=new Map;for(let[o,c]of t.entries())a.has(c)?r.set(c,a.get(c)):r.set(c,r.get(t[o-1]));this.ids=t,this.map=r,this.getFragment=s}getProgress(e,t){if(!this.ids)return;let A=this.ids[e],s=this.map.get(A);if(!s)return null;let{prev:n,items:a}=s;if(!a)return n;if(!t||a.length===1&&!a[0].fragment)return a[0].item;let r=t.startContainer.getRootNode();for(let[o,{fragment:c}]of a.entries()){let g=this.getFragment(r,c);if(g&&t.comparePoint(g,0)>0)return a[o-1]?.item??n}return a[a.length-1].item}},W1,tk,O1=class{constructor(e,t,A){D(this,W1);this.sizes=e.map(s=>s.linear!="no"&&s.size>0?s.size:0),this.sizePerLoc=t,this.sizePerTimeUnit=A,this.sizeTotal=this.sizes.reduce((s,n)=>s+n,0),this.sectionFractions=x(this,W1,tk).call(this)}getProgress(e,t,A=0){let{sizes:s,sizePerLoc:n,sizePerTimeUnit:a,sizeTotal:r}=this,o=s[e]??0,g=s.slice(0,e).reduce((u,f)=>u+f,0)+t*o,l=g+A*o,I=r-g,d=(1-t)*o;return{fraction:l/r,section:{current:e,total:s.length},location:{current:Math.floor(g/n),next:Math.floor(l/n),total:Math.ceil(r/n)},time:{section:d/a,total:I/a}}}getSection(e){if(e<=0)return[0,0];if(e>=1)return[this.sizes.length-1,1];e=e+Number.EPSILON;let{sizeTotal:t}=this,A=this.sectionFractions.findIndex(n=>n>e)-1;if(A<0)return[0,0];for(;!this.sizes[A];)A++;let s=(e-this.sectionFractions[A])/(this.sizes[A]/t);return[A,s]}};W1=new WeakSet,tk=function(){let{sizeTotal:e}=this,t=[0],A=0;for(let s of this.sizes)t.push((A+=s)/e);return t};var iU=(i,e)=>{let t=[];for(let A=e.currentNode;A;A=e.nextNode()){let s=i.comparePoint(A,0);if(s===0)t.push(A);else if(s>0)break}return t},AU=(i,e)=>{let t=[];for(let A=e.nextNode();A;A=e.nextNode())t.push(A);return t},sU=NodeFilter.SHOW_ELEMENT|NodeFilter.SHOW_TEXT|NodeFilter.SHOW_CDATA_SECTION,nU=i=>{if(i.nodeType===1){let e=i.tagName.toLowerCase();return e==="script"||e==="style"||e==="rt"||e==="rp"?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_SKIP}return NodeFilter.FILTER_ACCEPT},tm=function*(i,e,t){let A=i.commonAncestorContainer??i.body??i,s=document.createTreeWalker(A,sU,{acceptNode:t||nU}),a=(i.commonAncestorContainer?iU:AU)(i,s),r=a.map(c=>c.nodeValue),o=(c,g,l,I)=>{let d=document.createRange();return d.setStart(a[c],g),d.setEnd(a[l],I),d};for(let c of e(r,o))yield c};var M1="foliate-search:",hL="foliate-tts:",HK=async i=>{let e=new Uint8Array(await i.slice(0,4).arrayBuffer());return e[0]===80&&e[1]===75&&e[2]===3&&e[3]===4},JK=async i=>{let e=new Uint8Array(await i.slice(0,5).arrayBuffer());return e[0]===37&&e[1]===80&&e[2]===68&&e[3]===70&&e[4]===45},YK=({name:i,type:e})=>e==="application/vnd.comicbook+zip"||i.endsWith(".cbz"),PK=({name:i,type:e})=>e==="application/x-fictionbook+xml"||i.endsWith(".fb2"),_K=({name:i,type:e})=>e==="application/x-zip-compressed-fb2"||i.endsWith(".fb2.zip")||i.endsWith(".fbz"),KK=async i=>{let{configure:e,ZipReader:t,BlobReader:A,TextWriter:s,BlobWriter:n}=await Promise.resolve().then(()=>(wm(),tF));e({useWebWorkers:!1});let r=await new t(new A(i)).getEntries(),o=new Map(r.map(d=>[d.filename,d])),c=d=>(u,...f)=>o.has(u)?d(o.get(u),...f):null,g=c(d=>d.getData(new s)),l=c((d,u)=>d.getData(new n(u)));return{entries:r,loadText:g,loadBlob:l,getSize:d=>o.get(d)?.uncompressedSize??0}},IL=async i=>i.isFile?i:(await Promise.all(Array.from(await new Promise((e,t)=>i.createReader().readEntries(A=>e(A),A=>t(A))),IL))).flat(),qK=async i=>{let e=await IL(i),t=await Promise.all(e.map(g=>new Promise((l,I)=>g.file(d=>l([d,g.fullPath]),d=>I(d))))),A=new Map(t.map(([g,l])=>[l.replace(`${i.fullPath}/`,""),g])),s=new TextDecoder,n=g=>g?s.decode(g):null,a=g=>A.get(g)?.arrayBuffer()??null;return{loadText:async g=>n(await a(g)),loadBlob:g=>A.get(g),getSize:g=>A.get(g)?.size??0}},MS=class extends Error{},GS=class extends Error{},vS=class extends Error{},OK=async i=>{let e=await fetch(i);if(!e.ok)throw new MS(`${e.status} ${e.statusText}`,{cause:e});return new File([await e.blob()],new URL(e.url).pathname)},TS=async i=>{typeof i=="string"&&(i=await OK(i));let e;if(i.isDirectory){let t=await qK(i),{EPUB:A}=await Promise.resolve().then(()=>(IE(),Gm));e=await new A(t).init()}else if(i.size)if(await HK(i)){let t=await KK(i);if(YK(i)){let{makeComicBook:A}=await Promise.resolve().then(()=>(lF(),gF));e=A(t,i)}else if(_K(i)){let{makeFB2:A}=await Promise.resolve().then(()=>(Tm(),Um)),{entries:s}=t,n=s.find(r=>r.filename.endsWith(".fb2")),a=await t.loadBlob((n??s[0]).filename);e=await A(a)}else{let{EPUB:A}=await Promise.resolve().then(()=>(IE(),Gm));e=await new A(t).init()}}else if(await JK(i)){let{makePDF:t}=await Promise.resolve().then(()=>($D(),nv));e=await t(i)}else{let{isMOBI:t,MOBI:A}=await Promise.resolve().then(()=>(Cv(),fv));if(await t(i)){let s=await Promise.resolve().then(()=>(bv(),wv));e=await new A({unzlib:s.unzlibSync}).open(i)}else if(PK(i)){let{makeFB2:s}=await Promise.resolve().then(()=>(Tm(),Um));e=await s(i)}}else throw new GS("File not found");if(!e)throw new vS("File type not supported");return e},Jd,Vl,v1,Kn,HS=class HS{constructor(e,t,A={}){D(this,Jd);D(this,Vl);D(this,v1);D(this,Kn);m(this,Vl,e),m(this,v1,t),m(this,Kn,A),h(this,Kn).hidden&&this.hide(),h(this,Vl).addEventListener("mousemove",({screenX:s,screenY:n})=>{s===h(this,Kn).x&&n===h(this,Kn).y||(h(this,Kn).x=s,h(this,Kn).y=n,this.show(),h(this,Jd)&&clearTimeout(h(this,Jd)),t()&&m(this,Jd,setTimeout(this.hide.bind(this),1e3)))},!1)}cloneFor(e){return new HS(e,h(this,v1),h(this,Kn))}hide(){h(this,Vl).style.cursor="none",h(this,Kn).hidden=!0}show(){h(this,Vl).style.removeProperty("cursor"),h(this,Kn).hidden=!1}};Jd=new WeakMap,Vl=new WeakMap,v1=new WeakMap,Kn=new WeakMap;var LS=HS,qn,Fs,US=class extends EventTarget{constructor(){super(...arguments);D(this,qn,[]);D(this,Fs,-1)}pushState(t){let A=h(this,qn)[h(this,Fs)];A===t||A?.fraction&&A.fraction===t.fraction||(h(this,qn)[++hi(this,Fs)._]=t,h(this,qn).length=h(this,Fs)+1,this.dispatchEvent(new Event("index-change")))}replaceState(t){let A=h(this,Fs);h(this,qn)[A]=t}back(){let t=h(this,Fs);if(t<=0)return;let A={state:h(this,qn)[t-1]};m(this,Fs,t-1),this.dispatchEvent(new CustomEvent("popstate",{detail:A})),this.dispatchEvent(new Event("index-change"))}forward(){let t=h(this,Fs);if(t>=h(this,qn).length-1)return;let A={state:h(this,qn)[t+1]};m(this,Fs,t+1),this.dispatchEvent(new CustomEvent("popstate",{detail:A})),this.dispatchEvent(new Event("index-change"))}get canGoBack(){return h(this,Fs)>0}get canGoForward(){return h(this,Fs){if(!i)return{};try{let e=Intl.getCanonicalLocales(i)[0],t=new Intl.Locale(e),A=["zh","ja","kr"].includes(t.language),s=(t.getTextInfo?.()??t.textInfo)?.direction;return{canonical:e,locale:t,isCJK:A,direction:s}}catch(e){return console.warn(e),{}}},Px,qo,Da,bc,Oo,mr,_x,Yt,xr,dL,uL,fL,Yx,CL,BL,EL,G1=class extends HTMLElement{constructor(){super();D(this,Yt);D(this,Px,this.attachShadow({mode:"closed"}));D(this,qo);D(this,Da);D(this,bc);D(this,Oo,new Map);D(this,mr,{type:"outline",options:{}});D(this,_x,new LS(this,()=>this.hasAttribute("autohide-cursor")));pe(this,"isFixedLayout",!1);pe(this,"lastLocation");pe(this,"history",new US);this.history.addEventListener("popstate",({detail:t})=>{let A=this.resolveNavigation(t.state);this.renderer.goTo(A)})}async open(t){if((typeof t=="string"||typeof t.arrayBuffer=="function"||t.isDirectory)&&(t=await TS(t)),this.book=t,this.language=WK(t.metadata?.language),t.splitTOCHref&&t.getTOCFragment){let A=t.sections.map(a=>a.id);m(this,qo,new O1(t.sections,1500,1600));let s=t.splitTOCHref.bind(t),n=t.getTOCFragment.bind(t);m(this,Da,new Vd),await h(this,Da).init({toc:t.toc??[],ids:A,splitHref:s,getFragment:n}),m(this,bc,new Vd),await h(this,bc).init({toc:t.pageList??[],ids:A,splitHref:s,getFragment:n})}if(this.isFixedLayout=this.book.rendition?.layout==="pre-paginated",this.isFixedLayout?(await Promise.resolve().then(()=>(Fv(),kv)),this.renderer=document.createElement("foliate-fxl")):(await Promise.resolve().then(()=>(zv(),Zv)),this.renderer=document.createElement("foliate-paginator")),this.renderer.setAttribute("exportparts","head,foot,filter"),this.renderer.addEventListener("load",A=>x(this,Yt,uL).call(this,A.detail)),this.renderer.addEventListener("relocate",A=>x(this,Yt,dL).call(this,A.detail)),this.renderer.addEventListener("create-overlayer",A=>A.detail.attach(x(this,Yt,CL).call(this,A.detail))),this.renderer.open(t),h(this,Px).append(this.renderer),t.sections.some(A=>A.mediaOverlay)){let A=t.media.activeClass,s=t.media.playbackActiveClass;this.mediaOverlay=t.getMediaOverlay();let n;this.mediaOverlay.addEventListener("highlight",a=>{let r=this.resolveNavigation(a.detail.text);this.renderer.goTo(r).then(()=>{let{doc:o}=this.renderer.getContents().find(g=>g.index=r.index),c=r.anchor(o);c.classList.add(A),s&&c.ownerDocument.documentElement.classList.add(s),n=new WeakRef(c)})}),this.mediaOverlay.addEventListener("unhighlight",()=>{let a=n?.deref();a&&(a.classList.remove(A),s&&a.ownerDocument.documentElement.classList.remove(s))})}}close(){this.renderer?.destroy(),this.renderer?.remove(),m(this,qo,null),m(this,Da,null),m(this,bc,null),m(this,Oo,new Map),this.lastLocation=null,this.history.clear(),this.tts=null,this.mediaOverlay=null}goToTextStart(){return this.goTo(this.book.landmarks?.find(t=>t.type.includes("bodymatter")||t.type.includes("text"))?.href??this.book.sections.findIndex(t=>t.linear!=="no"))}async init({lastLocation:t,showTextStart:A}){let s=t?this.resolveNavigation(t):null;s?(await this.renderer.goTo(s),this.history.pushState(t)):A?await this.goToTextStart():(this.history.pushState(0),await this.next())}async addAnnotation(t,A){let{value:s,indicatorType:n="outline",indicatorOptions:a={}}=t;if(s.startsWith(M1)){let I=s.replace(M1,""),{index:d,anchor:u}=await this.resolveNavigation(I),f=x(this,Yt,Yx).call(this,d);if(f){let{overlayer:C,doc:B}=f;if(A){C.remove(s),C.remove(`${s}::underline`),C.remove(`${s}::tooltip`);return}let E=B?u(B):u,Q;n==="arrow"?Q=jo.arrow:Q=jo.outline,C.add(s,E,Q,a)}return}let r=s.startsWith(hL)?s.replace(hL,""):s,{index:o,anchor:c}=await this.resolveNavigation(r),g=x(this,Yt,Yx).call(this,o);if(g){let{overlayer:I,doc:d}=g;if(I.remove(s),I.remove(`${s}::underline`),I.remove(`${s}::tooltip`),A&&x(this,Yt,xr).call(this,"delete-annotation",{value:s,doc:d}),!A){let u=d?c(d):c,f=(C,B,E)=>{let Q=E?`${s}::${E}`:s;I.add(Q,u,C,B)};x(this,Yt,xr).call(this,"draw-annotation",{draw:f,annotation:t,doc:d,range:u})}}let l=h(this,Da).getProgress(o)?.label??"";return{index:o,label:l}}deleteAnnotation(t){return this.addAnnotation(t,!0)}async showAnnotation(t){let{value:A}=t,s=await this.goTo(A);if(s){let{index:n,anchor:a}=s,{doc:r}=x(this,Yt,Yx).call(this,n),o=a(r);x(this,Yt,xr).call(this,"show-annotation",{value:A,index:n,range:o})}}getCFI(t,A){let s=this.book.sections[t].cfi??Kd.fromIndex(t);return A?Zx(s,$x(A)):s}resolveCFI(t){if(this.book.resolveCFI)return this.book.resolveCFI(t);let A=Vn(t);return{index:Kd.toIndex((A.parent??A).shift()),anchor:a=>_d(a,A)}}resolveNavigation(t){try{if(typeof t=="number")return{index:t};if(typeof t.fraction=="number"){let[A,s]=h(this,qo).getSection(t.fraction);return{index:A,anchor:s}}return jl.test(t)?this.resolveCFI(t):this.book.resolveHref(t)}catch(A){console.error(A),console.error(`Could not resolve target ${t}`)}}async goTo(t){t=decodeURIComponent(t);let A=this.resolveNavigation(t);try{return await this.renderer.goTo(A),this.history.pushState(t),A}catch(s){console.error(s),console.error(`Could not go to ${t}`)}}async goToFraction(t){let[A,s]=h(this,qo).getSection(t);await this.renderer.goTo({index:A,anchor:s}),this.history.pushState({fraction:t})}async select(t){try{let A=await this.resolveNavigation(t);await this.renderer.goTo({...A,select:!0}),this.history.pushState(t)}catch(A){console.error(A),console.error(`Could not go to ${t}`)}}deselect(){for(let{doc:t}of this.renderer.getContents())t.defaultView.getSelection().removeAllRanges()}getSectionFractions(){return(h(this,qo)?.sectionFractions??[]).map(t=>t+Number.EPSILON)}getProgressOf(t,A){let s=h(this,Da)?.getProgress(t,A),n=h(this,bc)?.getProgress(t,A);return{tocItem:s,pageItem:n}}async getTOCItemOf(t){try{let{index:A,anchor:s}=await this.resolveNavigation(t),n=await this.book.sections[A].createDocument(),a=s(n),r=a instanceof Range,o=r?a:n.createRange();return r||o.selectNodeContents(a),h(this,Da).getProgress(A,o)}catch(A){console.error(A),console.error(`Could not get ${t}`)}}async prev(t){await this.renderer.prev(t)}async next(t){await this.renderer.next(t)}goLeft(){return this.book.dir==="rtl"?this.next():this.prev()}goRight(){return this.book.dir==="rtl"?this.prev():this.next()}async*search(t){this.clearSearch();let{searchMatcher:A}=await Promise.resolve().then(()=>(AL(),iL)),{query:s,index:n}=t,a=A(tm,{defaultLocale:this.language,...t}),r=n!=null?x(this,Yt,BL).call(this,a,s,n):x(this,Yt,EL).call(this,a,s),o=[];h(this,Oo).set(n,o);for await(let c of r)if(c.subitems){let g=c.subitems.map(({cfi:l})=>({value:M1+l}));h(this,Oo).set(c.index,g);for(let l of g){let I={...l,indicatorType:h(this,mr).type,indicatorOptions:h(this,mr).options};this.addAnnotation(I)}yield{label:h(this,Da).getProgress(c.index)?.label??"",subitems:c.subitems}}else{if(c.cfi){let g={value:M1+c.cfi};o.push(g);let l={...g,indicatorType:h(this,mr).type,indicatorOptions:h(this,mr).options};this.addAnnotation(l)}yield c}yield"done"}clearSearch(){for(let t of h(this,Oo).values())for(let A of t)this.deleteAnnotation(A);h(this,Oo).clear()}setSearchIndicator(t="outline",A={}){m(this,mr,{type:t,options:A})}async initTTS(t="word",A,s){let n=this.renderer.getContents(),a=this.renderer.primaryIndex,r=n.find(l=>l.index===a)??n[0],o=r?.doc,c=r?.index??0;if(!o)return;if(this.tts&&this.tts.doc===o){A&&(this.tts.highlight=A);return}let{TTS:g}=await Promise.resolve().then(()=>(lL(),gL));this.tts=new g(o,tm,s||null,A||(l=>this.renderer.scrollToAnchor(l,!0)),l=>this.getCFI(c,l),t)}startMediaOverlay(){let{index:t}=this.renderer.getContents()[0];return this.mediaOverlay.start(t)}};Px=new WeakMap,qo=new WeakMap,Da=new WeakMap,bc=new WeakMap,Oo=new WeakMap,mr=new WeakMap,_x=new WeakMap,Yt=new WeakSet,xr=function(t,A,s){return this.dispatchEvent(new CustomEvent(t,{detail:A,cancelable:s}))},dL=function({reason:t,range:A,index:s,fraction:n,size:a}){let r=h(this,qo)?.getProgress(s,n,a)??{},o=h(this,Da)?.getProgress(s,A),c=h(this,bc)?.getProgress(s,A),g=this.getCFI(s,A);this.lastLocation={...r,tocItem:o,pageItem:c,cfi:g,range:A},(t==="snap"||t==="page"||t==="scroll")&&this.history.replaceState(g),x(this,Yt,xr).call(this,"relocate",this.lastLocation)},uL=function({doc:t,index:A}){var s,n;(s=t.documentElement).lang||(s.lang=this.language.canonical??""),this.language.isCJK||(n=t.documentElement).dir||(n.dir=this.language.direction??""),x(this,Yt,fL).call(this,t,A),h(this,_x).cloneFor(t.documentElement),x(this,Yt,xr).call(this,"load",{doc:t,index:A})},fL=function(t,A){let{book:s}=this,n=s.sections[A];t.addEventListener("click",a=>{let r=a.target.closest("a[href]");if(!r)return;a.preventDefault();let o=r.getAttribute("href"),c=n?.resolveHref?.(o)??o;s?.isExternal?.(c)?Promise.resolve(x(this,Yt,xr).call(this,"external-link",{a:r,href:c},!0)).then(g=>g?globalThis.open(c,"_blank"):null).catch(g=>console.error(g)):Promise.resolve(x(this,Yt,xr).call(this,"link",{a:r,href:c},!0)).then(g=>g?this.goTo(c):null).catch(g=>console.error(g))})},Yx=function(t){return this.renderer.getContents().find(A=>A.index===t&&A.overlayer)},CL=function({doc:t,index:A}){let s=new jo;t.addEventListener("click",a=>{let[r,o]=s.hitTest(a);r&&!r.startsWith(M1)&&x(this,Yt,xr).call(this,"show-annotation",{value:r,index:A,range:o})},!1);let n=h(this,Oo).get(A);if(n)for(let a of n){let r={...a,indicatorType:h(this,mr).type,indicatorOptions:h(this,mr).options};this.addAnnotation(r)}return x(this,Yt,xr).call(this,"create-overlay",{index:A}),s},BL=async function*(t,A,s){let n=await this.book.sections[s].createDocument();for(let{range:a,excerpt:r}of t(n,A))yield{cfi:this.getCFI(s,a),excerpt:r}},EL=async function*(t,A){let{sections:s}=this.book;for(let[n,{createDocument:a}]of s.entries()){if(!a)continue;let r=await a(),o=Array.from(t(r,A),({range:g,excerpt:l})=>({cfi:this.getCFI(n,g),excerpt:l}));yield{progress:(n+1)/s.length},o.length&&(yield{index:n,subitems:o})}};customElements.get("foliate-view")||customElements.define("foliate-view",G1);q1();wm();IE();$D();window.makeBook=TS;window.Overlayer=jo;window.CFI=qd;window._zipJs={configure:aE,ZipReader:Au,BlobReader:th,TextWriter:iu,BlobWriter:ih};window._EPUB=nu;window._makePDFFromURL=ZD;window._extractPDFChapters=zD;customElements.get("foliate-view")||customElements.define("foliate-view",G1);window.ReactNativeWebView&&window.ReactNativeWebView.postMessage(JSON.stringify({type:"foliate-loaded"}));})(); + `,C.head.appendChild(B)}a&&r>0&&setTimeout(()=>{u.classList.add("foliate-arrow-fadeout"),setTimeout(()=>{u.parentNode&&u.parentNode.removeChild(u)},1e3)},r)}return c.append(u),c}static copyImage([e],t={}){let{src:A}=t,s=Ii("image"),{left:n,top:a,height:r,width:o}=e;return s.setAttribute("href",A),s.setAttribute("x",n),s.setAttribute("y",a),s.setAttribute("height",r),s.setAttribute("width",o),s}};Dc=new WeakMap,Od=new WeakMap,Vo=new WeakMap,Rs=new WeakMap,Wd=new WeakMap,ka=new WeakMap;var tU=i=>{let e=0,t=A=>{if(A.id=e++,A.subitems)for(let s of A.subitems)t(s)};for(let A of i)t(A);return i},ek=i=>i.flatMap(e=>e.subitems?.length?[e,ek(e.subitems)].flat():e),Vd=class{async init({toc:e,ids:t,splitHref:A,getFragment:s}){tU(e);let n=ek(e),a=new Map;for(let[o,c]of n.entries()){let[g,l]=await A(c?.href)??[],I={fragment:l,item:c};a.has(g)?a.get(g).items.push(I):a.set(g,{prev:n[o-1],items:[I]})}let r=new Map;for(let[o,c]of t.entries())a.has(c)?r.set(c,a.get(c)):r.set(c,r.get(t[o-1]));this.ids=t,this.map=r,this.getFragment=s}getProgress(e,t){if(!this.ids)return;let A=this.ids[e],s=this.map.get(A);if(!s)return null;let{prev:n,items:a}=s;if(!a)return n;if(!t||a.length===1&&!a[0].fragment)return a[0].item;let r=t.startContainer.getRootNode();for(let[o,{fragment:c}]of a.entries()){let g=this.getFragment(r,c);if(g&&t.comparePoint(g,0)>0)return a[o-1]?.item??n}return a[a.length-1].item}},W1,tk,O1=class{constructor(e,t,A){D(this,W1);this.sizes=e.map(s=>s.linear!="no"&&s.size>0?s.size:0),this.sizePerLoc=t,this.sizePerTimeUnit=A,this.sizeTotal=this.sizes.reduce((s,n)=>s+n,0),this.sectionFractions=x(this,W1,tk).call(this)}getProgress(e,t,A=0){let{sizes:s,sizePerLoc:n,sizePerTimeUnit:a,sizeTotal:r}=this,o=s[e]??0,g=s.slice(0,e).reduce((u,f)=>u+f,0)+t*o,l=g+A*o,I=r-g,d=(1-t)*o;return{fraction:l/r,section:{current:e,total:s.length},location:{current:Math.floor(g/n),next:Math.floor(l/n),total:Math.ceil(r/n)},time:{section:d/a,total:I/a}}}getSection(e){if(e<=0)return[0,0];if(e>=1)return[this.sizes.length-1,1];e=e+Number.EPSILON;let{sizeTotal:t}=this,A=this.sectionFractions.findIndex(n=>n>e)-1;if(A<0)return[0,0];for(;!this.sizes[A];)A++;let s=(e-this.sectionFractions[A])/(this.sizes[A]/t);return[A,s]}};W1=new WeakSet,tk=function(){let{sizeTotal:e}=this,t=[0],A=0;for(let s of this.sizes)t.push((A+=s)/e);return t};var iU=(i,e)=>{let t=[];for(let A=e.currentNode;A;A=e.nextNode()){let s=i.comparePoint(A,0);if(s===0)t.push(A);else if(s>0)break}return t},AU=(i,e)=>{let t=[];for(let A=e.nextNode();A;A=e.nextNode())t.push(A);return t},sU=NodeFilter.SHOW_ELEMENT|NodeFilter.SHOW_TEXT|NodeFilter.SHOW_CDATA_SECTION,nU=i=>{if(i.nodeType===1){let e=i.tagName.toLowerCase();return e==="script"||e==="style"||e==="rt"||e==="rp"?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_SKIP}return NodeFilter.FILTER_ACCEPT},tm=function*(i,e,t){let A=i.commonAncestorContainer??i.body??i,s=document.createTreeWalker(A,sU,{acceptNode:t||nU}),a=(i.commonAncestorContainer?iU:AU)(i,s),r=a.map(c=>c.nodeValue),o=(c,g,l,I)=>{let d=document.createRange();return d.setStart(a[c],g),d.setEnd(a[l],I),d};for(let c of e(r,o))yield c};var M1="foliate-search:",hL="foliate-tts:",HK=async i=>{let e=new Uint8Array(await i.slice(0,4).arrayBuffer());return e[0]===80&&e[1]===75&&e[2]===3&&e[3]===4},JK=async i=>{let e=new Uint8Array(await i.slice(0,5).arrayBuffer());return e[0]===37&&e[1]===80&&e[2]===68&&e[3]===70&&e[4]===45},YK=({name:i,type:e})=>e==="application/vnd.comicbook+zip"||i.endsWith(".cbz"),PK=({name:i,type:e})=>e==="application/x-fictionbook+xml"||i.endsWith(".fb2"),_K=({name:i,type:e})=>e==="application/x-zip-compressed-fb2"||i.endsWith(".fb2.zip")||i.endsWith(".fbz"),KK=async i=>{let{configure:e,ZipReader:t,BlobReader:A,TextWriter:s,BlobWriter:n}=await Promise.resolve().then(()=>(wm(),tF));e({useWebWorkers:!1});let r=await new t(new A(i)).getEntries(),o=new Map(r.map(d=>[d.filename,d])),c=d=>(u,...f)=>o.has(u)?d(o.get(u),...f):null,g=c(d=>d.getData(new s)),l=c((d,u)=>d.getData(new n(u)));return{entries:r,loadText:g,loadBlob:l,getSize:d=>o.get(d)?.uncompressedSize??0}},IL=async i=>i.isFile?i:(await Promise.all(Array.from(await new Promise((e,t)=>i.createReader().readEntries(A=>e(A),A=>t(A))),IL))).flat(),qK=async i=>{let e=await IL(i),t=await Promise.all(e.map(g=>new Promise((l,I)=>g.file(d=>l([d,g.fullPath]),d=>I(d))))),A=new Map(t.map(([g,l])=>[l.replace(`${i.fullPath}/`,""),g])),s=new TextDecoder,n=g=>g?s.decode(g):null,a=g=>A.get(g)?.arrayBuffer()??null;return{loadText:async g=>n(await a(g)),loadBlob:g=>A.get(g),getSize:g=>A.get(g)?.size??0}},MS=class extends Error{},GS=class extends Error{},vS=class extends Error{},OK=async i=>{let e=await fetch(i);if(!e.ok)throw new MS(`${e.status} ${e.statusText}`,{cause:e});return new File([await e.blob()],new URL(e.url).pathname)},TS=async i=>{typeof i=="string"&&(i=await OK(i));let e;if(i.isDirectory){let t=await qK(i),{EPUB:A}=await Promise.resolve().then(()=>(IE(),Gm));e=await new A(t).init()}else if(i.size)if(await HK(i)){let t=await KK(i);if(YK(i)){let{makeComicBook:A}=await Promise.resolve().then(()=>(lF(),gF));e=A(t,i)}else if(_K(i)){let{makeFB2:A}=await Promise.resolve().then(()=>(Tm(),Um)),{entries:s}=t,n=s.find(r=>r.filename.endsWith(".fb2")),a=await t.loadBlob((n??s[0]).filename);e=await A(a)}else{let{EPUB:A}=await Promise.resolve().then(()=>(IE(),Gm));e=await new A(t).init()}}else if(await JK(i)){let{makePDF:t}=await Promise.resolve().then(()=>($D(),nv));e=await t(i)}else{let{isMOBI:t,MOBI:A}=await Promise.resolve().then(()=>(Cv(),fv));if(await t(i)){let s=await Promise.resolve().then(()=>(bv(),wv));e=await new A({unzlib:s.unzlibSync}).open(i)}else if(PK(i)){let{makeFB2:s}=await Promise.resolve().then(()=>(Tm(),Um));e=await s(i)}}else throw new GS("File not found");if(!e)throw new vS("File type not supported");return e},Jd,Vl,v1,Kn,HS=class HS{constructor(e,t,A={}){D(this,Jd);D(this,Vl);D(this,v1);D(this,Kn);m(this,Vl,e),m(this,v1,t),m(this,Kn,A),h(this,Kn).hidden&&this.hide(),h(this,Vl).addEventListener("mousemove",({screenX:s,screenY:n})=>{s===h(this,Kn).x&&n===h(this,Kn).y||(h(this,Kn).x=s,h(this,Kn).y=n,this.show(),h(this,Jd)&&clearTimeout(h(this,Jd)),t()&&m(this,Jd,setTimeout(this.hide.bind(this),1e3)))},!1)}cloneFor(e){return new HS(e,h(this,v1),h(this,Kn))}hide(){h(this,Vl).style.cursor="none",h(this,Kn).hidden=!0}show(){h(this,Vl).style.removeProperty("cursor"),h(this,Kn).hidden=!1}};Jd=new WeakMap,Vl=new WeakMap,v1=new WeakMap,Kn=new WeakMap;var LS=HS,qn,Fs,US=class extends EventTarget{constructor(){super(...arguments);D(this,qn,[]);D(this,Fs,-1)}pushState(t){let A=h(this,qn)[h(this,Fs)];A===t||A?.fraction&&A.fraction===t.fraction||(h(this,qn)[++hi(this,Fs)._]=t,h(this,qn).length=h(this,Fs)+1,this.dispatchEvent(new Event("index-change")))}replaceState(t){let A=h(this,Fs);h(this,qn)[A]=t}back(){let t=h(this,Fs);if(t<=0)return;let A={state:h(this,qn)[t-1]};m(this,Fs,t-1),this.dispatchEvent(new CustomEvent("popstate",{detail:A})),this.dispatchEvent(new Event("index-change"))}forward(){let t=h(this,Fs);if(t>=h(this,qn).length-1)return;let A={state:h(this,qn)[t+1]};m(this,Fs,t+1),this.dispatchEvent(new CustomEvent("popstate",{detail:A})),this.dispatchEvent(new Event("index-change"))}get canGoBack(){return h(this,Fs)>0}get canGoForward(){return h(this,Fs){if(!i)return{};try{let e=Intl.getCanonicalLocales(i)[0],t=new Intl.Locale(e),A=["zh","ja","kr"].includes(t.language),s=(t.getTextInfo?.()??t.textInfo)?.direction;return{canonical:e,locale:t,isCJK:A,direction:s}}catch(e){return console.warn(e),{}}},Px,qo,Da,bc,Oo,mr,_x,Yt,xr,dL,uL,fL,Yx,CL,BL,EL,G1=class extends HTMLElement{constructor(){super();D(this,Yt);D(this,Px,this.attachShadow({mode:"closed"}));D(this,qo);D(this,Da);D(this,bc);D(this,Oo,new Map);D(this,mr,{type:"outline",options:{}});D(this,_x,new LS(this,()=>this.hasAttribute("autohide-cursor")));pe(this,"isFixedLayout",!1);pe(this,"lastLocation");pe(this,"history",new US);this.history.addEventListener("popstate",({detail:t})=>{let A=this.resolveNavigation(t.state);this.renderer.goTo(A)})}async open(t){if((typeof t=="string"||typeof t.arrayBuffer=="function"||t.isDirectory)&&(t=await TS(t)),this.book=t,this.language=WK(t.metadata?.language),t.splitTOCHref&&t.getTOCFragment){let A=t.sections.map(a=>a.id);m(this,qo,new O1(t.sections,1500,1600));let s=t.splitTOCHref.bind(t),n=t.getTOCFragment.bind(t);m(this,Da,new Vd),await h(this,Da).init({toc:t.toc??[],ids:A,splitHref:s,getFragment:n}),m(this,bc,new Vd),await h(this,bc).init({toc:t.pageList??[],ids:A,splitHref:s,getFragment:n})}if(this.isFixedLayout=this.book.rendition?.layout==="pre-paginated",this.isFixedLayout?(await Promise.resolve().then(()=>(Fv(),kv)),this.renderer=document.createElement("foliate-fxl")):(await Promise.resolve().then(()=>(zv(),Zv)),this.renderer=document.createElement("foliate-paginator")),this.renderer.setAttribute("exportparts","head,foot,filter"),this.renderer.addEventListener("load",A=>x(this,Yt,uL).call(this,A.detail)),this.renderer.addEventListener("relocate",A=>x(this,Yt,dL).call(this,A.detail)),this.renderer.addEventListener("create-overlayer",A=>A.detail.attach(x(this,Yt,CL).call(this,A.detail))),this.renderer.open(t),h(this,Px).append(this.renderer),t.sections.some(A=>A.mediaOverlay)){let A=t.media.activeClass,s=t.media.playbackActiveClass;this.mediaOverlay=t.getMediaOverlay();let n;this.mediaOverlay.addEventListener("highlight",a=>{let r=this.resolveNavigation(a.detail.text);this.renderer.goTo(r).then(()=>{let{doc:o}=this.renderer.getContents().find(g=>g.index=r.index),c=r.anchor(o);c.classList.add(A),s&&c.ownerDocument.documentElement.classList.add(s),n=new WeakRef(c)})}),this.mediaOverlay.addEventListener("unhighlight",()=>{let a=n?.deref();a&&(a.classList.remove(A),s&&a.ownerDocument.documentElement.classList.remove(s))})}}close(){this.renderer?.destroy(),this.renderer?.remove(),m(this,qo,null),m(this,Da,null),m(this,bc,null),m(this,Oo,new Map),this.lastLocation=null,this.history.clear(),this.tts=null,this.mediaOverlay=null}goToTextStart(){return this.goTo(this.book.landmarks?.find(t=>t.type.includes("bodymatter")||t.type.includes("text"))?.href??this.book.sections.findIndex(t=>t.linear!=="no"))}async init({lastLocation:t,showTextStart:A}){let s=t?this.resolveNavigation(t):null;s?(await this.renderer.goTo(s),this.history.pushState(t)):A?await this.goToTextStart():(this.history.pushState(0),await this.next())}async addAnnotation(t,A){let{value:s,indicatorType:n="outline",indicatorOptions:a={}}=t;if(s.startsWith(M1)){let I=s.replace(M1,""),{index:d,anchor:u}=await this.resolveNavigation(I),f=x(this,Yt,Yx).call(this,d);if(f){let{overlayer:C,doc:B}=f;if(A){C.remove(s),C.remove(`${s}::underline`),C.remove(`${s}::tooltip`);return}let E=B?u(B):u,Q;n==="arrow"?Q=jo.arrow:Q=jo.outline,C.add(s,E,Q,a)}return}let r=s.startsWith(hL)?s.replace(hL,""):s,{index:o,anchor:c}=await this.resolveNavigation(r),g=x(this,Yt,Yx).call(this,o);if(g){let{overlayer:I,doc:d}=g;if(I.remove(s),I.remove(`${s}::underline`),I.remove(`${s}::tooltip`),A&&x(this,Yt,xr).call(this,"delete-annotation",{value:s,doc:d}),!A){let u=d?c(d):c,f=(C,B,E)=>{let Q=E?`${s}::${E}`:s;I.add(Q,u,C,B)};x(this,Yt,xr).call(this,"draw-annotation",{draw:f,annotation:t,doc:d,range:u})}}let l=h(this,Da).getProgress(o)?.label??"";return{index:o,label:l}}deleteAnnotation(t){return this.addAnnotation(t,!0)}async showAnnotation(t){let{value:A}=t,s=await this.goTo(A);if(s){let{index:n,anchor:a}=s,{doc:r}=x(this,Yt,Yx).call(this,n),o=a(r);x(this,Yt,xr).call(this,"show-annotation",{value:A,index:n,range:o})}}getCFI(t,A){let s=this.book.sections[t].cfi??Kd.fromIndex(t);return A?Zx(s,$x(A)):s}resolveCFI(t){if(this.book.resolveCFI)return this.book.resolveCFI(t);let A=Vn(t);return{index:Kd.toIndex((A.parent??A).shift()),anchor:a=>_d(a,A)}}resolveNavigation(t){try{if(typeof t=="number")return{index:t};if(typeof t.fraction=="number"){let[A,s]=h(this,qo).getSection(t.fraction);return{index:A,anchor:s}}return jl.test(t)?this.resolveCFI(t):this.book.resolveHref(t)}catch(A){console.error(A),console.error(`Could not resolve target ${t}`)}}async goTo(t){t=decodeURIComponent(t);let A=this.resolveNavigation(t);try{return await this.renderer.goTo(A),this.history.pushState(t),A}catch(s){console.error(s),console.error(`Could not go to ${t}`)}}async goToFraction(t){let[A,s]=h(this,qo).getSection(t);await this.renderer.goTo({index:A,anchor:s}),this.history.pushState({fraction:t})}async select(t){try{let A=await this.resolveNavigation(t);await this.renderer.goTo({...A,select:!0}),this.history.pushState(t)}catch(A){console.error(A),console.error(`Could not go to ${t}`)}}deselect(){for(let{doc:t}of this.renderer.getContents())t.defaultView.getSelection().removeAllRanges()}getSectionFractions(){return(h(this,qo)?.sectionFractions??[]).map(t=>t+Number.EPSILON)}getProgressOf(t,A){let s=h(this,Da)?.getProgress(t,A),n=h(this,bc)?.getProgress(t,A);return{tocItem:s,pageItem:n}}async getTOCItemOf(t){try{let{index:A,anchor:s}=await this.resolveNavigation(t),n=await this.book.sections[A].createDocument(),a=s(n),r=a instanceof Range,o=r?a:n.createRange();return r||o.selectNodeContents(a),h(this,Da).getProgress(A,o)}catch(A){console.error(A),console.error(`Could not get ${t}`)}}async prev(t){await this.renderer.prev(t)}async next(t){await this.renderer.next(t)}goLeft(){return this.book.dir==="rtl"?this.next():this.prev()}goRight(){return this.book.dir==="rtl"?this.prev():this.next()}async*search(t){this.clearSearch();let{searchMatcher:A}=await Promise.resolve().then(()=>(AL(),iL)),{query:s,index:n}=t,a=A(tm,{defaultLocale:this.language,...t}),r=n!=null?x(this,Yt,BL).call(this,a,s,n):x(this,Yt,EL).call(this,a,s),o=[];h(this,Oo).set(n,o);for await(let c of r)if(c.subitems){let g=c.subitems.map(({cfi:l})=>({value:M1+l}));h(this,Oo).set(c.index,g);for(let l of g){let I={...l,indicatorType:h(this,mr).type,indicatorOptions:h(this,mr).options};this.addAnnotation(I)}yield{label:h(this,Da).getProgress(c.index)?.label??"",subitems:c.subitems}}else{if(c.cfi){let g={value:M1+c.cfi};o.push(g);let l={...g,indicatorType:h(this,mr).type,indicatorOptions:h(this,mr).options};this.addAnnotation(l)}yield c}yield"done"}clearSearch(){for(let t of h(this,Oo).values())for(let A of t)this.deleteAnnotation(A);h(this,Oo).clear()}setSearchIndicator(t="outline",A={}){m(this,mr,{type:t,options:A})}async initTTS(t="word",A,s){let n=this.renderer.getContents(),a=this.renderer.primaryIndex,r=n.find(l=>l.index===a)??n[0],o=r?.doc,c=r?.index??0;if(!o)return;if(this.tts&&this.tts.doc===o){A&&(this.tts.highlight=A);return}let{TTS:g}=await Promise.resolve().then(()=>(lL(),gL));this.tts=new g(o,tm,s||null,A||(l=>this.renderer.scrollToAnchor(l,!0)),l=>this.getCFI(c,l),t)}startMediaOverlay(){let{index:t}=this.renderer.getContents()[0];return this.mediaOverlay.start(t)}};Px=new WeakMap,qo=new WeakMap,Da=new WeakMap,bc=new WeakMap,Oo=new WeakMap,mr=new WeakMap,_x=new WeakMap,Yt=new WeakSet,xr=function(t,A,s){return this.dispatchEvent(new CustomEvent(t,{detail:A,cancelable:s}))},dL=function({reason:t,range:A,index:s,fraction:n,size:a}){let r=h(this,qo)?.getProgress(s,n,a)??{},o=h(this,Da)?.getProgress(s,A),c=h(this,bc)?.getProgress(s,A),g=this.getCFI(s,A);this.lastLocation={...r,tocItem:o,pageItem:c,cfi:g,range:A},(t==="snap"||t==="page"||t==="scroll")&&this.history.replaceState(g),x(this,Yt,xr).call(this,"relocate",this.lastLocation)},uL=function({doc:t,index:A}){var s,n;(s=t.documentElement).lang||(s.lang=this.language.canonical??""),this.language.isCJK||(n=t.documentElement).dir||(n.dir=this.language.direction??""),x(this,Yt,fL).call(this,t,A),h(this,_x).cloneFor(t.documentElement),x(this,Yt,xr).call(this,"load",{doc:t,index:A})},fL=function(t,A){let{book:s}=this,n=s.sections[A];t.addEventListener("click",a=>{let r=a.target.closest("a[href]");if(!r)return;a.preventDefault();let o=r.getAttribute("href"),c=n?.resolveHref?.(o)??o;s?.isExternal?.(c)?Promise.resolve(x(this,Yt,xr).call(this,"external-link",{a:r,href:c},!0)).then(g=>g?globalThis.open(c,"_blank"):null).catch(g=>console.error(g)):Promise.resolve(x(this,Yt,xr).call(this,"link",{a:r,href:c},!0)).then(g=>g?this.goTo(c):null).catch(g=>console.error(g))})},Yx=function(t){return this.renderer.getContents().find(A=>A.index===t&&A.overlayer)},CL=function({doc:t,index:A}){let s=new jo;t.addEventListener("click",a=>{let[r,o]=s.hitTest(a);r&&!r.startsWith(M1)&&x(this,Yt,xr).call(this,"show-annotation",{value:r,index:A,range:o})},!1);let n=h(this,Oo).get(A);if(n)for(let a of n){let r={...a,indicatorType:h(this,mr).type,indicatorOptions:h(this,mr).options};this.addAnnotation(r)}return x(this,Yt,xr).call(this,"create-overlay",{index:A}),s},BL=async function*(t,A,s){let n=await this.book.sections[s].createDocument();for(let{range:a,excerpt:r}of t(n,A))yield{cfi:this.getCFI(s,a),excerpt:r}},EL=async function*(t,A){let{sections:s}=this.book;for(let[n,{createDocument:a}]of s.entries()){if(!a)continue;let r=await a(),o=Array.from(t(r,A),({range:g,excerpt:l})=>({cfi:this.getCFI(n,g),excerpt:l}));yield{progress:(n+1)/s.length},o.length&&(yield{index:n,subitems:o})}};customElements.get("foliate-view")||customElements.define("foliate-view",G1);q1();wm();IE();$D();(async()=>{let i=null;try{let e=navigator.userAgentData;if(e&&typeof e.getHighEntropyValues=="function"){let{fullVersionList:t}=await e.getHighEntropyValues(["fullVersionList"]),A=(t||[]).find(s=>/Android WebView|Microsoft Edge/i.test(s.brand));A&&(i=A.version)}}catch{}window.ReactNativeWebView&&window.ReactNativeWebView.postMessage(JSON.stringify({type:"readany-ua",ua:navigator.userAgent,fullVersion:i}))})();window.makeBook=TS;window.Overlayer=jo;window.CFI=qd;window._zipJs={configure:aE,ZipReader:Au,BlobReader:th,TextWriter:iu,BlobWriter:ih};window._EPUB=nu;window._makePDFFromURL=ZD;window._extractPDFChapters=zD;customElements.get("foliate-view")||customElements.define("foliate-view",G1);window.ReactNativeWebView&&window.ReactNativeWebView.postMessage(JSON.stringify({type:"foliate-loaded"}));})(); diff --git a/packages/app-expo/metro.config.js b/packages/app-expo/metro.config.js index cfedb77fe..a2faaf810 100644 --- a/packages/app-expo/metro.config.js +++ b/packages/app-expo/metro.config.js @@ -27,6 +27,10 @@ config.resolver.blockList = [ /node_modules\/esbuild\/.*/, /node_modules\/typescript\/.*/, /node_modules\/@biomejs\/.*/, + // Rust build artifacts churn constantly during tauri dev/build; Metro's + // Windows fallback watcher crashes (ENOENT) trying to watch them, which + // kills the whole bundler. + /[\\/]src-tauri[\\/]target[\\/].*/, ]; // 4. Add support for TypeScript files diff --git a/packages/app-expo/scripts/build-reader.js b/packages/app-expo/scripts/build-reader.js index fe180c0f6..8a0039d5f 100644 --- a/packages/app-expo/scripts/build-reader.js +++ b/packages/app-expo/scripts/build-reader.js @@ -17,6 +17,25 @@ const OUTPUT = path.resolve(ASSETS_DIR, "reader.html"); async function buildReader() { // Create a temporary entry point const entryContent = ` + // Report the WebView's own UA (plus the full build via Client Hints — + // this page is served from the local server, a secure context, so the + // high-entropy value is readable) to RN once at startup — Settings → + // About shows it as the "WebView version" (RN itself has no real UA). + (async () => { + let fullVersion = null; + try { + const uaData = navigator.userAgentData; + if (uaData && typeof uaData.getHighEntropyValues === "function") { + const { fullVersionList } = await uaData.getHighEntropyValues(["fullVersionList"]); + const hit = (fullVersionList || []).find((b) => /Android WebView|Microsoft Edge/i.test(b.brand)); + if (hit) fullVersion = hit.version; + } + } catch (e) {} + if (window.ReactNativeWebView) { + window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'readany-ua', ua: navigator.userAgent, fullVersion })); + } + })(); + import { makeBook, View } from "${FOLIATE_DIR.replace(/\\/g, "/")}/view.js"; import { Overlayer } from "${FOLIATE_DIR.replace(/\\/g, "/")}/overlayer.js"; import * as CFI from "${FOLIATE_DIR.replace(/\\/g, "/")}/epubcfi.js"; diff --git a/packages/app-expo/src/App.tsx b/packages/app-expo/src/App.tsx index 5e9ecbe6d..d9e12a7db 100644 --- a/packages/app-expo/src/App.tsx +++ b/packages/app-expo/src/App.tsx @@ -31,6 +31,7 @@ import { GestureHandlerRootView } from "react-native-gesture-handler"; import { KeyboardProvider } from "react-native-keyboard-controller"; import { SafeAreaProvider } from "react-native-safe-area-context"; +import { UAProbe } from "@/components/common/UAProbe"; import { AnimatedSplash } from "@/components/splash/AnimatedSplash"; import { rnSessionEventSource } from "@/hooks"; import { setStreamingFetch } from "@readany/core/ai/llm-provider"; @@ -292,6 +293,7 @@ function AppInner() { + diff --git a/packages/app-expo/src/components/common/UAProbe.tsx b/packages/app-expo/src/components/common/UAProbe.tsx new file mode 100644 index 000000000..46be31754 --- /dev/null +++ b/packages/app-expo/src/components/common/UAProbe.tsx @@ -0,0 +1,77 @@ +import { useWebviewInfoStore } from "@/stores/webview-info-store"; +import { useEffect, useState } from "react"; +import { View } from "react-native"; +import { WebView } from "react-native-webview"; + +const PROBE_HTML = + ""; + +// The UA string is reduced (Chrome/138.0.0.0) on modern Chromium WebViews, so +// also ask Client Hints for the fullVersionList of the matching brand — the +// About screen then shows the real build (e.g. 138.0.7204.67) instead of +// x.0.0.0. Keep this brand list in sync with the desktop client-hints lookup +// in packages/app/src/lib/webview-info.ts. +const INJECTED_JS = `(async () => { + let fullVersion = null; + try { + const uaData = navigator.userAgentData; + if (uaData && typeof uaData.getHighEntropyValues === "function") { + const { fullVersionList } = await uaData.getHighEntropyValues(["fullVersionList"]); + const hit = (fullVersionList || []).find((b) => /Android WebView|Microsoft Edge/i.test(b.brand)); + if (hit) fullVersion = hit.version; + } + } catch (e) {} + if (window.ReactNativeWebView) { + window.ReactNativeWebView.postMessage(JSON.stringify({ type: "readany-ua", ua: navigator.userAgent, fullVersion })); + } +})(); true;`; + +/** + * A 0×0 hidden WebView that reports the system WebView's real + * navigator.userAgent (plus the full build via Client Hints) to the + * webview-info store, then unmounts. + * + * The RN layer has no real UA (App.tsx polyfills "ReactNative"), and our only + * other webview — the reader — mounts per book. This probe makes + * Settings → About show the exact engine/build immediately at app startup, + * before any book is opened. Best-effort by design: if the probe never + * reports (load failure, no bridge), it unmounts after a short timeout and + * the reader bridge fills the store on the first book open instead. + */ +export function UAProbe() { + const ua = useWebviewInfoStore((s) => s.ua); + const [gaveUp, setGaveUp] = useState(false); + + useEffect(() => { + if (ua) return; + const timer = setTimeout(() => setGaveUp(true), 5000); + return () => clearTimeout(timer); + }, [ua]); + + if (ua || gaveUp) return null; + + return ( + + { + try { + const msg = JSON.parse(event.nativeEvent.data) as { + type?: string; + ua?: string; + fullVersion?: string | null; + }; + if (msg.type === "readany-ua" && msg.ua) { + useWebviewInfoStore.getState().setUa(msg.ua, msg.fullVersion ?? undefined); + } + } catch { + // ignore probe noise + } + }} + onError={() => setGaveUp(true)} + style={{ height: 0, width: 0 }} + /> + + ); +} diff --git a/packages/app-expo/src/components/onboarding/steps/AppearancePage.tsx b/packages/app-expo/src/components/onboarding/steps/AppearancePage.tsx index 4abff783b..e966a594e 100644 --- a/packages/app-expo/src/components/onboarding/steps/AppearancePage.tsx +++ b/packages/app-expo/src/components/onboarding/steps/AppearancePage.tsx @@ -2,6 +2,7 @@ import { DarkModeSvg } from "@/components/DarkModeSvg"; import { type ThemeMode, useTheme } from "@/styles/ThemeContext"; import { useNavigation } from "@react-navigation/native"; import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; +import { changeAndPersistLanguage } from "@readany/core/i18n"; import { Coffee, Moon, Sun } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; @@ -46,7 +47,6 @@ export function AppearancePage() { const handleLangChange = async (code: string) => { try { - const { changeAndPersistLanguage } = await import("@readany/core/i18n"); await changeAndPersistLanguage(code); } catch (err) { console.warn("[Settings] Failed to change and persist language:", err); diff --git a/packages/app-expo/src/hooks/use-reader-bridge.ts b/packages/app-expo/src/hooks/use-reader-bridge.ts index 07bed2b88..c8d3e8231 100644 --- a/packages/app-expo/src/hooks/use-reader-bridge.ts +++ b/packages/app-expo/src/hooks/use-reader-bridge.ts @@ -1,4 +1,5 @@ import { useSettingsStore } from "@/stores"; +import { useWebviewInfoStore } from "@/stores/webview-info-store"; import type { TOCItem } from "@readany/core/types"; /** * useReaderBridge — encapsulates RN ↔ WebView postMessage communication @@ -712,232 +713,238 @@ export function useReaderBridge(callbacks: ReaderBridgeCallbacks) { // ─── Handle messages from WebView ─── - const handleMessage = useCallback( - (event: { nativeEvent: { data: string } }) => { - try { - const msg = JSON.parse(event.nativeEvent.data); - const cb = callbacksRef.current; - - switch (msg.type) { - case "ready": - cb.onReady?.(); - break; - case "loaded": - cb.onLoaded?.(); - break; - case "relocate": - cb.onRelocate?.(msg); - break; - case "bookTextMetrics": - cb.onBookTextMetrics?.({ - totalCharacters: Number(msg.totalCharacters) || 0, + const handleMessage = useCallback((event: { nativeEvent: { data: string } }) => { + try { + const msg = JSON.parse(event.nativeEvent.data); + const cb = callbacksRef.current; + + switch (msg.type) { + case "ready": + cb.onReady?.(); + break; + case "loaded": + cb.onLoaded?.(); + break; + case "relocate": + cb.onRelocate?.(msg); + break; + case "bookTextMetrics": + cb.onBookTextMetrics?.({ + totalCharacters: Number(msg.totalCharacters) || 0, + }); + break; + case "toc": + cb.onTocReady?.(msg.items || []); + break; + case "selection": + cb.onSelection?.(msg); + break; + case "selectionCleared": + cb.onSelectionCleared?.(); + break; + case "tap": + cb.onTap?.(); + break; + case "searchResult": + cb.onSearchResult?.(msg.index || 0, msg.count || 0); + break; + case "searchComplete": + cb.onSearchComplete?.(msg.count || 0); + break; + case "error": + console.error("[ReaderBridge] Error from WebView:", msg.message); + cb.onError?.(msg.message || "Unknown error"); + break; + case "foliate-loaded": + break; + case "readany-ua": + // reader.html reports its navigator.userAgent once at startup — + // Settings → About displays it as the WebView version. + if (msg.ua) { + useWebviewInfoStore + .getState() + .setUa(String(msg.ua), msg.fullVersion ? String(msg.fullVersion) : undefined); + } + break; + case "show-annotation": + if (msg.value && msg.position) { + cb.onShowAnnotation?.({ + value: msg.value, + range: msg.range, + position: msg.position, }); - break; - case "toc": - cb.onTocReady?.(msg.items || []); - break; - case "selection": - cb.onSelection?.(msg); - break; - case "selectionCleared": - cb.onSelectionCleared?.(); - break; - case "tap": - cb.onTap?.(); - break; - case "searchResult": - cb.onSearchResult?.(msg.index || 0, msg.count || 0); - break; - case "searchComplete": - cb.onSearchComplete?.(msg.count || 0); - break; - case "error": - console.error("[ReaderBridge] Error from WebView:", msg.message); - cb.onError?.(msg.message || "Unknown error"); - break; - case "foliate-loaded": - break; - case "show-annotation": - if (msg.value && msg.position) { - cb.onShowAnnotation?.({ - value: msg.value, - range: msg.range, - position: msg.position, - }); - } - break; - case "note-tooltip": - if (msg.cfi && msg.note && msg.position) { - cb.onNoteTooltip?.({ - cfi: msg.cfi, - note: msg.note, - position: msg.position, - }); - } - break; - case "pageSnippet": - cb.onPageSnippet?.(msg.textSnippet || ""); - break; - case "bookmarkSnippet": - cb.onBookmarkSnippet?.(msg.textSnippet || ""); - break; - case "toggleBookmark": - cb.onToggleBookmark?.(); - break; - case "bookmarkPull": - cb.onBookmarkPull?.({ - offset: typeof msg.offset === "number" ? msg.offset : 0, - armed: !!msg.armed, - active: !!msg.active, + } + break; + case "note-tooltip": + if (msg.cfi && msg.note && msg.position) { + cb.onNoteTooltip?.({ + cfi: msg.cfi, + note: msg.note, + position: msg.position, }); - break; - case "visibleText": - console.log( - "[ReaderBridge] received visibleText:", - JSON.stringify({ - textLength: msg.text?.length || 0, - error: msg.error || "none", - debug: msg.debug || null, - }), - ); - if (pendingVisibleTextResolveRef.current) { - pendingVisibleTextResolveRef.current(msg.text || ""); - pendingVisibleTextResolveRef.current = null; + } + break; + case "pageSnippet": + cb.onPageSnippet?.(msg.textSnippet || ""); + break; + case "bookmarkSnippet": + cb.onBookmarkSnippet?.(msg.textSnippet || ""); + break; + case "toggleBookmark": + cb.onToggleBookmark?.(); + break; + case "bookmarkPull": + cb.onBookmarkPull?.({ + offset: typeof msg.offset === "number" ? msg.offset : 0, + armed: !!msg.armed, + active: !!msg.active, + }); + break; + case "visibleText": + console.log( + "[ReaderBridge] received visibleText:", + JSON.stringify({ + textLength: msg.text?.length || 0, + error: msg.error || "none", + debug: msg.debug || null, + }), + ); + if (pendingVisibleTextResolveRef.current) { + pendingVisibleTextResolveRef.current(msg.text || ""); + pendingVisibleTextResolveRef.current = null; + } + break; + case "visibleTTSSegments": + { + if (msg.debug) { + console.log("[ReaderBridge] visibleTTSSegments debug:", JSON.stringify(msg.debug)); } - break; - case "visibleTTSSegments": - { - if (msg.debug) { - console.log("[ReaderBridge] visibleTTSSegments debug:", JSON.stringify(msg.debug)); + const requestId = typeof msg.requestId === "string" ? msg.requestId : null; + const pendingResolve = requestId + ? pendingVisibleTTSSegmentsResolveRef.current.get(requestId) + : pendingVisibleTTSSegmentsResolveRef.current.values().next().value; + if (pendingResolve) { + if (msg.error) { + console.warn("[ReaderBridge] visibleTTSSegments error:", msg.error); } - const requestId = typeof msg.requestId === "string" ? msg.requestId : null; - const pendingResolve = requestId - ? pendingVisibleTTSSegmentsResolveRef.current.get(requestId) - : pendingVisibleTTSSegmentsResolveRef.current.values().next().value; - if (pendingResolve) { - if (msg.error) { - console.warn("[ReaderBridge] visibleTTSSegments error:", msg.error); - } - pendingResolve(msg.segments || []); - if (requestId) { - pendingVisibleTTSSegmentsResolveRef.current.delete(requestId); - } else { - pendingVisibleTTSSegmentsResolveRef.current.clear(); - } + pendingResolve(msg.segments || []); + if (requestId) { + pendingVisibleTTSSegmentsResolveRef.current.delete(requestId); + } else { + pendingVisibleTTSSegmentsResolveRef.current.clear(); } } - break; - case "ttsSegmentContext": - { - const requestId = typeof msg.requestId === "string" ? msg.requestId : null; - const pendingResolve = requestId - ? pendingTTSContextResolveRef.current.get(requestId) - : pendingTTSContextResolveRef.current.values().next().value; - if (pendingResolve) { - if (msg.error) { - console.warn("[ReaderBridge] ttsSegmentContext error:", msg.error); - } - pendingResolve({ - before: msg.before || [], - after: msg.after || [], - }); - if (requestId) { - pendingTTSContextResolveRef.current.delete(requestId); - } else { - pendingTTSContextResolveRef.current.clear(); - } + } + break; + case "ttsSegmentContext": + { + const requestId = typeof msg.requestId === "string" ? msg.requestId : null; + const pendingResolve = requestId + ? pendingTTSContextResolveRef.current.get(requestId) + : pendingTTSContextResolveRef.current.values().next().value; + if (pendingResolve) { + if (msg.error) { + console.warn("[ReaderBridge] ttsSegmentContext error:", msg.error); + } + pendingResolve({ + before: msg.before || [], + after: msg.after || [], + }); + if (requestId) { + pendingTTSContextResolveRef.current.delete(requestId); + } else { + pendingTTSContextResolveRef.current.clear(); } } - break; - case "hrefTTSSegments": - { - const requestId = typeof msg.requestId === "string" ? msg.requestId : null; - const pendingResolve = requestId - ? pendingHrefTTSSegmentsResolveRef.current.get(requestId) - : pendingHrefTTSSegmentsResolveRef.current.values().next().value; - if (pendingResolve) { - if (msg.error) { - console.warn("[ReaderBridge] hrefTTSSegments error:", msg.error); - } - pendingResolve(msg.segments || []); - if (requestId) { - pendingHrefTTSSegmentsResolveRef.current.delete(requestId); - } else { - pendingHrefTTSSegmentsResolveRef.current.clear(); - } + } + break; + case "hrefTTSSegments": + { + const requestId = typeof msg.requestId === "string" ? msg.requestId : null; + const pendingResolve = requestId + ? pendingHrefTTSSegmentsResolveRef.current.get(requestId) + : pendingHrefTTSSegmentsResolveRef.current.values().next().value; + if (pendingResolve) { + if (msg.error) { + console.warn("[ReaderBridge] hrefTTSSegments error:", msg.error); + } + pendingResolve(msg.segments || []); + if (requestId) { + pendingHrefTTSSegmentsResolveRef.current.delete(requestId); + } else { + pendingHrefTTSSegmentsResolveRef.current.clear(); } } - break; - case "sectionTTSSegments": - { - const requestId = typeof msg.requestId === "string" ? msg.requestId : null; - const pendingResolve = requestId - ? pendingSectionTTSSegmentsResolveRef.current.get(requestId) - : pendingSectionTTSSegmentsResolveRef.current.values().next().value; - if (pendingResolve) { - if (msg.error) { - console.warn("[ReaderBridge] sectionTTSSegments error:", msg.error); - } - pendingResolve(msg.segments || []); - if (requestId) { - pendingSectionTTSSegmentsResolveRef.current.delete(requestId); - } else { - pendingSectionTTSSegmentsResolveRef.current.clear(); - } + } + break; + case "sectionTTSSegments": + { + const requestId = typeof msg.requestId === "string" ? msg.requestId : null; + const pendingResolve = requestId + ? pendingSectionTTSSegmentsResolveRef.current.get(requestId) + : pendingSectionTTSSegmentsResolveRef.current.values().next().value; + if (pendingResolve) { + if (msg.error) { + console.warn("[ReaderBridge] sectionTTSSegments error:", msg.error); + } + pendingResolve(msg.segments || []); + if (requestId) { + pendingSectionTTSSegmentsResolveRef.current.delete(requestId); + } else { + pendingSectionTTSSegmentsResolveRef.current.clear(); } } - break; - case "chapterParagraphs": - console.log( - "[ChapterTranslation] Received chapterParagraphs:", - JSON.stringify({ - count: msg.paragraphs?.length || 0, - error: msg.error || "none", - }), + } + break; + case "chapterParagraphs": + console.log( + "[ChapterTranslation] Received chapterParagraphs:", + JSON.stringify({ + count: msg.paragraphs?.length || 0, + error: msg.error || "none", + }), + ); + if (pendingChapterParagraphsResolveRef.current) { + if (msg.error) { + console.warn("[ChapterTranslation] WebView error:", msg.error); + } + pendingChapterParagraphsResolveRef.current(msg.paragraphs || []); + pendingChapterParagraphsResolveRef.current = null; + } else { + console.warn( + "[ChapterTranslation] No pending resolve for chapterParagraphs (timed out?)", ); - if (pendingChapterParagraphsResolveRef.current) { + } + break; + case "chapterTranslationsInjected": + { + const requestId = typeof msg.requestId === "string" ? msg.requestId : null; + const pendingResolve = requestId + ? pendingChapterTranslationInjectionResolveRef.current.get(requestId) + : pendingChapterTranslationInjectionResolveRef.current.values().next().value; + if (pendingResolve) { if (msg.error) { - console.warn("[ChapterTranslation] WebView error:", msg.error); + console.warn("[ChapterTranslation] WebView injection error:", msg.error); } - pendingChapterParagraphsResolveRef.current(msg.paragraphs || []); - pendingChapterParagraphsResolveRef.current = null; - } else { - console.warn( - "[ChapterTranslation] No pending resolve for chapterParagraphs (timed out?)", - ); - } - break; - case "chapterTranslationsInjected": - { - const requestId = typeof msg.requestId === "string" ? msg.requestId : null; - const pendingResolve = requestId - ? pendingChapterTranslationInjectionResolveRef.current.get(requestId) - : pendingChapterTranslationInjectionResolveRef.current.values().next().value; - if (pendingResolve) { - if (msg.error) { - console.warn("[ChapterTranslation] WebView injection error:", msg.error); - } - pendingResolve(); - if (requestId) { - pendingChapterTranslationInjectionResolveRef.current.delete(requestId); - } else { - pendingChapterTranslationInjectionResolveRef.current.clear(); - } + pendingResolve(); + if (requestId) { + pendingChapterTranslationInjectionResolveRef.current.delete(requestId); + } else { + pendingChapterTranslationInjectionResolveRef.current.clear(); } } - break; - case "debug": - console.log("[WebView]", msg.message); - break; - default: - break; - } - } catch (err) { - console.error("[ReaderBridge] Parse error:", err); + } + break; + case "debug": + console.log("[WebView]", msg.message); + break; + default: + break; } - }, - [], - ); + } catch (err) { + console.error("[ReaderBridge] Parse error:", err); + } + }, []); return useMemo( () => ({ diff --git a/packages/app-expo/src/screens/settings/AboutScreen.tsx b/packages/app-expo/src/screens/settings/AboutScreen.tsx index 7695c8083..7f78889b9 100644 --- a/packages/app-expo/src/screens/settings/AboutScreen.tsx +++ b/packages/app-expo/src/screens/settings/AboutScreen.tsx @@ -1,7 +1,10 @@ +import { useResponsiveLayout } from "@/hooks/use-responsive-layout"; +import { useUpdateStore } from "@/stores/update-store"; +import { useWebviewLabel } from "@/stores/webview-info-store"; import { getPlatformService } from "@readany/core/services"; import { checkForUpdate } from "@readany/core/update"; -import { useResponsiveLayout } from "@/hooks/use-responsive-layout"; -import { useCallback, useEffect, useState } from "react"; +import * as Clipboard from "expo-clipboard"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ActivityIndicator, @@ -15,6 +18,7 @@ import { View, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; +import AppIcon from "../../../assets/icon.png"; import { type ThemeColors, fontSize, @@ -23,9 +27,7 @@ import { spacing, useColors, } from "../../styles/theme"; -import { useUpdateStore } from "@/stores/update-store"; import { SettingsHeader } from "./SettingsHeader"; -import AppIcon from "../../../assets/icon.png"; const TECH_STACK = [ { label: "Expo SDK 55", descKey: "about.nativeContainer" }, @@ -48,6 +50,27 @@ export default function AboutScreen() { const layout = useResponsiveLayout(); const [version, setVersion] = useState("1.0.0"); const [checking, setChecking] = useState(false); + const [copied, setCopied] = useState(false); + const copiedTimer = useRef>(undefined); + + // Filled at startup by the hidden UA probe webview (see UAProbe), and kept + // fresh by the reader WebView on every book load via the bridge. + const webviewLabel = useWebviewLabel(); + + // Unmount cleanup for the copy feedback timer. + useEffect(() => () => clearTimeout(copiedTimer.current), []); + + const handleCopyVersion = useCallback(async () => { + const versionInfo = [`ReadAny ${version}`, webviewLabel].filter(Boolean).join("\n"); + try { + await Clipboard.setStringAsync(versionInfo); + setCopied(true); + clearTimeout(copiedTimer.current); + copiedTimer.current = setTimeout(() => setCopied(false), 1500); + } catch (error) { + console.error("[AboutScreen] Copy version info failed:", error); + } + }, [version, webviewLabel]); const checkResult = useUpdateStore((s) => s.checkResult); const setCheckResult = useUpdateStore((s) => s.setCheckResult); @@ -83,15 +106,40 @@ export default function AboutScreen() { > - + {/* Logo & Version */} - + ReadAny - v{version} + {/* Tap the two version lines to copy both — bug reports need the + pair (app version + web engine build). No hover on touch. */} + void handleCopyVersion()} + activeOpacity={0.7} + style={styles.versionBlock} + > + v{version} + {webviewLabel || copied ? ( + + {copied ? `✓ ${t("common.copied")}` : webviewLabel} + + ) : null} + {t("about.desc", "一个跨平台的智能电子书阅读器,支持 AI 对话、TTS 朗读、多语言翻译")} @@ -203,6 +251,10 @@ const makeStyles = (colors: ThemeColors) => color: colors.mutedForeground, marginTop: 4, }, + versionBlock: { + alignItems: "center", + paddingVertical: 4, + }, desc: { fontSize: fontSize.sm, color: colors.mutedForeground, diff --git a/packages/app-expo/src/screens/settings/AppearanceSettingsScreen.tsx b/packages/app-expo/src/screens/settings/AppearanceSettingsScreen.tsx index 5446682c5..4b6d3b4ba 100644 --- a/packages/app-expo/src/screens/settings/AppearanceSettingsScreen.tsx +++ b/packages/app-expo/src/screens/settings/AppearanceSettingsScreen.tsx @@ -3,6 +3,7 @@ import { useResponsiveLayout } from "@/hooks/use-responsive-layout"; import { useTheme } from "@/styles/ThemeContext"; import type { ThemeMode } from "@/styles/ThemeContext"; import { fontSize, fontWeight, radius, spacing } from "@/styles/theme"; +import { changeAndPersistLanguage } from "@readany/core/i18n"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { @@ -49,7 +50,6 @@ export default function AppearanceSettingsScreen() { setLang(code); setShowLangPicker(false); try { - const { changeAndPersistLanguage } = await import("@readany/core/i18n"); await changeAndPersistLanguage(code); } catch (err) { console.warn("[Settings] Failed to change and persist language:", err); diff --git a/packages/app-expo/src/screens/settings/FeedbackScreen.tsx b/packages/app-expo/src/screens/settings/FeedbackScreen.tsx index e82215844..86fe6c672 100644 --- a/packages/app-expo/src/screens/settings/FeedbackScreen.tsx +++ b/packages/app-expo/src/screens/settings/FeedbackScreen.tsx @@ -1,5 +1,6 @@ import { KeyboardAwareScrollView } from "@/components/ui/KeyboardAwareScrollView"; import type { RootStackParamList } from "@/navigation/RootNavigator"; +import { useWebviewLabel } from "@/stores/webview-info-store"; /** * FeedbackScreen — Submit bug reports / feature requests and track history. * Submissions are sent to a Cloudflare Worker that creates GitHub Issues. @@ -112,9 +113,7 @@ export default function FeedbackScreen() { > {t("feedback.historyTab", "我的反馈")} - {hasUnread && ( - - )} + {hasUnread && } @@ -139,6 +138,7 @@ function SubmitTab({ colors, t, locale }: FeedbackTabProps & { locale: string }) const [description, setDescription] = useState(""); const [includeLogs, setIncludeLogs] = useState(true); const [submitting, setSubmitting] = useState(false); + const webviewLabel = useWebviewLabel(); const remaining = getRemainingSubmissions(); const deviceInfo: DeviceInfo = useMemo( @@ -147,9 +147,10 @@ function SubmitTab({ colors, t, locale }: FeedbackTabProps & { locale: string }) platform: Platform.OS as DeviceInfo["platform"], osVersion: `${Platform.OS} ${Platform.Version}`, appVersion: Constants.expoConfig?.version ?? "unknown", + webview: webviewLabel || undefined, locale, }), - [locale], + [locale, webviewLabel], ); const canSubmit = title.trim().length > 0 && description.trim().length > 0 && remaining > 0; @@ -199,132 +200,128 @@ function SubmitTab({ colors, t, locale }: FeedbackTabProps & { locale: string }) extraKeyboardSpace={spacing.xxl * 2} contentBottomInset={spacing.xxl * 3} > - - - {t("feedback.title", "反馈建议")} - - - {t("feedback.desc", "提交 bug 报告或功能建议,我们会尽快处理")} - - - - - {t("feedback.type", "类型")} + + + {t("feedback.title", "反馈建议")} - - {FEEDBACK_TYPES.map((ft) => ( - setType(ft.key)} - > - - - {t(ft.labelKey, ft.fallback)} - - - ))} - - - {/* Title */} - - {t("feedback.titleLabel", "标题")} * - - - - {/* Description */} - - {t("feedback.descLabel", "详细描述")} * + + {t("feedback.desc", "提交 bug 报告或功能建议,我们会尽快处理")} - + - + {t("feedback.type", "类型")} + + {FEEDBACK_TYPES.map((ft) => ( setIncludeLogs((checked) => !checked)} - activeOpacity={0.75} + key={ft.key} + style={[ + styles.typeBtn, + { borderColor: type === ft.key ? colors.primary : colors.border }, + type === ft.key && { backgroundColor: `${colors.primary}15` }, + ]} + onPress={() => setType(ft.key)} > - + - {includeLogs && } - - - {t("feedback.uploadLogs", "上传应用日志")} + {t(ft.labelKey, ft.fallback)} - - {t("feedback.logsHint", "仅在勾选时附带最近 1 小时诊断日志,帮助定位问题。")} - - + ))} + - - - {t("feedback.deviceInfo", "{{platform}} · v{{version}} · {{locale}}", { - platform: `${deviceInfo.platform} ${deviceInfo.osVersion}`, - version: deviceInfo.appVersion, - locale: deviceInfo.locale, - })} - - + {/* Title */} + + {t("feedback.titleLabel", "标题")} * + + - {/* Submit */} + {/* Description */} + + {t("feedback.descLabel", "详细描述")} * + + + + setIncludeLogs((checked) => !checked)} + activeOpacity={0.75} > - {submitting ? ( - - ) : ( - - {t("feedback.submit", "提交反馈")} - - )} + + {includeLogs && } + + + {t("feedback.uploadLogs", "上传应用日志")} + - - {t("feedback.remaining", "今日还可提交 {{count}} 次", { count: remaining })} + + {t("feedback.logsHint", "仅在勾选时附带最近 1 小时诊断日志,帮助定位问题。")} + + + + + {t("feedback.deviceInfo", "{{platform}} · v{{version}} · {{locale}}", { + platform: `${deviceInfo.platform} ${deviceInfo.osVersion}`, + version: deviceInfo.appVersion, + locale: deviceInfo.locale, + })} + + + + {/* Submit */} + + {submitting ? ( + + ) : ( + + {t("feedback.submit", "提交反馈")} + + )} + + + {t("feedback.remaining", "今日还可提交 {{count}} 次", { count: remaining })} + ); } diff --git a/packages/app-expo/src/stores/webview-info-store.ts b/packages/app-expo/src/stores/webview-info-store.ts new file mode 100644 index 000000000..0319f5485 --- /dev/null +++ b/packages/app-expo/src/stores/webview-info-store.ts @@ -0,0 +1,54 @@ +import { formatWebviewInfo, parseWebviewInfo } from "@readany/core/utils/webview-info"; +import { Platform } from "react-native"; +import { create } from "zustand"; + +interface WebviewInfoState { + /** + * navigator.userAgent of the reader WebView, forwarded once per page load + * by reader.html over the RN bridge (`type: "readany-ua"`), and at startup + * by the hidden UAProbe webview. The mobile app itself has no real UA + * (App.tsx polyfills a "ReactNative" placeholder), so the reader WebView is + * the source of truth for the engine/version shown in Settings → About. + */ + ua: string | null; + /** + * Full build from Client Hints (e.g. 138.0.7204.67) — the plain UA parse + * yields a reduced x.0.0.0 on modern Chromium WebViews. + */ + fullVersion: string | null; + setUa: (ua: string, fullVersion?: string | null) => void; +} + +export const useWebviewInfoStore = create((set) => ({ + ua: null, + fullVersion: null, + setUa: (ua, fullVersion) => + set({ + ua, + // Reader bridge messages carry no fullVersion — keep the probe's value. + ...(fullVersion !== undefined ? { fullVersion } : {}), + }), +})); + +/** Engine-only label when no UA has been reported yet. */ +const FALLBACK_LABELS: Partial> = { + ios: "WebKit", + android: "Android WebView", +}; + +/** + * Engine + build label for About and feedback payloads + * ("Android WebView 138.0.7204.67"). Before the probe/bridge has reported a + * UA, falls back to the engine name only. + */ +export function useWebviewLabel(): string { + const ua = useWebviewInfoStore((s) => s.ua); + const fullVersion = useWebviewInfoStore((s) => s.fullVersion); + const parsed = ua ? parseWebviewInfo(ua) : null; + // Key the fallback on a missing engine (not on a missing UA): an + // unrecognized WebView UA must not make the label silently disappear. + if (!parsed?.engine) { + return FALLBACK_LABELS[Platform.OS] ?? ""; + } + return formatWebviewInfo({ ...parsed, version: fullVersion || parsed.version }); +} diff --git a/packages/app/src/components/settings/AboutSettings.tsx b/packages/app/src/components/settings/AboutSettings.tsx index 8407de6e2..995e964cb 100644 --- a/packages/app/src/components/settings/AboutSettings.tsx +++ b/packages/app/src/components/settings/AboutSettings.tsx @@ -18,12 +18,15 @@ import { resetStatus, subscribeToUpdates, } from "@/lib/updater"; +import { getWebviewLabel } from "@/lib/webview-info"; +import { buildVersionInfo } from "@readany/core/utils/webview-info"; import { getVersion } from "@tauri-apps/api/app"; import { AlertCircle, BookOpen, Check, Code2, + Copy, Download, ExternalLink, Github, @@ -34,8 +37,10 @@ import { /** * AboutSettings — 关于页面 */ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +// Timer cleanup for the copy feedback flag (unmount-safe). import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; const TECH_STACK = [ { name: "Tauri", descKey: "settings.techStackTauri", icon: Shield }, @@ -56,11 +61,31 @@ export function AboutSettings() { const [isChecking, setIsChecking] = useState(false); const [isRelaunching, setIsRelaunching] = useState(false); const [appVersion, setAppVersion] = useState(""); + const [webviewLabel, setWebviewLabel] = useState(""); + const [copied, setCopied] = useState(false); + const copiedTimer = useRef(undefined); useEffect(() => { getVersion().then(setAppVersion).catch(console.error); }, []); + // Unmount cleanup for the copy feedback timer. + useEffect(() => () => clearTimeout(copiedTimer.current), []); + + useEffect(() => { + // Async: the full WebView2/Chrome build needs a Client Hints round-trip + // (the UA string itself is reduced to x.0.0.0). + let mounted = true; + getWebviewLabel() + .then((label) => { + if (mounted && label) setWebviewLabel(label); + }) + .catch(() => {}); + return () => { + mounted = false; + }; + }, []); + useEffect(() => { return subscribeToUpdates((s, u, p, e) => { setStatus(s); @@ -86,6 +111,22 @@ export function AboutSettings() { checkForUpdate(); }; + // Both version lines at once — the pair is what a bug report needs (see the + // justify engine-fallback work: features vary per WebView build). + const handleCopyVersion = async () => { + const versionInfo = buildVersionInfo(appVersion, webviewLabel); + try { + await navigator.clipboard.writeText(versionInfo); + setCopied(true); + toast.success(t("common.copied")); + if (copiedTimer.current) clearTimeout(copiedTimer.current); + copiedTimer.current = window.setTimeout(() => setCopied(false), 1500); + } catch (error) { + console.error("[AboutSettings] Copy version info failed:", error); + toast.error(t("common.failed")); + } + }; + const handleDownload = () => { setDialogType("none"); downloadAndInstall(); @@ -129,7 +170,8 @@ export function AboutSettings() {

{t("settings.aboutDesc")}

- {/* Version Card */} + {/* Version Card — the copy button copies the app version and the web + engine together for bug reports. */}
{t("settings.version")} @@ -138,6 +180,16 @@ export function AboutSettings() { {appVersion || "..."} +
+
+ {t("settings.webviewVersion")} + {webviewLabel || "..."} +
{/* Download Progress */} diff --git a/packages/app/src/components/settings/FeedbackSettings.tsx b/packages/app/src/components/settings/FeedbackSettings.tsx index 575858689..824895219 100644 --- a/packages/app/src/components/settings/FeedbackSettings.tsx +++ b/packages/app/src/components/settings/FeedbackSettings.tsx @@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; +import { getWebviewLabel } from "@/lib/webview-info"; import { collectDeviceInfo, collectLogs, @@ -21,6 +22,8 @@ import type { FeedbackType, } from "@readany/core/feedback"; import { cn } from "@readany/core/utils"; +import { getVersion } from "@tauri-apps/api/app"; +import i18n from "i18next"; import { AlertCircle, ArrowLeft, @@ -36,8 +39,6 @@ import { import type { LucideIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import i18n from "i18next"; -import { getVersion } from "@tauri-apps/api/app"; const FEEDBACK_TYPES: { key: FeedbackType; @@ -79,8 +80,14 @@ export function FeedbackSettings() { const remaining = getRemainingSubmissions(); const [appVersion, setAppVersion] = useState("..."); + const [webview, setWebview] = useState(""); useEffect(() => { - getVersion().then(setAppVersion).catch(() => setAppVersion("unknown")); + getVersion() + .then(setAppVersion) + .catch(() => setAppVersion("unknown")); + getWebviewLabel() + .then((label) => setWebview(label)) + .catch(() => setWebview("")); }, []); const deviceInfo: DeviceInfo = useMemo(() => { @@ -94,9 +101,10 @@ export function FeedbackSettings() { platform, osVersion: navigator.userAgent, appVersion, + webview: webview || undefined, locale: i18n.language || navigator.language, }); - }, [appVersion]); + }, [appVersion, webview, i18n.language]); const loadRecords = useCallback(async (refreshStatus = false) => { const history = await getFeedbackHistory(); @@ -300,11 +308,7 @@ export function FeedbackSettings() { {t("feedback.remaining", "今日还可提交 {{count}} 次", { count: remaining })} -