From bb768911efebe7c2205eb39bdd1267b911b622ea Mon Sep 17 00:00:00 2001 From: litruv Date: Sun, 9 Aug 2026 20:49:05 +1000 Subject: [PATCH] Wire Android SSO Custom Tabs, carousel-safe swipes, and core saveMedia. Open SSO in Capacitor Browser with paarrot://android return; stop swipe-to-reply stealing carousel scrolls; bump cinny for shared save helpers and register MediaStore from the overlay. --- android/app/capacitor.build.gradle | 3 + android/app/src/main/AndroidManifest.xml | 8 + android/capacitor.settings.gradle | 9 + cinny | 2 +- overlay/package-additions.json | 2 + .../app/components/Pdf-viewer/PdfViewer.tsx | 251 --------------- .../src/app/components/message/FileHeader.tsx | 84 ----- .../message/content/FileContent.tsx | 303 ------------------ .../mobile/MobileSwipeGestureHost.tsx | 40 ++- .../components/video-viewer/VideoViewer.tsx | 74 ----- overlay/src/app/hooks/usePathWithOrigin.ts | 49 +++ overlay/src/app/utils/androidDeepLink.ts | 127 ++++++++ ...eMedia.ts => registerAndroidMediaSaver.ts} | 58 +--- overlay/src/app/utils/tauri.ts | 21 +- overlay/src/index.tsx | 8 + package-lock.json | 24 +- package.json | 2 + 17 files changed, 289 insertions(+), 776 deletions(-) delete mode 100644 overlay/src/app/components/Pdf-viewer/PdfViewer.tsx delete mode 100644 overlay/src/app/components/message/FileHeader.tsx delete mode 100644 overlay/src/app/components/message/content/FileContent.tsx delete mode 100644 overlay/src/app/components/video-viewer/VideoViewer.tsx create mode 100644 overlay/src/app/hooks/usePathWithOrigin.ts create mode 100644 overlay/src/app/utils/androidDeepLink.ts rename overlay/src/app/utils/{saveMedia.ts => registerAndroidMediaSaver.ts} (52%) diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index a064f6f..0c46991 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -9,7 +9,10 @@ android { apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" dependencies { + implementation project(':capacitor-app') + implementation project(':capacitor-browser') implementation project(':capacitor-local-notifications') + implementation project(':capacitor-status-bar') } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a4bdfc2..6e38c67 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -40,6 +40,14 @@ + + + + + + + + void; -}; - -export const PdfViewer = as<'div', PdfViewerProps>( - ({ className, name, src, requestClose, ...props }, ref) => { - const containerRef = useRef(null); - const scrollRef = useRef(null); - const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2); - - const [pdfJSState, loadPdfJS] = usePdfJSLoader(); - const [docState, loadPdfDocument] = usePdfDocumentLoader( - pdfJSState.status === AsyncStatus.Success ? pdfJSState.data : undefined, - src - ); - const isLoading = - pdfJSState.status === AsyncStatus.Loading || docState.status === AsyncStatus.Loading; - const isError = - pdfJSState.status === AsyncStatus.Error || docState.status === AsyncStatus.Error; - const [pageNo, setPageNo] = useState(1); - const [jumpAnchor, setJumpAnchor] = useState(); - - useEffect(() => { - loadPdfJS(); - }, [loadPdfJS]); - useEffect(() => { - if (pdfJSState.status === AsyncStatus.Success) { - loadPdfDocument(); - } - }, [pdfJSState, loadPdfDocument]); - - useEffect(() => { - if (docState.status === AsyncStatus.Success) { - const doc = docState.data; - if (pageNo < 0 || pageNo > doc.numPages) return; - createPage(doc, pageNo, { scale: zoom }).then((canvas) => { - const container = containerRef.current; - if (!container) return; - container.textContent = ''; - container.append(canvas); - scrollRef.current?.scrollTo({ - top: 0, - }); - }); - } - }, [docState, pageNo, zoom]); - - const handleDownload = async () => { - try { - const res = await fetch(src); - if (!res.ok) throw new Error('Failed to fetch PDF'); - await saveMediaBlob(await res.blob(), name); - } catch (error) { - console.warn('[PdfViewer] Failed to download:', error); - } - }; - - const handleJumpSubmit: FormEventHandler = (evt) => { - evt.preventDefault(); - if (docState.status !== AsyncStatus.Success) return; - const jumpInput = evt.currentTarget.jumpInput as HTMLInputElement; - if (!jumpInput) return; - const jumpTo = parseInt(jumpInput.value, 10); - setPageNo(Math.max(1, Math.min(docState.data.numPages, jumpTo))); - setJumpAnchor(undefined); - }; - - const handlePrevPage = () => { - setPageNo((n) => Math.max(n - 1, 1)); - }; - - const handleNextPage = () => { - if (docState.status !== AsyncStatus.Success) return; - setPageNo((n) => Math.min(n + 1, docState.data.numPages)); - }; - - const handleOpenJump: MouseEventHandler = (evt) => { - setJumpAnchor(evt.currentTarget.getBoundingClientRect()); - }; - - return ( - -
- - - - - - {name} - - - - - - - setZoom(zoom === 1 ? 2 : 1)}> - {Math.round(zoom * 100)}% - - 1 ? 'Success' : 'SurfaceVariant'} - outlined={zoom > 1} - size="300" - radii="Pill" - onClick={zoomIn} - aria-label="Zoom In" - > - - - } - > - Download - - -
- - {isLoading && } - {isError && ( - <> - Failed to load PDF - - - )} - {docState.status === AsyncStatus.Success && ( - - -
- - - )} - - {docState.status === AsyncStatus.Success && ( -
- } - onClick={handlePrevPage} - aria-disabled={pageNo <= 1} - > - Previous - - - setJumpAnchor(undefined), - clickOutsideDeactivates: true, - escapeDeactivates: stopPropagation, - }} - > - - - - - - - - } - > - - {`${pageNo}/${docState.data.numPages}`} - - - - } - onClick={handleNextPage} - aria-disabled={pageNo >= docState.data.numPages} - > - Next - -
- )} - - ); - } -); diff --git a/overlay/src/app/components/message/FileHeader.tsx b/overlay/src/app/components/message/FileHeader.tsx deleted file mode 100644 index d86d5ff..0000000 --- a/overlay/src/app/components/message/FileHeader.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { Badge, Box, IconButton, Spinner, Text, as, toRem } from 'folds'; -import { Icon, Icons } from '../icons'; -import React, { ReactNode, useCallback } from 'react'; -import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; -import { mimeTypeToExt } from '../../utils/mimeTypes'; -import { useMatrixClient } from '../../hooks/useMatrixClient'; -import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; -import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; -import { - decryptFile, - downloadEncryptedMedia, - downloadMedia, - mxcUrlToHttp, -} from '../../utils/matrix'; -import { getCurrentAccessToken } from '../../utils/auth'; -import { saveMediaBlob } from '../../utils/saveMedia'; - -const badgeStyles = { maxWidth: toRem(100) }; - -type FileDownloadButtonProps = { - filename: string; - url: string; - mimeType: string; - encInfo?: EncryptedAttachmentInfo; -}; -export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDownloadButtonProps) { - const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); - - const [downloadState, download] = useAsyncCallback( - useCallback(async () => { - const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; - // Always use current session's token to avoid stale tokens during account switches - const accessToken = getCurrentAccessToken(); - const fileContent = encInfo - ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken) - : await downloadMedia(mediaUrl, accessToken); - - await saveMediaBlob(fileContent, filename); - return true; - }, [mx, url, useAuthentication, mimeType, encInfo, filename]) - ); - - const downloading = downloadState.status === AsyncStatus.Loading; - const hasError = downloadState.status === AsyncStatus.Error; - return ( - - {downloading ? ( - - ) : ( - - )} - - ); -} - -export type FileHeaderProps = { - body: string; - mimeType: string; - after?: ReactNode; -}; -export const FileHeader = as<'div', FileHeaderProps>(({ body, mimeType, after, ...props }, ref) => ( - - - - - {mimeTypeToExt(mimeType)} - - - - - - {body} - - - {after} - -)); diff --git a/overlay/src/app/components/message/content/FileContent.tsx b/overlay/src/app/components/message/content/FileContent.tsx deleted file mode 100644 index 82f9706..0000000 --- a/overlay/src/app/components/message/content/FileContent.tsx +++ /dev/null @@ -1,303 +0,0 @@ -import React, { ReactNode, useCallback, useState } from 'react'; -import { Box, Button, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds'; -import { Icon, Icons } from '../../icons'; -import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; -import { saveMediaBlob } from '../../../utils/saveMedia'; -import FocusTrap from 'focus-trap-react'; -import { IFileInfo } from '../../../../types/matrix/common'; -import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { bytesToSize } from '../../../utils/common'; -import { - READABLE_EXT_TO_MIME_TYPE, - READABLE_TEXT_MIME_TYPES, - getFileNameExt, - mimeTypeToExt, -} from '../../../utils/mimeTypes'; -import { stopPropagation } from '../../../utils/keyboard'; -import { - decryptFile, - downloadEncryptedMedia, - downloadMedia, - mxcUrlToHttp, -} from '../../../utils/matrix'; -import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; -import { getCurrentAccessToken } from '../../../utils/auth'; -import { ModalWide } from '../../../styles/Modal.css'; - -const renderErrorButton = (retry: () => void, text: string) => ( - - Failed to load file! - - } - position="Top" - align="Center" - > - {(triggerRef) => ( - - )} - -); - -type RenderTextViewerProps = { - name: string; - text: string; - langName: string; - requestClose: () => void; -}; -type ReadTextFileProps = { - body: string; - mimeType: string; - url: string; - encInfo?: EncryptedAttachmentInfo; - renderViewer: (props: RenderTextViewerProps) => ReactNode; -}; -export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: ReadTextFileProps) { - const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); - const [textViewer, setTextViewer] = useState(false); - - const [textState, loadText] = useAsyncCallback( - useCallback(async () => { - const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; - // Always use current session's token to avoid stale tokens during account switches - const accessToken = getCurrentAccessToken(); - const fileContent = encInfo - ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken) - : await downloadMedia(mediaUrl, accessToken); - - const text = fileContent.text(); - setTextViewer(true); - return text; - }, [mx, useAuthentication, mimeType, encInfo, url]) - ); - - return ( - <> - {textState.status === AsyncStatus.Success && ( - }> - - setTextViewer(false), - clickOutsideDeactivates: true, - escapeDeactivates: stopPropagation, - }} - > - evt.stopPropagation()} - > - {renderViewer({ - name: body, - text: textState.data, - langName: READABLE_TEXT_MIME_TYPES.includes(mimeType) - ? mimeTypeToExt(mimeType) - : mimeTypeToExt(READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)] ?? mimeType), - requestClose: () => setTextViewer(false), - })} - - - - - )} - {textState.status === AsyncStatus.Error ? ( - renderErrorButton(loadText, 'Open File') - ) : ( - - )} - - ); -} - -type RenderPdfViewerProps = { - name: string; - src: string; - requestClose: () => void; -}; -export type ReadPdfFileProps = { - body: string; - mimeType: string; - url: string; - encInfo?: EncryptedAttachmentInfo; - renderViewer: (props: RenderPdfViewerProps) => ReactNode; -}; -export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: ReadPdfFileProps) { - const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); - const [pdfViewer, setPdfViewer] = useState(false); - - const [pdfState, loadPdf] = useAsyncCallback( - useCallback(async () => { - const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; - // Always use current session's token to avoid stale tokens during account switches - const accessToken = getCurrentAccessToken(); - const fileContent = encInfo - ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken) - : await downloadMedia(mediaUrl, accessToken); - setPdfViewer(true); - return URL.createObjectURL(fileContent); - }, [mx, url, useAuthentication, mimeType, encInfo]) - ); - - return ( - <> - {pdfState.status === AsyncStatus.Success && ( - }> - - setPdfViewer(false), - clickOutsideDeactivates: true, - escapeDeactivates: stopPropagation, - }} - > - evt.stopPropagation()} - > - {renderViewer({ - name: body, - src: pdfState.data, - requestClose: () => setPdfViewer(false), - })} - - - - - )} - {pdfState.status === AsyncStatus.Error ? ( - renderErrorButton(loadPdf, 'Open PDF') - ) : ( - - )} - - ); -} - -export type DownloadFileProps = { - body: string; - mimeType: string; - url: string; - info: IFileInfo; - encInfo?: EncryptedAttachmentInfo; -}; -export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFileProps) { - const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); - - const [downloadState, download] = useAsyncCallback( - useCallback(async () => { - const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; - // Always use current session's token to avoid stale tokens during account switches - const accessToken = getCurrentAccessToken(); - const fileContent = encInfo - ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken) - : await downloadMedia(mediaUrl, accessToken); - - await saveMediaBlob(fileContent, body); - return fileContent; - }, [mx, url, useAuthentication, mimeType, encInfo, body]) - ); - - return downloadState.status === AsyncStatus.Error ? ( - renderErrorButton(download, `Retry Download (${bytesToSize(info.size ?? 0)})`) - ) : ( - - ); -} - -type FileContentProps = { - body: string; - mimeType: string; - renderAsTextFile: () => ReactNode; - renderAsPdfFile: () => ReactNode; -}; -export const FileContent = as<'div', FileContentProps>( - ({ body, mimeType, renderAsTextFile, renderAsPdfFile, children, ...props }, ref) => ( - - {(READABLE_TEXT_MIME_TYPES.includes(mimeType) || - READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)]) && - renderAsTextFile()} - {mimeType === 'application/pdf' && renderAsPdfFile()} - {children} - - ) -); diff --git a/overlay/src/app/components/mobile/MobileSwipeGestureHost.tsx b/overlay/src/app/components/mobile/MobileSwipeGestureHost.tsx index 25c47e1..4809ebc 100644 --- a/overlay/src/app/components/mobile/MobileSwipeGestureHost.tsx +++ b/overlay/src/app/components/mobile/MobileSwipeGestureHost.tsx @@ -45,22 +45,42 @@ type MobileSwipeGestureHostProps = { children: ReactNode; }; +const IGNORE_BACK_SELECTOR = + 'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-back="true"]'; + +const IGNORE_REPLY_SELECTOR = + 'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-reply="true"]'; + +/** True when the touch starts on (or inside) a horizontally scrollable region like an image carousel. */ +const isInsideHorizontalScroller = (target: EventTarget | null): boolean => { + if (!(target instanceof Element)) return false; + + let el: Element | null = target; + while (el && el !== document.documentElement) { + if (el instanceof HTMLElement) { + const style = window.getComputedStyle(el); + const overflowX = style.overflowX; + if ( + (overflowX === 'auto' || overflowX === 'scroll' || overflowX === 'overlay') && + el.scrollWidth > el.clientWidth + 1 + ) { + return true; + } + } + el = el.parentElement; + } + + return false; +}; + const shouldIgnoreBackTarget = (target: EventTarget | null): boolean => { if (!(target instanceof Element)) return true; - return Boolean( - target.closest( - 'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-back="true"]' - ) - ); + return Boolean(target.closest(IGNORE_BACK_SELECTOR)) || isInsideHorizontalScroller(target); }; const shouldIgnoreReplyTarget = (target: EventTarget | null): boolean => { if (!(target instanceof Element)) return true; - return Boolean( - target.closest( - 'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-reply="true"]' - ) - ); + return Boolean(target.closest(IGNORE_REPLY_SELECTOR)) || isInsideHorizontalScroller(target); }; const findMessageElement = (target: EventTarget | null): HTMLElement | null => { diff --git a/overlay/src/app/components/video-viewer/VideoViewer.tsx b/overlay/src/app/components/video-viewer/VideoViewer.tsx deleted file mode 100644 index 99a3176..0000000 --- a/overlay/src/app/components/video-viewer/VideoViewer.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import React from 'react'; -import classNames from 'classnames'; -import { Box, Chip, Header, IconButton, Text, as } from 'folds'; -import { Icon, Icons } from '../icons'; -import * as css from './VideoViewer.css'; -import { downloadAndSaveMedia } from '../../utils/saveMedia'; -import { getCurrentAccessToken } from '../../utils/auth'; - -export type VideoViewerProps = { - alt: string; - src: string; - requestClose: () => void; -}; - -export const VideoViewer = as<'div', VideoViewerProps>( - ({ className, alt, src, requestClose, ...props }, ref) => { - const handleDownload = async () => { - try { - await downloadAndSaveMedia(src, alt, getCurrentAccessToken()); - } catch (error) { - console.warn('[VideoViewer] Failed to download media:', error); - try { - window.open(src, '_blank'); - } catch { - // ignore - } - } - }; - - return ( - -
- - - - - - {alt} - - - - } - > - Download - - -
- - -
- ); - } -); diff --git a/overlay/src/app/hooks/usePathWithOrigin.ts b/overlay/src/app/hooks/usePathWithOrigin.ts new file mode 100644 index 0000000..e4959f3 --- /dev/null +++ b/overlay/src/app/hooks/usePathWithOrigin.ts @@ -0,0 +1,49 @@ +import { useMemo } from 'react'; +import { useClientConfig } from './useClientConfig'; +import { trimLeadingSlash, trimSlash, trimTrailingSlash } from '../utils/common'; +import { isCapacitorNative, isElectron } from '../utils/tauri'; + +/** Authority shown on Matrix SSO consent ("Continue to paarrot://desktop"). */ +export const ELECTRON_PROTOCOL_HOST = 'desktop'; + +/** Authority shown on Matrix SSO consent for the Android Capacitor app. */ +export const ANDROID_PROTOCOL_HOST = 'android'; + +/** + * Build an absolute URL for the given in-app path. + * + * In Electron / Capacitor, SSO (and other browser handoffs) must return via + * paarrot://{host}/... — a named host so consent UI identifies the app, and a + * real pathname (not a hash) because browsers strip fragments when launching + * custom protocols. + */ +export const usePathWithOrigin = (path: string): string => { + const { hashRouter } = useClientConfig(); + const { origin } = window.location; + + const pathWithOrigin = useMemo(() => { + if (isElectron() || isCapacitorNative()) { + const host = isElectron() ? ELECTRON_PROTOCOL_HOST : ANDROID_PROTOCOL_HOST; + const basename = trimSlash(hashRouter?.basename ?? ''); + const routeParts = [basename, trimLeadingSlash(path)].filter(Boolean); + const route = `/${routeParts.join('/')}`.replace(/\/{2,}/g, '/'); + return `paarrot://${host}${route}`; + } + + let url: string = trimSlash(origin); + + url += `/${trimSlash(import.meta.env.BASE_URL ?? '')}`; + url = trimTrailingSlash(url); + + if (hashRouter?.enabled) { + url += `/#/${trimSlash(hashRouter.basename ?? '')}`; + url = trimTrailingSlash(url); + } + + url += `/${trimLeadingSlash(path)}`; + + return url; + }, [path, hashRouter, origin]); + + return pathWithOrigin; +}; diff --git a/overlay/src/app/utils/androidDeepLink.ts b/overlay/src/app/utils/androidDeepLink.ts new file mode 100644 index 0000000..b4a22bd --- /dev/null +++ b/overlay/src/app/utils/androidDeepLink.ts @@ -0,0 +1,127 @@ +import { App } from '@capacitor/app'; +import { Browser } from '@capacitor/browser'; +import { isCapacitorNative } from './tauri'; + +const PROTOCOL_SCHEME = 'paarrot'; + +/** + * Converts a paarrot:// deep link into an in-app hash route. + * Supports: + * - Hash routes: paarrot://anything/#/login/server/ + * - Path routes (SSO-safe): paarrot://android/login/server/?loginToken=... + * Preserves SSO loginToken from the URL query (Synapse inserts it before `#`). + */ +export function deepLinkToHashRoute(deepLink: string): string | null { + if (!deepLink) { + return null; + } + + if (!deepLink.toLowerCase().startsWith(`${PROTOCOL_SCHEME}://`)) { + return null; + } + + let hashRoute: string | null = null; + let loginToken: string | null = null; + + try { + const parsed = new URL(deepLink); + loginToken = parsed.searchParams.get('loginToken'); + + if (parsed.hash && parsed.hash.length > 1) { + const hashValue = parsed.hash.slice(1); + hashRoute = hashValue.startsWith('/') ? hashValue : `/${hashValue}`; + } else if (parsed.pathname && parsed.pathname !== '/') { + // paarrot://android/login/... → /login/... + hashRoute = parsed.pathname; + if (parsed.search && parsed.search.length > 1) { + const params = new URLSearchParams(parsed.search); + params.delete('loginToken'); + const rest = params.toString(); + if (rest) { + hashRoute += `?${rest}`; + } + } + } + } catch { + const hashIndex = deepLink.indexOf('#'); + if (hashIndex !== -1) { + const hashValue = deepLink.slice(hashIndex + 1); + if (hashValue) { + hashRoute = hashValue.startsWith('/') ? hashValue : `/${hashValue}`; + } + } + + const match = deepLink.match(/[?&]loginToken=([^&#]+)/i); + if (match) { + try { + loginToken = decodeURIComponent(match[1]); + } catch { + loginToken = match[1]; + } + } + } + + if (loginToken) { + if (!hashRoute) { + hashRoute = '/'; + } + const queryIndex = hashRoute.indexOf('?'); + const pathPart = queryIndex === -1 ? hashRoute : hashRoute.slice(0, queryIndex); + const params = new URLSearchParams(queryIndex === -1 ? '' : hashRoute.slice(queryIndex + 1)); + if (!params.has('loginToken')) { + params.set('loginToken', loginToken); + } + const query = params.toString(); + hashRoute = query ? `${pathPart}?${query}` : pathPart; + } + + return hashRoute; +} + +async function applyDeepLink(deepLink: string): Promise { + const hashRoute = deepLinkToHashRoute(deepLink); + if (!hashRoute) { + return; + } + + try { + await Browser.close(); + } catch { + // Browser may already be closed or never opened. + } + + const nextHash = hashRoute.startsWith('/') ? `#${hashRoute}` : `#/${hashRoute}`; + if (window.location.hash === nextHash) { + // Force TokenLogin to re-run if we land on the same route again. + window.location.hash = '#/'; + window.setTimeout(() => { + window.location.hash = nextHash; + }, 0); + return; + } + + window.location.hash = nextHash; +} + +/** + * Listen for paarrot:// SSO returns (and cold-start launch URLs) on Capacitor Android. + */ +export async function initAndroidDeepLinks(): Promise { + if (!isCapacitorNative()) { + return; + } + + try { + const launch = await App.getLaunchUrl(); + if (launch?.url) { + await applyDeepLink(launch.url); + } + } catch (error) { + console.warn('[androidDeepLink] Failed to read launch URL:', error); + } + + await App.addListener('appUrlOpen', ({ url }) => { + if (!url) return; + void applyDeepLink(url); + }); +} diff --git a/overlay/src/app/utils/saveMedia.ts b/overlay/src/app/utils/registerAndroidMediaSaver.ts similarity index 52% rename from overlay/src/app/utils/saveMedia.ts rename to overlay/src/app/utils/registerAndroidMediaSaver.ts index 98e1474..c4c23d5 100644 --- a/overlay/src/app/utils/saveMedia.ts +++ b/overlay/src/app/utils/registerAndroidMediaSaver.ts @@ -1,7 +1,5 @@ -import FileSaver from 'file-saver'; import { Capacitor, registerPlugin } from '@capacitor/core'; -import { downloadMedia } from './matrix'; -import { getCurrentAccessToken } from './auth'; +import { registerMediaSaver } from './saveMedia'; interface AndroidShareHandlerSavePlugin { saveFile(options: { @@ -9,19 +7,10 @@ interface AndroidShareHandlerSavePlugin { mimeType?: string; base64: string; }): Promise<{ saved: boolean; shared?: boolean; uri?: string }>; - shareFile(options: { - filename: string; - mimeType?: string; - base64: string; - }): Promise<{ shared: boolean }>; } const AndroidShareHandler = registerPlugin('AndroidShareHandler'); -const isAndroidNative = (): boolean => - Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android'; - -/** Convert a Blob to raw base64 (no data: prefix). */ const blobToBase64 = (blob: Blob): Promise => new Promise((resolve, reject) => { const reader = new FileReader(); @@ -62,42 +51,17 @@ const guessMimeFromName = (filename: string): string | undefined => { }; /** - * Persist a Blob to disk. On Capacitor Android, uses MediaStore via the native - * ShareHandler plugin (FileSaver / `` does not work in WebView). - * Elsewhere falls back to FileSaver. + * Wire Capacitor Android MediaStore saves into the core saveMedia helpers. + * Call once at app startup from the mobile overlay entry. */ -export const saveMediaBlob = async (blob: Blob, filename: string): Promise => { - if (isAndroidNative()) { +export function registerAndroidMediaSaver(): void { + if (!(Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android')) { + return; + } + + registerMediaSaver(async (blob, filename) => { const base64 = await blobToBase64(blob); const mimeType = blob.type || guessMimeFromName(filename) || 'application/octet-stream'; await AndroidShareHandler.saveFile({ filename, mimeType, base64 }); - return; - } - FileSaver.saveAs(blob, filename); -}; - -/** - * Fetch media (auth download or blob:/http URL) and save it via [saveMediaBlob]. - */ -export const downloadAndSaveMedia = async ( - src: string, - filename: string, - accessToken?: string | null -): Promise => { - let blob: Blob; - try { - if (src.startsWith('blob:') || src.startsWith('data:')) { - const res = await fetch(src); - if (!res.ok) throw new Error(`Failed to fetch ${src}`); - blob = await res.blob(); - } else { - blob = await downloadMedia(src, accessToken ?? getCurrentAccessToken()); - } - } catch (error) { - console.warn('[saveMedia] downloadMedia failed, trying fetch fallback:', error); - const res = await fetch(src); - if (!res.ok) throw error; - blob = await res.blob(); - } - await saveMediaBlob(blob, filename); -}; + }); +} diff --git a/overlay/src/app/utils/tauri.ts b/overlay/src/app/utils/tauri.ts index f03994c..8bde8b2 100644 --- a/overlay/src/app/utils/tauri.ts +++ b/overlay/src/app/utils/tauri.ts @@ -4,7 +4,7 @@ */ export const openExternalUrl = async (url: string): Promise => { console.log('[openExternalUrl] called with:', url); - + // Use Electron's shell.openExternal if in Electron if (isElectron()) { try { @@ -18,7 +18,20 @@ export const openExternalUrl = async (url: string): Promise => { console.warn('[openExternalUrl] Electron shell.openExternal failed:', err); } } - + + // Capacitor: SSO (and other handoffs) need Chrome Custom Tabs / in-app Browser + // so paarrot:// redirects return into the app instead of a blank external tab. + if (isCapacitorNative()) { + try { + const { Browser } = await import('@capacitor/browser'); + await Browser.open({ url, presentationStyle: 'popover' }); + console.log('[openExternalUrl] Capacitor Browser.open succeeded'); + return; + } catch (err) { + console.warn('[openExternalUrl] Capacitor Browser.open failed:', err); + } + } + // Use Tauri for actual Tauri builds if (isTauri() && !isElectron()) { try { @@ -30,7 +43,7 @@ export const openExternalUrl = async (url: string): Promise => { return; } catch (pluginErr) { console.warn('[openExternalUrl] Tauri opener plugin failed:', pluginErr); - + // Fallback: try the custom command (useful if plugin fails due to ACL) try { console.log('[openExternalUrl] trying Tauri invoke command fallback...'); @@ -43,7 +56,7 @@ export const openExternalUrl = async (url: string): Promise => { } } } - + console.log('[openExternalUrl] falling back to window.open'); window.open(url, '_blank'); }; diff --git a/overlay/src/index.tsx b/overlay/src/index.tsx index d710701..b9487f5 100644 --- a/overlay/src/index.tsx +++ b/overlay/src/index.tsx @@ -16,6 +16,8 @@ import { trimTrailingSlash } from './app/utils/common'; import App from './app/pages/App'; import { applySafeAreaInsets, isTauri } from './app/utils/tauri'; import { enableViewTransitionsForNavigation } from './app/utils/viewTransitions'; +import { initAndroidDeepLinks } from './app/utils/androidDeepLink'; +import { registerAndroidMediaSaver } from './app/utils/registerAndroidMediaSaver'; // import i18n (needs to be bundled ;)) import './app/i18n'; @@ -28,6 +30,12 @@ applySafeAreaInsets(); // Enable View Transitions API for smooth navigation enableViewTransitionsForNavigation(); +// Capacitor: paarrot:// SSO return closes Custom Tabs and lands on loginToken +void initAndroidDeepLinks(); + +// Capacitor Android: MediaStore saves for images / videos / files +registerAndroidMediaSaver(); + // Register Service Worker if ('serviceWorker' in navigator) { const swUrl = diff --git a/package-lock.json b/package-lock.json index 0710034..39d7f9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,17 @@ { "name": "paarrot", - "version": "4.11.121", + "version": "4.11.150", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paarrot", - "version": "4.11.121", + "version": "4.11.150", "license": "AGPL-3.0-only", "dependencies": { "@capacitor/android": "8.3.0", + "@capacitor/app": "8.0.1", + "@capacitor/browser": "8.0.1", "@capacitor/cli": "8.3.0", "@capacitor/core": "8.3.0", "@capacitor/local-notifications": "8.0.2", @@ -33,6 +35,24 @@ "@capacitor/core": "^8.3.0" } }, + "node_modules/@capacitor/app": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/app/-/app-8.0.1.tgz", + "integrity": "sha512-yeG3yyA0ETKqvgqexwHMBlmVOF13A1hRXzv/km0Ptv5TrNIZvZJK4MTI3uiqvnbHrzoJGP5DwWAjEXEfi90v3Q==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/browser": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/browser/-/browser-8.0.1.tgz", + "integrity": "sha512-cCEYK9DGJvamfCKOh95QP+VafApblySDR/eodOcl1Qt+d/Y+NNPzc8NYm/dztQlRf3aMLJ7ne1HrRCfu2UOMnw==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@capacitor/cli": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.3.0.tgz", diff --git a/package.json b/package.json index 6476778..7b732bd 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,8 @@ "license": "AGPL-3.0-only", "dependencies": { "@capacitor/android": "8.3.0", + "@capacitor/app": "8.0.1", + "@capacitor/browser": "8.0.1", "@capacitor/cli": "8.3.0", "@capacitor/core": "8.3.0", "@capacitor/local-notifications": "8.0.2",