@@ -25,8 +25,12 @@ function isIOSInAppBrowser(userAgent: string): boolean {
2525}
2626
2727/**
28- * Generate an interstitial HTML page for iOS in-app browsers.
29- * Tries to open the app via custom scheme URL, falls back to the App Store.
28+ * Generate an interstitial HTML page that tries to open the app via custom scheme,
29+ * then falls back to the App Store / Play Store.
30+ *
31+ * The JavaScript reads the URL fragment (window.location.hash) and appends it
32+ * to the scheme URL. This preserves the E2E encryption key, which lives only
33+ * in the fragment and is never sent to the server.
3034 */
3135function generateInterstitialHTML ( schemeUrl : string , fallbackUrl : string , title ?: string ) : string {
3236 const safeSchemeUrl = schemeUrl . replace ( / " / g, '"' ) . replace ( / < / g, '<' ) ;
@@ -54,11 +58,15 @@ function generateInterstitialHTML(schemeUrl: string, fallbackUrl: string, title?
5458 <div class="spinner"></div>
5559 <h1>Opening ${ safeTitle } ...</h1>
5660 <p>If the app doesn't open automatically:</p>
57- <a class="btn btn-primary" href="${ safeSchemeUrl } ">Open App</a>
58- <a class="btn btn-secondary" href="${ safeFallbackUrl } ">Download App</a>
61+ <a class="btn btn-primary" id="open-btn" href="${ safeSchemeUrl } ">Open App</a>
62+ <a class="btn btn-secondary" id="store-btn" href="${ safeFallbackUrl } ">Download App</a>
5963</div>
6064<script>
61- window.location = "${ safeSchemeUrl } ";
65+ // Preserve URL fragment (E2E encryption key) through the scheme redirect
66+ var hash = window.location.hash || '';
67+ var schemeUrl = "${ safeSchemeUrl } " + hash;
68+ document.getElementById('open-btn').href = schemeUrl;
69+ window.location = schemeUrl;
6270 setTimeout(function() { window.location.replace("${ safeFallbackUrl } "); }, 1500);
6371</script>
6472</body></html>` ;
@@ -460,16 +468,21 @@ export async function redirectRoutes(fastify: FastifyInstance) {
460468 }
461469 }
462470
463- // For iOS in-app browsers (Gmail, Facebook, etc.), serve an interstitial page
464- // that tries to open the app via custom scheme, then falls back to the App Store.
465- // Universal Links don't fire from WKWebView, so a direct 302 would skip the app entirely.
466- if ( device === 'ios' && isIOSInAppBrowser ( userAgent ) ) {
471+ // Serve an interstitial page for mobile requests when a custom scheme is available.
472+ // The interstitial tries to open the app via URI scheme, then falls back to the store.
473+ // This works for both in-app browsers (where Universal Links don't fire) and regular
474+ // browsers (where a 302 to a custom scheme fails silently if the app isn't installed).
475+ // The interstitial JavaScript preserves the URL fragment (E2E encryption key).
476+ if ( ( device === 'ios' || device === 'android' ) && link . app_scheme ) {
477+ const deepPath = link . deep_link_path ? link . deep_link_path . replace ( / ^ \/ / , '' ) : '' ;
467478 const schemeUrl = link . custom_scheme_url
468- || ( link . app_scheme && link . deep_link_path
469- ? `${ link . app_scheme } ://${ link . deep_link_path . replace ( / ^ \/ / , '' ) } `
470- : null ) ;
479+ || `${ link . app_scheme } ://${ deepPath } ` ;
471480
472- if ( schemeUrl ) {
481+ const storeFallback = device === 'ios'
482+ ? ( iosUrl || webFallbackUrl || link . original_url )
483+ : ( androidUrl || webFallbackUrl || link . original_url ) ;
484+
485+ if ( storeFallback ) {
473486 let fullSchemeUrl = schemeUrl ;
474487 if ( link . deep_link_parameters && Object . keys ( link . deep_link_parameters ) . length > 0 ) {
475488 const params = new URLSearchParams (
@@ -478,7 +491,6 @@ export async function redirectRoutes(fastify: FastifyInstance) {
478491 fullSchemeUrl += ( fullSchemeUrl . includes ( '?' ) ? '&' : '?' ) + params . toString ( ) ;
479492 }
480493
481- const storeFallback = iosUrl || webFallbackUrl || link . original_url ;
482494 return reply
483495 . header ( 'Content-Type' , 'text/html; charset=utf-8' )
484496 . send ( generateInterstitialHTML ( fullSchemeUrl , storeFallback , link . title || link . og_title ) ) ;
0 commit comments