fix(ios): open the share sheet on iOS 16 for files with spaces or non-ASCII names, closes #6719 (#6770)

* fix(ios): open the share sheet on iOS 16 for files with spaces or non-ASCII names, closes #6719

The share plugin parses the path it gets with URL(string:), which on iOS 16
returns nil for a space or non-ASCII character. The share was rejected,
the rejection was treated as a cancel, and the export reported success
with no share sheet. iOS now gets a percent-encoded file URL.

The plugin also copies the file to <tmp>/<name> and keeps a copy that is
already there, so a second export of the same book shared the first one.
That copy is removed before sharing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ios): share an "Open with" book's file URI without wrapping it again

On iOS an "Open with" book keeps its decoded file:// URI as its file
path, which the share path then encoded into a second file URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Huang Xin 2026-10-14 00:55:49 +08:00 • committed by GitHub
parent 354f90341b
commit bb55cda8b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 49 additions and 2 deletions

View file

@ -7,6 +7,7 @@ const mkdirMock = vi.fn().mockResolvedValue(undefined);
const saveDialogMock = vi.fn().mockResolvedValue('/tmp/exported.md');
const shareFileMock = vi.fn().mockResolvedValue(undefined);
const copyURIToPathMock = vi.fn().mockResolvedValue({ success: true });
const removeMock = vi.fn().mockResolvedValue(undefined);
vi.mock('@tauri-apps/plugin-os', () => ({
type: () => osTypeMock(),
@ -20,7 +21,7 @@ vi.mock('@tauri-apps/plugin-fs', () => ({
writeTextFile: (...args: unknown[]) => writeTextFileMock(...args),
writeFile: (...args: unknown[]) => writeFileMock(...args),
readDir: vi.fn().mockResolvedValue([]),
remove: vi.fn().mockResolvedValue(undefined),
remove: (...args: unknown[]) => removeMock(...args),
copyFile: vi.fn().mockResolvedValue(undefined),
stat: vi.fn().mockResolvedValue({ size: 0 }),
BaseDirectory: {},
@ -91,6 +92,7 @@ describe('NativeAppService.saveFile share gating', () => {
saveDialogMock.mockClear();
shareFileMock.mockClear();
copyURIToPathMock.mockClear();
removeMock.mockClear();
});
test('uses a unique cache path for same-named content-provider files', async () => {
@ -169,7 +171,41 @@ describe('NativeAppService.saveFile share gating', () => {
const bytes = new Uint8Array([1, 2, 3]).buffer;
await service.saveFile('image.png', bytes, { share: true, mimeType: 'image/png' });
expect(shareFileMock.mock.calls[0]![0]).toBe('/tmp/shared/image.png');
expect(shareFileMock.mock.calls[0]![0]).toBe('file:///tmp/shared/image.png');
});
// Regression (#6719): the iOS plugin parses the path with `URL(string:)`,
// which on iOS 16 returns nil for a space or non-ASCII character, so the
// share sheet never opened. It gets a percent-encoded file URL instead.
test('hands iOS a percent-encoded file URL', async () => {
const service = await loadServiceWithOS('ios');
await service.saveFile('三体 (1).md', 'notes', { share: true, mimeType: 'text/markdown' });
expect(shareFileMock.mock.calls[0]![0]).toBe('file:///tmp/shared/%E4%B8%89%E4%BD%93%20(1).md');
});
// An iOS "Open with" book keeps its decoded `file://` URI as its file path.
test('does not re-wrap a file URI on iOS', async () => {
const service = await loadServiceWithOS('ios');
await service.saveFile('My Book.epub', null, {
share: true,
filePath: 'file:///private/var/Inbox/My Book.epub',
});
expect(shareFileMock.mock.calls[0]![0]).toBe('file:///private/var/Inbox/My%20Book.epub');
expect(removeMock).toHaveBeenCalledWith('/tmp/My Book.epub');
});
// The iOS plugin copies the file to `<tmp>/<name>` and silently keeps an
// existing copy, so a second export of the same book shared the first one.
test('clears the plugin copy of a previous share on iOS', async () => {
const service = await loadServiceWithOS('ios');
await service.saveFile('notes.md', 'hello', { share: true, mimeType: 'text/markdown' });
expect(removeMock).toHaveBeenCalledWith('/tmp/notes.md');
expect(removeMock.mock.invocationCallOrder[0]).toBeLessThan(
shareFileMock.mock.invocationCallOrder[0]!,
);
});
// The book "Send" flow hands an already-on-disk file straight to the share

View file

@ -920,6 +920,17 @@ export class NativeAppService extends BaseAppService {
await writeFile(shareablePath, new Uint8Array(content));
}
}
if (this.isIOSApp) {
// An "Open with" book keeps its already-decoded `file://` URI as its path.
shareablePath = shareablePath.replace(/^file:\/\//, '');
// The iOS plugin copies the file to `<tmp>/<name>` and keeps any copy
// already there, which would share a previous export of the same name.
const pluginCopy = await this.resolveFilePath(getFilename(shareablePath), 'Temp');
if (pluginCopy !== shareablePath) await remove(pluginCopy).catch(() => {});
// It also parses the path with `URL(string:)`, which on iOS 16 fails
// on a space or non-ASCII character, so hand it an encoded file URL.
shareablePath = `file://${shareablePath.split('/').map(encodeURIComponent).join('/')}`;
}
try {
await shareFile(shareablePath, {
mimeType: options?.mimeType || 'application/octet-stream',