From 2c00d244d9cab6ae40f56a4745307d4557145abe Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:22:55 -0400 Subject: [PATCH 1/2] fix(download): keep iOS home screen app saves in-app via the share sheet --- frontend/src/api/files.ts | 10 +-- frontend/src/api/repos.ts | 10 +-- .../components/file-browser/FilePreview.tsx | 8 +-- .../src/components/file-browser/FileTree.tsx | 9 +-- .../settings/OpenCodeConfigManager.tsx | 8 +-- frontend/src/lib/download.test.ts | 70 +++++++++++++++++++ frontend/src/lib/download.ts | 66 +++++++++++++++++ frontend/src/lib/exportSession.ts | 13 +--- frontend/src/pages/SessionDetail.tsx | 2 +- 9 files changed, 150 insertions(+), 46 deletions(-) create mode 100644 frontend/src/lib/download.test.ts create mode 100644 frontend/src/lib/download.ts 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..969640034 --- /dev/null +++ b/frontend/src/lib/download.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { saveFile } from './download' +import { showToast } from './toast' + +vi.mock('./toast', () => ({ + showToast: { + error: vi.fn(), + info: 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(() => { + 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 () => { + await saveFile(new Blob(['x'], { type: 'text/markdown' }), 'notes.md') + + 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 }) + + await saveFile(new Blob(['x'], { type: 'text/markdown' }), 'notes.md') + + expect(clickSpy).not.toHaveBeenCalled() + const shared = share.mock.calls[0][0].files[0] as File + expect(shared.name).toBe('notes.md') + }) + + 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 }) + + await saveFile(new Blob(['x'], { type: 'text/markdown' }), 'notes.md') + + const options = vi.mocked(showToast.info).mock.calls[0][1] + options?.action?.onClick() + await vi.waitFor(() => expect(share).toHaveBeenCalledTimes(2)) + expect(clickSpy).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/lib/download.ts b/frontend/src/lib/download.ts new file mode 100644 index 000000000..f5bf366b1 --- /dev/null +++ b/frontend/src/lib/download.ts @@ -0,0 +1,66 @@ +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 + } + + try { + await navigator.share({ files: [file] }) + } catch (error) { + const name = error instanceof DOMException ? error.name : '' + if (name === 'AbortError') return + if (name === 'NotAllowedError') { + showToast.info(`Tap Save to finish saving ${filename}`, { + action: { label: 'Save', onClick: () => void saveViaShareSheet(blob, filename) }, + }) + return + } + showToast.error(`Failed to save ${filename}`) + } +} + +export async function saveFile(blob: Blob, filename: string): Promise { + if (isIosHomeScreenApp()) { + await saveViaShareSheet(blob, filename) + return + } + + const url = URL.createObjectURL(blob) + saveViaAnchor(url, filename) + setTimeout(() => URL.revokeObjectURL(url), 0) +} + +export async function saveFileFromUrl(url: string, filename: string): Promise { + if (isIosHomeScreenApp()) { + await saveFile(await fetchWrapperBlob(url), filename) + return + } + + saveViaAnchor(url, filename) +} diff --git a/frontend/src/lib/exportSession.ts b/frontend/src/lib/exportSession.ts index 949ae1c3a..4b7d20437 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 { + await 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..453267623 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -420,7 +420,7 @@ export function SessionDetail() { } const { filename, content } = exportSession(data, session) - downloadMarkdown(content, filename) + void downloadMarkdown(content, filename) showToast.success(`Exported to ${filename}`) }, [getMessagesWithParts, session]); From cc9965beb441d2b7a90f69c38fc9900fdf9dcfcf Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:36:46 -0400 Subject: [PATCH 2/2] fix: apply CodeRabbit auto-fixes --- frontend/src/lib/download.test.ts | 51 +++++++++++++++++++++++++--- frontend/src/lib/download.ts | 27 +++++++++------ frontend/src/lib/exportSession.ts | 4 +-- frontend/src/pages/SessionDetail.tsx | 7 ++-- 4 files changed, 70 insertions(+), 19 deletions(-) diff --git a/frontend/src/lib/download.test.ts b/frontend/src/lib/download.test.ts index 969640034..fcf014e5b 100644 --- a/frontend/src/lib/download.test.ts +++ b/frontend/src/lib/download.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { saveFile } from './download' +import { saveFile, saveFileFromUrl } from './download' +import { fetchWrapperBlob } from '@/api/fetchWrapper' import { showToast } from './toast' vi.mock('./toast', () => ({ @@ -9,6 +10,10 @@ vi.mock('./toast', () => ({ }, })) +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 }) @@ -18,6 +23,7 @@ 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'), @@ -34,8 +40,9 @@ describe('saveFile', () => { }) it('downloads via an anchor outside iOS home screen apps', async () => { - await saveFile(new Blob(['x'], { type: 'text/markdown' }), 'notes.md') + const saved = await saveFile(new Blob(['x'], { type: 'text/markdown' }), 'notes.md') + expect(saved).toBe(true) expect(clickSpy).toHaveBeenCalledTimes(1) }) @@ -45,13 +52,26 @@ describe('saveFile', () => { Object.defineProperty(navigator, 'canShare', { value: () => true, configurable: true }) Object.defineProperty(navigator, 'share', { value: share, configurable: true }) - await saveFile(new Blob(['x'], { type: 'text/markdown' }), 'notes.md') + 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() @@ -60,11 +80,34 @@ describe('saveFile', () => { Object.defineProperty(navigator, 'canShare', { value: () => true, configurable: true }) Object.defineProperty(navigator, 'share', { value: share, configurable: true }) - await saveFile(new Blob(['x'], { type: 'text/markdown' }), 'notes.md') + 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 index f5bf366b1..16025dd9d 100644 --- a/frontend/src/lib/download.ts +++ b/frontend/src/lib/download.ts @@ -22,45 +22,52 @@ function saveViaAnchor(href: string, filename: string): void { document.body.removeChild(link) } -async function saveViaShareSheet(blob: Blob, filename: string): Promise { +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 + return false } try { await navigator.share({ files: [file] }) + return true } catch (error) { const name = error instanceof DOMException ? error.name : '' - if (name === 'AbortError') return + 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 + return false } showToast.error(`Failed to save ${filename}`) + return false } } -export async function saveFile(blob: Blob, filename: string): Promise { +export async function saveFile(blob: Blob, filename: string): Promise { if (isIosHomeScreenApp()) { - await saveViaShareSheet(blob, filename) - return + 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 { +export async function saveFileFromUrl(url: string, filename: string): Promise { if (isIosHomeScreenApp()) { - await saveFile(await fetchWrapperBlob(url), filename) - return + 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 4b7d20437..17cd60893 100644 --- a/frontend/src/lib/exportSession.ts +++ b/frontend/src/lib/exportSession.ts @@ -116,8 +116,8 @@ function generateSessionMarkdown( return lines.join('\n') } -export async function downloadMarkdown(content: string, filename: string): Promise { - await saveFile(new Blob([content], { type: 'text/markdown' }), filename) +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 453267623..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) - void 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) => {