birdclaw 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +113 -7
  3. package/bin/birdclaw.mjs +50 -11
  4. package/package.json +30 -28
  5. package/playwright.config.ts +1 -0
  6. package/public/birdclaw-mark.png +0 -0
  7. package/public/favicon.ico +0 -0
  8. package/public/logo192.png +0 -0
  9. package/public/logo512.png +0 -0
  10. package/public/manifest.json +2 -2
  11. package/scripts/browser-perf.mjs +399 -0
  12. package/scripts/build-docs-site.mjs +940 -0
  13. package/scripts/docs-site-assets.mjs +311 -0
  14. package/scripts/run-vitest.mjs +21 -0
  15. package/scripts/sanitize-node-options.mjs +23 -0
  16. package/scripts/start-test-server.mjs +29 -0
  17. package/src/cli.ts +496 -19
  18. package/src/components/AppNav.tsx +66 -29
  19. package/src/components/AvatarChip.tsx +10 -5
  20. package/src/components/BrandMark.tsx +67 -0
  21. package/src/components/ConversationThread.tsx +126 -0
  22. package/src/components/DmWorkspace.tsx +118 -105
  23. package/src/components/EmbeddedTweetCard.tsx +20 -14
  24. package/src/components/FeedState.tsx +147 -0
  25. package/src/components/InboxCard.tsx +104 -90
  26. package/src/components/LinkPreviewCard.tsx +270 -0
  27. package/src/components/ProfilePreview.tsx +8 -3
  28. package/src/components/SavedTimelineView.tsx +89 -71
  29. package/src/components/SyncNowButton.tsx +105 -0
  30. package/src/components/ThemeSlider.tsx +10 -59
  31. package/src/components/TimelineCard.tsx +326 -86
  32. package/src/components/TimelineRouteFrame.tsx +156 -0
  33. package/src/components/TweetMediaGrid.tsx +120 -23
  34. package/src/components/TweetRichText.tsx +19 -4
  35. package/src/components/useTimelineRouteData.ts +137 -0
  36. package/src/lib/api-client.ts +229 -0
  37. package/src/lib/archive-finder.ts +24 -20
  38. package/src/lib/archive-import.ts +1582 -67
  39. package/src/lib/authored-live.ts +1074 -0
  40. package/src/lib/backup.ts +316 -14
  41. package/src/lib/bird-actions.ts +1 -10
  42. package/src/lib/bird-command.ts +57 -0
  43. package/src/lib/bird.ts +89 -5
  44. package/src/lib/config.ts +1 -1
  45. package/src/lib/conversation-surface.ts +174 -0
  46. package/src/lib/db.ts +193 -4
  47. package/src/lib/follow-graph.ts +1053 -0
  48. package/src/lib/link-index.ts +11 -98
  49. package/src/lib/link-insights.ts +834 -0
  50. package/src/lib/link-preview-metadata.ts +334 -0
  51. package/src/lib/media-fetch.ts +787 -0
  52. package/src/lib/media-includes.ts +165 -0
  53. package/src/lib/mention-threads-live.ts +535 -43
  54. package/src/lib/mentions-live.ts +623 -19
  55. package/src/lib/moderation-target.ts +6 -0
  56. package/src/lib/profile-hydration.ts +1 -1
  57. package/src/lib/profile-resolver.ts +115 -1
  58. package/src/lib/queries.ts +326 -35
  59. package/src/lib/seed.ts +127 -8
  60. package/src/lib/timeline-collections-live.ts +145 -22
  61. package/src/lib/timeline-live.ts +10 -15
  62. package/src/lib/tweet-account-edges.ts +9 -2
  63. package/src/lib/tweet-render.ts +97 -0
  64. package/src/lib/types.ts +185 -2
  65. package/src/lib/ui.ts +383 -147
  66. package/src/lib/url-expansion-store.ts +110 -0
  67. package/src/lib/url-expansion.ts +20 -3
  68. package/src/lib/web-sync.ts +443 -0
  69. package/src/lib/x-profile.ts +7 -2
  70. package/src/lib/xurl.ts +296 -12
  71. package/src/routeTree.gen.ts +126 -0
  72. package/src/routes/__root.tsx +7 -3
  73. package/src/routes/api/conversation.tsx +34 -0
  74. package/src/routes/api/link-insights.tsx +79 -0
  75. package/src/routes/api/link-preview.tsx +43 -0
  76. package/src/routes/api/profile-hydrate.tsx +51 -0
  77. package/src/routes/api/sync.tsx +59 -0
  78. package/src/routes/blocks.tsx +111 -86
  79. package/src/routes/dms.tsx +172 -87
  80. package/src/routes/inbox.tsx +98 -86
  81. package/src/routes/index.tsx +22 -115
  82. package/src/routes/links.tsx +928 -0
  83. package/src/routes/mentions.tsx +22 -115
  84. package/src/styles.css +169 -43
  85. package/vite.config.ts +8 -0
