Ceno, browse the web without internet access (ceno.app)

by mohsen1 40 comments 134 points
Read article View on HN

40 comments

[−] DoctorOW 64d ago

>

What's the difference between using the Tor and Ceno browsers?

> Unlike Tor Browser, Ceno Browser is not a tool for anonymity, which is Tor's primary purpose. In the Tor network, network traffic is encrypted and routed through a network of relays run by volunteers, and appears to originate from the IP address of an exit node. Tor is an excellent option for privacy from Internet surveillance and website operators. If it works in your network environment, we recommend it, provided that you've also read their support documentation.

> Ceno's primary distinction from a VPN is that it does attempt to route all of your website requests through the decentralized network. When a website is available without restriction, Ceno will simply connect to it like a normal web browser. Also, Ceno users cache and share content with each other. This reduces the strain on censorship circumvention nodes and improves deliverability.

source: https://ceno.app/en/faq.html

[−] delfinom 64d ago

>Ceno users cache

Good way to get in trouble for cp

[−] rendx 64d ago
USA: DMCA 17 U.S. Code § 512 (b) System Caching https://www.law.cornell.edu/uscode/text/17/512

EU: Digital Services Act Article 5: Caching https://www.eu-digital-services-act.com/Digital_Services_Act...

[−] equalitie 51d ago
You dont need the Web to browse web pages. You need 1) a BitTorrent bootstrap server in your network 2) web pages cache inside your network 3) Ceno browser

This is why Ceno scrapes, caches then announces hundreds of media websites on BT https://schedule.ceno.app/

If the cache is inside your network (e.g. Iran) then that website is available through Ceno browser

[−] keyle 64d ago

     In Public mode, Ceno will look into the BitTorrent network to see if another Ceno user has recently shared the requested page. If the service can identify the requested page, it will retrieve that page from another user's device. If the content is not available, Ceno will contact several Injectors to request that website and have it delivered to you.

     In Personal mode, you will only contact the Injectors to have that website fetched and delivered to you. The search will not connect to the BitTorrent network and will not attempt to locate the content on other users' devices.

    To ensure that your Ceno client can always contact an Injector, we have also created Bridges. If the Injectors are blocked on your network, the Ceno app will look for available Bridges, who will forward your request to the Injectors. The Ceno network currently features around 6,000 Bridges. Their number is always growing.
So on the one side it's some kind of shared cache of website resources, and on the other some kind of distributed tor-like edge network?

Quite clever! I wonder if it works well though, and if there is a risk of content injection by adversaries.

[−] jadbox 64d ago
I wonder why BitTorrent was picked rather than IPFS?
[−] keyle 63d ago
Because one works well, the other doesn't.
[−] karel-3d 64d ago
How is Ceno making sure someone is not poisoning the cache?

edit: I try to read the paper and it's just referencing some RFC, which is not making me smart at all.

Again, how am I sure that when I am reading something from the cache, it's really serving what the site was serving somewhere else, and the person saving it there didn't modify it? Is it signed by the original page SSL cert?

edit2: ahh the "injector server", which is run by Ceno, retrieves the page and signs it. So you are moving the trust to Ceno and the central Ceno server actually does the browsing...? So the injectors can just see all the traffic? But that's inevitable I guess, someone needs to see the traffic

