diff --git a/frontend/src/api/files.ts b/frontend/src/api/files.ts index 7337d9f31..f2dd08345 100644 --- a/frontend/src/api/files.ts +++ b/frontend/src/api/files.ts @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query' import { fetchWrapper, fetchWrapperBlob } from './fetchWrapper' import { API_BASE_URL } from '@/config' +import { saveFile } from '@/lib/download' import type { FileInfo, ChunkedFileInfo, PatchOperation } from '@/types/files' interface FileApiUrlOptions { @@ -59,15 +60,8 @@ export async function downloadDirectoryAsZip(path: string, options?: DownloadOpt }) const blob = await fetchWrapperBlob(url) - const urlObj = window.URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = urlObj const dirName = path.split('/').pop() || 'download' - a.download = `${dirName}.zip` - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - window.URL.revokeObjectURL(urlObj) + await saveFile(blob, `${dirName}.zip`) } export async function applyFilePatches(path: string, patches: PatchOperation[]): Promise<{ success: boolean; totalLines: number }> { diff --git a/frontend/src/api/repos.ts b/frontend/src/api/repos.ts index 651a6d1c9..cc70aa32e 100644 --- a/frontend/src/api/repos.ts +++ b/frontend/src/api/repos.ts @@ -1,6 +1,7 @@ import type { Repo } from './types' import { FetchError, fetchWrapper, fetchWrapperVoid, fetchWrapperBlob } from './fetchWrapper' import { API_BASE_URL } from '@/config' +import { saveFile } from '@/lib/download' import type { DiscoverReposResponse, AssistantModeStatus, AssistantModeInitRequest } from '@opencode-manager/shared/types' export interface CreateRepoOptions { @@ -196,14 +197,7 @@ export async function downloadRepo(id: number, repoName: string, options?: Downl const url = `${API_BASE_URL}/api/repos/${id}/download${params.toString() ? '?' + params.toString() : ''}` const blob = await fetchWrapperBlob(url) - const urlObj = window.URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = urlObj - a.download = `${repoName}.zip` - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - window.URL.revokeObjectURL(urlObj) + await saveFile(blob, `${repoName}.zip`) } export async function updateRepoOrder(order: number[]): Promise { diff --git a/frontend/src/components/file-browser/FilePreview.tsx b/frontend/src/components/file-browser/FilePreview.tsx index 9f8dfcb33..55617c23a 100644 --- a/frontend/src/components/file-browser/FilePreview.tsx +++ b/frontend/src/components/file-browser/FilePreview.tsx @@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button' import { Download, X, Edit3, Save, X as XIcon, WrapText, Eye, Code } from 'lucide-react' import type { FileInfo } from '@/types/files' import { getFileApiUrl } from '@/api/files' +import { saveFileFromUrl } from '@/lib/download' import { VirtualizedTextView, type VirtualizedTextViewHandle } from '@/components/ui/virtualized-text-view' import { MarkdownRenderer } from './MarkdownRenderer' @@ -94,12 +95,7 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false, } const handleDownload = () => { - const link = document.createElement('a') - link.href = getFileApiUrl(file.path, { params: { download: true } }) - link.download = file.name - document.body.appendChild(link) - link.click() - document.body.removeChild(link) + void saveFileFromUrl(getFileApiUrl(file.path, { params: { download: true } }), file.name) } const decodeBase64 = (base64: string): string => { diff --git a/frontend/src/components/file-browser/FileTree.tsx b/frontend/src/components/file-browser/FileTree.tsx index 8d6017519..f32b5342d 100644 --- a/frontend/src/components/file-browser/FileTree.tsx +++ b/frontend/src/components/file-browser/FileTree.tsx @@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { DeleteDialog } from '@/components/ui/delete-dialog' import { getFileApiUrl } from '@/api/files' +import { saveFileFromUrl } from '@/lib/download' import { File, Folder, @@ -95,13 +96,7 @@ function TreeNode({ file, level, onFileSelect, onDirectoryClick, selectedFile, o const handleDownload = () => { if (file.isDirectory) return - const downloadUrl = getFileApiUrl(file.path, { params: { download: true } }) - const link = document.createElement('a') - link.href = downloadUrl - link.download = file.name - document.body.appendChild(link) - link.click() - document.body.removeChild(link) + void saveFileFromUrl(getFileApiUrl(file.path, { params: { download: true } }), file.name) } const getFileIcon = () => { diff --git a/frontend/src/components/settings/OpenCodeConfigManager.tsx b/frontend/src/components/settings/OpenCodeConfigManager.tsx index 87dbc0a12..63177ff7e 100644 --- a/frontend/src/components/settings/OpenCodeConfigManager.tsx +++ b/frontend/src/components/settings/OpenCodeConfigManager.tsx @@ -24,6 +24,7 @@ import { useServerHealth } from '@/hooks/useServerHealth' import { useOpenCodeServerActions } from '@/hooks/useOpenCodeServerActions' import { parseJsonc, hasJsoncComments } from '@/lib/jsonc' import { showToast } from '@/lib/toast' +import { saveFile } from '@/lib/download' import { invalidateConfigCaches } from '@/lib/queryInvalidation' import { getOpenCodeApiErrorMessage } from '@/lib/opencode-errors' import { FetchError } from '@/api/fetchWrapper' @@ -308,12 +309,7 @@ export function OpenCodeConfigManager() { const content = config.rawContent || JSON.stringify(config.content, null, 2) const extension = config.rawContent && hasJsoncComments(config.rawContent) ? 'jsonc' : 'json' const blob = new Blob([content], { type: 'application/json' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = `${config.name}.${extension}` - a.click() - URL.revokeObjectURL(url) + void saveFile(blob, `${config.name}.${extension}`) } diff --git a/frontend/src/lib/download.test.ts b/frontend/src/lib/download.test.ts new file mode 100644 index 000000000..fcf014e5b --- /dev/null +++ b/frontend/src/lib/download.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { saveFile, saveFileFromUrl } from './download' +import { fetchWrapperBlob } from '@/api/fetchWrapper' +import { showToast } from './toast' + +vi.mock('./toast', () => ({ + showToast: { + error: vi.fn(), + info: vi.fn(), + }, +})) + +vi.mock('@/api/fetchWrapper', () => ({ + fetchWrapperBlob: vi.fn(), +})) + +function setIosHomeScreenApp(): void { + Object.defineProperty(navigator, 'userAgent', { value: 'iPhone', configurable: true }) + Object.defineProperty(navigator, 'standalone', { value: true, configurable: true }) +} + +describe('saveFile', () => { + let clickSpy: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + vi.stubGlobal('URL', Object.assign(URL, { + createObjectURL: vi.fn(() => 'blob:mock'), + revokeObjectURL: vi.fn(), + })) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + Reflect.deleteProperty(navigator, 'standalone') + Reflect.deleteProperty(navigator, 'canShare') + Reflect.deleteProperty(navigator, 'share') + }) + + it('downloads via an anchor outside iOS home screen apps', async () => { + const saved = await saveFile(new Blob(['x'], { type: 'text/markdown' }), 'notes.md') + + expect(saved).toBe(true) + expect(clickSpy).toHaveBeenCalledTimes(1) + }) + + it('uses the share sheet instead of navigating in an iOS home screen app', async () => { + setIosHomeScreenApp() + const share = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'canShare', { value: () => true, configurable: true }) + Object.defineProperty(navigator, 'share', { value: share, configurable: true }) + + const saved = await saveFile(new Blob(['x'], { type: 'text/markdown' }), 'notes.md') + + expect(saved).toBe(true) + expect(clickSpy).not.toHaveBeenCalled() + const shared = share.mock.calls[0][0].files[0] as File + expect(shared.name).toBe('notes.md') + }) + + it('reports a cancelled share as unsaved', async () => { + setIosHomeScreenApp() + const share = vi.fn().mockRejectedValue(new DOMException('cancelled', 'AbortError')) + Object.defineProperty(navigator, 'canShare', { value: () => true, configurable: true }) + Object.defineProperty(navigator, 'share', { value: share, configurable: true }) + + const saved = await saveFile(new Blob(['x'], { type: 'text/markdown' }), 'notes.md') + + expect(saved).toBe(false) + expect(clickSpy).not.toHaveBeenCalled() + }) + + it('offers a retry when the share loses user activation', async () => { + setIosHomeScreenApp() + const share = vi.fn() + .mockRejectedValueOnce(new DOMException('no activation', 'NotAllowedError')) + .mockResolvedValueOnce(undefined) + Object.defineProperty(navigator, 'canShare', { value: () => true, configurable: true }) + Object.defineProperty(navigator, 'share', { value: share, configurable: true }) + + const saved = await saveFile(new Blob(['x'], { type: 'text/markdown' }), 'notes.md') + + expect(saved).toBe(false) + const options = vi.mocked(showToast.info).mock.calls[0][1] + options?.action?.onClick() + await vi.waitFor(() => expect(share).toHaveBeenCalledTimes(2)) + expect(clickSpy).not.toHaveBeenCalled() + }) +}) + +describe('saveFileFromUrl', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + Reflect.deleteProperty(navigator, 'standalone') + }) + + it('reports a fetch failure and returns false in an iOS home screen app', async () => { + setIosHomeScreenApp() + vi.mocked(fetchWrapperBlob).mockRejectedValueOnce(new Error('network')) + + const saved = await saveFileFromUrl('/api/files/notes.md', 'notes.md') + + expect(saved).toBe(false) + expect(showToast.error).toHaveBeenCalledWith('Failed to save notes.md') + }) +}) diff --git a/frontend/src/lib/download.ts b/frontend/src/lib/download.ts new file mode 100644 index 000000000..16025dd9d --- /dev/null +++ b/frontend/src/lib/download.ts @@ -0,0 +1,73 @@ +import { fetchWrapperBlob } from '@/api/fetchWrapper' +import { showToast } from './toast' + +function isIosHomeScreenApp(): boolean { + const isIos = + /iPad|iPhone|iPod/.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) + if (!isIos) return false + + const isStandalone = + (navigator as Navigator & { standalone?: boolean }).standalone === true || + window.matchMedia('(display-mode: standalone)').matches + return isStandalone +} + +function saveViaAnchor(href: string, filename: string): void { + const link = document.createElement('a') + link.href = href + link.download = filename + document.body.appendChild(link) + link.click() + document.body.removeChild(link) +} + +async function saveViaShareSheet(blob: Blob, filename: string): Promise { + const file = new File([blob], filename, { type: blob.type || 'application/octet-stream' }) + + if (!navigator.canShare?.({ files: [file] })) { + showToast.error(`${filename} cannot be saved from this device`) + return false + } + + try { + await navigator.share({ files: [file] }) + return true + } catch (error) { + const name = error instanceof DOMException ? error.name : '' + if (name === 'AbortError') return false + if (name === 'NotAllowedError') { + showToast.info(`Tap Save to finish saving ${filename}`, { + action: { label: 'Save', onClick: () => void saveViaShareSheet(blob, filename) }, + }) + return false + } + showToast.error(`Failed to save ${filename}`) + return false + } +} + +export async function saveFile(blob: Blob, filename: string): Promise { + if (isIosHomeScreenApp()) { + return saveViaShareSheet(blob, filename) + } + + const url = URL.createObjectURL(blob) + saveViaAnchor(url, filename) + setTimeout(() => URL.revokeObjectURL(url), 0) + return true +} + +export async function saveFileFromUrl(url: string, filename: string): Promise { + if (isIosHomeScreenApp()) { + try { + return await saveFile(await fetchWrapperBlob(url), filename) + } catch { + showToast.error(`Failed to save ${filename}`) + return false + } + } + + saveViaAnchor(url, filename) + return true +} diff --git a/frontend/src/lib/exportSession.ts b/frontend/src/lib/exportSession.ts index 949ae1c3a..17cd60893 100644 --- a/frontend/src/lib/exportSession.ts +++ b/frontend/src/lib/exportSession.ts @@ -1,4 +1,5 @@ import type { MessageWithParts, Session, Part } from '@/api/types' +import { saveFile } from './download' function formatDate(timestamp: number): string { return new Date(timestamp).toLocaleString() @@ -115,16 +116,8 @@ function generateSessionMarkdown( return lines.join('\n') } -export function downloadMarkdown(content: string, filename: string): void { - const blob = new Blob([content], { type: 'text/markdown' }) - const url = URL.createObjectURL(blob) - const link = document.createElement('a') - link.href = url - link.download = filename - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - URL.revokeObjectURL(url) +export async function downloadMarkdown(content: string, filename: string): Promise { + return saveFile(new Blob([content], { type: 'text/markdown' }), filename) } export function exportSession( diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index 1d832801e..cf2f78dc2 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -412,7 +412,7 @@ export function SessionDetail() { return newValue }, [preferences?.expandToolCalls, updateSettings]); - const handleExportSession = useCallback(() => { + const handleExportSession = useCallback(async () => { const data = getMessagesWithParts() if (!data || !session) { showToast.error('No session data to export') @@ -420,8 +420,9 @@ export function SessionDetail() { } const { filename, content } = exportSession(data, session) - downloadMarkdown(content, filename) - showToast.success(`Exported to ${filename}`) + if (await downloadMarkdown(content, filename)) { + showToast.success(`Exported to ${filename}`) + } }, [getMessagesWithParts, session]); const handleUndoMessage = useCallback((restoredPrompt: string) => {