@@ -1,39 +1,136 @@
1
+ import { X } from "lucide-react";
2
+ import { useState } from "react";
1
3
  import type { TweetMediaItem } from "#/lib/types";
2
4
  import { tweetMediaGridClass, tweetMediaTileClass } from "#/lib/ui";
3
5
 
4
6
  export function TweetMediaGrid({ items }: { items: TweetMediaItem[] }) {
7
+ const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
5
8
  if (items.length === 0) {
6
9
  return null;
7
10
  }
8
11
 
12
+ const visibleItems = items.slice(0, 4);
13
+ const selectedItem =
14
+ selectedIndex === null ? null : (visibleItems[selectedIndex] ?? null);
15
+ const selectedVideoUrl =
16
+ selectedItem?.type === "video" || selectedItem?.type === "gif"
17
+ ? (selectedItem.variants?.[0]?.url ?? playableVideoUrl(selectedItem.url))
18
+ : null;
19
+
9
20
  return (
10
- <div className={tweetMediaGridClass(Math.min(items.length, 4))}>
11
- {items.slice(0, 4).map((item, index) => (
12
- <a
13
- key={item.url + String(index)}
14
- className={tweetMediaTileClass(index, Math.min(items.length, 4))}
15
- href={item.url}
16
- rel="noreferrer"
17
- target="_blank"
21
+ <>
22
+ <div className={tweetMediaGridClass(Math.min(items.length, 4))}>
23
+ {visibleItems.map((item, index) => (
24
+ <button
25
+ key={item.url + String(index)}
26
+ aria-label={`Open tweet media ${String(index + 1)}`}
27
+ className={tweetMediaTileClass(index, Math.min(items.length, 4))}
28
+ onClick={(event) => {
29
+ event.stopPropagation();
30
+ setSelectedIndex(index);
31
+ }}
32
+ style={
33
+ visibleItems.length === 1 && item.width && item.height
34
+ ? {
35
+ aspectRatio: `${String(item.width)} / ${String(item.height)}`,
36
+ }
37
+ : undefined
38
+ }
39
+ type="button"
40
+ >
41
+ {item.type === "image" ? (
42
+ <img
43
+ alt={item.altText ?? `Tweet media ${String(index + 1)}`}
44
+ className="tweet-media-image block size-full object-contain"
45
+ loading="lazy"
46
+ src={item.thumbnailUrl ?? item.url}
47
+ />
48
+ ) : (
49
+ <span className="tweet-media-fallback grid min-h-40 place-items-center font-semibold text-[var(--ink-soft)]">
50
+ {item.type === "video"
51
+ ? "Video"
52
+ : item.type === "gif"
53
+ ? "GIF"
54
+ : "Media"}
55
+ </span>
56
+ )}
57
+ </button>
58
+ ))}
59
+ </div>
60
+ {selectedItem ? (
61
+ <div
62
+ aria-modal="true"
63
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 p-4"
64
+ onClick={(event) => {
65
+ event.stopPropagation();
66
+ setSelectedIndex(null);
67
+ }}
68
+ role="dialog"
18
69
  >
19
- {item.type === "image" ? (
70
+ <button
71
+ aria-label="Close media viewer"
72
+ className="absolute right-4 top-4 grid size-10 place-items-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20"
73
+ onClick={(event) => {
74
+ event.stopPropagation();
75
+ setSelectedIndex(null);
76
+ }}
77
+ type="button"
78
+ >
79
+ <X className="size-5" strokeWidth={1.8} />
80
+ </button>
81
+ {selectedItem.type === "image" ? (
20
82
  <img
21
- alt={item.altText ?? `Tweet media ${String(index + 1)}`}
22
- className="tweet-media-image block size-full object-cover"
23
- loading="lazy"
24
- src={item.thumbnailUrl ?? item.url}
83
+ alt={selectedItem.altText ?? "Tweet media"}
84
+ className="max-h-[92vh] max-w-[92vw] object-contain"
85
+ onClick={(event) => event.stopPropagation()}
86
+ src={selectedItem.url}
87
+ />
88
+ ) : selectedVideoUrl ? (
89
+ <video
90
+ autoPlay={selectedItem.type === "gif"}
91
+ className="max-h-[92vh] max-w-[92vw]"
92
+ controls
93
+ loop={selectedItem.type === "gif"}
94
+ muted={selectedItem.type === "gif"}
95
+ onClick={(event) => event.stopPropagation()}
96
+ playsInline
97
+ poster={selectedItem.thumbnailUrl}
98
+ src={selectedVideoUrl}
25
99
  />
26
100
  ) : (
27
- <span className="tweet-media-fallback grid min-h-40 place-items-center font-semibold text-[var(--ink-soft)]">
28
- {item.type === "video"
29
- ? "Video"
30
- : item.type === "gif"
31
- ? "GIF"
32
- : "Media"}
33
- </span>
101
+ <div
102
+ className="grid min-h-64 min-w-80 place-items-center gap-3 rounded-2xl border border-white/20 bg-black p-6 text-white"
103
+ onClick={(event) => event.stopPropagation()}
104
+ >
105
+ <span>
106
+ {selectedItem.type === "video"
107
+ ? "Video"
108
+ : selectedItem.type === "gif"
109
+ ? "GIF"
110
+ : "Media"}
111
+ </span>
112
+ <a
113
+ className="rounded-full bg-white/10 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-white/20"
114
+ href={selectedItem.url}
115
+ rel="noreferrer"
116
+ target="_blank"
117
+ >
118
+ Open media
119
+ </a>
120
+ </div>
34
121
  )}
35
- </a>
36
- ))}
37
- </div>
122
+ </div>
123
+ ) : null}
124
+ </>
38
125
  );
39
126
  }
127
+
128
+ function playableVideoUrl(url: string) {
129
+ try {
130
+ const parsed = new URL(url);
131
+ if (parsed.hostname === "video.twimg.com") return url;
132
+ return /\.(?:mp4|m3u8)(?:$|[?#])/i.test(parsed.pathname) ? url : undefined;
133
+ } catch {
134
+ return /\.(?:mp4|m3u8)(?:$|[?#])/i.test(url) ? url : undefined;
135
+ }
136
+ }
@@ -1,5 +1,9 @@
1
1
  import { Fragment } from "react";
2
- import { collectTweetSegments } from "#/lib/tweet-render";
2
+ import type { ReactNode } from "react";
3
+ import {
4
+ collectTweetSegments,
5
+ enrichFallbackUrlEntities,
6
+ } from "#/lib/tweet-render";
3
7
  import type { TweetEntities } from "#/lib/types";
4
8
  import {
5
9
  bodyCopyClass,
@@ -13,12 +17,15 @@ export function TweetRichText({
13
17
  text,
14
18
  entities,
15
19
  className = "body-copy",
20
+ hiddenUrlRanges = [],
16
21
  }: {
17
22
  text: string;
18
23
  entities: TweetEntities;
19
24
  className?: string;
25
+ hiddenUrlRanges?: Array<{ start: number; end: number }>;
20
26
  }) {
21
- const segments = collectTweetSegments(entities);
27
+ const richEntities = enrichFallbackUrlEntities(text, entities);
28
+ const segments = collectTweetSegments(richEntities);
22
29
  let cursor = 0;
23
30
 
24
31
  return (
@@ -35,12 +42,20 @@ export function TweetRichText({
35
42
  const prefix = text.slice(cursor, segment.start);
36
43
  cursor = segment.end;
37
44
 
38
- let node = (
45
+ let node: ReactNode = (
39
46
  <Fragment key={`segment-${String(index)}`}>
40
47
  {text.slice(segment.start, segment.end)}
41
48
  </Fragment>
42
49
  );
43
- if (segment.kind === "mention" && segment.profile) {
50
+ if (
51
+ segment.kind === "url" &&
52
+ hiddenUrlRanges.some(
53
+ (range) =>
54
+ range.start === segment.start && range.end === segment.end,
55
+ )
56
+ ) {
57
+ node = null;
58
+ } else if (segment.kind === "mention" && segment.profile) {
44
59
  node = (
45
60
  <ProfilePreview
46
61
  key={`segment-${String(index)}`}
@@ -0,0 +1,137 @@
1
+ import { useEffect, useState } from "react";
2
+ import type {
3
+ QueryEnvelope,
4
+ ReplyFilter,
5
+ ResourceKind,
6
+ TimelineItem,
7
+ } from "#/lib/types";
8
+ import {
9
+ fetchQueryEnvelope,
10
+ fetchQueryResponse,
11
+ postAction,
12
+ } from "#/lib/api-client";
13
+
14
+ interface UseTimelineRouteDataOptions {
15
+ resource: Exclude<ResourceKind, "dms">;
16
+ search: string;
17
+ errorFallback: string;
18
+ replyFilter?: ReplyFilter;
19
+ likedOnly?: boolean;
20
+ bookmarkedOnly?: boolean;
21
+ }
22
+
23
+ export function useTimelineRouteData({
24
+ resource,
25
+ search,
26
+ errorFallback,
27
+ replyFilter,
28
+ likedOnly = false,
29
+ bookmarkedOnly = false,
30
+ }: UseTimelineRouteDataOptions) {
31
+ const [meta, setMeta] = useState<QueryEnvelope | null>(null);
32
+ const [items, setItems] = useState<TimelineItem[]>([]);
33
+ const [loading, setLoading] = useState(true);
34
+ const [error, setError] = useState<string | null>(null);
35
+ const [refreshTick, setRefreshTick] = useState(0);
36
+
37
+ async function loadStatus() {
38
+ setMeta(await fetchQueryEnvelope());
39
+ }
40
+
41
+ useEffect(() => {
42
+ void loadStatus();
43
+ }, []);
44
+
45
+ useEffect(() => {
46
+ const url = new URL("/api/query", window.location.origin);
47
+ url.searchParams.set("resource", resource);
48
+ url.searchParams.set("refresh", String(refreshTick));
49
+ if (replyFilter) {
50
+ url.searchParams.set("replyFilter", replyFilter);
51
+ }
52
+ if (likedOnly) {
53
+ url.searchParams.set("liked", "true");
54
+ }
55
+ if (bookmarkedOnly) {
56
+ url.searchParams.set("bookmarked", "true");
57
+ }
58
+ if (search.trim()) {
59
+ url.searchParams.set("search", search.trim());
60
+ }
61
+
62
+ const controller = new AbortController();
63
+ let active = true;
64
+ setError(null);
65
+ setLoading(true);
66
+ fetchQueryResponse(url, { signal: controller.signal })
67
+ .then((data) => {
68
+ if (active) {
69
+ setItems(data.items as TimelineItem[]);
70
+ }
71
+ })
72
+ .catch((fetchError: unknown) => {
73
+ if (
74
+ fetchError instanceof DOMException &&
75
+ fetchError.name === "AbortError"
76
+ ) {
77
+ return;
78
+ }
79
+ if (!active) return;
80
+ setError(
81
+ fetchError instanceof Error ? fetchError.message : errorFallback,
82
+ );
83
+ setItems([]);
84
+ })
85
+ .finally(() => {
86
+ if (active) {
87
+ setLoading(false);
88
+ }
89
+ });
90
+
91
+ return () => {
92
+ active = false;
93
+ controller.abort();
94
+ };
95
+ }, [
96
+ bookmarkedOnly,
97
+ errorFallback,
98
+ likedOnly,
99
+ refreshTick,
100
+ replyFilter,
101
+ resource,
102
+ search,
103
+ ]);
104
+
105
+ function retry() {
106
+ setRefreshTick((value) => value + 1);
107
+ }
108
+
109
+ function refreshLocalView() {
110
+ setRefreshTick((value) => value + 1);
111
+ void loadStatus();
112
+ }
113
+
114
+ async function replyToTweet(tweetId: string) {
115
+ const text = window.prompt("Reply text");
116
+ if (!text?.trim()) return;
117
+
118
+ await postAction({
119
+ kind: "replyTweet",
120
+ accountId: "acct_primary",
121
+ tweetId,
122
+ text,
123
+ });
124
+
125
+ retry();
126
+ }
127
+
128
+ return {
129
+ meta,
130
+ items,
131
+ loading,
132
+ error,
133
+ retry,
134
+ refreshLocalView,
135
+ replyToTweet,
136
+ };
137
+ }
@@ -0,0 +1,229 @@
1
+ import { z } from "zod";
2
+ import type {
3
+ DmConversationItem,
4
+ DmMessageItem,
5
+ QueryEnvelope,
6
+ QueryResponse,
7
+ TimelineItem,
8
+ } from "./types";
9
+ import type {
10
+ WebSyncJobSnapshot,
11
+ WebSyncKind,
12
+ WebSyncResponse,
13
+ } from "./web-sync";
14
+
15
+ const jsonRecordSchema = z.object({}).passthrough();
16
+ const resourceKindSchema = z.enum(["home", "mentions", "authored", "dms"]);
17
+ const webSyncKindSchema = z.enum([
18
+ "timeline",
19
+ "mentions",
20
+ "likes",
21
+ "bookmarks",
22
+ "dms",
23
+ ]);
24
+
25
+ const queryEnvelopeSchema = z
26
+ .object({
27
+ accounts: z.array(jsonRecordSchema),
28
+ archives: z.array(jsonRecordSchema),
29
+ transport: z
30
+ .object({
31
+ statusText: z.string(),
32
+ })
33
+ .passthrough(),
34
+ stats: z.object({
35
+ home: z.number(),
36
+ mentions: z.number(),
37
+ dms: z.number(),
38
+ needsReply: z.number(),
39
+ inbox: z.number(),
40
+ }),
41
+ })
42
+ .transform((value) => value as unknown as QueryEnvelope);
43
+
44
+ const queryResponseSchema = z
45
+ .object({
46
+ resource: resourceKindSchema,
47
+ items: z.array(jsonRecordSchema),
48
+ selectedConversation: z
49
+ .object({
50
+ conversation: jsonRecordSchema,
51
+ messages: z.array(jsonRecordSchema),
52
+ })
53
+ .nullish(),
54
+ })
55
+ .transform(
56
+ (value) =>
57
+ ({
58
+ ...value,
59
+ items: value.items as unknown as Array<
60
+ TimelineItem | DmConversationItem
61
+ >,
62
+ selectedConversation: value.selectedConversation
63
+ ? {
64
+ conversation: value.selectedConversation
65
+ .conversation as unknown as DmConversationItem,
66
+ messages: value.selectedConversation
67
+ .messages as unknown as DmMessageItem[],
68
+ }
69
+ : value.selectedConversation,
70
+ }) as QueryResponse,
71
+ );
72
+
73
+ const webSyncResponseSchema = z
74
+ .object({
75
+ ok: z.boolean(),
76
+ kind: webSyncKindSchema,
77
+ accountId: z.string().optional(),
78
+ summary: z.string(),
79
+ steps: z.array(jsonRecordSchema),
80
+ startedAt: z.string().optional(),
81
+ finishedAt: z.string().optional(),
82
+ inProgress: z.boolean().optional(),
83
+ backup: z.unknown().optional(),
84
+ error: z.string().optional(),
85
+ })
86
+ .transform((value) => value as unknown as WebSyncResponse);
87
+
88
+ const webSyncJobSchema = z
89
+ .object({
90
+ id: z.string(),
91
+ kind: webSyncKindSchema,
92
+ accountId: z.string().optional(),
93
+ status: z.enum(["running", "succeeded", "failed"]),
94
+ startedAt: z.string(),
95
+ finishedAt: z.string().optional(),
96
+ summary: z.string(),
97
+ inProgress: z.boolean(),
98
+ result: webSyncResponseSchema.optional(),
99
+ error: z.string().optional(),
100
+ })
101
+ .transform((value) => value as unknown as WebSyncJobSnapshot);
102
+
103
+ const actionResponseSchema = jsonRecordSchema;
104
+ const SYNC_POLL_INTERVAL_MS = 500;
105
+
106
+ export class ApiFetchError extends Error {
107
+ constructor(
108
+ message: string,
109
+ readonly status?: number,
110
+ ) {
111
+ super(message);
112
+ this.name = "ApiFetchError";
113
+ }
114
+ }
115
+
116
+ function responseMessage(data: unknown, fallback: string) {
117
+ if (data && typeof data === "object") {
118
+ const record = data as {
119
+ message?: unknown;
120
+ error?: unknown;
121
+ summary?: unknown;
122
+ };
123
+ if (typeof record.message === "string") return record.message;
124
+ if (typeof record.error === "string") return record.error;
125
+ if (typeof record.summary === "string") return record.summary;
126
+ }
127
+ return fallback;
128
+ }
129
+
130
+ async function readJson(response: Response) {
131
+ try {
132
+ return await response.json();
133
+ } catch {
134
+ return null;
135
+ }
136
+ }
137
+
138
+ export async function fetchJson<T>(
139
+ input: RequestInfo | URL,
140
+ init: RequestInit | undefined,
141
+ schema: z.ZodType<T>,
142
+ fallbackMessage: string,
143
+ ): Promise<T> {
144
+ const response = await fetch(input, init);
145
+ const data = await readJson(response);
146
+ if (!response.ok) {
147
+ throw new ApiFetchError(
148
+ responseMessage(data, fallbackMessage),
149
+ response.status,
150
+ );
151
+ }
152
+
153
+ const parsed = schema.safeParse(data);
154
+ if (!parsed.success) {
155
+ throw new ApiFetchError(fallbackMessage);
156
+ }
157
+ return parsed.data;
158
+ }
159
+
160
+ export function fetchQueryEnvelope(init?: RequestInit) {
161
+ return fetchJson(
162
+ "/api/status",
163
+ init,
164
+ queryEnvelopeSchema,
165
+ "Status unavailable",
166
+ );
167
+ }
168
+
169
+ export function fetchQueryResponse(
170
+ input: RequestInfo | URL,
171
+ init?: RequestInit,
172
+ ) {
173
+ return fetchJson(input, init, queryResponseSchema, "Query unavailable");
174
+ }
175
+
176
+ export function postAction(body: Record<string, unknown>) {
177
+ return fetchJson(
178
+ "/api/action",
179
+ {
180
+ method: "POST",
181
+ headers: { "content-type": "application/json" },
182
+ body: JSON.stringify(body),
183
+ },
184
+ actionResponseSchema,
185
+ "Action failed",
186
+ );
187
+ }
188
+
189
+ export function postSync(kind: WebSyncKind, accountId?: string) {
190
+ return fetchJson(
191
+ "/api/sync",
192
+ {
193
+ method: "POST",
194
+ headers: { "content-type": "application/json" },
195
+ body: JSON.stringify({
196
+ kind,
197
+ ...(accountId ? { accountId } : {}),
198
+ }),
199
+ },
200
+ webSyncJobSchema,
201
+ "Sync failed",
202
+ ).then(waitForWebSyncJob);
203
+ }
204
+
205
+ function fetchSyncJob(id: string) {
206
+ const url = new URL("/api/sync", window.location.origin);
207
+ url.searchParams.set("id", id);
208
+ return fetchJson(url, undefined, webSyncJobSchema, "Sync status unavailable");
209
+ }
210
+
211
+ function delay(ms: number) {
212
+ return new Promise((resolve) => setTimeout(resolve, ms));
213
+ }
214
+
215
+ async function waitForWebSyncJob(job: WebSyncJobSnapshot) {
216
+ let current = job;
217
+ while (current.inProgress) {
218
+ await delay(SYNC_POLL_INTERVAL_MS);
219
+ current = await fetchSyncJob(current.id);
220
+ }
221
+
222
+ if (!current.result) {
223
+ throw new ApiFetchError(current.error ?? current.summary);
224
+ }
225
+ if (!current.result.ok) {
226
+ throw new ApiFetchError(current.result.error ?? current.result.summary);
227
+ }
228
+ return current.result;
229
+ }
@@ -96,27 +96,31 @@ export async function findArchives(): Promise<ArchiveCandidate[]> {
96
96
  'kMDItemDisplayName == "*archive*.zip" && kMDItemKind == "Zip archive"',
97
97
  ];
98
98
 
99
- for (const query of queries) {
100
- try {
101
- const { stdout } = await execAsync(`mdfind -onlyin ~ '${query}'`, {
102
- timeout: 5000,
103
- });
104
-
105
- const paths = stdout
106
- .split("\n")
107
- .map((item) => item.trim())
108
- .filter((item) => item.length > 0 && item.endsWith(".zip"));
109
-
110
- const candidates = await Promise.all(
111
- paths.map((filePath) => getCandidate(filePath)),
112
- );
113
- for (const candidate of candidates) {
114
- if (candidate) {
115
- found.set(candidate.path, candidate);
116
- }
99
+ const spotlightCandidates = await Promise.all(
100
+ queries.map(async (query) => {
101
+ try {
102
+ const { stdout } = await execAsync(`mdfind -onlyin ~ '${query}'`, {
103
+ timeout: 5000,
104
+ });
105
+
106
+ const paths = stdout
107
+ .split("\n")
108
+ .map((item) => item.trim())
109
+ .filter((item) => item.length > 0 && item.endsWith(".zip"));
110
+
111
+ return Promise.all(paths.map((filePath) => getCandidate(filePath)));
112
+ } catch {
113
+ // Best-effort only.
114
+ return [];
115
+ }
116
+ }),
117
+ );
118
+
119
+ for (const candidates of spotlightCandidates) {
120
+ for (const candidate of candidates) {
121
+ if (candidate) {
122
+ found.set(candidate.path, candidate);
117
123
  }
118
- } catch {
119
- // Best-effort only.
120
124
  }
121
125
  }
122
126