[−] voidUpdate 64d ago
Am I reading this right? You do still need internet access, to actually retrieve the page from someone else. Also I'm not sure how this will reduce data costs. Do providers charge different amounts for getting data from different servers? The same amount of data is still going into your device, it's just coming from somewhere else than usual
[−] pentagrama 64d ago
Yes, I was confused as well. The current Hacker News title is “Ceno, browse the web without internet access”, but on the official site the headline/ reads “Ceno Browser | Share the Web!”.<p>That mismatch is likely what is causing the confusion. The HN title probably should be updated to reflect the current title used on the site.<p>Another possibility is that the original title actually was “browse the web without internet access” and the developers later changed the site headline after the post was submitted to HN.</div> </div> </div> <div class="comment comment-indent-1" data-comment-id="47362177" data-child-count="0"> <div class="comment-header" role="button" tabindex="0" aria-expanded="true" aria-controls="comment-content-47362177" onclick="toggleComment(47362177, event)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleComment(47362177,event)}"> <span class="collapse-indicator">[−]</span> <a href="/user/mlnj" class="comment-author" onclick="event.stopPropagation()">mlnj</a> <span class="comment-time">64d ago</span> <span class="child-count" style="display: none;">[+0]</span> </div> <div class="comment-content" id="comment-content-47362177"> <div class="comment-text">It seems to be a way to circumvent censorship.</div> </div> </div> <div class="comment comment-indent-1" data-comment-id="47376435" data-child-count="0"> <div class="comment-header" role="button" tabindex="0" aria-expanded="true" aria-controls="comment-content-47376435" onclick="toggleComment(47376435, event)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleComment(47376435,event)}"> <span class="collapse-indicator">[−]</span> <a href="/user/casey2" class="comment-author" onclick="event.stopPropagation()">casey2</a> <span class="comment-time">63d ago</span> <span class="child-count" style="display: none;">[+0]</span> </div> <div class="comment-content" id="comment-content-47376435"> <div class="comment-text">They mean you can also share on a local network.<p>The hope/promise is that the handful of people with internet access in a blackedout country can spread content around in a way that is seemless to the end user.<p>I'd prefer mesh networks running spam resistant, private&anonymous protocols.</div> </div> </div> </div> </div> </div> <div class="comment " data-comment-id="47366231" data-child-count="0"> <div class="comment-header" role="button" tabindex="0" aria-expanded="true" aria-controls="comment-content-47366231" onclick="toggleComment(47366231, event)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleComment(47366231,event)}"> <span class="collapse-indicator">[−]</span> <a href="/user/omoikane" class="comment-author" onclick="event.stopPropagation()">omoikane</a> <span class="comment-time">64d ago</span> <span class="child-count" style="display: none;">[+0]</span> </div> <div class="comment-content" id="comment-content-47366231"> <div class="comment-text"><p class="quote-paragraph">> route all of your website requests through the decentralized network</p><p>I thought this sounded like Freenet. Searching for "ceno" and "freenet" together led to this repository, which said "CENO uses the Freenet censorship resistant platform for communications and storage":<p><a href="https://github.com/censorship-no-archive/ceno1" rel="nofollow">https://github.com/censorship-no-archive/ceno1</a><p>Looks like they have since archived everything on github and moved to gitlab.</div> </div> </div> <div class="comment " data-comment-id="47363665" data-child-count="0"> <div class="comment-header" role="button" tabindex="0" aria-expanded="true" aria-controls="comment-content-47363665" onclick="toggleComment(47363665, event)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleComment(47363665,event)}"> <span class="collapse-indicator">[−]</span> <a href="/user/Springtime" class="comment-author" onclick="event.stopPropagation()">Springtime</a> <span class="comment-time">64d ago</span> <span class="child-count" style="display: none;">[+0]</span> </div> <div class="comment-content" id="comment-content-47363665"> <div class="comment-text">I think the relevant use case for this are places like Russia (one is even quoted in the testimonials) where I've seen concern about the country isolating itself from the outside internet, due to the various regional tests actually trialling this.<p>I've seen such users ask about ways to prepare storing outside data in the event it becomes permanent. Some have suggested mesh networks, others downloading Wikipedia and torrenting things.<p>So it seems that this is useful where internet is still available but is restricted at say the ISP level. It seems to be a browser that when a page is unavailable it checks for Ceno torrents of the page from other users and serves that instead.</div> </div> </div> <div class="comment " data-comment-id="47364871" data-child-count="0"> <div class="comment-header" role="button" tabindex="0" aria-expanded="true" aria-controls="comment-content-47364871" onclick="toggleComment(47364871, event)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleComment(47364871,event)}"> <span class="collapse-indicator">[−]</span> <a href="/user/Sophira" class="comment-author" onclick="event.stopPropagation()">Sophira</a> <span class="comment-time">64d ago</span> <span class="child-count" style="display: none;">[+0]</span> </div> <div class="comment-content" id="comment-content-47364871"> <div class="comment-text">This looks like a great project, but there's one big problem that I can see...<p>If it's based on BitTorrent, then surely that means that anybody who has the content that you want to see (or who <i>advertises</i> that they have the content you want to see...) will be able to see your IP address? Like how the movie industry can catch people who are sharing movies on BitTorrent?<p>Obviously, an attacker wwould probably need to use a separate BitTorrent client to do this, because I'm sure the IP addresses won't be displayed in the app itself, but that seems like it could potentially be possible.<p>I really hope I'm wrong on this, because other than that seemingly-big privacy flaw, this seems pretty great otherwise.</div> </div> </div> <div class="comment " data-comment-id="47363992" data-child-count="0"> <div class="comment-header" role="button" tabindex="0" aria-expanded="true" aria-controls="comment-content-47363992" onclick="toggleComment(47363992, event)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleComment(47363992,event)}"> <span class="collapse-indicator">[−]</span> <a href="/user/mrbluecoat" class="comment-author" onclick="event.stopPropagation()">mrbluecoat</a> <span class="comment-time">64d ago</span> <span class="child-count" style="display: none;">[+0]</span> </div> <div class="comment-content" id="comment-content-47363992"> <div class="comment-text">Bad title.<p>Better executive summary: "A browser that lets you bypass censorship via BitTorrent-based residential proxies and Ceno-owned proxies"</div> </div> </div> <div class="comment " data-comment-id="47363502" data-child-count="0"> <div class="comment-header" role="button" tabindex="0" aria-expanded="true" aria-controls="comment-content-47363502" onclick="toggleComment(47363502, event)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleComment(47363502,event)}"> <span class="collapse-indicator">[−]</span> <a href="/user/olalonde" class="comment-author" onclick="event.stopPropagation()">olalonde</a> <span class="comment-time">64d ago</span> <span class="child-count" style="display: none;">[+0]</span> </div> <div class="comment-content" id="comment-content-47363502"> <div class="comment-text">Isn't this essentially a "free" proxy where the install base also act as exit nodes? It's a common pattern among "free" VPN services and kinda risky.</div> </div> </div> <div class="comment " data-comment-id="47363578" data-child-count="0"> <div class="comment-header" role="button" tabindex="0" aria-expanded="true" aria-controls="comment-content-47363578" onclick="toggleComment(47363578, event)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleComment(47363578,event)}"> <span class="collapse-indicator">[−]</span> <a href="/user/mohsen1" class="comment-author" onclick="event.stopPropagation()">mohsen1</a> <span class="comment-time">64d ago</span> <span class="child-count" style="display: none;">[+0]</span> </div> <div class="comment-content" id="comment-content-47363578"> <div class="comment-text">This is a life saver in Iran right now. Maybe only 0.01% have access to internet using Starlink</div> </div> </div> <div class="comment " data-comment-id="47363414" data-child-count="0"> <div class="comment-header" role="button" tabindex="0" aria-expanded="true" aria-controls="comment-content-47363414" onclick="toggleComment(47363414, event)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleComment(47363414,event)}"> <span class="collapse-indicator">[−]</span> <a href="/user/lxgr" class="comment-author" onclick="event.stopPropagation()">lxgr</a> <span class="comment-time">64d ago</span> <span class="child-count" style="display: none;">[+0]</span> </div> <div class="comment-content" id="comment-content-47363414"> <div class="comment-text">“Browse the web with partially censored Internet access” would be more honest, it seems.</div> </div> </div> <div class="comment " data-comment-id="47369054" data-child-count="0"> <div class="comment-header" role="button" tabindex="0" aria-expanded="true" aria-controls="comment-content-47369054" onclick="toggleComment(47369054, event)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleComment(47369054,event)}"> <span class="collapse-indicator">[−]</span> <a href="/user/ninalanyon" class="comment-author" onclick="event.stopPropagation()">ninalanyon</a> <span class="comment-time">64d ago</span> <span class="child-count" style="display: none;">[+0]</span> </div> <div class="comment-content" id="comment-content-47369054"> <div class="comment-text">Wouldn't this be better as a proxy? I don't want to install yet another browser. I already av Vivaldi in addition to Firefox so that I can access web sites that haven't been tested with Firefox.</div> </div> </div> <div class="comment " data-comment-id="47365332" data-child-count="0"> <div class="comment-header" role="button" tabindex="0" aria-expanded="true" aria-controls="comment-content-47365332" onclick="toggleComment(47365332, event)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleComment(47365332,event)}"> <span class="collapse-indicator">[−]</span> <a href="/user/sunshine-o" class="comment-author" onclick="event.stopPropagation()">sunshine-o</a> <span class="comment-time">64d ago</span> <span class="child-count" style="display: none;">[+0]</span> </div> <div class="comment-content" id="comment-content-47365332"> <div class="comment-text">Could there be an opportunity to use and contribute to the Internet Archive through this type of protocol or app?<p>If I understand correctly the Internet Archive provides torrents for everything they archive.</div> </div> </div> <div class="comment " data-comment-id="47384218" data-child-count="0"> <div class="comment-header" role="button" tabindex="0" aria-expanded="true" aria-controls="comment-content-47384218" onclick="toggleComment(47384218, event)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleComment(47384218,event)}"> <span class="collapse-indicator">[−]</span> <a href="/user/terrycody" class="comment-author" onclick="event.stopPropagation()">terrycody</a> <span class="comment-time">63d ago</span> <span class="child-count" style="display: none;">[+0]</span> </div> <div class="comment-content" id="comment-content-47384218"> <div class="comment-text">I think this is similar as Hola, you basically use other's internet, isn't it?</div> </div> </div> <div class="comment " data-comment-id="47362873" data-child-count="0"> <div class="comment-header" role="button" tabindex="0" aria-expanded="true" aria-controls="comment-content-47362873" onclick="toggleComment(47362873, event)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleComment(47362873,event)}"> <span class="collapse-indicator">[−]</span> <a href="/user/gr__or" class="comment-author" onclick="event.stopPropagation()">gr__or</a> <span class="comment-time">64d ago</span> <span class="child-count" style="display: none;">[+0]</span> </div> <div class="comment-content" id="comment-content-47362873"> <div class="comment-text">thinking out loud: it'd be great if web servers could sign their responses+timestamp, so you could guarantee getting the right content even through such intermediaries</div> </div> </div> <div class="comment " data-comment-id="47389303" data-child-count="0"> <div class="comment-header" role="button" tabindex="0" aria-expanded="true" aria-controls="comment-content-47389303" onclick="toggleComment(47389303, event)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleComment(47389303,event)}"> <span class="collapse-indicator">[−]</span> <a href="/user/hulitu" class="comment-author" onclick="event.stopPropagation()">hulitu</a> <span class="comment-time">62d ago</span> <span class="child-count" style="display: none;">[+0]</span> </div> <div class="comment-content" id="comment-content-47389303"> <div class="comment-text"><p class="quote-paragraph">> Ceno, browse the web without internet access</p><p>Another Crypto AG.</div> </div> </div> </section> </main> <footer> <p>Data from <a href="https://news.ycombinator.com" target="_blank" rel="noopener">Hacker News</a> • <a href="/">home</a> • <a href="https://github.com/HackerNews/API" target="_blank" rel="noopener">API</a> • <a href="#" id="settings-link">settings</a></p> </footer> <!-- Settings Modal --> <div id="settings-modal" role="dialog" aria-modal="true" aria-labelledby="settings-title" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 1000; justify-content: center; align-items: center;"> <div style="background: var(--bg-color); border: 2px solid var(--hn-orange); border-radius: 8px; width: 90%; max-width: 400px; padding: 1.5rem;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; border-bottom: 1px solid var(--border-color); padding-bottom: 1rem;"> <h2 id="settings-title" style="margin: 0; font-size: 1.25rem; color: var(--text-color);">Settings</h2> <button id="close-settings" aria-label="Close settings" style="background: none; border: none; font-size: 1.5rem; color: var(--text-color); cursor: pointer;">×</button> </div> <div style="margin-bottom: 1.5rem;"> <label style="display: block; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-color); font-size: 0.9rem;">Theme</label> <select id="theme-select" style="width: 100%; padding: 0.75rem; font-size: 1rem; background: var(--card-bg); color: var(--text-color); border: 1px solid var(--border-color); border-radius: 4px; cursor: pointer;"> <option value="system">System Default</option> <option value="light">Light</option> <option value="dark">Dark</option> <option value="sepia">Sepia</option> </select> </div> <div style="margin-bottom: 1.5rem;"> <label style="display: block; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-color); font-size: 0.9rem;">Font</label> <select id="font-select" style="width: 100%; padding: 0.75rem; font-size: 1rem; background: var(--card-bg); color: var(--text-color); border: 1px solid var(--border-color); border-radius: 4px; cursor: pointer;"> <option value="system">System Default</option> <option value="serif">Serif (Georgia)</option> <option value="sans-serif">Sans Serif</option> <option value="monospace">Monospace</option> <option value="oxanium">Oxanium</option> <option value="ibm-plex-serif">IBM Plex Serif</option> <option value="ibm-plex-sans">IBM Plex Sans</option> <option value="inter">Inter</option> <option value="jetbrains-mono">JetBrains Mono</option> <option value="source-code-pro">Source Code Pro</option> <option value="merriweather">Merriweather</option> </select> </div> <div> <label style="display: block; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-color); font-size: 0.9rem;">Font Size</label> <select id="font-size-select" style="width: 100%; padding: 0.75rem; font-size: 1rem; background: var(--card-bg); color: var(--text-color); border: 1px solid var(--border-color); border-radius: 4px; cursor: pointer;"> <option value="">Automatic (System)</option> <option value="small">Small</option> <option value="medium">Medium</option> <option value="large">Large</option> <option value="xlarge">Extra Large</option> </select> </div> </div> </div> <script> // Comment collapse/expand functionality (function() { const STORAGE_KEY = 'collapsedComments:' + window.location.pathname // Load collapsed comments from localStorage let collapsedIds = new Set() try { const saved = localStorage.getItem(STORAGE_KEY) if (saved) { collapsedIds = new Set(JSON.parse(saved)) } } catch (e) { // localStorage not available (private mode, etc.) } // Apply initial collapsed state collapsedIds.forEach(id => collapseComment(id, false)) // Global toggle function window.toggleComment = function(id, event) { if (event) { event.stopPropagation() if (event.target.closest('a, button, input, select, textarea, [role="button"]')) { return } } if (collapsedIds.has(id)) { expandComment(id) } else { collapseComment(id, true) } saveState() } function collapseComment(id, save) { const comment = document.querySelector('[data-comment-id="' + id + '"]') if (!comment) return const content = comment.querySelector('.comment-content') const indicator = comment.querySelector('.collapse-indicator') const childCount = comment.querySelector('.child-count') const childCountValue = comment.getAttribute('data-child-count') if (content) content.style.display = 'none' if (indicator) indicator.style.display = 'none' if (childCount) { childCount.textContent = '[+' + childCountValue + ']' childCount.style.display = 'inline' } comment.classList.add('collapsed') collapsedIds.add(id) if (save) saveState() } function expandComment(id) { const comment = document.querySelector('[data-comment-id="' + id + '"]') if (!comment) return const content = comment.querySelector('.comment-content') const indicator = comment.querySelector('.collapse-indicator') const childCount = comment.querySelector('.child-count') if (content) content.style.display = '' if (indicator) indicator.style.display = 'inline' if (childCount) childCount.style.display = 'none' comment.classList.remove('collapsed') collapsedIds.delete(id) } function saveState() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(Array.from(collapsedIds))) } catch (e) { // localStorage not available } } })() // Settings functionality ;(function() { function setCookie(name, value, days) { const expires = new Date() expires.setTime(expires.getTime() + (days * 24 * 60 * 60 * 1000)) document.cookie = name + '=' + encodeURIComponent(value) + ';expires=' + expires.toUTCString() + ';path=/;SameSite=Lax' } function getCookie(name) { const nameEQ = name + '=' const ca = document.cookie.split(';') for (let i = 0; i < ca.length; i++) { let c = ca[i] while (c.charAt(0) === ' ') c = c.substring(1) if (c.indexOf(nameEQ) === 0) return decodeURIComponent(c.substring(nameEQ.length)) } return null } function applyTheme(theme) { const root = document.documentElement if (theme === 'system') { root.removeAttribute('data-theme') } else { root.setAttribute('data-theme', theme) } } function applyFont(font) { const root = document.documentElement if (font === 'system') { root.removeAttribute('data-font') } else { root.setAttribute('data-font', font) } } function applyFontSize(fontSize) { const root = document.documentElement if (!fontSize || fontSize === 'auto' || fontSize === '') { root.removeAttribute('data-font-size') } else { root.setAttribute('data-font-size', fontSize) } } const savedTheme = getCookie('theme') || 'system' const savedFont = getCookie('font') || 'system' const savedFontSize = getCookie('fontSize') || '' applyTheme(savedTheme) applyFont(savedFont) applyFontSize(savedFontSize) const modal = document.getElementById('settings-modal') const closeBtn = document.getElementById('close-settings') const settingsLink = document.getElementById('settings-link') let lastFocusedElement = null function getFocusableElements() { return modal.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])') } function trapFocus(e) { if (e.key !== 'Tab') return const focusable = Array.from(getFocusableElements()) const first = focusable[0] const last = focusable[focusable.length - 1] if (e.shiftKey && document.activeElement === first) { e.preventDefault() last.focus() } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault() first.focus() } } if (settingsLink) { settingsLink.addEventListener('click', function(e) { e.preventDefault() lastFocusedElement = document.activeElement modal.style.display = 'flex' if (closeBtn) closeBtn.focus() modal.addEventListener('keydown', trapFocus) }) } if (closeBtn) { closeBtn.addEventListener('click', function() { modal.style.display = 'none' modal.removeEventListener('keydown', trapFocus) if (lastFocusedElement) lastFocusedElement.focus() }) } if (modal) { modal.addEventListener('click', function(e) { if (e.target === this) { this.style.display = 'none' this.removeEventListener('keydown', trapFocus) if (lastFocusedElement) lastFocusedElement.focus() } }) } const themeSelect = document.getElementById('theme-select') if (themeSelect) { themeSelect.value = savedTheme themeSelect.addEventListener('change', function() { setCookie('theme', this.value, 365) applyTheme(this.value) }) } const fontSelect = document.getElementById('font-select') if (fontSelect) { fontSelect.value = savedFont fontSelect.addEventListener('change', function() { setCookie('font', this.value, 365) applyFont(this.value) }) } const fontSizeSelect = document.getElementById('font-size-select') if (fontSizeSelect) { fontSizeSelect.value = savedFontSize fontSizeSelect.addEventListener('change', function() { setCookie('fontSize', this.value, 365) applyFontSize(this.value) }) } document.addEventListener('keydown', function(e) { if (e.key === 'Escape') { const modal = document.getElementById('settings-modal') if (modal && modal.style.display === 'flex') { modal.style.display = 'none' modal.removeEventListener('keydown', trapFocus) if (lastFocusedElement) lastFocusedElement.focus() } } }) })(); // Pull to refresh (function() { const indicator = document.getElementById('pull-indicator') const main = document.getElementById('main-content') if (!indicator || !main) return const spinner = indicator.querySelector('.pull-spinner') let startY = 0 let startX = 0 let currentY = 0 let currentX = 0 let isPulling = false let isAtTop = false let hasTriggeredRefresh = false const REFRESH_KEY = 'pull_refresh_pending' const threshold = 80 const edgeBuffer = 24 // Ignore touches near screen edges (swipe-back gestures) // Clear spinner when page is restored from bfcache (swipe-back) window.addEventListener('pageshow', function(e) { if (e.persisted && sessionStorage.getItem(REFRESH_KEY)) { sessionStorage.removeItem(REFRESH_KEY) indicator.classList.remove('loading') if (spinner) spinner.style.display = 'none' indicator.style.transform = 'translateY(-100px)' indicator.style.opacity = '0' main.style.transform = '' } }) if (sessionStorage.getItem(REFRESH_KEY)) { indicator.classList.add('loading') if (spinner) spinner.style.display = 'block' indicator.style.transform = 'translateY(0px)' indicator.style.opacity = '1' main.style.transform = 'translateY(80px)' const hideSpinner = function() { sessionStorage.removeItem(REFRESH_KEY) indicator.classList.remove('loading') if (spinner) spinner.style.display = 'none' indicator.style.transform = 'translateY(-100px)' indicator.style.opacity = '0' main.style.transform = '' } if (document.readyState === 'complete') { hideSpinner() } else { window.addEventListener('load', hideSpinner) setTimeout(hideSpinner, 3000) } } function updateIndicator(translateY, opacity) { indicator.style.transform = 'translateY(' + translateY + 'px)' indicator.style.opacity = opacity } function triggerRefresh() { if (hasTriggeredRefresh) return hasTriggeredRefresh = true sessionStorage.setItem(REFRESH_KEY, '1') indicator.classList.add('loading') if (spinner) spinner.style.display = 'block' indicator.style.transform = 'translateY(0px)' indicator.style.opacity = '1' main.style.transform = 'translateY(80px)' // Navigate to refresh URL (appends ?refresh=1) const url = new URL(window.location.href) url.searchParams.set('refresh', '1') window.location.href = url.toString() } document.addEventListener('touchstart', function(e) { isAtTop = window.scrollY <= 0 if (!isAtTop) return // Ignore touches near screen edges to allow swipe-back/forward gestures const touchX = e.touches[0].clientX const screenWidth = window.innerWidth if (touchX < edgeBuffer || touchX > screenWidth - edgeBuffer) return hasTriggeredRefresh = false startY = e.touches[0].clientY startX = touchX isPulling = true }, { passive: true }) document.addEventListener('touchmove', function(e) { if (!isPulling || !isAtTop) return currentY = e.touches[0].clientY currentX = e.touches[0].clientX const diffY = currentY - startY const diffX = currentX - startX // Only treat as pull-to-refresh if movement is mostly vertical if (diffY > 0 && diffY < threshold * 2 && Math.abs(diffY) > Math.abs(diffX)) { e.preventDefault() main.style.transform = 'translateY(' + (diffY * 0.4) + 'px)' updateIndicator(diffY * 0.3, Math.min(diffY / threshold, 1)) } }, { passive: false }) document.addEventListener('touchend', function() { if (!isPulling) return isPulling = false const diffY = currentY - startY const diffX = currentX - startX if (diffY > threshold && isAtTop && !hasTriggeredRefresh && Math.abs(diffY) > Math.abs(diffX)) { triggerRefresh() } else { main.style.transform = '' updateIndicator(-100, 0) } currentY = 0 currentX = 0 startY = 0 startX = 0 }) document.addEventListener('mousedown', function(e) { isAtTop = window.scrollY <= 0 if (!isAtTop) return const mouseX = e.clientX const screenWidth = window.innerWidth if (mouseX < edgeBuffer || mouseX > screenWidth - edgeBuffer) return hasTriggeredRefresh = false startY = e.clientY startX = mouseX isPulling = true }) document.addEventListener('mousemove', function(e) { if (!isPulling || !isAtTop) return currentY = e.clientY currentX = e.clientX const diffY = currentY - startY const diffX = currentX - startX if (diffY > 0 && diffY < threshold * 2 && Math.abs(diffY) > Math.abs(diffX)) { main.style.willChange = 'transform' indicator.style.willChange = 'transform, opacity' const pullProgress = Math.min(diffY / threshold, 1) const translateAmount = Math.min(diffY * 0.4, threshold * 0.4) main.style.transform = 'translateY(' + translateAmount + 'px)' updateIndicator(diffY * 0.3, pullProgress) } }) document.addEventListener('mouseup', function() { if (!isPulling) return isPulling = false main.style.willChange = '' indicator.style.willChange = '' const diffY = currentY - startY const diffX = currentX - startX if (diffY > threshold && isAtTop && !hasTriggeredRefresh && Math.abs(diffY) > Math.abs(diffX)) { triggerRefresh() } else { main.style.transition = 'transform 0.2s ease-out' main.style.transform = '' updateIndicator(-100, 0) setTimeout(function() { main.style.transition = '' }, 200) } currentY = 0 currentX = 0 startY = 0 startX = 0 }) })(); </script> <script> if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js'); } </script> </body> </html>