diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c8c27dac8..b0fa29a23 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -124,6 +124,7 @@ tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugi ] } tauri-plugin-edge-to-edge = { git = "https://github.com/SableClient/tauri-plugin-edge-to-edge.git", rev = "33c6116c27be28c06df5a9d02231ecc5fdeb93c5" } tauri-plugin-sharekit = { git = "https://github.com/Choochmeque/tauri-plugin-sharekit", rev = "9f2b4c5d8a4f0ab910d900ba234ed8bae0ab854b" } +tauri-plugin-fs = "2" [target.'cfg(target_os = "ios")'.dependencies] objc2 = "0.6" @@ -146,7 +147,6 @@ objc2-photos = { version = "0.3.2", default-features = false, features = [ "dispatch2", ] } block2 = "0.6" -tauri-plugin-fs = "2" objc2-avf-audio = { version = "0.3", default-features = false, features = [ "AVAudioSession", "AVAudioSessionTypes", diff --git a/src-tauri/capabilities/android.json b/src-tauri/capabilities/android.json index fc3fa717e..973636d65 100644 --- a/src-tauri/capabilities/android.json +++ b/src-tauri/capabilities/android.json @@ -4,6 +4,8 @@ "platforms": ["android"], "windows": ["main"], "permissions": [ + "dialog:allow-open", + "fs:allow-read-file", "android-fs:allow-check-public-files-permission", "android-fs:allow-create-new-public-file", "android-fs:allow-create-new-public-image-file", diff --git a/src-tauri/capabilities/ios.json b/src-tauri/capabilities/ios.json index c02f6dc52..0d0baad6e 100644 --- a/src-tauri/capabilities/ios.json +++ b/src-tauri/capabilities/ios.json @@ -3,5 +3,13 @@ "identifier": "ios-capability", "platforms": ["iOS"], "windows": ["main"], - "permissions": ["dialog:allow-save", "fs:allow-write-file"] + "permissions": [ + "dialog:allow-save", + "dialog:allow-open", + "fs:allow-write-file", + { + "identifier": "fs:allow-read-file", + "allow": [{ "path": "$TEMP/**/*" }, { "path": "$APPCACHE/**/*" }] + } + ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1db10918d..ad7f6bece 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -352,6 +352,11 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()); + #[cfg(target_os = "android")] + let builder = builder + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_fs::init()); + #[cfg(mobile)] let builder = builder .plugin(tauri_plugin_edge_to_edge::init()) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index d081c7e22..d01a75b3e 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -115,7 +115,7 @@ import { getEditedEvent, getMentionContent, getThreadReplyEvents } from '$utils/ import { buildReplacementContent } from './buildReplacementContent'; import { htmlToMarkdown } from '$plugins/markdown'; import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '$hooks/useCommands'; -import { isMobileOrTablet } from '$utils/platform'; +import { isMobileOrTablet, isMobileTauri } from '$utils/platform'; import { Reply, ThreadIndicator } from '$components/message'; import { roomToParentsAtom } from '$state/room/roomToParents'; import { nicknamesAtom } from '$state/nicknames'; @@ -163,7 +163,6 @@ import { dropzoneIcon, File as FileIcon, Gif, - Image as ImageIcon, ListBullets, MapPinPlusIcon, menuIcon, @@ -217,6 +216,7 @@ import * as prefix from '$unstable/prefixes'; import { PollDialog } from './poll-modals'; import { useClientConfig } from '$hooks/useClientConfig'; import { PersonaPicker, type PersonaPickerTab } from './persona-picker/PersonaPicker.tsx'; +import { pickNativeFile } from './nativeFilePicker'; const LocationDialog = lazy(() => import('./location-modal').then((module) => ({ default: module.LocationDialog })) @@ -492,6 +492,24 @@ export const RoomInput = forwardRef( [setSelectedFiles, room] ); const pickFile = useFilePicker(handleFiles, true); + const pickAttachment = useCallback( + async (pickerMode: 'media' | 'document', accept: string) => { + if (!isMobileTauri()) { + await pickFile(accept); + return; + } + + try { + const files = await pickNativeFile(pickerMode, (path, error) => { + log.warn('Failed to read native attachment file:', path, error); + }); + if (files.length > 0) await handleFiles(files); + } catch (error) { + log.error('Failed to open native attachment picker', { roomId }, error); + } + }, + [handleFiles, pickFile, roomId] + ); const handlePaste = useFilePasteHandler(handleFiles); const dropZoneVisible = useFileDropZone(fileDropContainerRef, handleFiles); const [hasText, setHasText] = useState(false); @@ -2091,10 +2109,10 @@ export const RoomInput = forwardRef( {() => ( { - pickFile('image/*,.tgs'); + void pickAttachment('media', 'image/*,video/*,.tgs'); }} onPickFile={() => { - pickFile('*'); + void pickAttachment('document', '*'); }} onPickPoll={() => { setShowPollPicker(true); @@ -2148,17 +2166,6 @@ export const RoomInput = forwardRef( > Add Location - { - pickFile('image/*,.tgs'); - setAddMenuAnchor(undefined); - }} - before={menuIcon(ImageIcon)} - > - Photos - ({ + open: vi.fn< + (options: { + pickerMode: 'media' | 'document'; + multiple: true; + }) => Promise + >(), + readFile: vi.fn<(path: string) => Promise>(), +})); + +vi.mock('@tauri-apps/plugin-dialog', () => ({ open: mocks.open })); +vi.mock('@tauri-apps/plugin-fs', () => ({ readFile: mocks.readFile })); + +describe('pickNativeFile', () => { + beforeEach(() => { + mocks.open.mockResolvedValue(null); + mocks.readFile.mockResolvedValue(new Uint8Array([1, 2, 3])); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('opens the native media picker and converts selected paths to Files', async () => { + mocks.open.mockResolvedValue(['/photos/My%20photo.JPG', '/videos/clip.mp4']); + + const files = await pickNativeFile('media'); + + expect(open).toHaveBeenCalledWith({ pickerMode: 'media', multiple: true }); + expect(readFile).toHaveBeenCalledWith('/photos/My%20photo.JPG'); + expect(readFile).toHaveBeenCalledWith('/videos/clip.mp4'); + expect(files.map(({ name, type }) => ({ name, type }))).toEqual([ + { name: 'My photo.JPG', type: 'image/jpeg' }, + { name: 'clip.mp4', type: 'video/mp4' }, + ]); + }); + + it('returns readable files when an individual read fails', async () => { + const failure = new Error('permission denied'); + mocks.open.mockResolvedValue(['/photos/readable.png', '/photos/unreadable.jpg']); + mocks.readFile.mockResolvedValueOnce(new Uint8Array([1])).mockRejectedValueOnce(failure); + const onReadFailure = vi.fn<(path: string, error: unknown) => void>(); + + const files = await pickNativeFile('media', onReadFailure); + + expect(files).toHaveLength(1); + expect(files[0]?.name).toBe('readable.png'); + expect(onReadFailure).toHaveBeenCalledWith('/photos/unreadable.jpg', failure); + }); + + it('does not read files after picker cancellation', async () => { + const files = await pickNativeFile('media'); + + expect(files).toEqual([]); + expect(readFile).not.toHaveBeenCalled(); + }); + + it('propagates picker errors without attempting another picker', async () => { + const error = new Error('picker failed'); + mocks.open.mockRejectedValue(error); + + await expect(pickNativeFile('media')).rejects.toBe(error); + expect(readFile).not.toHaveBeenCalled(); + }); + + it('throws instead of silently dropping entries that are not paths', async () => { + mocks.open.mockResolvedValue([{ relative: 'file:///photos/img.HEIC' }] as never); + + await expect(pickNativeFile('media')).rejects.toThrow('unusable entries'); + expect(readFile).not.toHaveBeenCalled(); + }); + + it('opens the native document picker and converts selected paths to Files', async () => { + mocks.open.mockResolvedValue('/documents/report.pdf'); + + const files = await pickNativeFile('document'); + + expect(open).toHaveBeenCalledWith({ pickerMode: 'document', multiple: true }); + expect(readFile).toHaveBeenCalledWith('/documents/report.pdf'); + expect(files.map(({ name, type }) => ({ name, type }))).toEqual([ + { name: 'report.pdf', type: 'application/pdf' }, + ]); + }); +}); diff --git a/src/app/features/room/nativeFilePicker.ts b/src/app/features/room/nativeFilePicker.ts new file mode 100644 index 000000000..b2ab8fec5 --- /dev/null +++ b/src/app/features/room/nativeFilePicker.ts @@ -0,0 +1,93 @@ +import { createLogger } from '$utils/debug'; +import { FALLBACK_MIMETYPE, TGS_MIMETYPE } from '$utils/mimeTypes'; + +const log = createLogger('nativeFilePicker'); + +const MIME_TYPES_BY_EXTENSION: Record = { + apng: 'image/apng', + avif: 'image/avif', + bmp: 'image/bmp', + gif: 'image/gif', + heic: 'image/heic', + heif: 'image/heif', + json: 'application/json', + jpeg: 'image/jpeg', + jpg: 'image/jpeg', + md: 'text/markdown', + mov: 'video/quicktime', + mp4: 'video/mp4', + m4v: 'video/mp4', + ogg: 'video/ogg', + ogv: 'video/ogg', + pdf: 'application/pdf', + png: 'image/png', + svg: 'image/svg+xml', + tgs: TGS_MIMETYPE, + txt: 'text/plain', + webm: 'video/webm', + webp: 'image/webp', +}; + +export type NativePickerMode = 'media' | 'document'; +export type NativeFileReadFailureHandler = (path: string, error: unknown) => void; + +const normalizeSelectedPaths = (selected: unknown): string[] => { + if (selected === null || selected === undefined) return []; + + const values = Array.isArray(selected) ? (selected as unknown[]) : [selected]; + const paths = values.filter( + (value): value is string => typeof value === 'string' && value.length > 0 + ); + if (paths.length !== values.length) { + throw new Error(`Native picker returned unusable entries: ${JSON.stringify(selected)}`); + } + + return paths; +}; + +const getFileName = (path: string, index: number): string => { + const pathName = path.split(/[\\/]/).pop(); + if (!pathName) return `attachment-${index + 1}`; + + try { + return decodeURIComponent(pathName); + } catch { + return pathName; + } +}; + +const getMimeType = (fileName: string): string => { + const extension = fileName.split('.').pop()?.toLowerCase(); + return extension ? (MIME_TYPES_BY_EXTENSION[extension] ?? FALLBACK_MIMETYPE) : FALLBACK_MIMETYPE; +}; + +export const pickNativeFile = async ( + pickerMode: NativePickerMode, + onReadFailure?: NativeFileReadFailureHandler +): Promise => { + const { open } = await import('@tauri-apps/plugin-dialog'); + const selected = await open({ pickerMode, multiple: true }); + log.log('picker returned', pickerMode, typeof selected, selected); + + const paths = normalizeSelectedPaths(selected); + if (paths.length === 0) { + log.warn('picker resolved without any path (cancelled, or a swallowed native failure)'); + return []; + } + + const { readFile } = await import('@tauri-apps/plugin-fs'); + const files = await Promise.all( + paths.map(async (path, index): Promise => { + try { + const name = getFileName(path, index); + const contents = await readFile(path); + return new File([contents], name, { type: getMimeType(name) }); + } catch (error) { + onReadFailure?.(path, error); + return undefined; + } + }) + ); + + return files.filter((file): file is File => file !== undefined); +}; diff --git a/src/app/utils/platform.test.ts b/src/app/utils/platform.test.ts index 263793cee..1ba9f3351 100644 --- a/src/app/utils/platform.test.ts +++ b/src/app/utils/platform.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; -import { getAppOrigin, getWindowOrigin } from './platform'; +import { getAppOrigin, getWindowOrigin, isMobileOrTablet, ua } from './platform'; import { isTauri } from '@tauri-apps/api/core'; +import { type as osType } from '@tauri-apps/plugin-os'; vi.mock('@tauri-apps/api/core', () => ({ isTauri: vi.fn<() => boolean>(), @@ -45,6 +46,25 @@ describe('getAppOrigin', () => { }); }); +describe('isMobileOrTablet', () => { + const originalDeviceType = ua.device.type; + const originalOsName = ua.os.name; + + afterEach(() => { + ua.device.type = originalDeviceType; + ua.os.name = originalOsName; + }); + + it('uses the desktop Tauri OS instead of a mobile-looking WebView user agent', () => { + vi.mocked(isTauri).mockReturnValue(true); + vi.mocked(osType).mockReturnValue('windows'); + ua.device.type = 'mobile'; + ua.os.name = 'Android'; + + expect(isMobileOrTablet()).toBe(false); + }); +}); + describe('getWindowOrigin', () => { afterEach(() => { vi.unstubAllGlobals(); diff --git a/src/app/utils/platform.ts b/src/app/utils/platform.ts index 9c278c885..30ac40b51 100644 --- a/src/app/utils/platform.ts +++ b/src/app/utils/platform.ts @@ -27,6 +27,7 @@ const DESKTOP_TAURI_OS = ['linux', 'macos', 'windows'] as const; export function isMobileOrTablet(): boolean { const tauriOS = getTauriOS(); if (tauriOS && (MOBILE_TAURI_OS as readonly string[]).includes(tauriOS)) return true; + if (tauriOS && (DESKTOP_TAURI_OS as readonly string[]).includes(tauriOS)) return false; const { os, device } = ua; if (device.type === 'mobile' || device.type === 'tablet') return true;