1export const COMPOSER_ATTACHMENT_DIRECTORY = "t3-composer-attachments";
2
3const UUID_PATTERN = "[a-f\\d]{8}-[a-f\\d]{4}-[a-f\\d]{4}-[a-f\\d]{4}-[a-f\\d]{12}";
4const GENERATED_FILE_NAME = new RegExp(`^${UUID_PATTERN}-`, "i");
5const IOS_DOCUMENTS_PATH = new RegExp(
6 `^(.*/Containers/Data/Application/)${UUID_PATTERN}/Documents$`,
7 "i",
8);
9const retainedFiles = new Map<string, number>();
10
11function 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
27function 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. */
42export 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. */
57export 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
76export 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 */
85export 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}