| 1 | export const COMPOSER_ATTACHMENT_DIRECTORY = "t3-composer-attachments"; |
| 2 | |
| 3 | const UUID_PATTERN = "[a-f\\d]{8}-[a-f\\d]{4}-[a-f\\d]{4}-[a-f\\d]{4}-[a-f\\d]{12}"; |
| 4 | const GENERATED_FILE_NAME = new RegExp(`^${UUID_PATTERN}-`, "i"); |
| 5 | const IOS_DOCUMENTS_PATH = new RegExp( |
| 6 | `^(.*/Containers/Data/Application/)${UUID_PATTERN}/Documents$`, |
| 7 | "i", |
| 8 | ); |
| 9 | const retainedFiles = new Map<string, number>(); |
| 10 | |
| 11 | function fileUriPath(uri: string): string | null { |
| 12 | try { |
| 13 | const url = new URL(uri); |
| 14 | if (url.protocol !== "file:" || url.hostname || url.search || url.hash) { |
| 15 | return null; |
| 16 | } |
| 17 | const path = decodeURIComponent(url.pathname); |
| 18 | if (path.includes("\\") || path.includes("\0") || path.split("/").includes("..")) { |
| 19 | return null; |
| 20 | } |
| 21 | return path.startsWith("/private/var/") ? path.slice("/private".length) : path; |
| 22 | } catch { |
| 23 | return null; |
| 24 | } |
| 25 | } |
| 26 | |
| 27 | function ownedFileLocation(uri: string) { |
| 28 | const path = fileUriPath(uri); |
| 29 | if (path === null) { |
| 30 | return null; |
| 31 | } |
| 32 | const separator = `/${COMPOSER_ATTACHMENT_DIRECTORY}/`; |
| 33 | const index = path.lastIndexOf(separator); |
| 34 | const name = index < 0 ? "" : path.slice(index + separator.length); |
| 35 | if (!name || name === "." || name.includes("/")) { |
| 36 | return null; |
| 37 | } |
| 38 | return { documentPath: path.slice(0, index), name }; |
| 39 | } |
| 40 | |
| 41 | /** Compares references across iOS data-container moves without rewriting saved drafts. */ |
| 42 | export function composerAttachmentFileReferenceKey(uri: string): string { |
| 43 | const location = ownedFileLocation(uri); |
| 44 | if (!location) { |
| 45 | return uri; |
| 46 | } |
| 47 | const containerPrefix = GENERATED_FILE_NAME.test(location.name) |
| 48 | ? IOS_DOCUMENTS_PATH.exec(location.documentPath)?.[1] |
| 49 | : undefined; |
| 50 | const documentPath = containerPrefix |
| 51 | ? `${containerPrefix}<app>/Documents` |
| 52 | : location.documentPath; |
| 53 | return `file://${documentPath}/${COMPOSER_ATTACHMENT_DIRECTORY}/${encodeURIComponent(location.name)}`; |
| 54 | } |
| 55 | |
| 56 | /** Holds a local copy until its last player or share-copy operation releases it. */ |
| 57 | export function retainComposerAttachmentFile(uri: string, onLastRelease: () => void): () => void { |
| 58 | const key = composerAttachmentFileReferenceKey(uri); |
| 59 | retainedFiles.set(key, (retainedFiles.get(key) ?? 0) + 1); |
| 60 | let released = false; |
| 61 | return () => { |
| 62 | if (released) { |
| 63 | return; |
| 64 | } |
| 65 | released = true; |
| 66 | const remaining = (retainedFiles.get(key) ?? 1) - 1; |
| 67 | if (remaining > 0) { |
| 68 | retainedFiles.set(key, remaining); |
| 69 | return; |
| 70 | } |
| 71 | retainedFiles.delete(key); |
| 72 | onLastRelease(); |
| 73 | }; |
| 74 | } |
| 75 | |
| 76 | export function isComposerAttachmentFileRetained(uri: string): boolean { |
| 77 | return retainedFiles.has(composerAttachmentFileReferenceKey(uri)); |
| 78 | } |
| 79 | |
| 80 | /** |
| 81 | * Resolves only our saved attachment copies. iOS preserves Documents on updates |
| 82 | * but can change its container UUID. Picker and open-in-place source URIs must |
| 83 | * bypass this resolver so another app's document keeps its original location. |
| 84 | */ |
| 85 | export function resolveOwnedComposerAttachmentFileUri( |
| 86 | uri: string, |
| 87 | documentDirectoryUri: string, |
| 88 | ): string | null { |
| 89 | const location = ownedFileLocation(uri); |
| 90 | const documentPath = fileUriPath(documentDirectoryUri)?.replace(/\/+$/, ""); |
| 91 | if (!location || !documentPath) { |
| 92 | return null; |
| 93 | } |
| 94 | if (location.documentPath !== documentPath) { |
| 95 | const currentContainerPrefix = IOS_DOCUMENTS_PATH.exec(documentPath)?.[1]; |
| 96 | if ( |
| 97 | !currentContainerPrefix || |
| 98 | currentContainerPrefix !== IOS_DOCUMENTS_PATH.exec(location.documentPath)?.[1] || |
| 99 | !GENERATED_FILE_NAME.test(location.name) |
| 100 | ) { |
| 101 | return null; |
| 102 | } |
| 103 | } |
| 104 | const resolved = new URL(documentDirectoryUri); |
| 105 | resolved.pathname = `${resolved.pathname.replace(/\/+$/, "")}/${COMPOSER_ATTACHMENT_DIRECTORY}/${encodeURIComponent(location.name)}`; |
| 106 | return resolved.href; |
| 107 | } |