mirror of
https://github.com/readest/readest.git
synced 2026-10-19 10:13:29 +00:00
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:
parent
354f90341b
commit
bb55cda8b4
2 changed files with 49 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue