diff --git a/.dockerignore b/.dockerignore index e61ff58d2..060be0c1f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,11 +12,16 @@ scripts/ !scripts/lite/ !scripts/build-plugin-sandbox.mjs !scripts/build-pst-worker.mjs +!scripts/build-store-worker.mjs +!scripts/fetch-sqlite3mc.mjs !scripts/healthcheck.mjs !scripts/sourcemaps.mjs !scripts/standalone-guard.mjs # Built in the image by scripts/build-plugin-sandbox.mjs public/plugin-sandbox-assets/ +# Built in the image by scripts/build-store-worker.mjs +public/store-worker.js +public/sqlite3mc/ TODO.md *.md !README.md diff --git a/.gitattributes b/.gitattributes index c9944983b..b944ef477 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,3 +4,5 @@ .husky/* text eol=lf # Demo fixtures record exact byte sizes of these files; never normalise them. public/demo/** -text +# Checked against pinned hashes by scripts/fetch-sqlite3mc.mjs; never normalise them. +vendor/sqlite3mc/** -text diff --git a/.gitignore b/.gitignore index 0914d4091..67e25a46d 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,9 @@ /public/plugin-sandbox-assets/ # scripts/build-pst-worker.mjs /public/pst-worker.js +# scripts/build-store-worker.mjs +/public/store-worker.js +/public/sqlite3mc/ # production /build diff --git a/Dockerfile b/Dockerfile index ed2713b1f..d8d5081c8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,6 +35,7 @@ RUN rm -rf "app/api/dev-jmap" && \ \( -name __tests__ -o -name "*.test.ts" -o -name "*.test.tsx" \) \ -prune -exec rm -rf {} \; && \ node scripts/build-pst-worker.mjs && \ + node scripts/build-store-worker.mjs && \ node scripts/build-plugin-sandbox.mjs && \ npx next build --webpack && \ node scripts/standalone-guard.mjs diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 305421961..992bef2ec 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -27,6 +27,7 @@ import { PluginOAuthCallbackListener } from "@/components/providers/plugin-oauth import { PWAInstallPrompt } from "@/components/pwa-install-prompt"; import { AppUpdatePrompt } from "@/components/app-update-prompt"; import { OfflineHost } from "@/components/offline/offline-host"; +import { NotificationActionsHost } from "@/components/providers/notification-actions-host"; import { DevicePolicyHost } from "@/components/device/device-policy-host"; import { PushNotificationPrompt } from "@/components/push-notification-prompt"; import { AccountArchiveRestorePrompt } from "@/components/account-archive-prompt"; @@ -114,6 +115,7 @@ export default async function LocaleLayout({ {/* Offline mode isn't in Lite (no service worker there). */} {!IS_LITE && } + {!IS_LITE && } diff --git a/app/(main)/protocol/share/route.ts b/app/(main)/protocol/share/route.ts new file mode 100644 index 000000000..851f7a1ba --- /dev/null +++ b/app/(main)/protocol/share/route.ts @@ -0,0 +1,74 @@ +/** + * The manifest's share_target, when no service worker took it (public/sw.js + * normally answers first): the first visit, a browser without one, or a worker + * from before this existed. Nothing keeps files here, so text and links go on + * in the address's fragment, which stays out of access logs, and the page asks + * the user and says the files are gone + * (components/protocol/protocol-launch-handler-provider.tsx). + * + * Open to any origin, like a mailto: link: it writes nothing and acts as nobody. + */ + +const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, ""); +// A form with text and links is small. Anything bigger carries files, and +// isn't read into memory just to throw them away. +const MAX_READ_BYTES = 256 * 1024; +// Encoded lengths: a proxy in front has to pass the Location header, and one +// Japanese character takes 9 bytes in it. +const BUDGET = { title: 400, url: 1000, text: 1800 }; + +export const dynamic = "force-dynamic"; + +function land(fragment: string): Response { + return new Response(null, { status: 303, headers: { Location: `${BASE_PATH}/${fragment}` } }); +} + +/** As much of `value` as takes up to `budget` bytes once encoded, whole characters only. */ +function fitEncoded(value: string, budget: number): { value: string; cut: boolean } { + let used = 0; + let out = ""; + for (const character of value) { + let size: number; + try { + size = encodeURIComponent(character).length; + } catch { + continue; // A lone surrogate. + } + used += size; + if (used > budget) return { value: out, cut: true }; + out += character; + } + return { value: out, cut: false }; +} + +export async function POST(request: Request): Promise { + const length = Number(request.headers.get("content-length") ?? ""); + const params = new URLSearchParams({ share: "unavailable" }); + if (!Number.isFinite(length) || length <= 0 || length > MAX_READ_BYTES) { + params.set("share_files", "1"); + return land(`#${params}`); + } + try { + const form = await request.formData(); + let cut = false; + for (const name of ["title", "text", "url"] as const) { + const value = form.get(name); + if (typeof value !== "string" || !value) continue; + const fitted = fitEncoded(value, BUDGET[name]); + params.set(`share_${name}`, fitted.value); + cut ||= fitted.cut; + } + if (cut) params.set("share_cut", "1"); + if (form.getAll("files").some((file) => typeof file !== "string" && file.size > 0)) { + params.set("share_files", "1"); + } + } catch { + params.set("share_files", "1"); + } + return land(`#${params}`); +} + +/** Nothing to share: the app. */ +export function GET(): Response { + return land(""); +} diff --git a/app/api/push/action/route.ts b/app/api/push/action/route.ts new file mode 100644 index 000000000..7ad419076 --- /dev/null +++ b/app/api/push/action/route.ts @@ -0,0 +1,315 @@ +import { cookies } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; +import { logger } from '@/lib/logger'; +import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; +import { configManager } from '@/lib/admin/config-manager'; +import { resolvePolicy } from '@/lib/admin/policy-scopes'; +import { strictDeviceSubject } from '@/lib/admin/server-device-policy'; +import { forwardedClientHeaders, loginClientAddress } from '@/lib/auth/client-address'; +import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint'; +import { UPSTREAM_JSON_MAX_BYTES, UpstreamBodyTooLargeError, readBodyCapped, readJsonCapped } from '@/lib/security/capped-body'; +import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; +import { DisallowedUrlError } from '@/lib/security/url-guard'; +import { readStalwartAuthContextFromStore, type StalwartAuthContext } from '@/lib/stalwart/auth-context'; +import { rebaseApiUrl } from '@/lib/stalwart/jmap-api'; +import type { SettingsPolicy } from '@/lib/admin/types'; +import { fetchJmapServer, isTrustedJmapServerUrl } from '@/lib/stalwart/server-fetch'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const USING = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail']; +const MAIL_CAPABILITY = 'urn:ietf:params:jmap:mail'; +const MAX_BODY_BYTES = 4096; +/** RFC 8620 ids, which is all an email or account id can be. */ +const JMAP_ID = /^[A-Za-z0-9_-]{1,255}$/; +const MAILBOX_PROPERTIES = ['id', 'name', 'parentId', 'role', 'myRights']; + +type ArchiveMode = 'single' | 'year' | 'month'; + +interface ActionRequest { + action: 'mark-read' | 'archive'; + accountId: string; + emailId: string; + login: string; + archiveMode: ArchiveMode; + timeZone?: string; +} + +interface JmapMailbox { + id: string; + name: string; + parentId?: string | null; + role?: string | null; + myRights?: { mayAddItems?: boolean }; +} + +type MethodResponse = [string, Record, string]; + +const fail = (status: number, error: string) => + NextResponse.json({ error }, { status, headers: { 'Cache-Control': 'no-store' } }); + +function validTimeZone(value: unknown): string | undefined { + if (typeof value !== 'string' || value.length > 64) return undefined; + try { + new Intl.DateTimeFormat('en-US', { timeZone: value }); + return value; + } catch { + return undefined; + } +} + +async function readActionRequest(request: Request): Promise { + const bytes = await readBodyCapped(request, MAX_BODY_BYTES); + if (!bytes) return null; + let body: Record; + try { + body = JSON.parse(new TextDecoder().decode(bytes)) as Record; + } catch { + return null; + } + if (!body || typeof body !== 'object') return null; + const { action, accountId, emailId, login, archiveMode } = body; + if (action !== 'mark-read' && action !== 'archive') return null; + if (typeof accountId !== 'string' || !JMAP_ID.test(accountId)) return null; + if (typeof emailId !== 'string' || !JMAP_ID.test(emailId)) return null; + if (typeof login !== 'string' || !login) return null; + return { + action, + accountId, + emailId, + login, + archiveMode: archiveMode === 'year' || archiveMode === 'month' ? archiveMode : 'single', + timeZone: validTimeZone(body.timeZone), + }; +} + +function emailSetOutcome(set: Record | undefined, emailId: string): 'updated' | 'notFound' | 'refused' { + const { updated, notUpdated } = (set ?? {}) as { + updated?: Record | null; + notUpdated?: Record | null; + }; + if (updated && Object.prototype.hasOwnProperty.call(updated, emailId)) return 'updated'; + return notUpdated?.[emailId]?.type === 'notFound' ? 'notFound' : 'refused'; +} + +/** The signed-in logins this browser's cookies name as `login`: the notification's own, or none. */ +async function loginsNamed(login: string): Promise { + const cookieStore = await cookies(); + const found: StalwartAuthContext[] = []; + for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) { + const ctx = readStalwartAuthContextFromStore(cookieStore, slot); + if (ctx && (await loginFingerprint(ctx.serverUrl, ctx.username)) === login) found.push(ctx); + } + return found; +} + +/** + * The policy for this login's scopes. The worker's copies of the settings + * decide what a toast offers; this decides what works. Null when the scopes + * can't be worked out, and then nothing is done from here. + */ +async function policyFor(ctx: StalwartAuthContext, trusted: boolean): Promise { + await configManager.ensureLoaded(); + const subject = await strictDeviceSubject({ ...ctx, trusted }); + return subject ? resolvePolicy(configManager.getPolicy(), subject).policy : null; +} + +/** The year and month a message falls in where the user is. */ +function dateParts(receivedAt: string, timeZone: string | undefined): { year: string; month: string } { + const parts = new Intl.DateTimeFormat('en-CA', { timeZone: timeZone ?? 'UTC', year: 'numeric', month: '2-digit' }) + .formatToParts(new Date(receivedAt)); + return { + year: parts.find((part) => part.type === 'year')?.value ?? '', + month: parts.find((part) => part.type === 'month')?.value ?? '', + }; +} + +/** + * Where Archive files a message, as the app's own Archive does + * (JMAPClient.archiveEmails, ensureArchiveMailbox): the Archive folder, or a + * year or year/month folder in it, creating what isn't there. Creation ids + * resolve in the request that carries the Mailbox/set. + */ +function archiveDestination( + mailboxes: JmapMailbox[], + mode: ArchiveMode, + receivedAt: string, + timeZone: string | undefined, +): { destination: string; create: Record> } { + const create: Record> = {}; + const archive = mailboxes.find((m) => m.role === 'archive') + ?? mailboxes.find((m) => m.name.toLowerCase() === 'archive'); + let destination = archive?.id ?? '#archive'; + if (!archive) create.archive = { name: 'Archive', parentId: null, role: 'archive' }; + + const descend = (name: string, cid: string) => { + const existing = destination.startsWith('#') + ? undefined + : mailboxes.find((m) => m.parentId === destination && m.name === name); + if (existing) { + destination = existing.id; + return; + } + create[cid] = { name, parentId: destination }; + destination = `#${cid}`; + }; + if (mode !== 'single') { + const { year, month } = dateParts(receivedAt, timeZone); + descend(year, 'year'); + if (mode === 'month') descend(month, 'month'); + } + return { destination, create }; +} + +/** + * POST /api/push/action + * + * A notification's Mark read or Archive when no tab is open to do it. The + * service worker has no login of its own, only the context cookie, whose + * token a page refreshes and which ends with the browser session. Every + * refusal here means the same to the worker: open the message in the app. + * + * Like /api/push/preview it never hands the worker a credential. The cookie + * is sent because the request is same-origin; the proxy and this handler both + * refuse any other. Plugins run in pages, so the worker doesn't come here while + * an enabled plugin may stop or watch these actions. + */ +export async function POST(request: NextRequest) { + const rejected = rejectCrossOriginRequest(request); + if (rejected) return rejected; + try { + const body = await readActionRequest(request); + if (!body) return fail(400, 'Invalid request'); + + const logins = await loginsNamed(body.login); + if (logins.length === 0) return fail(401, 'Not authenticated'); + if (logins.length > 1) return fail(409, 'Login is in more than one cookie slot'); + const ctx = logins[0]; + + const serverUrl = ctx.serverUrl.replace(/\/+$/, ''); + const trusted = await isTrustedJmapServerUrl(serverUrl); + const forwarded = forwardedClientHeaders(loginClientAddress(request)); + const sessionRes = await fetchJmapServer(`${serverUrl}/.well-known/jmap`, { + headers: { Authorization: ctx.authHeader, ...forwarded }, + }, trusted); + // An expired token (only a page refreshes it), or a password changed since. + if (sessionRes.status === 401 || sessionRes.status === 403) return fail(401, 'Not authenticated'); + if (!sessionRes.ok) return fail(502, 'JMAP session failed'); + const session = (await readJsonCapped(sessionRes, UPSTREAM_JSON_MAX_BYTES)) as { + apiUrl?: string; + primaryAccounts?: Record; + accounts?: Record }>; + }; + const apiUrl = rebaseApiUrl(session, serverUrl); + if (!apiUrl) return fail(502, 'JMAP session failed'); + // The worker named this account; it has to be one this login can reach. + const { accountId, emailId } = body; + const account = session.accounts && Object.prototype.hasOwnProperty.call(session.accounts, accountId) + ? session.accounts[accountId] + : undefined; + if (!account && session.primaryAccounts?.[MAIL_CAPABILITY] !== accountId) { + return fail(403, 'Account is not in this login'); + } + + // A session limit is the page's to apply (activity, sign-in time): acting + // from here would keep a login going past it. The app opens and applies it. + const policy = await policyFor(ctx, trusted); + const limits = policy?.organisation?.session; + if (!policy || (limits?.maxHours ?? 0) > 0 || (limits?.idleMinutes ?? 0) > 0) return fail(401, 'Not authenticated'); + if (body.action === 'archive' && policy.features?.archiveActionEnabled === false) return fail(403, 'Archive is turned off'); + + const jmap = async (methodCalls: unknown[]): Promise => { + const res = await fetchJmapServer(apiUrl, { + method: 'POST', + headers: { Authorization: ctx.authHeader, 'Content-Type': 'application/json', ...forwarded }, + body: JSON.stringify({ using: USING, methodCalls }), + }, trusted); + if (!res.ok) return null; + const data = (await readJsonCapped(res, UPSTREAM_JSON_MAX_BYTES)) as { methodResponses?: MethodResponse[] }; + return Array.isArray(data.methodResponses) ? data.methodResponses : null; + }; + const result = (responses: MethodResponse[], name: string, id: string) => + responses.find(([method, , callId]) => method === name && callId === id)?.[1]; + + const readFolders = async (): Promise => { + const read = await jmap([['Mailbox/get', { accountId, ids: null, properties: MAILBOX_PROPERTIES }, 'mailboxes']]); + const list = read && result(read, 'Mailbox/get', 'mailboxes')?.list; + return Array.isArray(list) ? (list as JmapMailbox[]) : null; + }; + + const read = await jmap([ + ['Mailbox/get', { accountId, ids: null, properties: MAILBOX_PROPERTIES }, 'mailboxes'], + ['Email/get', { accountId, ids: [emailId], properties: ['id', 'receivedAt', 'mailboxIds'] }, 'email'], + ]); + if (!read) return fail(502, 'JMAP request failed'); + let mailboxes = result(read, 'Mailbox/get', 'mailboxes')?.list as JmapMailbox[] | undefined; + const emails = result(read, 'Email/get', 'email')?.list as { receivedAt?: string; mailboxIds?: Record }[] | undefined; + if (!Array.isArray(mailboxes) || !Array.isArray(emails)) return fail(502, 'JMAP request failed'); + const email = emails[0]; + if (!email?.receivedAt) return fail(404, 'Message not found'); + // Since the notification it may have gone to Trash or Junk: not for a toast + // to bring back or to mark. The app opens it. + const bins = new Set(mailboxes.filter((m) => m.role === 'trash' || m.role === 'junk').map((m) => m.id)); + if (Object.keys(email.mailboxIds ?? {}).some((id) => bins.has(id))) return fail(409, 'Message is in Trash or Junk'); + + let answer: ReturnType = 'refused'; + const createdFolders: string[] = []; + // Twice at most: another client may make the same folder between our read + // and our write, and then the folder is there to file into. + for (let attempt = 0; attempt < 2; attempt++) { + let update: Record = { 'keywords/$seen': true }; + let create: Record> = {}; + if (body.action === 'archive') { + const planned = archiveDestination(mailboxes, body.archiveMode, email.receivedAt, body.timeZone); + // Only an explicit refusal stops it, as in the app. + if (planned.create.archive && account?.accountCapabilities?.[MAIL_CAPABILITY]?.mayCreateTopLevelMailbox === false) { + return fail(403, 'Archive folder is missing'); + } + if (mailboxes.find((m) => m.id === planned.destination)?.myRights?.mayAddItems === false) { + return fail(403, 'Archive folder is read-only'); + } + create = planned.create; + update = { mailboxIds: { [planned.destination]: true } }; + } + const written = await jmap([ + ...(Object.keys(create).length > 0 ? [['Mailbox/set', { accountId, create }, 'folders']] : []), + ['Email/set', { accountId, update: { [emailId]: update } }, 'write'], + ]); + if (!written) return fail(502, 'JMAP request failed'); + const folders = result(written, 'Mailbox/set', 'folders') as { + created?: Record | null; + notCreated?: Record | null; + } | undefined; + // Deepest first, for removing them again. + for (const cid of ['month', 'year', 'archive']) { + const id = folders?.created?.[cid]?.id; + if (id) createdFolders.push(id); + } + answer = emailSetOutcome(result(written, 'Email/set', 'write'), emailId); + if (answer !== 'refused' || Object.keys(folders?.notCreated ?? {}).length === 0 || attempt > 0) break; + const fresh = await readFolders(); + if (!fresh) break; + mailboxes = fresh; + } + + if (answer === 'updated') return NextResponse.json({ ok: true }, { headers: { 'Cache-Control': 'no-store' } }); + // Nothing went in: no empty folder left behind. The server refuses to + // destroy one someone else has filed into since. + if (createdFolders.length > 0) await jmap([['Mailbox/set', { accountId, destroy: createdFolders }, 'cleanup']]).catch(() => null); + if (answer === 'notFound') return fail(404, 'Message not found'); + return fail(502, 'JMAP server refused the change'); + } catch (error) { + if (error instanceof DisallowedUrlError) { + logger.warn('push action refused non-public server address', { error: error.message }); + return fail(502, 'JMAP server address is not allowed'); + } + if (error instanceof UpstreamBodyTooLargeError) { + logger.warn('push action: JMAP response too large', { maxBytes: error.maxBytes }); + return fail(502, 'JMAP server response is too large'); + } + const err = error as Error & { cause?: { code?: string; message?: string } }; + logger.error('push action failed', { error: err?.message ?? 'Unknown error', causeCode: err?.cause?.code }); + return fail(500, 'Internal error'); + } +} diff --git a/app/api/push/preview/route.ts b/app/api/push/preview/route.ts index 8e48073ba..c862eeb99 100644 --- a/app/api/push/preview/route.ts +++ b/app/api/push/preview/route.ts @@ -14,6 +14,7 @@ import { forwardedClientHeaders, loginClientAddress } from '@/lib/auth/client-ad import { DisallowedUrlError } from '@/lib/security/url-guard'; import { UPSTREAM_JSON_MAX_BYTES, UpstreamBodyTooLargeError, readJsonCapped } from '@/lib/security/capped-body'; import { serverDevicePolicy } from '@/lib/admin/server-device-policy'; +import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -359,6 +360,9 @@ export async function GET(request: NextRequest) { unreadTotal, ...(redacted ? { redacted: true } : {}), ...(target.slot !== undefined ? { slot: target.slot } : {}), + // For the notification's buttons, which must not land on whoever signs + // into this slot later (app/api/push/action). + ...(target.slot !== undefined && email ? { login: await loginFingerprint(target.serverUrl, target.username) } : {}), }, { headers: { // SW already gates on its own logic - don't let push events get diff --git a/app/manifest.ts b/app/manifest.ts index 951269292..c95478c26 100644 --- a/app/manifest.ts +++ b/app/manifest.ts @@ -1,5 +1,8 @@ import type { MetadataRoute } from "next"; import { headers } from "next/headers"; +import { getTranslations } from "next-intl/server"; +import { localeFromAcceptLanguage } from "@/i18n/locale-matcher"; +import { routing } from "@/i18n/routing"; import { configManager } from "@/lib/admin/config-manager"; import { matchDomainBranding, @@ -15,9 +18,28 @@ type WebAppProtocolHandler = { url: string; }; +type WebAppFileHandler = { + action: string; + accept: Record; +}; + +type WebAppShareTarget = { + action: string; + method: "POST"; + enctype: "multipart/form-data"; + params: { + title: string; + text: string; + url: string; + files: Array<{ name: string; accept: string[] }>; + }; +}; + type ExtendedManifest = Omit & { display_override?: Array<"window-controls-overlay" | "standalone" | "minimal-ui" | "browser" | "fullscreen">; protocol_handlers?: WebAppProtocolHandler[]; + file_handlers?: WebAppFileHandler[]; + share_target?: WebAppShareTarget; launch_handler?: { client_mode?: "navigate-existing" | "auto" | "focus-existing" | "navigate-new" | Array<"navigate-existing" | "auto" | "focus-existing" | "navigate-new">; @@ -33,7 +55,8 @@ const withBase = (p: string) => `${BASE_PATH}${p}`; export default async function manifest(): Promise { await configManager.ensureLoaded(); - const host = pickRequestHost(await headers()); + const requestHeaders = await headers(); + const host = pickRequestHost(requestHeaders); const domainOverrides = matchDomainBranding( host, parseDomainBranding(configManager.get("domainBranding", [])), @@ -84,6 +107,17 @@ export default async function manifest(): Promise { { src: withBase("/icon-maskable-dark-512x512.png"), sizes: "512x512", type: "image/png", purpose: "maskable" }, ]; + // Shortcut names show in the OS menu, in the language the browser asks in. + const t = await getTranslations({ + locale: localeFromAcceptLanguage(requestHeaders.get("accept-language"), routing.locales) ?? routing.defaultLocale, + namespace: "pwaLaunch", + }); + const shortcutIcons = [{ + src: withBase(hasCustomIcon ? "/api/pwa-icon/192" : "/icon-192x192.png"), + sizes: "192x192", + type: "image/png", + }]; + return { name: appName, short_name: shortName, @@ -122,6 +156,31 @@ export default async function manifest(): Promise { { protocol: "mailto", url: withBase("/protocol/mailto?url=%s") }, { protocol: "webcal", url: withBase("/protocol/webcal?url=%s") }, ], + shortcuts: [ + { name: t("shortcut_new_message"), url: withBase("/?compose=1"), icons: shortcutIcons }, + { name: t("shortcut_calendar"), url: withBase("/calendar"), icons: shortcutIcons }, + { name: t("shortcut_contacts"), url: withBase("/contacts"), icons: shortcutIcons }, + ], + // Where a file goes is decided by its type, in lib/launch-handoff.ts. The + // actions are the pages a new window should start on. + file_handlers: [ + { action: withBase("/mail"), accept: { "message/rfc822": [".eml"] } }, + { action: withBase("/calendar"), accept: { "text/calendar": [".ics", ".ical"] } }, + { action: withBase("/contacts"), accept: { "text/vcard": [".vcf", ".vcard"], "text/x-vcard": [".vcf", ".vcard"] } }, + ], + // Not under /api/: proxy.ts refuses a POST from outside there. The service + // worker takes it (public/sw.js); without one, app/(main)/protocol/share. + share_target: { + action: withBase("/protocol/share"), + method: "POST", + enctype: "multipart/form-data", + params: { + title: "title", + text: "text", + url: "url", + files: [{ name: "files", accept: ["*/*"] }], + }, + }, launch_handler: { client_mode: ["focus-existing", "navigate-new"], }, diff --git a/components/calendar/__tests__/ical-import-initial-file.test.tsx b/components/calendar/__tests__/ical-import-initial-file.test.tsx new file mode 100644 index 000000000..72a8959aa --- /dev/null +++ b/components/calendar/__tests__/ical-import-initial-file.test.tsx @@ -0,0 +1,110 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useOfflineStore } from '@/lib/offline/state'; +import { ICalImportModal } from '../ical-import-modal'; + +// A .ics opened from the OS. The server reads it, so without a connection the +// dialog has to say so instead of calling a good file invalid. + +vi.mock('@/stores/calendar-store', () => ({ + useCalendarStore: (selector: (state: { importEvents: () => Promise }) => unknown) => selector({ importEvents: async () => 1 }), +})); +vi.mock('@/stores/settings-store', () => ({ + useSettingsStore: (selector: (state: { timeFormat: string }) => unknown) => selector({ timeFormat: '24h' }), +})); +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +const calendars = [{ id: 'cal-1', name: 'Home', isDefault: true }] as never[]; +const ics = () => new File(['BEGIN:VCALENDAR'], 'invite.ics', { type: 'text/calendar' }); + +function client(overrides: Record = {}) { + return { + getServerUrl: () => 'https://mail.example.org', + getCalendarsAccountId: () => 'acc', + uploadBlob: vi.fn(async () => ({ blobId: 'blob-1' })), + parseCalendarEvents: vi.fn(async () => [{ title: 'Standup', start: '2026-11-03T09:00:00' }]), + ...overrides, + } as never as { uploadBlob: ReturnType; parseCalendarEvents: ReturnType }; +} + +beforeEach(() => { + useOfflineStore.setState({ browserOffline: false, downServers: [] }); +}); + +afterEach(() => { + useOfflineStore.setState({ browserOffline: false, downServers: [] }); +}); + +describe('ICalImportModal with an initial file', () => { + it('reads the file at once and shows the preview', async () => { + const c = client(); + render(); + await screen.findByText('Standup'); + expect(c.uploadBlob).toHaveBeenCalledTimes(1); + expect(c.parseCalendarEvents).toHaveBeenCalledWith('acc', 'blob-1'); + }); + + it('reads nothing without one', async () => { + const c = client(); + render(); + await act(async () => {}); + expect(c.uploadBlob).not.toHaveBeenCalled(); + }); + + it('says the server is needed when offline, and starts by itself once back online', async () => { + useOfflineStore.setState({ browserOffline: true }); + const c = client(); + render(); + expect(await screen.findByText('ics_needs_server')).toBeInTheDocument(); + expect(c.uploadBlob).not.toHaveBeenCalled(); + + useOfflineStore.setState({ browserOffline: false }); + await act(async () => { window.dispatchEvent(new Event('online')); }); + await screen.findByText('Standup'); + expect(c.uploadBlob).toHaveBeenCalledTimes(1); + }); + + it('says the same when the connection fails while it reads', async () => { + const c = client({ + uploadBlob: vi.fn(async () => { + useOfflineStore.setState({ downServers: ['https://mail.example.org'] }); + throw new TypeError('Failed to fetch'); + }), + }); + render(); + expect(await screen.findByText('ics_needs_server')).toBeInTheDocument(); + }); + + it('still calls a bad file invalid while online', async () => { + const c = client({ parseCalendarEvents: vi.fn(async () => { throw new Error('parse'); }) }); + render(); + expect(await screen.findByText('invalid_format')).toBeInTheDocument(); + }); + + it('applies the size limit to it', async () => { + const big = new File([new Uint8Array(10 * 1024 * 1024 + 1)], 'big.ics'); + const c = client(); + render(); + expect(await screen.findByText('file_too_large')).toBeInTheDocument(); + expect(c.uploadBlob).not.toHaveBeenCalled(); + }); + + it('picks the default calendar when the calendars load after it opened', async () => { + const importEvents = vi.fn(); + const c = client(); + const view = render(); + await screen.findByText('Standup'); + view.rerender( + , + ); + await waitFor(() => expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe('b')); + expect(importEvents).not.toHaveBeenCalled(); + }); +}); diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 907a6f8bc..291c715b6 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -93,6 +93,7 @@ import { useCompanyCalendars } from "./use-company-calendars"; import { sharedCalendarColorKey, autoAssignSharedCalendarColors } from "@/lib/shared-calendar-colors"; import { debug } from "@/lib/debug"; import { consumePendingWebcal, hasPendingWebcal, subscribeToPendingWebcal } from "@/lib/protocol-handlers/session"; +import { useHandoffFiles } from "@/hooks/use-handoff-files"; import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal"; import { appPath, buildCalendarPath, parseCalendarPath, type CalendarDeepLink } from "@/lib/deep-links"; import { consumePendingDeepLinkEntry, subscribePendingDeepLink } from "@/lib/deep-link-handoff"; @@ -155,6 +156,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = // The server's reason for a refused change, where the user can act on it. const describeError = useCalendarErrorMessage(); const tSidebar = useTranslations("sidebar"); + const tOffline = useTranslations("calendar_offline"); useDocumentTitle(tSidebar("calendar")); const skeletonLabels = useSkeletonLabels(); const tWebcalAction = useTranslations("calendar.webcal_action"); @@ -178,6 +180,10 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); const { client, isAuthenticated, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading } = useAuthStore(); const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); + // A .ics opened from the OS (lib/launch-handoff.ts). The standard page that + // renders while the Pro shell takes over leaves it for the Pro one. A demo + // has no import. + const launchedFile = useHandoffFiles("calendar", isAuthenticated && !!client && !demoMode && (isEmbedded || !proInterfaceActive)); const { quota, isPushConnected } = useEmailStore(); const { calendars, events, selectedDate, viewMode, selectedCalendarIds, @@ -1446,9 +1452,13 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = debug.error("Failed to refresh calendar after scope operation"); } } catch (error) { + if (error instanceof Error && error.name === "SeriesSplitNeedsServerError") { + toast.error(tOffline("split_needs_server")); + return; + } toast.error(describeError(error, t(type === "delete" ? "notifications.event_delete_error" : "notifications.event_error"))); } - }, [client, pendingScopeAction, updateEvent, deleteEvent, createEvent, findMasterEvent, currentUserEmails, resolveContactName, refetchCurrentRange, submitRsvp, describeError, t]); + }, [client, pendingScopeAction, updateEvent, deleteEvent, createEvent, findMasterEvent, currentUserEmails, resolveContactName, refetchCurrentRange, submitRsvp, describeError, t, tOffline]); // A drag or resize. A series asks which occurrences it covers, as the // editor does; one event moves at once and can be moved back. @@ -2574,6 +2584,16 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = /> )} + {launchedFile.file && client && ( + + )} + {showSubscriptionModal && client && ( void; initialUrl?: string; + /** A file opened from the OS: read at once, as if picked here. */ + initialFile?: File | null; } const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB @@ -30,8 +33,9 @@ const ACCEPTED_EXTENSIONS = [".ics", ".ical"]; type ImportStep = "select" | "preview" | "importing"; type ImportMode = "file" | "url"; -export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICalImportModalProps) { +export function ICalImportModal({ calendars, client, onClose, initialUrl, initialFile }: ICalImportModalProps) { const t = useTranslations("calendar.import"); + const tLaunch = useTranslations("pwaLaunch"); const tCal = useTranslations("calendar"); const locale = useLocale(); const tForm = useTranslations("calendar.form"); @@ -56,6 +60,15 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal const dropZoneRef = useRef(null); const urlInputRef = useRef(null); const targetId = useId(); + // The file that couldn't be read for lack of a connection, read again when + // it is back. + const waitingForServer = useRef(null); + + // Opened before the calendars had loaded, from a launch: the default once they have. + useEffect(() => { + if (calendarId || calendars.length === 0) return; + setCalendarId((calendars.find((c) => c.isDefault) ?? calendars[0]).id); + }, [calendars, calendarId]); const validateFile = useCallback((file: File): string | null => { if (file.size > MAX_FILE_SIZE) return t("file_too_large"); @@ -71,6 +84,16 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal return; } + // The server reads the file (uploadBlob, then CalendarEvent/parse). Say so + // rather than call a good file invalid. + const serverUrl = client.getServerUrl(); + if (isOfflineNow(serverUrl)) { + waitingForServer.current = file; + setError(tLaunch("ics_needs_server")); + return; + } + waitingForServer.current = null; + setError(null); setIsParsing(true); setStep("select"); @@ -91,11 +114,31 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal setSelectedIndices(new Set(events.map((_, i) => i))); setStep("preview"); } catch { - setError(t("invalid_format")); + if (isOfflineNow(serverUrl)) { + waitingForServer.current = file; + setError(tLaunch("ics_needs_server")); + } else { + setError(t("invalid_format")); + } } finally { setIsParsing(false); } - }, [client, validateFile, t]); + }, [client, validateFile, t, tLaunch]); + + const initialFileRead = useRef(false); + useEffect(() => { + if (!initialFile || initialFileRead.current) return; + initialFileRead.current = true; + void handleFile(initialFile); + }, [initialFile, handleFile]); + + useEffect(() => { + const retry = () => { + if (waitingForServer.current) void handleFile(waitingForServer.current); + }; + window.addEventListener("online", retry); + return () => window.removeEventListener("online", retry); + }, [handleFile]); const handleFileChange = useCallback((e: React.ChangeEvent) => { const file = e.target.files?.[0]; diff --git a/components/contacts/__tests__/contact-import-initial-file.test.tsx b/components/contacts/__tests__/contact-import-initial-file.test.tsx new file mode 100644 index 000000000..2a424dedc --- /dev/null +++ b/components/contacts/__tests__/contact-import-initial-file.test.tsx @@ -0,0 +1,62 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useOfflineStore } from '@/lib/offline/state'; +import { ContactImportDialog } from '../contact-import-dialog'; + +// A .vcf opened from the OS: it parses in the browser, also without a connection. + +const VCARD = ['BEGIN:VCARD', 'VERSION:3.0', 'FN:Ada Lovelace', 'EMAIL:ada@example.org', 'END:VCARD', ''].join('\r\n'); +const vcf = (body = VCARD) => { + const file = new File([body], 'people.vcf', { type: 'text/vcard' }); + // jsdom's File has no text(). + if (typeof file.text !== 'function') Object.defineProperty(file, 'text', { value: async () => body }); + return file; +}; + +afterEach(() => { + useOfflineStore.setState({ browserOffline: false, downServers: [] }); +}); + +describe('ContactImportDialog with an initial file', () => { + it('parses it at once and offers the cards', async () => { + render(); + expect(await screen.findByText('Ada Lovelace')).toBeInTheDocument(); + }); + + it('parses it offline as well', async () => { + useOfflineStore.setState({ browserOffline: true }); + render(); + expect(await screen.findByText('Ada Lovelace')).toBeInTheDocument(); + }); + + it('applies the size limit to it', async () => { + const big = new File([new Uint8Array(5 * 1024 * 1024 + 1)], 'big.vcf'); + render(); + expect(await screen.findByText('import.file_too_large')).toBeInTheDocument(); + }); + + it('shows the file input without one', async () => { + const { container } = render(); + expect(container.ownerDocument.querySelector('input[type="file"]')).not.toBeNull(); + expect(screen.queryByText('Ada Lovelace')).toBeNull(); + }); + + it('says the server is needed when the import fails offline', async () => { + const onImport = vi.fn(async () => { + useOfflineStore.setState({ browserOffline: true }); + throw new TypeError('Failed to fetch'); + }); + render(); + await screen.findByText('Ada Lovelace'); + fireEvent.click(screen.getByRole('button', { name: 'import.import_button' })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('contacts_need_server')); + }); + + it('calls a failed import failed while online', async () => { + const onImport = vi.fn(async () => { throw new Error('server said no'); }); + render(); + await screen.findByText('Ada Lovelace'); + fireEvent.click(screen.getByRole('button', { name: 'import.import_button' })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('import.failed')); + }); +}); diff --git a/components/contacts/contact-import-dialog.tsx b/components/contacts/contact-import-dialog.tsx index 91c2dbc64..dca624055 100644 --- a/components/contacts/contact-import-dialog.tsx +++ b/components/contacts/contact-import-dialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useRef, useCallback } from "react"; +import { useState, useRef, useCallback, useEffect } from "react"; import { useTranslations } from "next-intl"; import { Upload, FileText, AlertTriangle, Check } from "@/components/icons"; import { Button } from "@/components/ui/button"; @@ -9,19 +9,24 @@ import { cn } from "@/lib/utils"; import { parseVCard, detectDuplicates } from "@/lib/vcard"; import type { ContactCard } from "@/lib/jmap/types"; import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; +import { isOfflineNow } from "@/lib/offline/state"; interface ContactImportDialogProps { existingContacts: ContactCard[]; onImport: (contacts: ContactCard[]) => Promise; onClose: () => void; + /** A file opened from the OS: read at once, as if picked here. */ + initialFile?: File | null; } export function ContactImportDialog({ existingContacts, onImport, onClose, + initialFile, }: ContactImportDialogProps) { const t = useTranslations("contacts"); + const tLaunch = useTranslations("pwaLaunch"); const fileRef = useRef(null); const [parsed, setParsed] = useState([]); const [selected, setSelected] = useState>(new Set()); @@ -30,10 +35,7 @@ export function ContactImportDialog({ const [result, setResult] = useState(null); const [error, setError] = useState(null); - const handleFileChange = useCallback(async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - + const readFile = useCallback(async (file: File) => { setError(null); setResult(null); @@ -66,6 +68,18 @@ export function ContactImportDialog({ } }, [existingContacts, t]); + const handleFileChange = useCallback(async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) await readFile(file); + }, [readFile]); + + const initialFileRead = useRef(false); + useEffect(() => { + if (!initialFile || initialFileRead.current) return; + initialFileRead.current = true; + void readFile(initialFile); + }, [initialFile, readFile]); + const toggleSelect = (idx: number) => { const next = new Set(selected); if (next.has(idx)) { @@ -94,7 +108,7 @@ export function ContactImportDialog({ setResult(count); } catch (error) { console.error('Failed to import contacts:', error); - setError(t("import.failed")); + setError(isOfflineNow() ? tLaunch("contacts_need_server") : t("import.failed")); } finally { setIsImporting(false); } diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx index 71e8a7adc..fcf3b0afd 100644 --- a/components/contacts/contacts-app.tsx +++ b/components/contacts/contacts-app.tsx @@ -51,6 +51,7 @@ import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/typ import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog"; import { appPath, buildContactsPath, parseContactsPath, type ContactDeepLink } from "@/lib/deep-links"; import { consumePendingDeepLinkEntry, subscribePendingDeepLink } from "@/lib/deep-link-handoff"; +import { useHandoffFiles } from "@/hooks/use-handoff-files"; import { useDeepLinkUrl } from "@/hooks/use-deep-link-url"; import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect"; import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments"; @@ -356,6 +357,14 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = // focused tab writes the address bar; the standard instance that renders // while Pro takes over a route stays silent. const proInterfaceActive = useProInterfaceActive(); + // A .vcf opened from the OS (lib/launch-handoff.ts). It waits for the + // contacts, or everything would look new to the duplicate check. The + // standard page that renders while the Pro shell takes over leaves it for + // the Pro one. + const launchedFile = useHandoffFiles( + "contacts", + isAuthenticated && !!client && (contactsLoaded || !supportsSync) && (isEmbedded || !proInterfaceActive), + ); const isFocusedProTab = useIsFocusedProTab(); const contactsLinkPath = appPath(buildContactsPath({ contactId: view === 'detail' || view === 'edit' ? selectedContactId : null, @@ -1572,6 +1581,15 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = if (movingContactIds) void handleDropContacts(movingContactIds, book); }} /> + {launchedFile.file && ( + + )} {showImportDialog && ( { + afterEach(() => { + useAuthStore.setState({ client: null }); + vi.clearAllMocks(); + hoisted.beforeAttachmentUpload.mockImplementation(async () => true); + }); + + const uploading = () => filesClient({ uploadBlob: vi.fn(() => new Promise(() => {})), getMaxSizeUpload: () => 0 }); + + const handedIn = (extra: Record = {}) => ({ + to: '', cc: '', bcc: '', subject: 'Shared subject', body: 'Shared text', showCc: false, showBcc: false, + selectedIdentityId: null, subAddressTag: '', mode: 'compose', draftId: null, unedited: true, ...extra, + }) as never; + const settled = (client: Record) => ({ + ...client, + uploadBlob: vi.fn(async () => ({ blobId: 'blob-shared', size: 1, type: 'text/plain' })), + getMaxSizeUpload: () => 0, + }); + + it('saves no draft from handed-in text and files until the first real edit', async () => { + const client = settled(filesClient()) as unknown as ReturnType; + useAuthStore.setState({ client: client as never }); + vi.useFakeTimers(); + render(); + await act(async () => { await vi.advanceTimersByTimeAsync(5000); }); + expect(screen.getByText('shared.pdf')).toBeInTheDocument(); + expect(client.createDraft).not.toHaveBeenCalled(); + + // Closing the page doesn't save it either. + await act(async () => { window.dispatchEvent(new Event('beforeunload')); await vi.advanceTimersByTimeAsync(100); }); + expect(client.createDraft).not.toHaveBeenCalled(); + + fireEvent.change(screen.getByPlaceholderText(/subject/i), { target: { value: 'Shared subject, edited' } }); + await act(async () => { await vi.advanceTimersByTimeAsync(2500); }); + expect(client.createDraft).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); + + it('closes without asking while nothing is the user\'s own', async () => { + const onClose = vi.fn(); + useAuthStore.setState({ client: settled(filesClient()) as never }); + const ref = { current: null as ((afterClose?: () => void) => void) | null }; + render(); + await screen.findByText('shared.pdf'); + await act(async () => { ref.current?.(); }); + expect(onClose).toHaveBeenCalled(); + }); + + it('counts a removed attachment as an edit', async () => { + const client = settled(filesClient()) as unknown as ReturnType; + useAuthStore.setState({ client: client as never }); + vi.useFakeTimers(); + render(); + await act(async () => { await vi.advanceTimersByTimeAsync(100); }); + const chip = screen.getByText('shared.pdf').closest('[data-testid], div')!.parentElement!; + const remove = chip.querySelector('button[aria-label]') ?? chip.querySelector('button'); + fireEvent.click(remove!); + await act(async () => { await vi.advanceTimersByTimeAsync(2500); }); + expect(client.createDraft).toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it('hands its unedited state back to a remount, without asking to save', async () => { + useAuthStore.setState({ client: settled(filesClient()) as never }); + const onSaveState = vi.fn(); + const view = render(); + await screen.findByText('shared.pdf'); + view.unmount(); + expect(onSaveState).toHaveBeenCalledWith(expect.objectContaining({ unedited: true, unsaved: false, subject: 'Shared subject' })); + }); + + it('attaches them when it opens', async () => { + useAuthStore.setState({ client: uploading() as never }); + render(); + expect(await screen.findByText('shared.pdf')).toBeInTheDocument(); + }); + + it('waits for the client', async () => { + const files = [new File(['x'], 'shared.pdf')]; + const view = render(); + await act(async () => {}); + expect(hoisted.beforeAttachmentUpload).not.toHaveBeenCalled(); + + useAuthStore.setState({ client: uploading() as never }); + view.rerender(); + expect(await screen.findByText('shared.pdf')).toBeInTheDocument(); + }); + + it('attaches an array once, also when the composer remounts with it', async () => { + useAuthStore.setState({ client: uploading() as never }); + const files = [new File(['x'], 'shared.pdf')]; + const first = render(); + await screen.findByText('shared.pdf'); + first.unmount(); + + render(); + await act(async () => {}); + expect(hoisted.beforeAttachmentUpload).toHaveBeenCalledTimes(1); + expect(screen.queryByText('shared.pdf')).toBeNull(); + }); + + it('runs the same checks as a picked file: a plugin may refuse it', async () => { + hoisted.beforeAttachmentUpload.mockImplementation(async (info) => info.name !== 'setup.exe'); + useAuthStore.setState({ client: uploading() as never }); + render(); + expect(await screen.findByText('notes.pdf')).toBeInTheDocument(); + expect(screen.queryByText('setup.exe')).toBeNull(); + }); + + it('runs the same checks as a picked file: the size limit', async () => { + useAuthStore.setState({ client: filesClient({ getMaxSizeUpload: () => 1 }) as never }); + render(); + await act(async () => {}); + expect(hoisted.toastError).toHaveBeenCalledWith('attachment_too_large'); + expect(screen.queryByText('big.pdf')).toBeNull(); + }); +}); + describe('attach from Files', () => { afterEach(() => { useAuthStore.setState({ client: null }); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index abcc22c2c..60aedca15 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -214,7 +214,16 @@ function createChipDragPreview(label: string): HTMLElement { // inserts as a single chip and expands into its members on send. type SuggestionItem = { name: string; email: string; group?: { id: string; memberCount: number } }; +// Which `initialFiles` arrays are attached: a remount gets the same array. +const attachedInitialFiles = new WeakSet(); + export interface ComposerDraftData { + /** + * Handed in from outside (a share, the desktop app) and not touched yet. + * It is only where the composer starts: no draft on the server until the + * user's first real edit. + */ + unedited?: boolean; to: string; cc: string; bcc: string; @@ -390,6 +399,12 @@ interface EmailComposerProps { sessionKey?: string; /** Id of the message being replied to or forwarded, for onComposerOpen. */ sourceEmailId?: string; + /** + * Files to attach when the composer opens, from a share or a launch (see + * lib/launch-handoff.ts). They go through the same checks as a picked file. + * Attached once per array, also when the composer remounts with it. + */ + initialFiles?: File[] | null; mode?: 'compose' | 'reply' | 'replyAll' | 'forward'; /** * Email of the mailbox/account the user is viewing when they start a new @@ -524,6 +539,7 @@ export function EmailComposer({ className, initialDraftText: initialDraftTextProp, initialData: initialDataProp, + initialFiles, prefill, sessionKey, sourceEmailId, @@ -1793,6 +1809,16 @@ export function EmailComposer({ } changeVersionRef.current += 1; }, [toStr, ccStr, bccStr, subject, body, attachments.length]); + // Handed-in text and files aren't the user's until they touch them. Nothing + // counts as dirty until then, so autosave, the page-close save and the flush + // before a sign-out all leave them alone. + const uneditedRef = useRef(initialData?.unedited === true); + const [firstEditSeen, setFirstEditSeen] = useState(false); + const markEdited = useCallback(() => { + if (!uneditedRef.current) return; + uneditedRef.current = false; + setFirstEditSeen(true); + }, []); const isDirtyRef = useRef(false); isDirtyRef.current = toStr !== initialValuesRef.current.to || ccStr !== initialValuesRef.current.cc || bccStr !== initialValuesRef.current.bcc || subject !== initialValuesRef.current.subject || @@ -1804,6 +1830,19 @@ export function EmailComposer({ if (priority !== initialPriorityRef.current) isDirtyRef.current = true; const initialLabelChoiceRef = useRef(classification.choice); if (classification.choice !== initialLabelChoiceRef.current) isDirtyRef.current = true; + if (uneditedRef.current) isDirtyRef.current = false; + // Changes only the user makes, also without typing: a picked recipient, a + // removed attachment, a priority. Handed-in files move the count they're + // compared with (addFiles). The body isn't here, a signature can change it; + // typing in it comes through the composer root's input events. + useEffect(() => { + if (!uneditedRef.current) return; + const start = initialValuesRef.current; + if (toStr !== start.to || ccStr !== start.cc || bccStr !== start.bcc || subject !== start.subject + || attachments.length !== start.attachmentCount || priority !== initialPriorityRef.current) { + markEdited(); + } + }, [toStr, ccStr, bccStr, subject, attachments.length, priority, markEdited]); const firstLabelRunRef = useRef(true); useEffect(() => { if (firstLabelRunRef.current) { @@ -1870,14 +1909,17 @@ export function EmailComposer({ classification: classificationRef.current.draftChoice, // Edits the last save didn't cover; a minimised composer with // none needs no save before an automatic sign-out. - unsaved: changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current, + unsaved: !uneditedRef.current && (changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current), + ...(uneditedRef.current ? { unedited: true } : {}), }); // Auto-save state on unmount (when user navigates away without explicitly closing) useEffect(() => { return () => { if (explicitCloseRef.current) return; - if (onSaveState && isDirtyRef.current) onSaveState(snapshotStateRef.current()); + // Kept in the host's memory across a remount, handed-in or not. Only + // the server stays out of it. + if (onSaveState && (isDirtyRef.current || uneditedRef.current)) onSaveState(snapshotStateRef.current()); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -2353,7 +2395,7 @@ export function EmailComposer({ return () => window.removeEventListener('keydown', handleTemplateKey); }, [templatesEnabled]); - const addFiles = useCallback(async (files: File[]) => { + const addFiles = useCallback(async (files: File[], opts?: { handedIn?: boolean }) => { if (!client || files.length === 0) return; // Let plugins veto each upload before it's queued. @@ -2407,6 +2449,8 @@ export function EmailComposer({ abortController: controller, }; }); + // Files handed in are where the composer starts, not an edit. + if (opts?.handedIn) initialValuesRef.current.attachmentCount += newAttachments.length; setAttachments(prev => [...prev, ...newAttachments]); for (let i = 0; i < files.length; i++) { @@ -2520,6 +2564,12 @@ export function EmailComposer({ } }, [client, t]); + useEffect(() => { + if (!client || !initialFiles || initialFiles.length === 0 || attachedInitialFiles.has(initialFiles)) return; + attachedInitialFiles.add(initialFiles); + void addFiles(initialFiles, { handedIn: true }); + }, [client, initialFiles, addFiles]); + const handleFileSelect = async (event: React.ChangeEvent) => { if (!event.target.files) return; await addFiles(Array.from(event.target.files)); @@ -3026,7 +3076,7 @@ export function EmailComposer({ // login page can say a draft wasn't saved. useEffect(() => registerComposeFlush(async () => { while (isSendingRef.current) await new Promise((resolve) => setTimeout(resolve, SEND_WAIT_POLL_MS)); - if (sentRef.current) return; + if (sentRef.current || uneditedRef.current) return; const unsaved = changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current; if (!unsaved) return; if (saveTimeoutRef.current) { @@ -3379,7 +3429,7 @@ export function EmailComposer({ } }; // eslint-disable-next-line react-hooks/exhaustive-deps -- saveDraft reads current state when called, not when effect is set up - }, [toStr, ccStr, bccStr, subject, body, attachments, priority]); + }, [toStr, ccStr, bccStr, subject, body, attachments, priority, firstEditSeen]); useEffect(() => { return () => { @@ -4446,6 +4496,13 @@ export function EmailComposer({ data-testid="email-composer" className={cn("flex h-full bg-background relative", className)} onKeyDownCapture={handleComposerKeyDown} + // Bubble phase, in the same batch as the field's own onChange. A state + // change in the capture phase renders first and puts the old value back. + onInput={markEdited} + onChange={markEdited} + onPaste={markEdited} + onCut={markEdited} + onDrop={markEdited} >
{liveMessage && {liveMessage.text}} diff --git a/components/mail/__tests__/blob-routing.test.tsx b/components/mail/__tests__/blob-routing.test.tsx index 59b96c183..29c180bbb 100644 --- a/components/mail/__tests__/blob-routing.test.tsx +++ b/components/mail/__tests__/blob-routing.test.tsx @@ -800,6 +800,25 @@ function expectProBlob(route: typeof proRoutes[number], action: ProBlobAction, i } describe('Pro blob routing', () => { + it("opens a compose tab with the typed text for a notification's Reply, once, when the message is in", async () => { + const { requestTabReply } = await import('@/lib/pwa-notify/tab-reply'); + const { useProTabStore } = await import('@/stores/pro-tab-store'); + const { a } = await conversationClients(); + vi.spyOn(a, 'getEmail').mockResolvedValue(message()); + useEmailStore.setState({ emails: [], selectedEmail: null, isUnifiedView: false, selectedMailbox: 'inbox' }); + useAuthStore.setState({ client: a, activeAccountId: 'login-primary' }); + const composeTabs = () => useProTabStore.getState().tabs.filter((tab) => tab.kind === 'compose'); + const before = composeTabs().length; + + requestTabReply('email', 'Typed on the toast'); + const view = render(); + await waitFor(() => expect(composeTabs()).toHaveLength(before + 1)); + expect(composeTabs().at(-1)?.composeData).toMatchObject({ mode: 'reply', sourceEmailId: 'email', initialDraftText: 'Typed on the toast' }); + + view.rerender(); + expect(composeTabs()).toHaveLength(before + 1); + }); + it.each(proRoutes.flatMap(route => proBlobActions.map(action => ({ ...route, action }))))( '$label: $action uses the message owner and authenticated client', async route => { const { origin, fetchMock } = await openProMessage(route, route.action); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 403114962..af75e3c96 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -142,6 +142,10 @@ import { usePluginStore } from "@/stores/plugin-store"; import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot"; import { canDownload } from "@/lib/device-policy"; import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session"; +import { setNotificationReplyOpener } from "@/lib/pwa-notify/page-actions"; +import { requestTabReply } from "@/lib/pwa-notify/tab-reply"; +import { consumePendingReplyText } from "@/lib/pwa-notify/worker-state"; +import { pendingHandoffs, subscribeHandoff, takeHandoff, type Handoff } from "@/lib/launch-handoff"; import { INTERNAL_MAILTO_EVENT, type ParsedMailto } from "@/lib/protocol-handlers/mailto"; import { PLUGIN_REPLY_EVENT, type PluginReplyRequest } from "@/lib/plugin-sandbox/host-api-compose"; import { onOpenEmail, type OpenEmailHandler, type OpenEmailOutcome } from "@/lib/plugin-sandbox/open-email"; @@ -173,7 +177,7 @@ import { resolveFolderRef, type MailDeepLink, } from "@/lib/deep-links"; -import { consumePendingDeepLink, subscribePendingDeepLink } from "@/lib/deep-link-handoff"; +import { consumePendingDeepLinkEntry, subscribePendingDeepLink } from "@/lib/deep-link-handoff"; import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect"; import type { QuoteHeader } from "@/lib/plugin-types"; import { initialLinkSearch, useLiteLinkSegments } from "@/hooks/use-lite-link-segments"; @@ -232,6 +236,9 @@ const UNIFIED_LIST_PUSH = { }, }; +/** A draft from outside: a mailto:, or a share with files. ParsedMailto stays JSON, for sessionStorage. */ +type DraftRequest = ParsedMailto & { initialFiles?: File[]; unedited?: boolean }; + export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // Static Lite build: the route params are empty, read the link from the URL. const linkSegments = useLiteLinkSegments('mail', routeSegments); @@ -259,6 +266,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const [showComposer, setShowComposer] = useState(false); const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose'); const [composerDraftText, setComposerDraftText] = useState(""); + // A notification's Reply, waiting for its message to be the open one. + const [pendingNotificationReply, setPendingNotificationReply] = useState<{ emailId: string; text: string } | null>(null); const [pendingDraft, setPendingDraft] = useState(null); const [composerSessionId, setComposerSessionId] = useState(0); // Fields a plugin filled in through onBeforeCompose for the composer about @@ -316,7 +325,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // Office attachments open read-only in the configured WOPI editor (#1047). const [officeAttachment, setOfficeAttachment] = useState<{ blobId: string; name: string; type?: string; size?: number; accountId: string; slot: number | null } | null>(null); const wopiStatus = useWopiStatus(true); - const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState(null); + const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState(null); + // Files from a share or a launch, for the next composer. It attaches an + // array once, so a stale one does nothing. + const [composerInitialFiles, setComposerInitialFiles] = useState(null); const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false); const markAsReadTimeoutRef = useRef(null); const lastUndoToastSubmissionRef = useRef(null); @@ -1378,6 +1390,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { initialDraftText: composerDraftText, initialData: pendingDraft, prefill: composerPrefill, + initialFiles: composerInitialFiles, sourceEmailId: selectedEmail?.id ?? null, sourceEmailSource: selectedEmail ? emailWriteSource(selectedEmail, client) : null, title, @@ -1388,6 +1401,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { setComposerDraftText(""); setPendingDraft(null); setComposerPrefill(null); + setComposerInitialFiles(null); setComposerQuoteHeader(null); // We only react to the rising edge of `showComposer` here; the other // variables read above are captured-but-stale-safe because the next @@ -1467,18 +1481,22 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { return true; }, [identities, t]); - const applyMailtoDraft = useCallback((pending: ParsedMailto, suppressOutgoingStash: boolean) => { + const applyMailtoDraft = useCallback((pending: DraftRequest, suppressOutgoingStash: boolean) => { if (suppressOutgoingStash) { suppressComposerStateSaveSessionRef.current = composerSessionId; } setComposerSessionId((id) => id + 1); - setPendingDraft(mailtoComposerDraft(pending, useSettingsStore.getState().plainTextMode)); + setPendingDraft({ + ...mailtoComposerDraft(pending, useSettingsStore.getState().plainTextMode), + ...(pending.unedited ? { unedited: true } : {}), + }); + setComposerInitialFiles(pending.initialFiles ?? null); setComposerMode("compose"); setShowComposer(true); if (isMobile) setActiveView("viewer"); }, [composerSessionId, isMobile, setActiveView]); - const openMailtoDraft = useCallback((pending: ParsedMailto) => withComposeGate('compose', { + const openMailtoDraft = useCallback((pending: DraftRequest) => withComposeGate('compose', { to: pending.to, cc: pending.cc, subject: pending.subject, body: pending.body, }, () => { // A live composer owns text the user has not sent. Replacing it outright @@ -1497,7 +1515,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { applyMailtoDraft(pending, showComposer); }), [applyMailtoDraft, showComposer, withComposeGate]); - const openMailtoForAccount = useCallback(async (pending: ParsedMailto, accountId: string) => { + const openMailtoForAccount = useCallback(async (pending: DraftRequest, accountId: string) => { setIsProtocolAccountSwitching(true); try { if (useAuthStore.getState().activeAccountId !== accountId) { @@ -1521,13 +1539,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }); }, []); - const handleMailtoProtocolRequest = useCallback(async (pending: ParsedMailto) => { - if (!(await mailtoAllowed(pending))) return; - // From another app of this webmail: the account in use, no question. - if (pending.inApp) { - openMailtoDraft(pending); - return; - } + // A draft that comes from outside: which account it goes out from. + const routeOutsideDraft = useCallback(async (pending: DraftRequest): Promise => { const protocolAccounts = getMailtoProtocolAccounts(); if (protocolAccounts.length > 1) { setPendingMailtoAccountChoice(pending); @@ -1536,12 +1549,87 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const accountId = protocolAccounts[0]?.id ?? activeAccountId; if (accountId) { - void openMailtoForAccount(pending, accountId); + await openMailtoForAccount(pending, accountId); return; } - openMailtoDraft(pending); - }, [activeAccountId, getMailtoProtocolAccounts, mailtoAllowed, openMailtoDraft, openMailtoForAccount]); + await openMailtoDraft(pending); + }, [activeAccountId, getMailtoProtocolAccounts, openMailtoDraft, openMailtoForAccount]); + + const handleMailtoProtocolRequest = useCallback(async (pending: ParsedMailto) => { + if (!(await mailtoAllowed(pending))) return; + // From another app of this webmail: the account in use, no question. + if (pending.inApp) { + openMailtoDraft(pending); + return; + } + void routeOutsideDraft(pending); + }, [mailtoAllowed, openMailtoDraft, routeOutsideDraft]); + + // A new draft from a shortcut, a share or the desktop app + // (lib/launch-handoff.ts). Not a mailto:, so onMailtoIntercept stays out of + // it; onBeforeCompose still runs. The standard page that renders while the + // Pro shell takes over must leave it for the Pro one. + const proTakingOver = proInterfaceActive && !isEmbedded; + // One at a time. A composer that is open holds the user's text, so what comes + // next stays queued until it closes, and a second one can't replace the + // first. So does one set aside by reading a message, while the server hasn't + // got what it holds. Under Pro a composer moves to its own tab, so nothing + // waits long. + const handoffOpeningRef = useRef(false); + const handoffToldWaitingRef = useRef(new Set()); + const composerInUseRef = useRef(false); + const composerInUse = showComposer || pendingMailtoAccountChoice !== null + || pendingDraft?.unsaved === true || pendingDraft?.unedited === true; + composerInUseRef.current = composerInUse; + const [handoffSettled, setHandoffSettled] = useState(0); + useEffect(() => { + if (!isAuthenticated || !client || proTakingOver) return; + + const open = async (handoff: Handoff) => { + if (!handoff.subject && !handoff.body && handoff.files.length === 0) { + await withComposeGate('compose', {}, () => { + startFreshComposerSession(); + setComposerMode('compose'); + setShowComposer(true); + if (isMobile) setActiveView('viewer'); + }); + return; + } + await routeOutsideDraft({ + to: [], + cc: [], + bcc: [], + subject: handoff.subject ?? '', + body: handoff.body ?? '', + initialFiles: handoff.files, + unedited: true, + }); + }; + + const openNext = () => { + if (handoffOpeningRef.current) return; + if (composerInUseRef.current) { + const waiting = pendingHandoffs().find((handoff) => handoff.kind === 'compose'); + if (waiting && !handoffToldWaitingRef.current.has(waiting.id)) { + handoffToldWaitingRef.current.add(waiting.id); + toast.info(t('pwaLaunch.compose_waiting')); + } + return; + } + const handoff = takeHandoff('compose'); + if (!handoff) return; + handoffOpeningRef.current = true; + void open(handoff).finally(() => { + handoffOpeningRef.current = false; + setHandoffSettled((n) => n + 1); + }); + }; + + openNext(); + return subscribeHandoff('compose', openNext); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isAuthenticated, client, proTakingOver, routeOutsideDraft, withComposeGate, isMobile, composerInUse, handoffSettled]); useEffect(() => { if (!isAuthenticated || !client) return; @@ -1770,7 +1858,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } } }; - const applyMailDeepLink = async (link: MailDeepLink, opts?: { onLoad?: boolean }) => { + const applyMailDeepLink = async (link: MailDeepLink, opts?: { onLoad?: boolean; replyText?: string }) => { // The folder list before an account switch, if one happened: until the // new mailbox's folders arrive the store still holds these (or nothing). let switchedFrom: Mailbox[] | null = null; @@ -1844,6 +1932,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // A shared account's message is fetched from that account through // the active login (#1351). const loginId = link.ownerAccountId ? useAuthStore.getState().activeAccountId ?? undefined : undefined; + // A notification's Reply: the tab opens its composer once it has the message. + if (link.reply) requestTabReply(link.id, opts?.replyText ?? await consumePendingReplyText(link.id)); useProTabStore.getState().openEmailTab({ accountId: link.ownerAccountId ?? '', ...(loginId ? { clientAccountId: loginId } : {}), @@ -1869,6 +1959,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } if (isMobile) setActiveView('viewer'); if (isTablet) setTabletListVisible(false); + if (link.reply) { + // A notification's Reply. The branch above may have skipped + // selecting, and the reply has to wait for the message to be open. + selectEmail(email); + setPendingNotificationReply({ emailId: email.id, text: opts?.replyText ?? await consumePendingReplyText(email.id) }); + } } finally { setLoadingEmail(false); } @@ -1923,8 +2019,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // briefly and must not steal the link parked for the Pro one. useEffect(() => { if (!isEmbedded) return; - return subscribePendingDeepLink('mail', (segments) => { - const link = parseMailPath(segments, new URLSearchParams(window.location.search)); + return subscribePendingDeepLink('mail', (segments, search) => { + const link = parseMailPath(segments, new URLSearchParams(search ?? window.location.search)); if (link) void applyMailDeepLinkRef.current(link); }); }, [isEmbedded]); @@ -1975,10 +2071,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } if (!initialMailLoadDone) return; + // Inside the Pro shell the route is /pro, so the link arrives through the + // handoff the redirect parked it in rather than as route params. Its query + // comes with it: the address bar may have none left by the first render. + const parked = linkSegments ? null : consumePendingDeepLinkEntry('mail'); + if (parked?.search) initialSearchRef.current = parked.search; const params = new URLSearchParams(initialSearchRef.current ?? window.location.search); - // Inside the Pro shell the route is /pro, so the segments arrive through - // the handoff the redirect parked them in rather than as route params. - const segments = linkSegments ?? consumePendingDeepLink('mail') ?? []; + const segments = linkSegments ?? parked?.segments ?? []; const link = parseMailPath(segments, params); const openLatestUnread = params.get('openLatestUnread') === '1'; @@ -2788,6 +2887,29 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { return () => window.removeEventListener(PLUGIN_REPLY_EVENT, onPluginReply); }, []); + // A notification's Reply, from a `?reply=1` link or from the service worker + // when this tab is open. Both come through applyMailDeepLink and end here, + // once the message is the open one. + useEffect(() => { + if (!pendingNotificationReply || selectedEmail?.id !== pendingNotificationReply.emailId) return; + setPendingNotificationReply(null); + void pluginReplyRef.current.handleReply(pendingNotificationReply.text); + }, [pendingNotificationReply, selectedEmail?.id]); + const composerOpenRef = useRef(showComposer); + composerOpenRef.current = showComposer; + useEffect(() => { + return setNotificationReplyOpener(({ emailId, accountId, slot, text }) => { + const open = () => void applyMailDeepLinkRef.current( + { kind: 'message', id: emailId, slot, ownerAccountId: accountId, reply: true }, + { replyText: text ?? '' }, + ); + // A live composer's text goes through its own "Save or discard?" first. + const requestClose = composerRequestCloseRef.current; + if (composerOpenRef.current && requestClose) requestClose(open); + else open(); + }); + }, []); + // A plugin opening a message (api.ui.openEmail), as a click on a search // result entry naming one does: handleEmailSelect, set once it is defined. // Not inside the Pro shell, which keeps this tab mounted while hidden and @@ -4872,6 +4994,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { initialDraftText={composerDraftText} initialData={pendingDraft} prefill={composerPrefill} + initialFiles={composerInitialFiles} sessionKey={`inline-${composerSessionId}`} sourceEmailId={composerSourceEmail?.id} onSaveState={(data) => { diff --git a/components/pro/pro-compose-tab-body.tsx b/components/pro/pro-compose-tab-body.tsx index 3094fa0ff..0f055e027 100644 --- a/components/pro/pro-compose-tab-body.tsx +++ b/components/pro/pro-compose-tab-body.tsx @@ -264,6 +264,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) { initialDraftText={data.initialDraftText} initialData={data.initialData} prefill={data.prefill} + initialFiles={data.initialFiles} sessionKey={`pro-${tabId}-${data.sessionId}`} sourceEmailId={data.sourceEmailId ?? undefined} composeFromAccountEmail={composeFromAccountEmail} diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index 224951911..e5056670a 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -28,6 +28,7 @@ import { labelSourceHeaders, reopenedLabelChoice } from '@/lib/outgoing/classifi import { useArchiveActionEnabled } from '@/lib/archive-gate'; import { confirmMailRemoval } from '@/lib/mail-removal-confirm'; import { useConfirmDialog } from '@/hooks/use-confirm-dialog'; +import { takeTabReply, useNotificationTabReply } from '@/lib/pwa-notify/tab-reply'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { saveQuickReplyDraft } from '@/lib/outgoing/quick-reply-envelope'; import { canDownload } from '@/lib/device-policy'; @@ -283,6 +284,14 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, }, { source: email, body: draftText ?? '' }); }, [email, openComposeTab, t, writeSource]); + // A notification's Reply, once this tab has the message (mail-app.tsx). + const pendingTabReply = useNotificationTabReply((s) => s.pending); + useEffect(() => { + if (!email || pendingTabReply?.emailId !== email.id) return; + const text = takeTabReply(email.id); + if (text !== null) handleReply(text); + }, [email, pendingTabReply, handleReply]); + const handleReplyAll = useCallback(() => { if (!email) return; openComposeTab({ diff --git a/components/protocol/__tests__/protocol-launch-handler-provider.test.tsx b/components/protocol/__tests__/protocol-launch-handler-provider.test.tsx new file mode 100644 index 000000000..15ffa7cf2 --- /dev/null +++ b/components/protocol/__tests__/protocol-launch-handler-provider.test.tsx @@ -0,0 +1,430 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FakeCaches } from '@/lib/__tests__/sw-harness'; + +// The launchQueue consumer. It is the only one the app may have: a second +// setConsumer replaces the first. Files used to be dropped here. + +const hoisted = vi.hoisted(() => ({ + push: vi.fn(), + pathname: { current: '/' }, + toastError: vi.fn(), + toastInfo: vi.fn(), + flushComposers: vi.fn(async () => ({ total: 0, settled: 0, failed: 0, timedOut: false })), + peek: vi.fn(), + adopt: vi.fn(), + discard: vi.fn(async () => {}), + savePendingMailto: vi.fn(), + protocolOpenMode: 'active-session', +})); + +vi.mock('@/i18n/navigation', () => ({ + useRouter: () => ({ push: hoisted.push, replace: vi.fn(), back: vi.fn() }), + usePathname: () => hoisted.pathname.current, +})); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Bulwark' }) })); +vi.mock('@/stores/toast-store', () => ({ toast: { error: hoisted.toastError, info: hoisted.toastInfo, success: vi.fn() } })); +vi.mock('@/lib/compose-flush-registry', () => ({ flushComposers: hoisted.flushComposers })); +vi.mock('@/lib/launch-handoff', async (importOriginal) => ({ + ...(await importOriginal()), + peekStoredHandoff: hoisted.peek, + adoptStoredHandoff: hoisted.adopt, + discardStoredHandoff: hoisted.discard, +})); +vi.mock('@/stores/settings-store', () => ({ + useSettingsStore: Object.assign(() => undefined, { getState: () => ({ protocolOpenMode: hoisted.protocolOpenMode }) }), +})); +vi.mock('@/lib/protocol-handlers/session', () => ({ + listenForMailtoRequests: () => () => {}, + notifyPendingMailto: vi.fn(), + notifyPendingWebcal: vi.fn(), + requestOpenMailtoInExistingClient: vi.fn(async () => false), + savePendingMailto: hoisted.savePendingMailto, + savePendingWebcal: vi.fn(), +})); +// The real modal has its own tests; here: does the launch reach it, and is +// its download gated by the same function. +vi.mock('@/components/files/file-preview-modal', () => ({ + FilePreviewModal: function PreviewStub({ name, onClose, onDownload, getFileContent }: { + name: string; onClose: () => void; onDownload: () => void; + getFileContent: () => Promise<{ blob: Blob; contentType: string }>; + }) { + const [type, setType] = React.useState(''); + React.useEffect(() => { void getFileContent().then((c) => setType(c.contentType)); }, [getFileContent]); + return ( +
+ {name} + + +
+ ); + }, +})); + +const { clearHandoffs, handOffFiles, pendingHandoffs, takeHandoff, HANDOFF_TTL_MS } = await import('@/lib/launch-handoff'); +const { setDownloadGuard } = await import('@/lib/download-guard'); +const { ProtocolLaunchHandlerProvider } = await import('../protocol-launch-handler-provider'); + +type Consumer = (params: { targetURL?: string; files?: Array<{ kind: string; getFile: () => Promise }> }) => void; + +let consumer: Consumer | null = null; +const setConsumer = vi.fn((fn: Consumer) => { consumer = fn; }); +const handle = (name: string, type = '') => ({ kind: 'file', getFile: async () => new File(['x'], name, { type }) }); +const flush = () => act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + +function mount() { + return render(
app
); +} + +beforeEach(() => { + consumer = null; + setConsumer.mockClear(); + hoisted.push.mockClear(); + hoisted.toastError.mockClear(); + hoisted.toastInfo.mockClear(); + hoisted.flushComposers.mockReset(); + hoisted.flushComposers.mockResolvedValue({ total: 0, settled: 0, failed: 0, timedOut: false }); + hoisted.peek.mockReset(); + hoisted.adopt.mockReset(); + hoisted.discard.mockClear(); + hoisted.savePendingMailto.mockClear(); + hoisted.pathname.current = '/'; + hoisted.protocolOpenMode = 'active-session'; + sessionStorage.clear(); + vi.stubGlobal('caches', new FakeCaches()); + window.launchQueue = { setConsumer } as never; +}); + +afterEach(() => { + clearHandoffs(); + delete window.launchQueue; + vi.unstubAllGlobals(); + window.history.replaceState(null, '', '/'); +}); + +describe('file launches', () => { + it('opens a .eml in the viewer without leaving the page', async () => { + mount(); + consumer!({ targetURL: `${window.location.origin}/mail`, files: [handle('note.eml')] }); + const viewer = await screen.findByTestId('eml-viewer'); + expect(viewer).toHaveTextContent('note.eml'); + await waitFor(() => expect(viewer).toHaveAttribute('data-type', 'message/rfc822')); + expect(hoisted.push).not.toHaveBeenCalled(); + }); + + it('shows several .eml files one after the other', async () => { + mount(); + consumer!({ files: [handle('one.eml'), handle('two.eml')] }); + expect(await screen.findByTestId('eml-viewer')).toHaveTextContent('one.eml'); + await act(async () => { screen.getByText('close').click(); }); + expect(await screen.findByTestId('eml-viewer')).toHaveTextContent('two.eml'); + await act(async () => { screen.getByText('close').click(); }); + expect(screen.queryByTestId('eml-viewer')).toBeNull(); + }); + + it("saves a .eml only if the device policy allows downloads", async () => { + const created = vi.fn(() => 'blob:x'); + vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: created, revokeObjectURL: vi.fn() })); + setDownloadGuard(() => false); + mount(); + consumer!({ files: [handle('note.eml')] }); + await screen.findByTestId('eml-viewer'); + await act(async () => { screen.getByText('save').click(); }); + expect(created).not.toHaveBeenCalled(); + setDownloadGuard(null); + }); + + it('takes a .ics to the calendar with the file waiting', async () => { + mount(); + consumer!({ targetURL: `${window.location.origin}/calendar`, files: [handle('invite.ics')] }); + await flush(); + expect(hoisted.push).toHaveBeenCalledWith('/calendar'); + expect(takeHandoff('calendar')?.files[0].name).toBe('invite.ics'); + }); + + it('takes a .vcf to the contacts with the file waiting', async () => { + mount(); + consumer!({ files: [handle('people.vcf')] }); + await flush(); + expect(hoisted.push).toHaveBeenCalledWith('/contacts'); + expect(takeHandoff('contacts')?.files[0].name).toBe('people.vcf'); + }); + + it('stays when already on the right page', async () => { + hoisted.pathname.current = '/calendar'; + mount(); + consumer!({ files: [handle('invite.ics')] }); + await flush(); + expect(hoisted.push).not.toHaveBeenCalled(); + }); + + it('goes to the page in the Pro shell too, which has one route', async () => { + hoisted.pathname.current = '/pro'; + mount(); + consumer!({ files: [handle('invite.ics')] }); + await flush(); + expect(hoisted.push).toHaveBeenCalledWith('/calendar'); + }); + + it('says so for a file it cannot handle', async () => { + mount(); + consumer!({ files: [handle('photo.png', 'image/png')] }); + await flush(); + expect(hoisted.toastError).toHaveBeenCalledWith('unsupported_file'); + expect(pendingHandoffs()).toEqual([]); + }); + + it('says so for a file it cannot read', async () => { + mount(); + consumer!({ files: [{ kind: 'file', getFile: async () => { throw new Error('denied'); } }] }); + await flush(); + expect(hoisted.toastError).toHaveBeenCalledWith('open_failed'); + }); + + it('waits on the sign-in page, and goes on after it', async () => { + hoisted.pathname.current = '/login'; + const view = mount(); + consumer!({ files: [handle('invite.ics')] }); + await flush(); + expect(hoisted.push).not.toHaveBeenCalled(); + + hoisted.pathname.current = '/'; + view.rerender(
app
); + await flush(); + expect(hoisted.push).toHaveBeenCalledTimes(1); + expect(hoisted.push).toHaveBeenCalledWith('/calendar'); + }); + + it('goes to the page only once for a handoff, so it does not trap a later visit elsewhere', async () => { + hoisted.pathname.current = '/settings'; + const view = mount(); + consumer!({ files: [handle('invite.ics')] }); + await flush(); + expect(hoisted.push).toHaveBeenCalledTimes(1); + + hoisted.pathname.current = '/mail'; + view.rerender(
app
); + await flush(); + expect(hoisted.push).toHaveBeenCalledTimes(1); + }); +}); + +describe('launches that are not files', () => { + it('still handles mailto:', async () => { + mount(); + consumer!({ targetURL: `${window.location.origin}/protocol/mailto?url=${encodeURIComponent('mailto:a@example.org')}` }); + await waitFor(() => expect(hoisted.savePendingMailto).toHaveBeenCalled()); + expect(hoisted.savePendingMailto.mock.calls[0][0].to).toEqual(['a@example.org']); + }); + + it('opens a new message from the shortcut in an open window', async () => { + hoisted.pathname.current = '/settings'; + mount(); + consumer!({ targetURL: `${window.location.origin}/?compose=1` }); + await flush(); + expect(hoisted.push).toHaveBeenCalledWith('/'); + expect(takeHandoff('compose')).toMatchObject({ files: [] }); + }); + + it('goes to the calendar from its shortcut in an open window', async () => { + mount(); + consumer!({ targetURL: `${window.location.origin}/calendar` }); + await flush(); + expect(hoisted.push).toHaveBeenCalledWith('/calendar'); + }); + + it('leaves an open window where it is for the plain app icon', async () => { + hoisted.pathname.current = '/settings'; + mount(); + consumer!({ targetURL: `${window.location.origin}/` }); + await flush(); + expect(hoisted.push).not.toHaveBeenCalled(); + expect(pendingHandoffs()).toEqual([]); + }); + + it('ignores a launch of the share target: the service worker has that', async () => { + mount(); + consumer!({ targetURL: `${window.location.origin}/protocol/share` }); + await flush(); + expect(hoisted.push).not.toHaveBeenCalled(); + expect(hoisted.toastError).not.toHaveBeenCalled(); + }); + + it('asks launchQueue once per consumer: never a second one on one render', () => { + mount(); + expect(setConsumer).toHaveBeenCalledTimes(1); + }); +}); + +describe('leaving a composer for a launched file', () => { + it('saves what was typed first, then goes', async () => { + const order: string[] = []; + hoisted.flushComposers.mockImplementation(async () => { order.push('flush'); return { total: 1, settled: 1, failed: 0, timedOut: false }; }); + hoisted.push.mockImplementation(() => { order.push('push'); }); + mount(); + consumer!({ files: [handle('people.vcf')] }); + await flush(); + expect(order).toEqual(['flush', 'push']); + hoisted.push.mockReset(); + }); + + it.each([ + ['the server refused the save', { total: 1, settled: 1, failed: 1, timedOut: false }], + ['the save took too long', { total: 1, settled: 0, failed: 0, timedOut: true }], + ])('stays when %s, with the file still waiting', async (_name, result) => { + hoisted.flushComposers.mockResolvedValue(result); + mount(); + consumer!({ files: [handle('people.vcf')] }); + await flush(); + expect(hoisted.push).not.toHaveBeenCalled(); + expect(hoisted.toastError).toHaveBeenCalledWith('save_first_failed'); + expect(pendingHandoffs().map((h) => h.kind)).toEqual(['contacts']); + }); + + it('does not save for a .eml, which leaves nothing', async () => { + mount(); + consumer!({ files: [handle('note.eml')] }); + await screen.findByTestId('eml-viewer'); + expect(hoisted.flushComposers).not.toHaveBeenCalled(); + }); +}); + +describe('a handoff that goes without arriving', () => { + it('says so', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + mount(); + handOffFiles([new File(['x'], 'a.ics')], 'calendar'); + vi.advanceTimersByTime(HANDOFF_TTL_MS + 1); + takeHandoff('calendar'); + vi.useRealTimers(); + expect(hoisted.toastError).toHaveBeenCalledWith('handoff_expired'); + }); +}); + +describe('the address a launch lands on', () => { + it('opens a new message once and removes the parameter', async () => { + window.history.replaceState(null, '', '/?compose=1&slot=2#x'); + mount(); + expect(window.location.search).toBe('?slot=2'); + expect(window.location.hash).toBe('#x'); + expect(takeHandoff('compose')).toMatchObject({ files: [] }); + expect(takeHandoff('compose')).toBeNull(); + }); + + it('counts the same launch once when launchQueue names it too', async () => { + window.history.replaceState(null, '', '/?compose=1'); + mount(); + consumer!({ targetURL: `${window.location.origin}/?compose=1` }); + await flush(); + expect(takeHandoff('compose')).not.toBeNull(); + expect(takeHandoff('compose')).toBeNull(); + }); +}); + +describe('a share from an address anyone can make', () => { + const dialogText = () => screen.getByTestId('share-confirm-details').textContent ?? ''; + const agree = () => fireEvent.click(screen.getByRole('button', { name: 'share_confirm_accept' })); + const refuse = () => fireEvent.click(screen.getByRole('button', { name: 'share_confirm_decline' })); + + it('asks about text in the address, and starts nothing before the answer', async () => { + window.history.replaceState(null, '', '/#share=unavailable&share_title=T&share_text=B&share_files=1&share_cut=1'); + mount(); + expect(window.location.hash).toBe(''); + await screen.findByText('share_confirm_message'); + expect(dialogText()).toContain('T'); + expect(pendingHandoffs()).toEqual([]); + expect(hoisted.push).not.toHaveBeenCalled(); + + agree(); + await flush(); + expect(takeHandoff('compose')).toMatchObject({ subject: 'T', body: 'B' }); + expect(hoisted.toastError).toHaveBeenCalledWith('share_files_lost'); + expect(hoisted.toastInfo).toHaveBeenCalledWith('share_cut'); + }); + + it('starts nothing when refused', async () => { + window.history.replaceState(null, '', '/#share=unavailable&share_text=B'); + mount(); + await screen.findByText('share_confirm_message'); + refuse(); + await flush(); + expect(pendingHandoffs()).toEqual([]); + }); + + it('only reports lost files when there was no text to ask about', async () => { + window.history.replaceState(null, '', '/#share=unavailable&share_files=1'); + mount(); + await flush(); + expect(screen.queryByText('share_confirm_message')).toBeNull(); + expect(hoisted.toastError).toHaveBeenCalledWith('share_files_lost'); + expect(pendingHandoffs()).toEqual([]); + }); + + it('ignores it in the query, which reaches logs and any link', async () => { + window.history.replaceState(null, '', '/?share=unavailable&share_text=B'); + mount(); + await flush(); + expect(screen.queryByText('share_confirm_message')).toBeNull(); + expect(pendingHandoffs()).toEqual([]); + }); + + it('asks about a stored share, naming the files, before anything is taken in', async () => { + hoisted.peek.mockResolvedValue({ subject: 'S', body: '', files: ['invoice.exe'] }); + hoisted.adopt.mockResolvedValue(true); + window.history.replaceState(null, '', '/#share=abc&key=k1'); + mount(); + expect(window.location.hash).toBe(''); + await screen.findByText('share_confirm_message'); + expect(hoisted.peek).toHaveBeenCalledWith('abc', 'k1'); + expect(dialogText()).toContain('invoice.exe'); + expect(hoisted.adopt).not.toHaveBeenCalledWith('abc', 'k1'); + + agree(); + await waitFor(() => expect(hoisted.adopt).toHaveBeenCalledWith('abc', 'k1')); + expect(hoisted.discard).not.toHaveBeenCalled(); + }); + + it('asks in an installed-app window as well: link capturing can bring a POST there', async () => { + const original = window.matchMedia; + window.matchMedia = ((query: string) => ({ matches: query.includes('standalone'), media: query, addEventListener: () => {}, removeEventListener: () => {} })) as never; + hoisted.peek.mockResolvedValue({ subject: 'S', body: '', files: [] }); + window.history.replaceState(null, '', '/#share=abc&key=k1'); + mount(); + await screen.findByText('share_confirm_message'); + expect(hoisted.adopt).not.toHaveBeenCalledWith('abc', 'k1'); + window.matchMedia = original; + }); + + it('drops a stored share when refused', async () => { + hoisted.peek.mockResolvedValue({ subject: 'S', body: '', files: ['invoice.exe'] }); + window.history.replaceState(null, '', '/#share=abc&key=k1'); + mount(); + await screen.findByText('share_confirm_message'); + refuse(); + await waitFor(() => expect(hoisted.discard).toHaveBeenCalledWith('abc')); + expect(hoisted.adopt).not.toHaveBeenCalledWith('abc', 'k1'); + }); + + it('says so when what was shared is gone', async () => { + hoisted.peek.mockResolvedValue(null); + window.history.replaceState(null, '', '/#share=abc&key=k1'); + mount(); + expect(window.location.hash).toBe(''); + await flush(); + expect(hoisted.toastError).toHaveBeenCalledWith('share_gone'); + expect(screen.queryByText('share_confirm_message')).toBeNull(); + }); +}); + +describe('a browser without launchQueue', () => { + it('renders the app and does nothing else', async () => { + delete window.launchQueue; + mount(); + await flush(); + expect(screen.getByText('app')).toBeInTheDocument(); + expect(setConsumer).not.toHaveBeenCalled(); + expect(hoisted.push).not.toHaveBeenCalled(); + expect(pendingHandoffs()).toEqual([]); + }); +}); diff --git a/components/protocol/launch-file-host.tsx b/components/protocol/launch-file-host.tsx new file mode 100644 index 000000000..4c18d2fd5 --- /dev/null +++ b/components/protocol/launch-file-host.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useCallback, useEffect, useState, type ComponentType, type ComponentProps } from "react"; +import { useTranslations } from "next-intl"; +import type { FilePreviewModal as FilePreviewModalType } from "@/components/files/file-preview-modal"; +import { useHandoffFiles } from "@/hooks/use-handoff-files"; +import { saveBlobAs } from "@/lib/download-guard"; +import { toast } from "@/stores/toast-store"; + +type PreviewComponent = ComponentType>; + +/** + * Shows a .eml opened from the OS. It parses in the browser, so it works + * offline and before sign-in. `policyAccount` stays out: the strictest device + * policy of the accounts signed in here decides about saving (FilePreviewModal + * and saveBlobAs check it). + * + * The preview is loaded when a file arrives, to keep it off the layout's + * bundle, and by hand rather than with next/dynamic: without a connection and + * without the offline shell its chunk can't load, which has to end in a + * message, not in the error page, and work again later. + */ +export function LaunchFileHost() { + const t = useTranslations("pwaLaunch"); + const { file, done } = useHandoffFiles("eml", true); + const [Preview, setPreview] = useState(null); + + useEffect(() => { + if (!file || Preview) return; + let stale = false; + import("@/components/files/file-preview-modal") + .then((module) => { + if (!stale) setPreview(() => module.FilePreviewModal); + }) + .catch(() => { + if (stale) return; + toast.error(t("open_failed")); + done(); + }); + return () => { stale = true; }; + }, [file, Preview, done, t]); + + const getFileContent = useCallback(async () => { + if (!file) throw new Error("no file"); + return { blob: file, contentType: "message/rfc822" }; + }, [file]); + + const onDownload = useCallback(() => { + if (file) saveBlobAs(file, file.name, { kind: "attachment" }); + }, [file]); + + if (!file || !Preview) return null; + return ( + + ); +} diff --git a/components/protocol/protocol-launch-handler-provider.tsx b/components/protocol/protocol-launch-handler-provider.tsx index dbf35ed56..3cab2f174 100644 --- a/components/protocol/protocol-launch-handler-provider.tsx +++ b/components/protocol/protocol-launch-handler-provider.tsx @@ -1,12 +1,32 @@ "use client"; -import { useEffect } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { ReactNode } from "react"; import { useTranslations } from "next-intl"; import { usePathname, useRouter } from "@/i18n/navigation"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { useConfig } from "@/hooks/use-config"; +import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; +import { flushComposers } from "@/lib/compose-flush-registry"; import { isPopoutPath } from "@/lib/popout/window-role"; import { getPathPrefix } from "@/lib/browser-navigation"; +import { matchSurface, type AppSurface } from "@/lib/deep-links"; +import { + adoptStoredHandoff, + discardStoredHandoff, + handOffDraft, + handOffLaunchFiles, + handOffNewMessage, + parseLaunchLanding, + peekStoredHandoff, + pendingHandoffs, + restoreStoredHandoffs, + shortcutFromTargetUrl, + stripLandingParams, + subscribeHandoff, + subscribeHandoffLost, + type HandoffKind, +} from "@/lib/launch-handoff"; import { productName } from "@/lib/product-name"; import { parseMailto } from "@/lib/protocol-handlers/mailto"; import { parseWebcal } from "@/lib/protocol-handlers/webcal"; @@ -19,8 +39,13 @@ import { savePendingWebcal, } from "@/lib/protocol-handlers/session"; import { useSettingsStore } from "@/stores/settings-store"; +import { toast } from "@/stores/toast-store"; +import { LaunchFileHost } from "./launch-file-host"; -type LaunchParams = { targetURL?: string }; +type LaunchParams = { + targetURL?: string; + files?: ReadonlyArray<{ kind?: string; getFile?: () => Promise }>; +}; type StandaloneNavigator = Navigator & { standalone?: boolean }; declare global { @@ -52,6 +77,21 @@ function getProtocolLaunch(targetURL: string): return null; } +/** The page a handoff waits for. A .eml has none: it opens over any page. */ +const HANDOFF_TARGET: Partial> = { + compose: { surface: "mail", path: "/" }, + calendar: { surface: "calendar", path: "/calendar" }, + contacts: { surface: "contacts", path: "/contacts" }, +}; + +/** How long a launch waits for an open composer to save before it leaves the page. */ +const FLUSH_BEFORE_LEAVING_MS = 5000; +/** How much of a share the question shows. */ +const OFFER_TEXT_LENGTH = 200; +const OFFER_FILE_COUNT = 5; + +type ShareOffer = { subject: string; body: string; files: string[] }; + function isStandaloneDisplayMode() { return window.matchMedia?.("(display-mode: standalone)").matches || (navigator as StandaloneNavigator).standalone === true; @@ -74,6 +114,97 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle const appName = productName(useConfig().appName); const router = useRouter(); const pathname = usePathname(); + const tLaunch = useTranslations("pwaLaunch"); + const sentToTarget = useRef(new Set()); + const { dialogProps, confirm } = useConfirmDialog(); + const [offer, setOffer] = useState(null); + + // Anyone can POST to the share target or link to a landing address, and + // Chromium's link capturing can bring a cross-site form POST into an + // installed app's window, so nothing tells a real share from a planted one. + // Nothing goes into an account until the user says yes. + const askAboutShare = useCallback(async (shared: ShareOffer) => { + setOffer(shared); + const agreed = await confirm({ + title: tLaunch("share_confirm_title"), + message: tLaunch("share_confirm_message"), + confirmText: tLaunch("share_confirm_accept"), + cancelText: tLaunch("share_confirm_decline"), + }); + setOffer(null); + return agreed; + }, [confirm, tLaunch]); + + // What the page's own address asks for: a shortcut's new message, or what + // was shared. Once: it leaves the address before anything else happens. + useEffect(() => { + const stopLossNotes = subscribeHandoffLost((loss) => { + toast.error(tLaunch(loss === "missing" ? "share_files_lost" : "handoff_expired")); + }); + const { search, hash, pathname: path } = window.location; + const landing = parseLaunchLanding(search, hash); + if (landing) { + const left = stripLandingParams(search, hash); + window.history.replaceState(window.history.state, "", path + left.search + left.hash); + } + if (landing?.type === "new-message") { + handOffNewMessage(); + } else if (landing?.type === "inline") { + void (async () => { + const { subject, body } = landing; + if ((subject || body) && !(await askAboutShare({ subject, body, files: [] }))) return; + if (subject || body) handOffDraft({ subject, body }); + if (landing.filesLost) toast.error(tLaunch("share_files_lost")); + if (landing.cut) toast.info(tLaunch("share_cut")); + })(); + } else if (landing?.type === "stored") { + void (async () => { + const shared = await peekStoredHandoff(landing.id, landing.key); + if (!shared) { + await discardStoredHandoff(landing.id); + toast.error(tLaunch("share_gone")); + return; + } + if (!(await askAboutShare(shared))) { + await discardStoredHandoff(landing.id); + return; + } + if (!(await adoptStoredHandoff(landing.id, landing.key))) toast.error(tLaunch("share_gone")); + })(); + } + void restoreStoredHandoffs(); + return stopLossNotes; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Leaving the mail page ends an open composer, and its autosave waits 2 + // seconds: what was just typed is saved first. If it can't be, stay. A file + // keeps waiting and opens once the user goes to that page. + const leaveFor = useCallback(async (path: "/" | "/calendar" | "/contacts") => { + const result = await flushComposers(FLUSH_BEFORE_LEAVING_MS); + if (result.failed > 0 || result.timedOut) { + toast.error(tLaunch("save_first_failed")); + return; + } + router.push(path); + }, [router, tLaunch]); + + // Takes a handoff to the page that handles it, once each: on arrival, or + // when the sign-in it had to wait for is done. + useEffect(() => { + const goToTarget = () => { + if (pathname === "/login" || pathname.startsWith("/login/") || pathname.startsWith("/auth/")) return; + for (const handoff of pendingHandoffs()) { + const target = HANDOFF_TARGET[handoff.kind]; + if (!target || sentToTarget.current.has(handoff.id)) continue; + sentToTarget.current.add(handoff.id); + if (matchSurface(pathname)?.surface !== target.surface) void leaveFor(target.path as "/" | "/calendar" | "/contacts"); + return; + } + }; + goToTarget(); + return subscribeHandoff("any", goToTarget); + }, [pathname, leaveFor]); useEffect(() => { if (pathname.startsWith("/protocol/") || isPopoutPath(pathname)) return; @@ -94,10 +225,23 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle if (typeof window === "undefined" || !window.launchQueue || isPopoutPath(pathname)) return; window.launchQueue.setConsumer((launchParams) => { + if (launchParams.files && launchParams.files.length > 0) { + void handOffLaunchFiles(launchParams.files).then(({ unsupported, failed }) => { + for (const name of unsupported) toast.error(tLaunch("unsupported_file", { name })); + if (failed > 0) toast.error(tLaunch("open_failed")); + }); + return; + } + if (!launchParams.targetURL) return; const launch = getProtocolLaunch(launchParams.targetURL); - if (!launch) return; + if (!launch) { + const shortcut = shortcutFromTargetUrl(launchParams.targetURL, window.location.origin); + if (shortcut === "new-message") handOffNewMessage(); + else if (shortcut && matchSurface(pathname)?.surface !== shortcut) void leaveFor(`/${shortcut}`); + return; + } if (launch.kind === "mailto") { const parsed = parseMailto(launch.raw); @@ -131,7 +275,23 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle notifyPendingWebcal(); if (pathname !== "/calendar") router.push("/calendar"); }); - }, [pathname, router]); + }, [pathname, router, tLaunch, leaveFor]); - return children; + const offeredFiles = offer?.files ?? []; + return ( + <> + {children} + + + {offer.subject &&
{tLaunch("share_confirm_subject")}
{offer.subject}
} + {offer.body &&
{tLaunch("share_confirm_text")}
{offer.body.slice(0, OFFER_TEXT_LENGTH)}
} + {offeredFiles.length > 0 &&
{tLaunch("share_confirm_files", { count: offeredFiles.length })}
{offeredFiles.slice(0, OFFER_FILE_COUNT).join(", ")}{offeredFiles.length > OFFER_FILE_COUNT ? ", …" : ""}
} + + )} + /> + + ); } diff --git a/components/providers/__tests__/notification-actions-host.test.tsx b/components/providers/__tests__/notification-actions-host.test.tsx new file mode 100644 index 000000000..f786d3e39 --- /dev/null +++ b/components/providers/__tests__/notification-actions-host.test.tsx @@ -0,0 +1,170 @@ +import { act, render } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint'; +import { NotificationActionsHost } from '../notification-actions-host'; + +// The tab's end of the service worker: it keeps the worker's copy of the +// labels and archive settings, runs notification buttons and flushes the +// outbox when the worker says so. + +const writeNotificationActionPrefs = vi.fn(async (..._args: unknown[]) => {}); +const runNotificationAction = vi.fn(async (_request: unknown, answer: (status: string) => void) => answer('done')); +const scheduleFlushAll = vi.fn(); +let state = { isAuthenticated: true, archiveMode: 'month', archiveEnabled: true, plugins: [] as Array>, accounts: [] as Array<{ serverUrl: string; username: string }> }; + +vi.mock('@/lib/pwa-notify/worker-state', () => ({ + writeNotificationActionPrefs: (...args: unknown[]) => writeNotificationActionPrefs(...args), +})); +vi.mock('@/lib/pwa-notify/page-actions', async () => { + const actual = await vi.importActual('@/lib/pwa-notify/page-actions'); + return { isNotificationActionRequest: actual.isNotificationActionRequest, runNotificationAction: (...args: Parameters) => runNotificationAction(...args) }; +}); +vi.mock('@/lib/offline/offline-mode', () => ({ scheduleFlushAll: (...args: unknown[]) => scheduleFlushAll(...args) })); +vi.mock('@/lib/archive-gate', () => ({ useArchiveActionEnabled: () => state.archiveEnabled })); +vi.mock('@/stores/auth-store', () => ({ + useAuthStore: (select: (s: { isAuthenticated: boolean }) => unknown) => select({ isAuthenticated: state.isAuthenticated }), +})); +vi.mock('@/stores/settings-store', () => ({ + useSettingsStore: (select: (s: { archiveMode: string }) => unknown) => select({ archiveMode: state.archiveMode }), +})); +vi.mock('@/stores/plugin-store', () => ({ + usePluginStore: (select: (s: { plugins: unknown[] }) => unknown) => select({ plugins: state.plugins }), +})); +vi.mock('@/stores/email-store', () => ({ useEmailStore: { getState: () => ({}) } })); +vi.mock('@/stores/account-store', () => { + const useAccountStore = (select: (s: { accounts: unknown[] }) => unknown) => select({ accounts: state.accounts }); + useAccountStore.getState = () => ({ accounts: state.accounts }); + return { useAccountStore }; +}); + +const worker = Object.assign(new EventTarget(), { controller: null as null | { postMessage: ReturnType } }); +const fromWorker = (data: unknown, ports: unknown[] = []) => act(async () => { + const event = new Event('message'); + Object.assign(event, { data, ports }); + worker.dispatchEvent(event); +}); + +beforeEach(() => { + vi.clearAllMocks(); + state = { isAuthenticated: true, archiveMode: 'month', archiveEnabled: true, plugins: [], accounts: [] }; + worker.controller = null; + Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: worker }); +}); + +afterEach(() => { + Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: undefined }); +}); + +describe('the mirror for the worker', () => { + it('writes the labels and archive settings once signed in', () => { + render(); + expect(writeNotificationActionPrefs).toHaveBeenCalledWith({ + archiveEnabled: true, + archiveMode: 'month', + pluginsMayVeto: false, + labels: { archive: 'action_archive', markRead: 'action_mark_read', reply: 'action_reply', replyPlaceholder: 'reply_placeholder' }, + }); + }); + + it('writes nothing while signed out', () => { + state.isAuthenticated = false; + render(); + expect(writeNotificationActionPrefs).not.toHaveBeenCalled(); + }); + + it('writes again on signing in, since signing out deleted the store', () => { + state.isAuthenticated = false; + const { rerender } = render(); + state.isAuthenticated = true; + rerender(); + expect(writeNotificationActionPrefs).toHaveBeenCalledTimes(1); + }); + + it('writes again when the archive mode or the gate changes, and not otherwise', () => { + const { rerender } = render(); + rerender(); + expect(writeNotificationActionPrefs).toHaveBeenCalledTimes(1); + state.archiveMode = 'year'; + rerender(); + expect(writeNotificationActionPrefs).toHaveBeenCalledTimes(2); + state.archiveEnabled = false; + rerender(); + expect(writeNotificationActionPrefs).toHaveBeenCalledTimes(3); + expect(writeNotificationActionPrefs.mock.calls[2][0]).toMatchObject({ archiveEnabled: false, archiveMode: 'year' }); + }); +}); + +describe('what the worker hears about plugins and logins', () => { + it('says when an enabled plugin may stop these actions, and when that ends', () => { + state.plugins = [{ enabled: true, permissions: ['email:write'] }]; + const { rerender } = render(); + expect(writeNotificationActionPrefs.mock.calls.at(-1)?.[0]).toMatchObject({ pluginsMayVeto: true }); + state.plugins = [{ enabled: false, permissions: ['email:write'] }]; + rerender(); + expect(writeNotificationActionPrefs.mock.calls.at(-1)?.[0]).toMatchObject({ pluginsMayVeto: false }); + }); + + it('tells the worker which logins this tab holds, as their fingerprints', async () => { + const postMessage = vi.fn(); + worker.controller = { postMessage }; + state.accounts = [{ serverUrl: 'https://one.example', username: 'alice@example.org' }]; + await act(async () => { render(); }); + await vi.waitFor(() => expect(postMessage).toHaveBeenCalled()); + expect(postMessage).toHaveBeenCalledWith({ + type: 'client-logins', + logins: [await loginFingerprint('https://one.example', 'alice@example.org')], + }); + }); + + it('tells it again when the logins change', async () => { + const postMessage = vi.fn(); + worker.controller = { postMessage }; + state.accounts = [{ serverUrl: 'https://one.example', username: 'alice@example.org' }]; + let view!: ReturnType; + await act(async () => { view = render(); }); + await vi.waitFor(() => expect(postMessage).toHaveBeenCalledTimes(1)); + state.accounts = []; + await act(async () => { view.rerender(); }); + await vi.waitFor(() => expect(postMessage).toHaveBeenCalledTimes(2)); + expect(postMessage).toHaveBeenLastCalledWith({ type: 'client-logins', logins: [] }); + }); +}); + +describe('messages from the worker', () => { + const request = { kind: 'notification-action', action: 'mark-read', emailId: 'e1', accountId: 'a', slot: 0, login: 'fp', at: Date.now() }; + + it('runs a notification action and answers on its port', async () => { + render(); + const port = { postMessage: vi.fn() }; + await fromWorker(request, [port]); + expect(runNotificationAction.mock.calls[0][0]).toMatchObject({ action: 'mark-read', emailId: 'e1' }); + expect(port.postMessage).toHaveBeenCalledWith({ status: 'done' }); + }); + + it('ignores anything else', async () => { + render(); + await fromWorker({ kind: 'notificationclick', tag: 't' }); + await fromWorker({ ...request, action: 'delete' }); + await fromWorker(null); + expect(runNotificationAction).not.toHaveBeenCalled(); + expect(scheduleFlushAll).not.toHaveBeenCalled(); + }); + + it('flushes the outbox on the word of a sync event', async () => { + render(); + await fromWorker({ kind: 'outbox-flush' }); + expect(scheduleFlushAll).toHaveBeenCalledWith(0); + }); + + it('stops listening when it goes', async () => { + const { unmount } = render(); + unmount(); + await fromWorker({ kind: 'outbox-flush' }); + expect(scheduleFlushAll).not.toHaveBeenCalled(); + }); + + it('does nothing in a browser without service workers', () => { + Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: undefined }); + expect(() => render()).not.toThrow(); + }); +}); diff --git a/components/providers/notification-actions-host.tsx b/components/providers/notification-actions-host.tsx new file mode 100644 index 000000000..4077951d3 --- /dev/null +++ b/components/providers/notification-actions-host.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { useEffect, useMemo } from 'react'; +import { useTranslations } from 'next-intl'; +import { useArchiveActionEnabled } from '@/lib/archive-gate'; +import { scheduleFlushAll } from '@/lib/offline/offline-mode'; +import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint'; +import { isNotificationActionRequest, runNotificationAction } from '@/lib/pwa-notify/page-actions'; +import { pluginsMayVeto } from '@/lib/pwa-notify/plugin-veto'; +import { writeNotificationActionPrefs } from '@/lib/pwa-notify/worker-state'; +import { useAccountStore } from '@/stores/account-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { usePluginStore } from '@/stores/plugin-store'; +import { useSettingsStore } from '@/stores/settings-store'; + +/** + * This tab's end of what the service worker can't do alone: it keeps the + * worker's copy of the labels and archive settings current, says which logins + * it holds, runs a notification's buttons and flushes the outbox on the + * worker's word. Not mounted in Lite (no service worker there). + */ +export function NotificationActionsHost() { + const t = useTranslations('pwaNotify'); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const archiveMode = useSettingsStore((s) => s.archiveMode); + const archiveEnabled = useArchiveActionEnabled(); + const mayVeto = usePluginStore((s) => pluginsMayVeto(s.plugins)); + const accounts = useAccountStore((s) => s.accounts); + const archive = t('action_archive'); + const markRead = t('action_mark_read'); + const reply = t('action_reply'); + const replyPlaceholder = t('reply_placeholder'); + + // Signing out deletes the worker's store, so signing in writes it again. + useEffect(() => { + if (!isAuthenticated) return; + void writeNotificationActionPrefs({ + archiveEnabled, + archiveMode, + pluginsMayVeto: mayVeto, + labels: { archive, markRead, reply, replyPlaceholder }, + }); + }, [isAuthenticated, archiveEnabled, archiveMode, mayVeto, archive, markRead, reply, replyPlaceholder]); + + // Which logins this tab holds: if it stays silent on a click for one of them, + // the worker opens the message rather than go round its plugins. + const loginKey = useMemo(() => accounts.map((a) => `${a.serverUrl}\n${a.username}`).join('\n\n'), [accounts]); + useEffect(() => { + const worker = navigator.serviceWorker; + if (!worker) return; + let stale = false; + void (async () => { + try { + const held = await Promise.all(useAccountStore.getState().accounts.map((a) => loginFingerprint(a.serverUrl, a.username))); + // Without a worker `ready` never settles, and nothing needs telling. + const target = worker.controller ?? (await worker.ready).active; + if (!stale) target?.postMessage({ type: 'client-logins', logins: held }); + } catch { + // The worker counts this tab as not holding them. + } + })(); + return () => { stale = true; }; + }, [loginKey, isAuthenticated]); + + useEffect(() => { + const worker = navigator.serviceWorker; + if (!worker) return; + const onMessage = (event: MessageEvent) => { + const message = event.data as { kind?: string } | null; + if (message?.kind === 'outbox-flush') { + scheduleFlushAll(0); + return; + } + if (message?.kind !== 'notification-action' || !isNotificationActionRequest(message)) return; + const port = event.ports[0]; + void runNotificationAction(message, (status) => port?.postMessage({ status })); + }; + worker.addEventListener('message', onMessage); + return () => worker.removeEventListener('message', onMessage); + }, []); + + return null; +} diff --git a/hooks/use-handoff-files.ts b/hooks/use-handoff-files.ts new file mode 100644 index 000000000..9ef0eea8e --- /dev/null +++ b/hooks/use-handoff-files.ts @@ -0,0 +1,28 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { subscribeHandoff, takeHandoff, type HandoffKind } from "@/lib/launch-handoff"; + +/** + * The files handed to this part of the app (lib/launch-handoff.ts), one at a + * time. Nothing is taken while `active` is false, for a surface that isn't + * ready for them yet. `done` moves on to the next file. + */ +export function useHandoffFiles(kind: HandoffKind, active: boolean): { file: File | null; done: () => void } { + const [files, setFiles] = useState([]); + + useEffect(() => { + if (!active) return; + const take = () => { + for (let handoff = takeHandoff(kind); handoff; handoff = takeHandoff(kind)) { + const received = handoff.files; + if (received.length > 0) setFiles((waiting) => [...waiting, ...received]); + } + }; + take(); + return subscribeHandoff(kind, take); + }, [kind, active]); + + const done = useCallback(() => setFiles((waiting) => waiting.slice(1)), []); + return { file: files[0] ?? null, done }; +} diff --git a/integration/tests/23-do-pwa-launch.spec.ts b/integration/tests/23-do-pwa-launch.spec.ts new file mode 100644 index 000000000..6d2330b72 --- /dev/null +++ b/integration/tests/23-do-pwa-launch.spec.ts @@ -0,0 +1,659 @@ +import { test, expect, type Browser, type BrowserContext, type Page } from '@playwright/test'; +import { accountSwitcher, neutralizeDevOverlay } from './helpers/app'; +import { WEBMAIL_URL } from './helpers/config'; +import { ENT, ENT_PASSWORD, adminContext, contextOptions, getPolicy, jmapAs, putPolicy, requireEntEnv, shot } from './helpers/ent'; + +/** + * The installed app's launches through the real UI: shortcuts, files opened + * from the OS, and shares. Chromium can't fire launchQueue from a test, so a + * stand-in catches the consumer the app registers, and the spec calls that + * consumer the way the browser would. Shares go through the real service + * worker as a real form POST. Needs a production build, the only one that + * registers the worker. + * + * BUILD=1 bash desktop-offline-rig/start-prod.sh 3230 pwa-launch-1.0 + * bash desktop-offline-rig/run-spec.sh 3230 1.0 integration/tests/23-do-pwa-launch.spec.ts + */ + +test.describe.configure({ mode: 'serial' }); +test.setTimeout(180_000); + +const RUN = Date.now().toString(36); +const composer = (page: Page) => page.locator('[data-testid="email-composer"]').first(); +const SHARE_CACHE = 'bulwark-launch-handoff-v1'; + +type LaunchFile = { name: string; type: string; body: string }; + +const EML: LaunchFile = { + name: 'note.eml', + type: 'message/rfc822', + body: [ + 'From: Bob ', + 'To: Alice ', + `Subject: Launched message ${RUN}`, + 'Date: Tue, 03 Nov 2026 10:00:00 +0000', + 'Content-Type: text/plain; charset=utf-8', + '', + 'Opened from the file system.', + '', + ].join('\r\n'), +}; + +const ICS: LaunchFile = { + name: 'invite.ics', + type: 'text/calendar', + body: [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Bulwark//Test//EN', + 'BEGIN:VEVENT', + `UID:launch-${RUN}@example.org`, + 'DTSTAMP:20261103T100000Z', + 'DTSTART:20261110T100000Z', + 'DTEND:20261110T110000Z', + `SUMMARY:Launched event ${RUN}`, + 'END:VEVENT', + 'END:VCALENDAR', + '', + ].join('\r\n'), +}; + +const VCF: LaunchFile = { + name: 'people.vcf', + type: 'text/vcard', + body: ['BEGIN:VCARD', 'VERSION:3.0', `FN:Launched Person ${RUN}`, `EMAIL:launched-${RUN}@example.org`, 'END:VCARD', ''].join('\r\n'), +}; + +/** Stands in for the browser's launchQueue, keeping the consumer the app sets. */ +function catchLaunchConsumer() { + Object.defineProperty(window, 'launchQueue', { + configurable: true, + value: { + setConsumer(fn: unknown) { + (window as unknown as { __launch: unknown }).__launch = fn; + }, + }, + }); +} + +async function launch(page: Page, opts: { target?: string; files?: LaunchFile[] }): Promise { + await page.waitForFunction(() => typeof (window as unknown as { __launch?: unknown }).__launch === 'function'); + await page.evaluate(({ target, files }) => { + const consumer = (window as unknown as { __launch: (params: unknown) => void }).__launch; + consumer({ + targetURL: new URL(target, window.location.origin).href, + ...(files.length > 0 ? { + files: files.map((f) => ({ kind: 'file', getFile: async () => new File([f.body], f.name, { type: f.type }) })), + } : {}), + }); + }, { target: opts.target ?? '/', files: opts.files ?? [] }); +} + +async function signIn(page: Page, email: string): Promise { + await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 }); + await page.fill('#username', email); + await page.fill('#password', ENT_PASSWORD); + await page.click('button[type="submit"]'); + await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60_000 }); +} + +/** A new window lands signed out or signed in, depending on what can be restored. */ +async function signInIfAsked(page: Page, email: string): Promise { + const login = page.locator('#username'); + await expect(login.or(accountSwitcher(page))).toBeVisible({ timeout: 60_000 }); + if (await login.isVisible()) await signIn(page, email); +} + +async function launchContext(browser: Browser, opts: Parameters[0] = {}): Promise { + const context = await browser.newContext({ ...contextOptions(), ...opts }); + await context.addInitScript(catchLaunchConsumer); + return context; +} + +async function signedIn(browser: Browser, opts: Parameters[0] = {}): Promise { + const page = await (await launchContext(browser, opts)).newPage(); + await neutralizeDevOverlay(page); + await page.goto('/'); + await signIn(page, ENT.alice); + return page; +} + +/** A real form POST to the share target, as the OS's share sheet sends it. */ +async function postShare(page: Page, fields: Record, files: LaunchFile[]): Promise { + await page.evaluate(({ fields: values, files: list }) => { + const form = document.createElement('form'); + form.method = 'POST'; + form.enctype = 'multipart/form-data'; + form.action = '/protocol/share'; + for (const [name, value] of Object.entries(values)) { + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = name; + input.value = value; + form.append(input); + } + const input = document.createElement('input'); + input.type = 'file'; + input.name = 'files'; + input.multiple = true; + const data = new DataTransfer(); + for (const f of list) data.items.add(new File([f.body], f.name, { type: f.type })); + input.files = data.files; + form.append(input); + document.body.append(form); + form.submit(); + }, { fields, files }); +} + +async function workerReady(page: Page): Promise { + const active = await page.evaluate(() => navigator.serviceWorker.ready.then((r) => !!r.active)); + expect(active).toBe(true); +} + +/** The question a share has to pass, wherever it came from. */ +const startMessage = (page: Page) => page.getByRole('button', { name: 'Start message', exact: true }); +const dontStart = (page: Page) => page.getByRole('button', { name: "Don't start", exact: true }); + +const HOSTILE = 'http://hostile.example'; + +/** A page on another origin that POSTs to the share target as soon as it loads. Nobody clicks. */ +function hostilePage(opts: { subject: string; fileName?: string }): string { + return `hostile`; +} + +async function hostileTab(context: BrowserContext, opts: Parameters[0]): Promise { + await context.route(`${HOSTILE}/**`, (route) => route.fulfill({ contentType: 'text/html', body: hostilePage(opts) })); + const tab = await context.newPage(); + await tab.goto(`${HOSTILE}/`); + return tab; +} + +/** Every message on the server with this subject. */ +async function draftsWithSubject(subject: string): Promise { + const client = await jmapAs(ENT.alice); + const res = await client.request([['Email/query', { accountId: client.accountId, filter: { subject } }, 'q']]); + return res.methodResponses[0][1].ids as string[]; +} + +async function deleteDrafts(ids: string[]): Promise { + if (ids.length === 0) return; + const client = await jmapAs(ENT.alice); + await client.request([['Email/set', { accountId: client.accountId, destroy: ids }, 'd']]); +} + +const storedShareEntries = (page: Page) => page.evaluate(async (name) => { + if (!(await caches.has(name))) return 0; + return (await (await caches.open(name)).keys()).length; +}, SHARE_CACHE); + +test.beforeAll(() => requireEntEnv()); + +test.describe('manifest', () => { + test('offers shortcuts, file handlers and the share target, in the browser language', async ({ browser }) => { + const context = await browser.newContext(contextOptions()); + const res = await context.request.get('/manifest.webmanifest', { headers: { 'Accept-Language': 'de-DE,de;q=0.9' } }); + expect(res.ok()).toBe(true); + const manifest = await res.json(); + expect(manifest.shortcuts.map((s: { name: string; url: string }) => [s.name, s.url])).toEqual([ + ['Neue Nachricht', '/?compose=1'], + ['Kalender', '/calendar'], + ['Kontakte', '/contacts'], + ]); + expect(manifest.file_handlers.map((h: { action: string }) => h.action)).toEqual(['/mail', '/calendar', '/contacts']); + expect(manifest.share_target).toMatchObject({ action: '/protocol/share', method: 'POST', enctype: 'multipart/form-data' }); + expect(manifest.protocol_handlers).toHaveLength(2); + await context.close(); + }); + + test('every address in it answers', async ({ browser }) => { + const context = await browser.newContext(contextOptions()); + const manifest = await (await context.request.get('/manifest.webmanifest')).json(); + for (const shortcut of manifest.shortcuts) { + expect((await context.request.get(shortcut.url)).ok(), shortcut.url).toBe(true); + expect((await context.request.get(shortcut.icons[0].src)).ok(), shortcut.icons[0].src).toBe(true); + } + for (const handler of manifest.file_handlers) { + expect((await context.request.get(handler.action)).ok(), handler.action).toBe(true); + } + await context.close(); + }); +}); + +test.describe('share target', () => { + test('files and a link go through the service worker into a new draft', async ({ browser }) => { + const page = await signedIn(browser); + await workerReady(page); + + const subject = `Shared ${RUN}`; + await postShare(page, { title: subject, text: 'Have a look', url: 'https://example.org/page' }, [ + { name: `shared-${RUN}.txt`, type: 'text/plain', body: 'shared bytes' }, + ]); + await page.waitForURL((url) => url.pathname.endsWith('/') || !url.pathname.includes('protocol')); + await startMessage(page).click({ timeout: 60_000 }); + await signInIfAsked(page, ENT.alice); + + await expect(composer(page)).toBeVisible({ timeout: 30_000 }); + await expect(composer(page).getByPlaceholder(/subject/i)).toHaveValue(subject); + await expect(composer(page).locator('[contenteditable="true"]').first()).toContainText('https://example.org/page'); + await expect(composer(page).getByText(`shared-${RUN}.txt`)).toBeVisible(); + // It reached the app through the worker's store, not the server's fallback. + await expect(page.getByText("The shared files couldn't be kept")).toHaveCount(0); + await expect.poll(() => new URL(page.url()).hash).toBe(''); + await expect.poll(() => storedShareEntries(page)).toBe(0); + await shot(page, 'pwa-launch-share'); + await page.context().close(); + }); + + test('survives a sign-in that reloads the page', async ({ browser }) => { + const page = await (await launchContext(browser)).newPage(); + await page.goto('/'); + await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 }); + await workerReady(page); + + await postShare(page, { url: 'https://example.org/waited' }, [{ name: `waited-${RUN}.txt`, type: 'text/plain', body: 'x' }]); + await startMessage(page).click({ timeout: 60_000 }); + await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 }); + await expect.poll(() => storedShareEntries(page)).toBe(3); + // What SSO does: away and back, in the same tab. + await page.reload(); + await signIn(page, ENT.alice); + + await expect(composer(page)).toBeVisible({ timeout: 30_000 }); + await expect(composer(page).getByText(`waited-${RUN}.txt`)).toBeVisible(); + await expect.poll(() => storedShareEntries(page)).toBe(0); + await page.context().close(); + }); + + test('without a service worker the link still arrives and the app says the files did not', async ({ browser }) => { + const page = await signedIn(browser, { serviceWorkers: 'block' }); + await postShare(page, { url: 'https://example.org/no-worker' }, [{ name: 'lost.txt', type: 'text/plain', body: 'x' }]); + await page.waitForLoadState('domcontentloaded'); + await startMessage(page).click({ timeout: 60_000 }); + await signInIfAsked(page, ENT.alice); + + await expect(composer(page)).toBeVisible({ timeout: 30_000 }); + await expect(composer(page).locator('[contenteditable="true"]').first()).toContainText('https://example.org/no-worker'); + await expect(composer(page).getByText('lost.txt')).toHaveCount(0); + await expect(page.getByText("The shared files couldn't be kept").first()).toBeVisible(); + await page.context().close(); + }); + + test('the server takes the POST from outside the origin, which /api/ would refuse', async ({ browser }) => { + const context = await browser.newContext(contextOptions()); + const res = await context.request.post('/protocol/share', { + headers: { Origin: 'https://other.example', 'Sec-Fetch-Site': 'cross-site' }, + multipart: { url: 'https://example.org/direct' }, + maxRedirects: 0, + }); + expect(res.status()).toBe(303); + const location = new URL(res.headers().location, 'http://x.invalid'); + // In the fragment: shared text stays out of access logs. + expect(location.search).toBe(''); + const fragment = new URLSearchParams(location.hash.slice(1)); + expect(fragment.get('share')).toBe('unavailable'); + expect(fragment.get('share_url')).toBe('https://example.org/direct'); + await context.close(); + }); + + test('long non-Latin text fits the address the fallback sends on', async ({ browser }) => { + const context = await browser.newContext(contextOptions()); + const res = await context.request.post('/protocol/share', { + multipart: { text: 'あ'.repeat(3000) }, + maxRedirects: 0, + }); + expect(res.status()).toBe(303); + const location = res.headers().location; + expect(location.length).toBeLessThan(4000); + expect((await context.request.get(location)).ok()).toBe(true); + await context.close(); + }); +}); + +test.describe('a share nobody asked for', () => { + test('a POST from another site leaves no draft on the server and asks first', async ({ browser }) => { + const signedInPage = await signedIn(browser); + await workerReady(signedInPage); + const subject = `hostile-${RUN}-a`; + const tab = await hostileTab(signedInPage.context(), { subject, fileName: `invoice-${RUN}.exe` }); + await tab.waitForURL((url) => url.href.startsWith(WEBMAIL_URL)); + + // The question comes before anything else, also before a sign-in. + await expect(startMessage(tab)).toBeVisible({ timeout: 30_000 }).catch(() => {}); + if (await startMessage(tab).isVisible()) { + await expect(tab.getByTestId('share-confirm-details')).toContainText(`invoice-${RUN}.exe`); + } else { + await signInIfAsked(tab, ENT.alice); + } + await tab.waitForTimeout(6000); + const drafts = await draftsWithSubject(subject); + await deleteDrafts(drafts); + expect(drafts).toEqual([]); + await expect(composer(tab)).toHaveCount(0); + await expect(startMessage(tab)).toBeVisible(); + await shot(tab, 'pwa-launch-share-confirm'); + + // Refused: what it stored goes too. + await dontStart(tab).click(); + await expect.poll(() => storedShareEntries(tab)).toBe(0); + await expect(composer(tab)).toHaveCount(0); + await signedInPage.context().close(); + }); + + test('agreed, it is only the starting state: no draft until the first real edit', async ({ browser }) => { + const signedInPage = await signedIn(browser); + await workerReady(signedInPage); + const subject = `hostile-${RUN}-b`; + const tab = await hostileTab(signedInPage.context(), { subject, fileName: `kept-${RUN}.txt` }); + await tab.waitForURL((url) => url.href.startsWith(WEBMAIL_URL)); + await startMessage(tab).click({ timeout: 60_000 }); + await signInIfAsked(tab, ENT.alice); + await expect(composer(tab).getByText(`kept-${RUN}.txt`)).toBeVisible({ timeout: 30_000 }); + await expect(composer(tab).getByPlaceholder(/subject/i)).toHaveValue(subject); + + await tab.waitForTimeout(6000); + expect(await draftsWithSubject(subject)).toEqual([]); + + // Leaving doesn't save it either. + await tab.evaluate(() => { window.dispatchEvent(new Event('beforeunload')); }); + await tab.waitForTimeout(3000); + expect(await draftsWithSubject(subject)).toEqual([]); + + // The user's own edit: from here it is a draft like any other. + await composer(tab).getByPlaceholder(/subject/i).fill(`${subject} edited`); + await expect(composer(tab).getByPlaceholder(/subject/i)).toHaveValue(`${subject} edited`); + await expect.poll(() => draftsWithSubject(subject), { timeout: 20_000 }).toHaveLength(1); + await deleteDrafts(await draftsWithSubject(subject)); + await signedInPage.context().close(); + }); + + test('a hidden frame stores nothing', async ({ browser }) => { + const page = await (await launchContext(browser)).newPage(); + await page.goto('/'); + await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 }); + await workerReady(page); + // From the app's own origin: another site's frame doesn't reach this worker + // at all in Chromium (partitioned storage), so only this shows the check. + await page.evaluate(() => { + const frame = document.createElement('iframe'); + frame.name = 'hidden-share'; + frame.style.display = 'none'; + document.body.append(frame); + const form = document.createElement('form'); + form.method = 'POST'; + form.enctype = 'multipart/form-data'; + form.action = '/protocol/share'; + form.target = 'hidden-share'; + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = 'title'; + input.value = 'frame'; + form.append(input); + document.body.append(form); + form.submit(); + }); + await page.waitForTimeout(3000); + expect(await storedShareEntries(page)).toBe(0); + await page.context().close(); + }); + + test('text in an address anyone can link to asks first too', async ({ browser }) => { + const page = await signedIn(browser); + await page.goto(`/#share=unavailable&share_title=${encodeURIComponent(`linked-${RUN}`)}&share_text=hello`); + await expect(startMessage(page)).toBeVisible({ timeout: 30_000 }); + await expect(composer(page)).toHaveCount(0); + await dontStart(page).click(); + await expect(composer(page)).toHaveCount(0); + await page.context().close(); + }); +}); + +test.describe('a handoff while a composer is open', () => { + test('one set aside by reading a message is not replaced by the next', async ({ browser }) => { + const page = await signedIn(browser); + await page.locator('[data-testid="email-list-item"]').first().waitFor({ timeout: 30_000 }); + const subject = `setaside${RUN}`; + await launch(page, { target: '/?compose=1' }); + await expect(composer(page)).toBeVisible({ timeout: 30_000 }); + await composer(page).getByPlaceholder(/subject/i).fill(subject); + await expect(composer(page).getByPlaceholder(/subject/i)).toHaveValue(subject); + // Reading a message suspends the composer before its autosave ran. + await page.locator('[data-testid="email-list-item"]').first().click(); + await expect(composer(page)).toHaveCount(0); + await page.waitForTimeout(2100); + await launch(page, { target: '/?compose=1' }); + await expect(page.getByText('Close this message to start the next one.').first()).toBeVisible({ timeout: 10_000 }); + await expect(composer(page)).toHaveCount(0); + await expect(page.getByText(subject).first()).toBeVisible(); + await page.context().close(); + }); + + test('two at once: the second waits until the first composer closes', async ({ browser }) => { + const page = await signedIn(browser); + // Let the page settle after the sign-in: the composer shouldn't race the first mail load. + await page.waitForTimeout(3000); + await launch(page, { target: '/?compose=1' }); + await expect(composer(page)).toBeVisible({ timeout: 30_000 }); + const subject = `kept-open-${RUN}`; + await composer(page).getByPlaceholder(/subject/i).fill(subject); + await expect(composer(page).getByPlaceholder(/subject/i)).toHaveValue(subject); + + await page.waitForTimeout(2100); + await launch(page, { target: '/?compose=1' }); + await expect(page.getByText('Close this message to start the next one.').first()).toBeVisible({ timeout: 10_000 }); + await expect(composer(page).getByPlaceholder(/subject/i)).toHaveValue(subject); + + await page.locator('[data-testid="composer-close"]').first().click(); + await expect(composer(page).getByPlaceholder(/subject/i)).toHaveValue('', { timeout: 15_000 }); + await deleteDrafts(await draftsWithSubject(subject)); + await page.context().close(); + }); + + test('a .vcf launch saves what was just typed before leaving the composer', async ({ browser }) => { + const page = await signedIn(browser); + await launch(page, { target: '/?compose=1' }); + await expect(composer(page)).toBeVisible({ timeout: 30_000 }); + const subject = `typedfirst${RUN}`; + await composer(page).getByPlaceholder(/subject/i).fill(subject); + // Well inside the autosave's 2 seconds. + await launch(page, { target: '/contacts', files: [VCF] }); + await page.waitForURL(/\/contacts/); + await expect(page.getByText(`Launched Person ${RUN}`).first()).toBeVisible({ timeout: 30_000 }); + await expect.poll(() => draftsWithSubject(subject), { timeout: 10_000 }).toHaveLength(1); + await deleteDrafts(await draftsWithSubject(subject)); + await page.context().close(); + }); +}); + +test.describe('file launches', () => { + test('.eml opens in the viewer, over the page', async ({ browser }) => { + const page = await signedIn(browser); + const before = new URL(page.url()).pathname; + await launch(page, { target: '/mail', files: [EML] }); + await expect(page.getByRole('heading', { name: `Launched message ${RUN}` })).toBeVisible({ timeout: 20_000 }); + expect(new URL(page.url()).pathname).toBe(before); + await expect(page.getByRole('button', { name: 'Download', exact: true }).first()).toBeVisible(); + await shot(page, 'pwa-launch-eml'); + await page.context().close(); + }); + + test('.eml opens before signing in', async ({ browser }) => { + const page = await (await launchContext(browser)).newPage(); + await page.goto('/'); + await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 }); + await launch(page, { target: '/mail', files: [EML] }); + await expect(page.getByRole('heading', { name: `Launched message ${RUN}` })).toBeVisible({ timeout: 20_000 }); + await page.context().close(); + }); + + test('.eml with downloads blocked: the viewer opens, with nothing to save', async ({ browser }) => { + const admin = await adminContext(browser); + const saved = await getPolicy(admin); + const organisation = (saved.organisation ?? {}) as Record; + await putPolicy(admin, { + ...saved, + organisation: { + ...organisation, + devices: { ...((organisation.devices ?? {}) as object), noDownloads: true }, + }, + }); + try { + const page = await signedIn(browser); + await launch(page, { target: '/mail', files: [EML] }); + await expect(page.getByRole('heading', { name: `Launched message ${RUN}` })).toBeVisible({ timeout: 20_000 }); + await expect(page.getByRole('button', { name: 'Download', exact: true })).toHaveCount(0); + await page.context().close(); + } finally { + await putPolicy(admin, saved); + await admin.close(); + } + }); + + test('.eml without a connection or the viewer chunk: a message, not the error page', async ({ browser }) => { + const page = await signedIn(browser); + await page.context().setOffline(true); + await launch(page, { target: '/mail', files: [EML] }); + await expect(page.getByText("The file couldn't be opened").first()).toBeVisible({ timeout: 20_000 }); + await expect(accountSwitcher(page)).toBeVisible(); + + await page.context().setOffline(false); + await launch(page, { target: '/mail', files: [EML] }); + await expect(page.getByRole('heading', { name: `Launched message ${RUN}` })).toBeVisible({ timeout: 20_000 }); + await page.context().close(); + }); + + test('.ics goes to the calendar import, which the server parses', async ({ browser }) => { + const page = await signedIn(browser); + await launch(page, { target: '/calendar', files: [ICS] }); + await page.waitForURL(/\/calendar/); + await expect(page.getByText(`Launched event ${RUN}`).first()).toBeVisible({ timeout: 30_000 }); + await shot(page, 'pwa-launch-ics'); + await page.context().close(); + }); + + test('.ics while offline says the server is needed, and carries on once back', async ({ browser }) => { + const page = await signedIn(browser); + // Into the calendar first: its code isn't on the device without offline mode. + await launch(page, { target: '/calendar' }); + await page.waitForURL(/\/calendar/); + await page.context().setOffline(true); + await launch(page, { target: '/calendar', files: [ICS] }); + await expect(page.getByText('Importing a calendar file needs a connection to the server.')).toBeVisible({ timeout: 20_000 }); + await shot(page, 'pwa-launch-ics-offline'); + + await page.context().setOffline(false); + await expect(page.getByText(`Launched event ${RUN}`).first()).toBeVisible({ timeout: 30_000 }); + await page.context().close(); + }); + + test('.vcf goes to the contacts import, parsed in the browser', async ({ browser }) => { + const page = await signedIn(browser); + await launch(page, { target: '/contacts', files: [VCF] }); + await page.waitForURL(/\/contacts/); + await expect(page.getByText(`Launched Person ${RUN}`).first()).toBeVisible({ timeout: 30_000 }); + await shot(page, 'pwa-launch-vcf'); + await page.context().close(); + }); + + test('a file launched before signing in waits for it', async ({ browser }) => { + const page = await (await launchContext(browser)).newPage(); + await page.goto('/'); + await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 }); + await launch(page, { target: '/contacts', files: [VCF] }); + await expect(page.locator('#username')).toBeVisible(); + await signIn(page, ENT.alice); + await page.waitForURL(/\/contacts/); + await expect(page.getByText(`Launched Person ${RUN}`).first()).toBeVisible({ timeout: 30_000 }); + await page.context().close(); + }); + + test('a file launched into a signed-in page with a reload on the way still arrives', async ({ browser }) => { + const page = await (await launchContext(browser)).newPage(); + await page.goto('/'); + await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 }); + await launch(page, { target: '/calendar', files: [ICS] }); + await expect.poll(() => storedShareEntries(page)).toBe(3); + await page.reload(); + await signIn(page, ENT.alice); + await page.waitForURL(/\/calendar/); + await expect(page.getByText(`Launched event ${RUN}`).first()).toBeVisible({ timeout: 30_000 }); + await expect.poll(() => storedShareEntries(page)).toBe(0); + await page.context().close(); + }); +}); + +test.describe('shortcuts', () => { + test('New message opens a composer from the address, once, and clears the address', async ({ browser }) => { + const page = await (await launchContext(browser)).newPage(); + await page.goto('/?compose=1'); + await signIn(page, ENT.alice); + await expect(composer(page)).toBeVisible({ timeout: 30_000 }); + expect(new URL(page.url()).search).toBe(''); + await page.context().close(); + }); + + test('New message in an open window', async ({ browser }) => { + const page = await signedIn(browser); + await expect(composer(page)).toHaveCount(0); + await launch(page, { target: '/?compose=1' }); + await expect(composer(page)).toBeVisible({ timeout: 30_000 }); + await page.context().close(); + }); + + test('Calendar and Contacts in an open window', async ({ browser }) => { + const page = await signedIn(browser); + await launch(page, { target: '/calendar' }); + await page.waitForURL(/\/calendar/); + await launch(page, { target: '/contacts' }); + await page.waitForURL(/\/contacts/); + await page.context().close(); + }); + + test('the plain app icon leaves an open window where it is', async ({ browser }) => { + const page = await signedIn(browser); + await launch(page, { target: '/calendar' }); + await page.waitForURL(/\/calendar/); + await launch(page, { target: '/' }); + await page.waitForTimeout(1000); + expect(new URL(page.url()).pathname).toMatch(/\/calendar(\/|$)/); + await expect(composer(page)).toHaveCount(0); + await page.context().close(); + }); +}); + +test.describe('in the Pro shell', () => { + const proOn = () => { + const key = 'settings-storage'; + const parsed = JSON.parse(localStorage.getItem(key) ?? '{"state":{},"version":0}'); + parsed.state = { ...parsed.state, proInterface: true }; + localStorage.setItem(key, JSON.stringify(parsed)); + }; + + test('a shared file reaches the composer tab, and a .ics the calendar tab', async ({ browser }) => { + const context = await launchContext(browser); + await context.addInitScript(proOn); + const page = await context.newPage(); + await page.goto('/'); + await signIn(page, ENT.alice); + await page.waitForURL(/\/pro/); + await workerReady(page); + + await postShare(page, { url: 'https://example.org/pro' }, [{ name: `pro-${RUN}.txt`, type: 'text/plain', body: 'x' }]); + await startMessage(page).click({ timeout: 60_000 }); + await signInIfAsked(page, ENT.alice); + await page.waitForURL(/\/pro/); + await expect(page.getByText(`pro-${RUN}.txt`).first()).toBeVisible({ timeout: 30_000 }); + + await launch(page, { target: '/calendar', files: [ICS] }); + await expect(page.getByText(`Launched event ${RUN}`).first()).toBeVisible({ timeout: 30_000 }); + await shot(page, 'pwa-launch-pro'); + await context.close(); + }); +}); diff --git a/integration/tests/23-do-pwa-notify.spec.ts b/integration/tests/23-do-pwa-notify.spec.ts new file mode 100644 index 000000000..5867c9c29 --- /dev/null +++ b/integration/tests/23-do-pwa-notify.spec.ts @@ -0,0 +1,566 @@ +import { test, expect, type BrowserContext, type Page, type Worker } from '@playwright/test'; +import { JMAP_URL } from './helpers/config'; +import { ENT, adminContext, deliver, jmapAs, requireEntEnv, userPage, withPolicy } from './helpers/ent'; + +/** + * A notification's Archive, Mark read and Reply, and Background Sync, in + * Chromium with the real service worker of a production build, the real + * /api/push/preview and /api/push/action, and a VM 106 test Stalwart. + * + * Headless Chromium has no toasts to click, and a page can't make a browser + * send a trusted notificationclick. So the spec dispatches the event inside + * the real worker, on its real listener: an Event carrying `action`, the + * `notification` the browser really shows, and a waitUntil of its own + * (ExtendableEvent.waitUntil refuses a synthetic event). Everything after + * that is real: matchAll, postMessage to the page, the cookies on the route. + * What can't run this way is openWindow, focus and navigate, which want the + * browser's user activation, so those are recorded on the worker's side and + * the spec then opens the recorded URL itself. + * + * The account is ent-dave, whom the other specs leave alone: the one-message + * notification needs an Inbox with exactly one unread message. + */ + +test.describe.configure({ mode: 'serial' }); +test.setTimeout(180_000); +// The headless shell denies notifications whatever is granted; full Chromium +// in headless mode really shows them, buttons and all, to getNotifications. +test.use({ channel: 'chromium' }); + +const RUN = Date.now().toString(36); +let serial = 0; +const USER = ENT.dave; + +interface ShownNotification { + title: string; + data: Record; + actions: Array<{ action: string; title: string; type?: string }>; +} + +async function workerOf(context: BrowserContext, page: Page): Promise { + // A production build registers the worker; the dev server removes it. + await page.evaluate(() => navigator.serviceWorker.ready.then(() => undefined)); + await expect.poll(() => context.serviceWorkers().length, { timeout: 30_000 }).toBeGreaterThan(0); + const sw = context.serviceWorkers().at(-1)!; + // What needs the user activation a dispatched click doesn't have. + await sw.evaluate(() => { + const self_ = globalThis as unknown as { __opened: string[]; __posts: string[]; clients: Record; fetch: typeof fetch }; + if (self_.__opened) return; + self_.__opened = []; + self_.__posts = []; + self_.clients.openWindow = async (url: string) => { self_.__opened.push(url); return null; }; + const realFetch = self_.fetch.bind(globalThis); + self_.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === 'POST') self_.__posts.push(String(input)); + return realFetch(input, init); + }) as typeof fetch; + }); + return sw; +} + +/** The real listener, with what the browser would pass. Resolves when its waitUntil settled. */ +async function click(sw: Worker, action: string, data: Record, reply?: string): Promise { + await sw.evaluate(async ({ action: a, data: d, reply: r }) => { + const waits: Promise[] = []; + const event = new Event('notificationclick') as Event & Record; + Object.assign(event, { + action: a, + reply: r ?? null, + notification: { data: d, tag: 'spec', close() {} }, + waitUntil: (p: Promise) => waits.push(p), + }); + self.dispatchEvent(event); + await Promise.all(waits); + }, { action, data, reply }); +} + +async function pushed(sw: Worker, accountId: string, emailId: string): Promise { + return sw.evaluate(async ({ accountId: account, emailId: id }) => { + const waits: Promise[] = []; + const event = new Event('push') as Event & Record; + Object.assign(event, { + data: { json: () => ({ kind: 'jmap-email-push', accountId: account, emailIds: [id] }) }, + waitUntil: (p: Promise) => waits.push(p), + }); + self.dispatchEvent(event); + await Promise.all(waits); + const registration = (self as unknown as { registration: ServiceWorkerRegistration }).registration; + const [shown] = await registration.getNotifications({ tag: `bulwark-mail:${account}` }); + return { title: shown.title, data: shown.data, actions: [...(shown as unknown as { actions: ShownNotification['actions'] }).actions] }; + }, { accountId, emailId }); +} + +async function opened(sw: Worker): Promise { + return sw.evaluate(() => (globalThis as unknown as { __opened: string[] }).__opened.splice(0)); +} + +async function routePosts(sw: Worker): Promise { + return sw.evaluate(() => (globalThis as unknown as { __posts: string[] }).__posts.splice(0)); +} + +/** Everything unread in the Inbox read, then one new message: what makes a notification name it. */ +async function oneUnread(): Promise<{ id: string; subject: string; accountId: string }> { + const client = await jmapAs(USER); + const inbox = await client.mailboxByRole('inbox'); + const query = await client.request([ + ['Email/query', { accountId: client.accountId, filter: { inMailbox: inbox!.id, notKeyword: '$seen' } }, 'q'], + ]); + const unread: string[] = query.methodResponses[0][1].ids; + if (unread.length > 0) { + await client.request([['Email/set', { + accountId: client.accountId, + update: Object.fromEntries(unread.map((id) => [id, { 'keywords/$seen': true }])), + }, 's']]); + } + const subject = `Notify ${RUN} ${++serial}`; + await deliver({ from: ENT.bob, to: [USER], subject, text: 'Please answer.' }); + const email = await client.waitForEmail(subject, { mailboxId: inbox!.id, timeoutMs: 30_000 }); + return { id: email.id, subject, accountId: client.accountId }; +} + +async function serverCopy(emailId: string): Promise<{ keywords: Record; mailboxIds: Record; receivedAt: string }> { + const client = await jmapAs(USER); + const res = await client.request([['Email/get', { accountId: client.accountId, ids: [emailId], properties: ['keywords', 'mailboxIds', 'receivedAt'] }, 'g']]); + return res.methodResponses[0][1].list[0]; +} + +async function archiveFolderId(): Promise { + const client = await jmapAs(USER); + return (await client.mailboxByRole('archive'))?.id; +} + +test.beforeAll(() => requireEntEnv()); + +test.describe('with the app open', () => { + let page: Page; + let context: BrowserContext; + let sw: Worker; + + test.beforeAll(async ({ browser }) => { + page = await userPage(browser, USER); + context = page.context(); + await context.grantPermissions(['notifications']); + sw = await workerOf(context, page); + // The mirror reaches the worker's store after the app has started. + await expect.poll(() => sw.evaluate(async () => { + const cache = await caches.open('bulwark-push-state-v1'); + const hit = await cache.match(`${self.location.origin}/__push-state/actions`); + return hit ? (await hit.json()).labels?.archive : null; + }), { timeout: 30_000 }).toBe('Archive'); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test('a push for one message shows the buttons the browser has room for', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + expect(shown.title).toContain(ENT.bob.split('@')[0]); + expect(shown.data).toMatchObject({ kind: 'email', emailId: mail.id, accountId: mail.accountId }); + expect(typeof shown.data.login).toBe('string'); + const max = await sw.evaluate(() => (Notification as unknown as { maxActions: number }).maxActions); + const names = shown.actions.map((a) => a.action); + expect(names.length).toBe(Math.min(3, max)); + expect(names).toContain('reply'); + expect(names).toContain('archive'); + expect(shown.actions.find((a) => a.action === 'archive')?.title).toBe('Archive'); + // Chromium has `reply` on the event: the text field. + expect(shown.actions.find((a) => a.action === 'reply')?.type).toBe('text'); + }); + + test('a group of messages gets no buttons', async () => { + const client = await jmapAs(USER); + const first = await oneUnread(); + const subject = `Notify ${RUN} second`; + await deliver({ from: ENT.bob, to: [USER], subject, text: 'Another.' }); + const second = await client.waitForEmail(subject, { timeoutMs: 30_000 }); + const shown = await pushed(sw, first.accountId, second.id); + expect(shown.data.kind).toBe('mail-list'); + expect(shown.actions).toEqual([]); + }); + + test('Mark read goes to the open tab, which marks it in the list too, not to the route', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + await routePosts(sw); + await click(sw, 'mark-read', shown.data); + await expect.poll(async () => (await serverCopy(mail.id)).keywords.$seen === true, { timeout: 15_000 }).toBe(true); + expect(await routePosts(sw)).toEqual([]); + expect(await opened(sw)).toEqual([]); + }); + + test('Archive goes to the open tab and files the message by the store', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + await routePosts(sw); + await click(sw, 'archive', shown.data); + const client = await jmapAs(USER); + const inbox = await client.mailboxByRole('inbox'); + await expect.poll(async () => !(await serverCopy(mail.id)).mailboxIds[inbox!.id], { timeout: 20_000 }).toBe(true); + const archive = await archiveFolderId(); + expect(archive).toBeTruthy(); + expect(Object.keys((await serverCopy(mail.id)).mailboxIds)).toEqual([archive]); + expect(await routePosts(sw)).toEqual([]); + }); + + test('a body click still opens the message and runs nothing', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + await routePosts(sw); + // focus() needs the activation a dispatched click lacks, so this ends in openWindow. + await click(sw, '', shown.data); + const urls = await opened(sw); + expect(urls).toHaveLength(1); + expect(urls[0]).toContain(`/mail/message/${mail.id}`); + expect(urls[0]).not.toContain('reply=1'); + expect(await routePosts(sw)).toEqual([]); + expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy(); + }); + + test('Reply opens the composer in the open tab, with the text typed on the notification', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + await routePosts(sw); + await click(sw, 'reply', shown.data, `Typed on the toast ${RUN}`); + await expect.poll(async () => page.evaluate((subjectText) => { + const inputs = [...document.querySelectorAll('input')] as HTMLInputElement[]; + return inputs.some((input) => input.value === `Re: ${subjectText}`); + }, mail.subject), { timeout: 20_000 }).toBe(true); + await expect(page.getByText(`Typed on the toast ${RUN}`).first()).toBeVisible({ timeout: 10_000 }); + expect(await routePosts(sw)).toEqual([]); + expect(await opened(sw)).toEqual([]); + }); +}); + +test.describe('Background Sync', () => { + test('a queued change registers the tag, and a sync event makes a tab that missed `online` flush it', async ({ browser }) => { + const page = await userPage(browser, USER); + const context = page.context(); + await context.grantPermissions(['notifications', 'background-sync']); + const sw = await workerOf(context, page); + await page.addInitScript(() => { + const w = window as unknown as { __syncTags: string[] }; + w.__syncTags = []; + const register = SyncManager.prototype.register; + SyncManager.prototype.register = function patched(tag: string) { + w.__syncTags.push(tag); + return register.call(this, tag); + }; + }); + await page.goto('/settings/backup/offline'); + const toggle = page.getByTestId('offline-switch'); + await expect(toggle).toBeVisible({ timeout: 30_000 }); + if ((await toggle.getAttribute('aria-checked')) !== 'true') await toggle.click(); + await expect(page.getByTestId('offline-status')).toContainText('updated', { timeout: 180_000 }); + await page.goto('/mail'); + await expect(page.locator('[data-testid="email-list-item"]').first()).toBeVisible({ timeout: 60_000 }); + + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + + // The mail server is gone, the browser still online: no `online` event will come. + const block = `${new URL(JMAP_URL).origin}/**`; + await context.route(block, (route) => route.abort('internetdisconnected')); + await page.keyboard.press('Shift+G'); + await expect(page.getByTestId('offline-banner')).toContainText("The mail server can't be reached", { timeout: 60_000 }); + + // The tab queues the change, as it does for its own Mark read button. + await click(sw, 'mark-read', shown.data); + await expect.poll(() => page.evaluate(() => (window as unknown as { __syncTags: string[] }).__syncTags)).toContain('bulwark-outbox'); + expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy(); + + await context.unroute(block); + await sw.evaluate(async () => { + const waits: Promise[] = []; + const event = new Event('sync') as Event & Record; + Object.assign(event, { tag: 'bulwark-outbox', waitUntil: (p: Promise) => waits.push(p) }); + self.dispatchEvent(event); + await Promise.all(waits); + }); + await expect.poll(async () => (await serverCopy(mail.id)).keywords.$seen === true, { timeout: 30_000 }).toBe(true); + + // Offline mode stays local to this context, which ends here. + await context.close(); + }); +}); + +test.describe('with no tab open', () => { + let page: Page; + let context: BrowserContext; + let sw: Worker; + + /** A blank tab keeps the context alive: it's outside the worker's scope. */ + async function closeTheApp(): Promise { + const blank = await context.newPage(); + await page.close(); + await expect.poll(() => sw.evaluate(async () => (await (self as unknown as { clients: Clients }).clients.matchAll({ type: 'window', includeUncontrolled: true })).length)).toBe(0); + return blank; + } + + test.beforeEach(async ({ browser }) => { + page = await userPage(browser, USER); + context = page.context(); + await context.grantPermissions(['notifications']); + sw = await workerOf(context, page); + await expect.poll(() => sw.evaluate(async () => { + const cache = await caches.open('bulwark-push-state-v1'); + return !!(await cache.match(`${self.location.origin}/__push-state/actions`)); + }), { timeout: 30_000 }).toBe(true); + }); + + test.afterEach(async () => { + await context.close(); + }); + + test('Mark read goes through the route on the context cookie', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + await closeTheApp(); + await routePosts(sw); + await click(sw, 'mark-read', shown.data); + expect((await routePosts(sw)).at(-1)).toContain('/api/push/action'); + expect((await serverCopy(mail.id)).keywords.$seen).toBe(true); + expect(await opened(sw)).toEqual([]); + }); + + test('Archive goes through the route, to the folder the app uses', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + await closeTheApp(); + await click(sw, 'archive', shown.data); + const archive = await archiveFolderId(); + expect(archive).toBeTruthy(); + expect(Object.keys((await serverCopy(mail.id)).mailboxIds)).toEqual([archive]); + expect(await opened(sw)).toEqual([]); + }); + + test('Archive follows the mirrored mode into year and month folders', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + await sw.evaluate(async () => { + const cache = await caches.open('bulwark-push-state-v1'); + const key = `${self.location.origin}/__push-state/actions`; + const prefs = await (await cache.match(key))!.json(); + await cache.put(key, new Response(JSON.stringify({ ...prefs, archiveMode: 'month' }))); + }); + await closeTheApp(); + await click(sw, 'archive', shown.data); + const client = await jmapAs(USER); + const copy = await serverCopy(mail.id); + const [folderId] = Object.keys(copy.mailboxIds); + const folders = await client.mailboxes(); + const month = folders.find((f) => f.id === folderId)!; + const year = folders.find((f) => f.id === month.parentId)!; + const when = new Date(copy.receivedAt); + expect(month.name).toBe(String(when.getMonth() + 1).padStart(2, '0')); + expect(year.name).toBe(String(when.getFullYear())); + expect(year.parentId).toBe(await archiveFolderId()); + }); + + test('Archive makes the Archive, year and month folders on an account without them', async () => { + const client = await jmapAs(USER); + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + await sw.evaluate(async () => { + const cache = await caches.open('bulwark-push-state-v1'); + const key = `${self.location.origin}/__push-state/actions`; + const prefs = await (await cache.match(key))!.json(); + await cache.put(key, new Response(JSON.stringify({ ...prefs, archiveMode: 'month' }))); + }); + await closeTheApp(); + // Out of the way, so the account has no Archive for the moment. + const old = await client.mailboxByRole('archive'); + const oldName = `Old archive ${RUN}`; + if (old) { + const renamed = await client.request([['Mailbox/set', { accountId: client.accountId, update: { [old.id]: { name: oldName, role: null } } }, 'r']]); + expect(renamed.methodResponses[0][1].updated).toHaveProperty(old.id); + } + + await click(sw, 'archive', shown.data); + + expect(await opened(sw)).toEqual([]); + const created = await client.mailboxByRole('archive'); + expect(created?.name).toBe('Archive'); + expect(created?.id).not.toBe(old?.id); + const copy = await serverCopy(mail.id); + const folders = await client.mailboxes(); + const month = folders.find((f) => f.id === Object.keys(copy.mailboxIds)[0])!; + const year = folders.find((f) => f.id === month.parentId)!; + expect(year.parentId).toBe(created!.id); + + if (old) { + // Only this spec's messages live in ent-dave's Archive. + await client.request([['Mailbox/set', { accountId: client.accountId, destroy: [old.id], onDestroyRemoveEmails: true }, 'd']]); + } + }); + + test('with an enabled plugin that may stop it, the route is skipped and the message opens', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + await sw.evaluate(async () => { + const cache = await caches.open('bulwark-push-state-v1'); + const key = `${self.location.origin}/__push-state/actions`; + const prefs = await (await cache.match(key))!.json(); + await cache.put(key, new Response(JSON.stringify({ ...prefs, pluginsMayVeto: true }))); + }); + await closeTheApp(); + await routePosts(sw); + await click(sw, 'mark-read', shown.data); + expect(await routePosts(sw)).toEqual([]); + expect((await opened(sw))[0]).toContain(`/mail/message/${mail.id}`); + expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy(); + }); + + test('a message since moved to Trash stays there, and opens in the app', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + await closeTheApp(); + const client = await jmapAs(USER); + const trash = await client.mailboxByRole('trash'); + await client.moveEmail(mail.id, trash!.id); + await click(sw, 'archive', shown.data); + expect((await opened(sw))[0]).toContain(`/mail/message/${mail.id}`); + expect(Object.keys((await serverCopy(mail.id)).mailboxIds)).toEqual([trash!.id]); + }); + + test('with the context cookie gone the message opens instead, and stays as it was', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + await closeTheApp(); + const jar = await context.cookies(); + await context.clearCookies({ name: /^jmap_stalwart_ctx/ }); + expect(jar.some((c) => c.name.startsWith('jmap_stalwart_ctx'))).toBe(true); + await routePosts(sw); + await click(sw, 'mark-read', shown.data); + expect((await routePosts(sw)).at(-1)).toContain('/api/push/action'); + const urls = await opened(sw); + expect(urls).toHaveLength(1); + expect(urls[0]).toContain(`/mail/message/${mail.id}`); + expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy(); + }); + + test('a notification from a login no longer signed in acts on nothing', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + await closeTheApp(); + await click(sw, 'mark-read', { ...shown.data, login: 'someone-else' }); + expect(await opened(sw)).toHaveLength(1); + expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy(); + }); + + test('Reply opens the link, the composer opens once with the typed text, and a reload leaves it shut', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + const blank = await closeTheApp(); + await click(sw, 'reply', shown.data, `Typed while closed ${RUN}`); + const [url] = await opened(sw); + expect(url).toContain(`/mail/message/${mail.id}`); + expect(url).toContain('reply=1'); + expect(url).not.toContain('Typed'); + + page = await context.newPage(); + await page.goto(url); + await blank.close(); + const replyInput = () => page.evaluate((subjectText) => { + const inputs = [...document.querySelectorAll('input')] as HTMLInputElement[]; + return inputs.some((input) => input.value === `Re: ${subjectText}`); + }, mail.subject); + await expect.poll(replyInput, { timeout: 45_000 }).toBe(true); + await expect(page.getByText(`Typed while closed ${RUN}`).first()).toBeVisible({ timeout: 10_000 }); + expect(page.url()).not.toContain('reply=1'); + + // Leave the draft and come back: the link's job is done. + await page.reload(); + await expect(page.getByText('Please answer.').first()).toBeVisible({ timeout: 45_000 }); + expect(await replyInput()).toBe(false); + }); +}); + +test.describe('Reply in the Pro shell', () => { + test('opens the composer in the message tab, with the typed text, from the live tab and from the link', async ({ browser }) => { + const page = await userPage(browser, USER); + const context = page.context(); + await context.grantPermissions(['notifications']); + await page.evaluate(() => { + const raw = localStorage.getItem('settings-storage'); + const stored = raw ? JSON.parse(raw) : { state: {} }; + stored.state = { ...stored.state, proInterface: true }; + localStorage.setItem('settings-storage', JSON.stringify(stored)); + }); + await page.goto('/pro'); + await expect(page.locator('[data-pro-takeover]')).toHaveCount(0, { timeout: 60_000 }); + const sw = await workerOf(context, page); + const replyIsOpen = (subject: string) => page.evaluate((text) => { + const inputs = [...document.querySelectorAll('input')] as HTMLInputElement[]; + return inputs.some((input) => input.value === `Re: ${text}`); + }, subject); + + const live = await oneUnread(); + const liveShown = await pushed(sw, live.accountId, live.id); + // Until the shell's mail surface is up the tab declines, and the worker + // falls back to the link: ask again. Taken once, no window opens. + await expect.poll(async () => { + await click(sw, 'reply', liveShown.data, `Pro typed ${RUN}`); + return (await opened(sw)).length; + }, { timeout: 45_000, intervals: [3_000] }).toBe(0); + await expect.poll(() => replyIsOpen(live.subject), { timeout: 30_000 }).toBe(true); + await expect(page.getByText(`Pro typed ${RUN}`).first()).toBeVisible({ timeout: 10_000 }); + + const linked = await oneUnread(); + await page.goto(`/mail/message/${linked.id}?owner=${linked.accountId}&reply=1`); + await expect.poll(() => replyIsOpen(linked.subject), { timeout: 60_000 }).toBe(true); + await context.close(); + }); +}); + +test.describe('session limits', () => { + test('under a maximum session age the route answers 401 and writes nothing', async ({ browser }) => { + const admin = await adminContext(browser); + const page = await userPage(browser, USER); + await page.context().grantPermissions(['notifications']); + const sw = await workerOf(page.context(), page); + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + await withPolicy(admin, { organisation: { session: { idleMinutes: 0, maxHours: 8, warnSeconds: 60 } } }, async () => { + const res = await page.request.post('/api/push/action', { + data: { action: 'mark-read', accountId: mail.accountId, emailId: mail.id, login: shown.data.login }, + }); + expect(res.status()).toBe(401); + }); + expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy(); + await page.context().close(); + await admin.close(); + }); +}); + +test.describe('the route, as a page or another site would call it', () => { + test('refuses a cross-site POST at the gate, with the cookies on it', async ({ browser }) => { + const page = await userPage(browser, USER); + const mail = await oneUnread(); + const res = await page.request.post('/api/push/action', { + headers: { 'Sec-Fetch-Site': 'cross-site' }, + data: { action: 'mark-read', accountId: mail.accountId, emailId: mail.id, login: 'x' }, + }); + expect(res.status()).toBe(403); + expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy(); + await page.context().close(); + }); + + test('refuses another account of the same login, and answers 401 without cookies', async ({ browser }) => { + const page = await userPage(browser, USER); + await page.context().grantPermissions(['notifications']); + const sw = await workerOf(page.context(), page); + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + const post = (over: Record) => page.request.post('/api/push/action', { + data: { action: 'mark-read', accountId: mail.accountId, emailId: mail.id, login: shown.data.login, ...over }, + }); + expect((await post({ accountId: 'zzzzzz' })).status()).toBe(403); + expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy(); + await page.context().clearCookies({ name: /^jmap_stalwart_ctx/ }); + expect((await post({})).status()).toBe(401); + expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy(); + await page.context().close(); + }); +}); diff --git a/lib/__tests__/deep-links.test.ts b/lib/__tests__/deep-links.test.ts index c9696f162..0dc59db84 100644 --- a/lib/__tests__/deep-links.test.ts +++ b/lib/__tests__/deep-links.test.ts @@ -185,6 +185,16 @@ describe('mail paths', () => { expect(parseMailPath(['thread', 't1'])).toEqual({ kind: 'thread', id: 't1', accountId: undefined }); }); + it("reads a notification's reply flag on a message link, and only there", () => { + expect(parseMailPath(['message', 'm1'], new URLSearchParams('slot=1&owner=a&reply=1'))).toMatchObject({ + kind: 'message', id: 'm1', slot: 1, ownerAccountId: 'a', reply: true, + }); + expect(parseMailPath(['message', 'm1'], new URLSearchParams('reply=0'))).not.toHaveProperty('reply'); + expect(parseMailPath(['message', 'm1'])).not.toHaveProperty('reply'); + expect(parseMailPath(['folder', 'inbox'], new URLSearchParams('reply=1'))).not.toHaveProperty('reply'); + expect(parseMailPath(['thread', 't1'], new URLSearchParams('reply=1'))).not.toHaveProperty('reply'); + }); + it('reads the fullscreen marker on message links', () => { expect(parseMailPath(['message', 'm1'], new URLSearchParams('view=fullscreen'))).toEqual({ kind: 'message', diff --git a/lib/__tests__/launch-handoff.test.ts b/lib/__tests__/launch-handoff.test.ts new file mode 100644 index 000000000..9dafa8f5d --- /dev/null +++ b/lib/__tests__/launch-handoff.test.ts @@ -0,0 +1,526 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FakeCaches, loadWorker } from './sw-harness'; +import { + HANDOFF_CACHE, + HANDOFF_MAX_BYTES, + HANDOFF_MAX_ENTRIES, + HANDOFF_TTL_MS, + adoptStoredHandoff, + clearHandoffs, + discardStoredHandoff, + expireHandoffs, + handOffDraft, + handOffFiles, + handOffLaunchFiles, + handOffNewMessage, + kindForFile, + parseLaunchLanding, + peekStoredHandoff, + pendingHandoffs, + restoreStoredHandoffs, + shareDraft, + shortcutFromTargetUrl, + stripLandingParams, + subscribeHandoff, + subscribeHandoffLost, + takeHandoff, +} from '../launch-handoff'; + +// Files and links from outside the app on their way to what handles them. + +const file = (name: string, type = '', body = 'x') => new File([body], name, { type }); +const handle = (f: File) => ({ kind: 'file', getFile: async () => f }); +// Sealing runs on Web Crypto's thread pool. +const settle = () => new Promise((r) => setTimeout(r, 60)); +const whenStored = () => vi.waitFor(() => expect(sessionStorage.getItem('bulwark:launch-handoffs')).not.toBeNull(), { timeout: 5000 }); + +function memoryStorage() { + const backing = new Map(); + return { + getItem: (key: string) => backing.get(key) ?? null, + setItem: (key: string, value: string) => { backing.set(key, value); }, + removeItem: (key: string) => { backing.delete(key); }, + }; +} + +let caches: FakeCaches; + +beforeEach(() => { + caches = new FakeCaches(); + vi.stubGlobal('caches', caches); + vi.stubGlobal('sessionStorage', memoryStorage()); +}); + +afterEach(() => { + clearHandoffs(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe('where a launched file goes', () => { + it.each([ + ['mail.eml', '', 'eml'], + ['MAIL.EML', '', 'eml'], + ['invite.ics', '', 'calendar'], + ['invite.ical', '', 'calendar'], + ['people.vcf', '', 'contacts'], + ['people.vcard', '', 'contacts'], + ['noextension', 'message/rfc822', 'eml'], + ['noextension', 'text/calendar', 'calendar'], + ['noextension', 'text/x-vcard', 'contacts'], + ['photo.png', 'image/png', null], + ])('%s (%s) goes to %s', (name, type, kind) => { + expect(kindForFile(file(name, type))).toBe(kind); + }); + + it('splits one launch by type and names what nothing handles', async () => { + const eml = file('a.eml'); + const ics = file('b.ics'); + const vcf = file('c.vcf'); + const result = await handOffLaunchFiles([handle(eml), handle(ics), handle(vcf), handle(file('d.png'))]); + + expect(result).toEqual({ unsupported: ['d.png'], failed: 0 }); + expect(takeHandoff('eml')?.files).toEqual([eml]); + expect(takeHandoff('calendar')?.files).toEqual([ics]); + expect(takeHandoff('contacts')?.files).toEqual([vcf]); + }); + + it('counts a file it could not read', async () => { + const result = await handOffLaunchFiles([{ kind: 'file', getFile: async () => { throw new Error('gone'); } }]); + expect(result).toEqual({ unsupported: [], failed: 1 }); + expect(pendingHandoffs()).toEqual([]); + }); + + it('skips a directory handle', async () => { + const result = await handOffLaunchFiles([{ kind: 'directory' }]); + expect(result).toEqual({ unsupported: [], failed: 0 }); + expect(pendingHandoffs()).toEqual([]); + }); +}); + +describe('handing off', () => { + it('gives a handoff to one taker, once', () => { + const ics = file('a.ics'); + handOffFiles([ics], 'calendar'); + expect(takeHandoff('contacts')).toBeNull(); + expect(takeHandoff('calendar')?.files).toEqual([ics]); + expect(takeHandoff('calendar')).toBeNull(); + }); + + it('tells the app first, while the handoff is still pending, then the taker', () => { + const order: string[] = []; + const offKind = subscribeHandoff('contacts', () => { + order.push(`taker:${takeHandoff('contacts') ? 'got it' : 'nothing'}`); + }); + const offAny = subscribeHandoff('any', (kind) => { + order.push(`app:${kind}:${pendingHandoffs().length}`); + }); + handOffFiles([file('a.vcf')], 'contacts'); + expect(order).toEqual(['app:contacts:1', 'taker:got it']); + offKind(); + offAny(); + }); + + it('stops telling a listener that unsubscribed', () => { + const listener = vi.fn(); + subscribeHandoff('calendar', listener)(); + handOffFiles([file('a.ics')], 'calendar'); + expect(listener).not.toHaveBeenCalled(); + }); + + it('does nothing for no files', () => { + handOffFiles([], 'calendar'); + expect(pendingHandoffs()).toEqual([]); + }); + + it('drops what waited too long', () => { + vi.useFakeTimers({ toFake: ['Date'] }); + handOffFiles([file('a.ics')], 'calendar'); + vi.advanceTimersByTime(HANDOFF_TTL_MS + 1); + expect(takeHandoff('calendar')).toBeNull(); + expect(pendingHandoffs()).toEqual([]); + }); + + it('counts a new message that arrives twice in one launch once', () => { + handOffNewMessage(); + handOffNewMessage(); + expect(takeHandoff('compose')).toMatchObject({ files: [] }); + expect(takeHandoff('compose')).toBeNull(); + }); + + it('carries the text of a draft', () => { + handOffDraft({ subject: 'Hi', body: 'There', files: [file('a.pdf')] }); + const handoff = takeHandoff('compose'); + expect(handoff).toMatchObject({ subject: 'Hi', body: 'There' }); + expect(handoff?.files.map((f) => f.name)).toEqual(['a.pdf']); + }); +}); + +describe('across a page load', () => { + // A sign-in through SSO leaves the page. A new module state stands in for + // the new page: the queue is emptied, sessionStorage and the cache stay. + async function newPage() { + pendingHandoffs(); + while (takeHandoffQuietly()) { /* the old page's memory goes */ } + } + function takeHandoffQuietly() { + const list = pendingHandoffs() as unknown as unknown[]; + return list.length > 0 ? list.splice(0, list.length) : null; + } + + it('brings back a file that waited for a sign-in, with its name and type', async () => { + handOffFiles([file('a.ics', 'text/calendar', 'BEGIN:VCALENDAR')], 'calendar'); + await whenStored(); + await newPage(); + expect(takeHandoff('calendar')).toBeNull(); + + await restoreStoredHandoffs(); + const handoff = takeHandoff('calendar'); + expect(handoff?.files[0].name).toBe('a.ics'); + expect(handoff?.files[0].type).toBe('text/calendar'); + expect(await handoff?.files[0].text()).toBe('BEGIN:VCALENDAR'); + }); + + it('removes the stored copy once it is taken', async () => { + handOffFiles([file('a.ics')], 'calendar'); + await whenStored(); + const store = await caches.open(HANDOFF_CACHE); + expect((await store.keys()).length).toBe(3); + + takeHandoff('calendar'); + await settle(); + await settle(); + expect((await store.keys()).length).toBe(0); + expect(sessionStorage.getItem('bulwark:launch-handoffs')).toBeNull(); + }); + + it('never stores a .eml: it opens at once and needs no sign-in', async () => { + handOffFiles([file('a.eml')], 'eml'); + await settle(); + expect(await caches.has(HANDOFF_CACHE)).toBe(false); + }); + + it("leaves another window's handoff alone", async () => { + handOffFiles([file('a.ics')], 'calendar'); + await whenStored(); + await newPage(); + // Another window: its own sessionStorage, the same cache. + vi.stubGlobal('sessionStorage', memoryStorage()); + await restoreStoredHandoffs(); + expect(takeHandoff('calendar')).toBeNull(); + expect((await (await caches.open(HANDOFF_CACHE)).keys()).length).toBe(3); + }); + + it('keeps no readable bytes, name or text at rest', async () => { + handOffDraft({ subject: 'Secret subject', body: 'Secret text', files: [file('secret-name.txt', 'text/plain', 'secret bytes')] }); + await whenStored(); + const store = await caches.open(HANDOFF_CACHE); + const keys = await store.keys(); + expect(keys.length).toBeGreaterThanOrEqual(3); + for (const key of keys) { + const stored = await (await store.match(key))!.text(); + for (const secret of ['Secret subject', 'Secret text', 'secret-name', 'secret bytes']) expect(stored).not.toContain(secret); + } + }); + + it('cannot read it once the tab with the key is gone', async () => { + handOffFiles([file('a.ics')], 'calendar'); + await whenStored(); + await newPage(); + vi.stubGlobal('sessionStorage', memoryStorage()); + const store = await caches.open(HANDOFF_CACHE); + const metaKey = (await store.keys()).find((key) => key.url.endsWith('/meta'))!.url; + const id = metaKey.split('/').slice(-2)[0]; + expect(await adoptStoredHandoff(id)).toBe(false); + expect(await adoptStoredHandoff(id, 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')).toBe(false); + }); + + it('says so when a stored handoff of this tab is gone after the reload', async () => { + const losses: string[] = []; + const off = subscribeHandoffLost((loss) => losses.push(loss)); + handOffFiles([file('a.ics')], 'calendar'); + await whenStored(); + await newPage(); + await caches.delete(HANDOFF_CACHE); + await restoreStoredHandoffs(); + expect(losses).toEqual(['expired']); + expect(sessionStorage.getItem('bulwark:launch-handoffs')).toBeNull(); + off(); + }); + + it('says so when a stored file is missing, and delivers the rest', async () => { + const losses: string[] = []; + const off = subscribeHandoffLost((loss) => losses.push(loss)); + handOffDraft({ subject: 'S', files: [file('one.txt'), file('two.txt')] }); + await whenStored(); + await newPage(); + const store = await caches.open(HANDOFF_CACHE); + const first = (await store.keys()).find((key) => key.url.endsWith('/0'))!; + await store.delete(first); + await restoreStoredHandoffs(); + expect(losses).toEqual(['missing']); + expect(takeHandoff('compose')?.files.map((f) => f.name)).toEqual(['two.txt']); + off(); + }); + + it('keeps no more than the limit, oldest out first', async () => { + const created: string[] = []; + for (let n = 0; n < HANDOFF_MAX_ENTRIES + 2; n += 1) { + const name = `f${n}.ics`; + handOffFiles([file(name)], 'calendar'); + created.push(name); + await settle(); + await settle(); + } + const store = await caches.open(HANDOFF_CACHE); + const metas = (await store.keys()).filter((key) => key.url.endsWith('/meta')); + expect(metas.length).toBeLessThanOrEqual(HANDOFF_MAX_ENTRIES); + }); + + it('does not store what is over the byte limit, and keeps it in memory', async () => { + const big = file('big.ics'); + Object.defineProperty(big, 'size', { value: HANDOFF_MAX_BYTES + 1 }); + handOffFiles([big], 'calendar'); + await settle(); + expect(await caches.has(HANDOFF_CACHE)).toBe(false); + expect(takeHandoff('calendar')?.files).toEqual([big]); + }); + + it('sweeps what nobody took, and keeps what is recent', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + handOffFiles([file('old.ics')], 'calendar'); + await whenStored(); + await newPage(); + vi.stubGlobal('sessionStorage', memoryStorage()); + vi.advanceTimersByTime(HANDOFF_TTL_MS + 1); + handOffFiles([file('new.vcf')], 'contacts'); + await whenStored(); + + await restoreStoredHandoffs(); + const store = await caches.open(HANDOFF_CACHE); + const urls = (await store.keys()).map((key) => key.url); + expect(urls).toHaveLength(3); + const meta = await store.match(urls.find((url) => url.endsWith('/meta'))!); + expect(((await meta!.json()) as { kind: string }).kind).toBe('contacts'); + }); + + it('keeps working in memory without the Cache API', async () => { + vi.stubGlobal('caches', undefined); + const ics = file('a.ics'); + handOffFiles([ics], 'calendar'); + await settle(); + expect(takeHandoff('calendar')?.files).toEqual([ics]); + expect(await adoptStoredHandoff('anything')).toBe(false); + }); + + it('keeps the file in memory when the cache is full', async () => { + const original = caches.open.bind(caches); + vi.spyOn(caches, 'open').mockImplementation(async (name: string) => ({ + ...(await original(name)), + put: async () => { throw new DOMException('full', 'QuotaExceededError'); }, + })); + const ics = file('a.ics'); + handOffFiles([ics], 'calendar'); + await settle(); + expect(sessionStorage.getItem('bulwark:launch-handoffs')).toBeNull(); + expect(takeHandoff('calendar')?.files).toEqual([ics]); + }); +}); + +describe('while the page stays open', () => { + it('drops what waited too long on a timer, and says so', async () => { + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }); + const losses: string[] = []; + const off = subscribeHandoffLost((loss) => losses.push(loss)); + handOffFiles([file('a.eml')], 'eml'); + await vi.advanceTimersByTimeAsync(HANDOFF_TTL_MS + 2000); + expect(pendingHandoffs()).toEqual([]); + expect(losses).toEqual(['expired']); + off(); + }); + + it('says so when a taker finds only an expired handoff', () => { + vi.useFakeTimers({ toFake: ['Date'] }); + const losses: string[] = []; + const off = subscribeHandoffLost((loss) => losses.push(loss)); + handOffFiles([file('a.eml')], 'eml'); + vi.advanceTimersByTime(HANDOFF_TTL_MS + 1); + expect(takeHandoff('eml')).toBeNull(); + expect(losses).toEqual(['expired']); + off(); + }); + + it('keeps what is still in time', async () => { + handOffFiles([file('a.eml')], 'eml'); + await expireHandoffs(); + expect(pendingHandoffs()).toHaveLength(1); + }); + + it('leaves the second of two drafts queued until it is taken', () => { + handOffDraft({ subject: 'first' }); + handOffDraft({ subject: 'second' }); + expect(takeHandoff('compose')?.subject).toBe('first'); + expect(pendingHandoffs().map((h) => h.subject)).toEqual(['second']); + }); +}); + +describe('sign-out', () => { + it('forgets what is waiting, in memory and stored', async () => { + handOffFiles([file('a.ics')], 'calendar'); + await whenStored(); + clearHandoffs(); + await settle(); + expect(pendingHandoffs()).toEqual([]); + expect(await caches.has(HANDOFF_CACHE)).toBe(false); + expect(sessionStorage.getItem('bulwark:launch-handoffs')).toBeNull(); + }); +}); + +describe('what the service worker stored', () => { + it('becomes a draft with the files, under the id in the address', async () => { + // The real worker writes it, so this fails if the two layouts drift apart. + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + const form = new FormData(); + form.set('title', 'A link'); + form.set('text', 'Look at this'); + form.set('url', 'https://example.org/page'); + form.append('files', file('photo.png', 'image/png', 'bytes')); + const answer = await worker.request({ url: 'https://mail.example.com/protocol/share', method: 'POST', mode: 'navigate', destination: 'document', formData: async () => form } as never); + const location = new URL(answer!.headers.get('location')!); + const landing = parseLaunchLanding(location.search, location.hash); + expect(landing?.type).toBe('stored'); + const { id, key } = landing as { id: string; key: string }; + + // Without the key from the address it can't be read; asking shows what it is. + expect(await peekStoredHandoff(id, '')).toBeNull(); + expect(await peekStoredHandoff(id, key)).toEqual({ subject: 'A link', body: 'Look at this\n\nhttps://example.org/page', files: ['photo.png'] }); + expect(pendingHandoffs()).toEqual([]); + + expect(await adoptStoredHandoff(id, key)).toBe(true); + const handoff = takeHandoff('compose'); + expect(handoff).toMatchObject({ subject: 'A link', body: 'Look at this\n\nhttps://example.org/page' }); + expect(handoff?.files[0].name).toBe('photo.png'); + expect(handoff?.files[0].type).toBe('image/png'); + expect(await handoff?.files[0].text()).toBe('bytes'); + await settle(); + await settle(); + expect((await (await caches.open(HANDOFF_CACHE)).keys()).length).toBe(0); + }); + + it('is gone when the id names nothing', async () => { + expect(await adoptStoredHandoff('11111111-2222', 'AAAA')).toBe(false); + expect(await peekStoredHandoff('11111111-2222', 'AAAA')).toBeNull(); + }); + + it('goes when the user refuses it', async () => { + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + const form = new FormData(); + form.append('files', file('photo.png')); + const answer = await worker.request({ url: 'https://mail.example.com/protocol/share', method: 'POST', mode: 'navigate', destination: 'document', formData: async () => form } as never); + const location = new URL(answer!.headers.get('location')!); + const { id, key } = parseLaunchLanding(location.search, location.hash) as { id: string; key: string }; + await discardStoredHandoff(id); + expect((await (await caches.open(HANDOFF_CACHE)).keys()).length).toBe(0); + expect(await peekStoredHandoff(id, key)).toBeNull(); + }); + + it('cleans control characters out of names and text, as for a mailto: link', async () => { + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + const form = new FormData(); + form.set('title', 'Hi\r\nBcc: evil@example.org'); + form.append('files', file('a\u202Egnp.exe')); + const answer = await worker.request({ url: 'https://mail.example.com/protocol/share', method: 'POST', mode: 'navigate', destination: 'document', formData: async () => form } as never); + const location = new URL(answer!.headers.get('location')!); + const { id, key } = parseLaunchLanding(location.search, location.hash) as { id: string; key: string }; + expect(await peekStoredHandoff(id, key)).toMatchObject({ subject: 'HiBcc: evil@example.org', files: ['agnp.exe'] }); + }); + + it('is gone when it is too old', async () => { + const store = await caches.open(HANDOFF_CACHE); + await store.put('/__bulwark/handoff/old/meta', new Response(JSON.stringify({ kind: 'compose', createdAt: Date.now() - HANDOFF_TTL_MS - 1, bytes: 0 }))); + expect(await adoptStoredHandoff('old', 'AAAA')).toBe(false); + expect((await store.keys()).length).toBe(0); + }); +}); + +describe('text of a share', () => { + it('puts the link under the text', () => { + expect(shareDraft({ title: ' T ', text: 'Hello', url: 'https://a.example' })).toEqual({ subject: 'T', body: 'Hello\n\nhttps://a.example' }); + }); + + it('does not repeat a link that is in the text already', () => { + expect(shareDraft({ text: 'See https://a.example now', url: 'https://a.example' }).body).toBe('See https://a.example now'); + }); + + it('copes with only a link', () => { + expect(shareDraft({ url: 'https://a.example' })).toEqual({ subject: '', body: 'https://a.example' }); + }); + + it('strips control characters from the subject, so no header can be planted', () => { + expect(shareDraft({ title: 'Hi\r\nBcc: evil@example.org' }).subject).toBe('HiBcc: evil@example.org'); + }); + + it('keeps line breaks in the body and drops other control characters', () => { + expect(shareDraft({ text: 'one\r\ntwo\u0000\u0007' }).body).toBe('one\ntwo'); + }); + + it('applies the limits of a mailto: link', () => { + const draft = shareDraft({ title: 'x'.repeat(2000), text: 'y'.repeat(100_000), url: 'https://a.example' }); + expect(draft.subject).toHaveLength(998); + expect(draft.body).toHaveLength(64 * 1024); + }); + + it('applies them to a draft handed in directly as well', () => { + handOffDraft({ subject: 'a\r\nb', body: 'y'.repeat(100_000) }); + const handoff = takeHandoff('compose'); + expect(handoff?.subject).toBe('ab'); + expect(handoff?.body).toHaveLength(64 * 1024); + }); +}); + +describe('the address a launch lands on', () => { + it.each([ + ['?compose=1', '', { type: 'new-message' }], + ['', '#share=abc-123&key=k1', { type: 'stored', id: 'abc-123', key: 'k1' }], + ['', '#share=unavailable&share_title=T&share_text=B&share_files=1', { type: 'inline', subject: 'T', body: 'B', filesLost: true, cut: false }], + ['', '#share=unavailable&share_url=https%3A%2F%2Fa.example&share_cut=1', { type: 'inline', subject: '', body: 'https://a.example', filesLost: false, cut: true }], + ['', '#share=..%2F..%2Fetc', null], + // Shared text stays out of the query, which reaches logs. + ['?share=unavailable&share_text=x', '', null], + ['?compose=0', '', null], + ['?openLatestUnread=1', '#x', null], + ['', '', null], + ])('%s %s', (search, hash, expected) => { + expect(parseLaunchLanding(search, hash)).toEqual(expected); + }); + + it('cleans text that came in the address', () => { + const landing = parseLaunchLanding('', '#share=unavailable&share_title=Hi%0D%0ABcc%3A%20evil'); + expect(landing).toMatchObject({ subject: 'HiBcc: evil' }); + }); + + it('leaves only what is not about the launch', () => { + expect(stripLandingParams('?compose=1', '')).toEqual({ search: '', hash: '' }); + expect(stripLandingParams('?slot=2', '#share=unavailable&share_text=x')).toEqual({ search: '?slot=2', hash: '' }); + expect(stripLandingParams('?compose=1&slot=2', '#section')).toEqual({ search: '?slot=2', hash: '#section' }); + expect(stripLandingParams('', '#share=a&key=b')).toEqual({ search: '', hash: '' }); + }); +}); + +describe('a shortcut reaching an open window', () => { + const origin = 'https://mail.example.com'; + it.each([ + [`${origin}/?compose=1`, 'new-message'], + [`${origin}/en/?compose=1`, 'new-message'], + [`${origin}/calendar`, 'calendar'], + [`${origin}/de/contacts`, 'contacts'], + // The plain app icon and links into the app don't move an open window. + [`${origin}/`, null], + [`${origin}/calendar/some-event`, null], + [`${origin}/settings`, null], + ['https://evil.example/calendar', null], + ])('%s', (target, expected) => { + expect(shortcutFromTargetUrl(target, origin)).toBe(expected); + }); +}); diff --git a/lib/__tests__/manifest-launch.test.ts b/lib/__tests__/manifest-launch.test.ts new file mode 100644 index 000000000..c4c2794f4 --- /dev/null +++ b/lib/__tests__/manifest-launch.test.ts @@ -0,0 +1,221 @@ +// @vitest-environment node +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Shortcuts, file handlers and the share target in the manifest, with what +// the route already did (per-domain branding, the deployment subpath). + +const hoisted = vi.hoisted(() => ({ + requestHeaders: {} as Record, + config: {} as Record, + sources: {} as Record, +})); + +vi.mock('next/headers', () => ({ + headers: async () => new Headers(hoisted.requestHeaders), +})); + +// The real strings, so a missing or misnamed key fails here. +vi.mock('next-intl/server', () => ({ + getTranslations: async ({ locale, namespace }: { locale: string; namespace: string }) => { + const messages = JSON.parse(readFileSync(join(process.cwd(), 'locales', locale, 'common.json'), 'utf8')); + return (key: string) => { + const value = messages[namespace]?.[key]; + if (typeof value !== 'string') throw new Error(`missing ${namespace}.${key} in ${locale}`); + return value; + }; + }, +})); + +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { + ensureLoaded: async () => {}, + get: (key: string, fallback: unknown) => hoisted.config[key] ?? fallback, + getAllWithSources: () => hoisted.sources, + }, +})); + +type Manifest = { + name: string; + shortcuts: Array<{ name: string; url: string; icons: Array<{ src: string }> }>; + file_handlers: Array<{ action: string; accept: Record }>; + share_target: { action: string; method: string; enctype: string; params: Record }; + protocol_handlers: Array<{ protocol: string; url: string }>; +}; + +async function load(): Promise { + vi.resetModules(); + const { default: manifest } = await import('@/app/manifest'); + return (await manifest()) as unknown as Manifest; +} + +beforeEach(() => { + hoisted.requestHeaders = {}; + hoisted.config = {}; + hoisted.sources = { + pwaIconUrl: { source: 'default' }, + faviconUrl: { source: 'default' }, + pwaScreenshotMobileUrl: { source: 'default' }, + pwaScreenshotDesktopUrl: { source: 'default' }, + }; +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('manifest launch fields', () => { + it('offers the three shortcuts', async () => { + const m = await load(); + expect(m.shortcuts.map((s) => [s.name, s.url])).toEqual([ + ['New message', '/?compose=1'], + ['Calendar', '/calendar'], + ['Contacts', '/contacts'], + ]); + }); + + it('names the shortcuts in the language the browser asks in', async () => { + hoisted.requestHeaders = { 'accept-language': 'de-DE,de;q=0.9,en;q=0.8' }; + expect((await load()).shortcuts.map((s) => s.name)).toEqual(['Neue Nachricht', 'Kalender', 'Kontakte']); + }); + + it('falls back to English for a language it does not have', async () => { + hoisted.requestHeaders = { 'accept-language': 'xx' }; + expect((await load()).shortcuts[0].name).toBe('New message'); + }); + + it('handles .eml, .ics and .vcf', async () => { + const m = await load(); + const extensions = (action: string) => Object.values(m.file_handlers.find((h) => h.action === action)?.accept ?? {}).flat(); + expect(extensions('/mail')).toEqual(['.eml']); + expect(extensions('/calendar')).toEqual(expect.arrayContaining(['.ics'])); + expect(extensions('/contacts')).toEqual(expect.arrayContaining(['.vcf'])); + expect(m.file_handlers).toHaveLength(3); + }); + + it('takes shares as multipart POST outside /api/, with files and links', async () => { + const { share_target: share } = await load(); + expect(share.action).toBe('/protocol/share'); + expect(share.action.startsWith('/api/')).toBe(false); + expect(share.method).toBe('POST'); + expect(share.enctype).toBe('multipart/form-data'); + expect(share.params).toEqual({ title: 'title', text: 'text', url: 'url', files: [{ name: 'files', accept: ['*/*'] }] }); + }); + + it('prefixes every new address with the subpath', async () => { + vi.stubEnv('NEXT_PUBLIC_BASE_PATH', '/webmail'); + const m = await load(); + expect(m.shortcuts.map((s) => s.url)).toEqual(['/webmail/?compose=1', '/webmail/calendar', '/webmail/contacts']); + expect(m.shortcuts[0].icons[0].src).toBe('/webmail/icon-192x192.png'); + expect(m.file_handlers.map((h) => h.action)).toEqual(['/webmail/mail', '/webmail/calendar', '/webmail/contacts']); + expect(m.share_target.action).toBe('/webmail/protocol/share'); + }); + + it("keeps a domain's branding, its icon on the shortcuts too", async () => { + hoisted.requestHeaders = { host: 'mail.acme.example' }; + hoisted.config = { domainBranding: [{ host: 'mail.acme.example', appName: 'Acme Mail', pwaIconUrl: 'https://cdn.example/i.png' }] }; + const m = await load(); + expect(m.name).toBe('Acme Mail'); + expect(m.shortcuts[0].icons[0].src).toBe('/api/pwa-icon/192'); + expect(m.file_handlers).toHaveLength(3); + expect(m.share_target.action).toBe('/protocol/share'); + }); + + it('leaves the protocol handlers as they are', async () => { + expect((await load()).protocol_handlers.map((h) => h.url)).toEqual(['/protocol/mailto?url=%s', '/protocol/webcal?url=%s']); + }); +}); + +describe('Lite', () => { + it('has no share target or file handlers: no service worker, no launch consumer behind them', async () => { + const { buildManifest } = await import('@/scripts/lite/lib.mjs'); + const lite = buildManifest({ appName: 'Acme' }); + expect(lite).not.toHaveProperty('share_target'); + expect(lite).not.toHaveProperty('file_handlers'); + expect(lite).not.toHaveProperty('shortcuts'); + }); +}); + +describe('share route without a service worker', () => { + const route = () => import('@/app/(main)/protocol/share/route'); + + function post(form: FormData | null, contentLength?: string) { + const request = new Request('https://mail.example.com/protocol/share', { method: 'POST', ...(form ? { body: form } : {}) }); + if (contentLength !== undefined) { + const headers = new Headers(request.headers); + headers.set('content-length', contentLength); + return { headers, formData: () => request.formData() } as unknown as Request; + } + return request; + } + + it('sends text and links on to a new draft, and goes to the app with a 303', async () => { + const { POST } = await route(); + const form = new FormData(); + form.set('title', 'T'); + form.set('text', 'B'); + form.set('url', 'https://example.org/'); + const res = await POST(post(form, '500')); + expect(res.status).toBe(303); + const landing = new URL(res.headers.get('location')!, 'https://x.example'); + expect(landing.pathname).toBe('/'); + // In the fragment: it never reaches an access log. + expect(landing.search).toBe(''); + expect(Object.fromEntries(new URLSearchParams(landing.hash.slice(1)))).toEqual({ share: 'unavailable', share_title: 'T', share_text: 'B', share_url: 'https://example.org/' }); + }); + + it('says files were lost', async () => { + const { POST } = await route(); + const form = new FormData(); + form.set('text', 'B'); + form.append('files', new File(['x'], 'a.png')); + const res = await POST(post(form, '500')); + expect(res.headers.get('location')).toContain('share_files=1'); + }); + + it('does not read a large upload into memory', async () => { + const { POST } = await route(); + const form = new FormData(); + form.set('text', 'B'); + const spy = vi.fn(async () => form); + const request = { headers: new Headers({ 'content-length': String(50 * 1024 * 1024) }), formData: spy } as unknown as Request; + const res = await POST(request); + expect(spy).not.toHaveBeenCalled(); + expect(res.headers.get('location')).toContain('share_files=1'); + }); + + it.each([ + ['Latin', 'x'], + ['Japanese, 9 encoded bytes each', 'あ'], + ['emoji, whole characters only', '😀'], + ])('keeps the address short and says it cut: %s', async (_name, character) => { + const { POST } = await route(); + const form = new FormData(); + form.set('text', character.repeat(3000)); + form.set('title', character.repeat(3000)); + form.set('url', character.repeat(3000)); + const res = await POST(post(form, '100000')); + const location = res.headers.get('location')!; + expect(location.length).toBeLessThan(4000); + const fragment = new URLSearchParams(new URL(location, 'https://x.example').hash.slice(1)); + expect(fragment.get('share_cut')).toBe('1'); + // Decodes cleanly: no half a character at the end. + expect(fragment.get('share_text')!.endsWith(character)).toBe(true); + }); + + it('does not say it cut what fits', async () => { + const { POST } = await route(); + const form = new FormData(); + form.set('text', 'あ'.repeat(100)); + const res = await POST(post(form, '1000')); + expect(res.headers.get('location')).not.toContain('share_cut'); + }); + + it('honours the subpath', async () => { + vi.stubEnv('NEXT_PUBLIC_BASE_PATH', '/webmail'); + vi.resetModules(); + const { GET } = await route(); + expect(GET().headers.get('location')).toBe('/webmail/'); + }); +}); diff --git a/lib/__tests__/plugin-post-denylist.test.ts b/lib/__tests__/plugin-post-denylist.test.ts index 5d0fad219..1c266ee1b 100644 --- a/lib/__tests__/plugin-post-denylist.test.ts +++ b/lib/__tests__/plugin-post-denylist.test.ts @@ -29,6 +29,7 @@ describe('routes no plugin may POST to', () => { '/api/%70lugin-proxy', '/api//plugin-secrets', '/api/account/stalwart/jmap', + '/api/push/action', ])('%s is denied', (path) => { expect(isPluginPostDenied(path)).toBe(true); }); diff --git a/lib/__tests__/push-action-route.test.ts b/lib/__tests__/push-action-route.test.ts new file mode 100644 index 000000000..c120db3d0 --- /dev/null +++ b/lib/__tests__/push-action-route.test.ts @@ -0,0 +1,428 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; +import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint'; + +// POST /api/push/action: a notification's Mark read and Archive with no tab +// open. Every refusal must leave the mail alone: the worker opens the message +// in the app instead. + +vi.mock('next/headers', () => ({ cookies: async () => ({ get: () => undefined }) })); +vi.mock('@/lib/logger', () => ({ logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() } })); +vi.mock('@/lib/account-utils', () => ({ MAX_ACCOUNT_SLOTS: 3 })); + +type Login = { serverUrl: string; username: string; authHeader: string }; +let LOGINS: Array = []; +vi.mock('@/lib/stalwart/auth-context', () => ({ + readStalwartAuthContextFromStore: (_store: unknown, slot: number) => LOGINS[slot] ?? null, +})); + +const ALICE: Login = { serverUrl: 'https://one.example/', username: 'alice@example.org', authHeader: 'Bearer alice' }; +const BOB: Login = { serverUrl: 'https://one.example', username: 'bob@example.org', authHeader: 'Bearer bob' }; + +let policy: Record = {}; +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { ensureLoaded: async () => {}, getPolicy: () => policy }, +})); +let subject: { email: string; groups: string[] } | null = { email: 'alice@example.org', groups: [] }; +vi.mock('@/lib/admin/server-device-policy', () => ({ strictDeviceSubject: async () => subject })); + +type Calls = Array<[string, Record, string]>; +const writes: Array<{ auth: string; url: string; calls: Calls }> = []; +let session: Record; +let sessionStatus = 200; +let mailboxes: Array>; +let email: Record | null; +let emailSetResult: Record | null = null; +// The answer to each Mailbox/set in turn; `undefined`: everything was created. +let mailboxSetResults: Array> = []; +let mailboxReads = 0; +let mailboxesOnSecondRead: Array> | null = null; +let emailSetResults: Array> = []; +let sessionsAsked = 0; + +const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } }); +vi.mock('@/lib/stalwart/server-fetch', () => ({ + isTrustedJmapServerUrl: async () => true, + fetchJmapServer: async (url: string, init?: { headers?: Record; body?: string }) => { + if (url.endsWith('/.well-known/jmap')) { + sessionsAsked++; + return sessionStatus === 200 ? json(session) : new Response('', { status: sessionStatus }); + } + const calls = (JSON.parse(init?.body ?? '{}') as { methodCalls: Calls }).methodCalls; + if (calls.some(([name]) => name.endsWith('/set'))) { + writes.push({ auth: init?.headers?.Authorization ?? '', url, calls }); + } + return json({ + methodResponses: calls.map(([name, args, id]) => { + if (name === 'Mailbox/get') { + mailboxReads++; + return [name, { list: mailboxReads >= 2 && mailboxesOnSecondRead ? mailboxesOnSecondRead : mailboxes }, id]; + } + if (name === 'Email/get') return [name, { list: email ? [email] : [] }, id]; + if (name === 'Mailbox/set') { + if (args.destroy) return [name, { destroyed: args.destroy }, id]; + return [name, mailboxSetResults.shift() ?? { + created: Object.fromEntries(Object.keys(args.create as object).map((cid) => [cid, { id: `new-${cid}` }])), + }, id]; + } + const emailId = Object.keys(args.update as Record)[0]; + return [name, emailSetResults.shift() ?? emailSetResult ?? { updated: { [emailId]: null } }, id]; + }), + }); + }, +})); + +const INBOX = { id: 'inbox', name: 'Inbox', role: 'inbox' }; +const ARCHIVE = { id: 'arch', name: 'Archive', role: 'archive' }; + +async function call(body: Record, headers: Record = { 'sec-fetch-site': 'same-origin' }) { + const { POST } = await import('@/app/api/push/action/route'); + const res = await POST(new NextRequest('https://mail.example.com/api/push/action', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify(body), + })); + return { status: res.status, body: await res.json() }; +} + +const fingerprint = (login: Login) => loginFingerprint(login.serverUrl, login.username); +const destroyed = () => writes.flatMap((w) => w.calls).find(([name, args]) => name === 'Mailbox/set' && args.destroy)?.[1].destroy; +const markRead = async (over: Record = {}) => ({ + action: 'mark-read', accountId: 'a', emailId: 'e1', login: await fingerprint(ALICE), ...over, +}); +const archive = async (over: Record = {}) => ({ ...(await markRead()), action: 'archive', ...over }); +const emailSet = () => { + const call = writes.at(-1)!.calls.find(([name]) => name === 'Email/set'); + return call?.[1] as { accountId: string; update: Record> }; +}; +const mailboxSet = () => writes[0].calls.find(([name]) => name === 'Mailbox/set')?.[1] as { create: Record> } | undefined; + +beforeEach(() => { + LOGINS = [ALICE]; + policy = {}; + subject = { email: 'alice@example.org', groups: [] }; + writes.length = 0; + sessionsAsked = 0; + sessionStatus = 200; + session = { + apiUrl: 'https://one.example/jmap/', + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'a' }, + accounts: { a: { name: 'alice@example.org', accountCapabilities: { 'urn:ietf:params:jmap:mail': {} } } }, + }; + mailboxes = [INBOX, ARCHIVE]; + email = { id: 'e1', receivedAt: '2026-03-31T23:30:00Z', mailboxIds: { inbox: true } }; + emailSetResult = null; + mailboxSetResults = []; + mailboxReads = 0; + mailboxesOnSecondRead = null; + emailSetResults = []; +}); + +describe('who may ask', () => { + it('refuses a request from another site before reading anything', async () => { + const res = await call(await markRead(), { 'sec-fetch-site': 'same-site' }); + expect(res.status).toBe(403); + expect(sessionsAsked).toBe(0); + expect(writes).toHaveLength(0); + }); + + it('accepts what a service worker sends', async () => { + expect((await call(await markRead())).status).toBe(200); + expect(writes).toHaveLength(1); + }); + + it.each([ + ['an unknown action', { action: 'delete' }], + ['an id that is no JMAP id', { emailId: '../../x' }], + ['no account', { accountId: undefined }], + ['no login', { login: undefined }], + ])('refuses %s', async (_name, over) => { + const res = await call(await markRead(over)); + expect(res.status).toBe(400); + expect(sessionsAsked).toBe(0); + }); +}); + +describe('which login', () => { + it('answers 401 with no cookie, without asking the mail server', async () => { + LOGINS = []; + expect((await call(await markRead())).status).toBe(401); + expect(sessionsAsked).toBe(0); + expect(writes).toHaveLength(0); + }); + + it('answers 401 when the slot went to someone else since the notification', async () => { + // Account ids repeat, so bob's session would accept account `a` as well. + LOGINS = [BOB]; + expect((await call(await markRead())).status).toBe(401); + expect(sessionsAsked).toBe(0); + expect(writes).toHaveLength(0); + }); + + it('finds the login in whichever slot it holds now and uses its credential', async () => { + LOGINS = [BOB, null, ALICE]; + expect((await call(await markRead())).status).toBe(200); + expect(writes[0].auth).toBe('Bearer alice'); + }); + + it('answers 409 when the login is in two slots', async () => { + LOGINS = [ALICE, { ...ALICE, authHeader: 'Bearer stale' }]; + expect((await call(await markRead())).status).toBe(409); + expect(writes).toHaveLength(0); + }); + + it('matches a server address written differently on either side', async () => { + LOGINS = [{ ...ALICE, serverUrl: 'https://one.example' }]; + const login = await loginFingerprint('https://ONE.example:443/', ALICE.username); + expect((await call(await markRead({ login }))).status).toBe(200); + expect(writes).toHaveLength(1); + }); + + it('answers 401 for an expired token: only a page refreshes it', async () => { + sessionStatus = 401; + expect((await call(await markRead())).status).toBe(401); + expect(writes).toHaveLength(0); + }); + + it('answers 502 when the mail server is down, which says nothing about the token', async () => { + sessionStatus = 503; + expect((await call(await markRead())).status).toBe(502); + expect(writes).toHaveLength(0); + }); + + it('refuses an account this login has no part in', async () => { + const res = await call(await markRead({ accountId: 'stranger' })); + expect(res.status).toBe(403); + expect(writes).toHaveLength(0); + }); + + it("doesn't take a name from Object.prototype for an account", async () => { + const res = await call(await markRead({ accountId: 'constructor' })); + expect(res.status).toBe(403); + expect(writes).toHaveLength(0); + }); +}); + +describe('session limits', () => { + // The page applies them, from activity and sign-in time: an action from here + // would keep the login going past them. + it.each([ + ['a maximum session age', { maxHours: 8 }], + ['an idle limit', { idleMinutes: 30 }], + ])('answers 401 under %s, so the app opens and applies it', async (_name, session) => { + policy = { organisation: { session } }; + expect((await call(await markRead())).status).toBe(401); + expect((await call(await archive())).status).toBe(401); + expect(writes).toHaveLength(0); + }); + + it('follows the scope this login is in', async () => { + policy = { scopes: [{ id: 'team', label: 'Team', match: { domains: ['example.org'] }, policy: { organisation: { session: { maxHours: 8 } } } }] }; + expect((await call(await markRead())).status).toBe(401); + expect(writes).toHaveLength(0); + }); + + it("isn't troubled by a scope this login is not in", async () => { + policy = { scopes: [{ id: 'other', label: 'Other', match: { domains: ['other.test'] }, policy: { organisation: { session: { maxHours: 8 } } } }] }; + expect((await call(await markRead())).status).toBe(200); + }); + + it("answers 401 when the login's scopes can't be worked out", async () => { + subject = null; + expect((await call(await markRead())).status).toBe(401); + expect(writes).toHaveLength(0); + }); +}); + +describe('a message since moved to Trash or Junk', () => { + it.each(['trash', 'junk'])('stays as it is in %s, and the app opens it', async (role) => { + mailboxes = [INBOX, ARCHIVE, { id: 'bin', name: 'Bin', role }]; + email = { id: 'e1', receivedAt: '2026-03-31T23:30:00Z', mailboxIds: { bin: true } }; + expect((await call(await markRead())).status).toBe(409); + expect((await call(await archive())).status).toBe(409); + expect(writes).toHaveLength(0); + }); + + it('answers 404 for Mark read on a message that is gone, writing nothing', async () => { + email = null; + expect((await call(await markRead())).status).toBe(404); + expect(writes).toHaveLength(0); + }); +}); + +describe('Mark read', () => { + it('sets $seen on that message, at the rebased API address', async () => { + expect(await call(await markRead())).toEqual({ status: 200, body: { ok: true } }); + expect(writes[0].url).toBe('https://one.example/jmap/'); + expect(emailSet()).toEqual({ accountId: 'a', update: { e1: { 'keywords/$seen': true } } }); + expect(mailboxSet()).toBeUndefined(); + }); + + it('acts in a shared account the login reaches', async () => { + session.accounts = { ...(session.accounts as object), team: { name: 'team@example.org' } }; + expect((await call(await markRead({ accountId: 'team' }))).status).toBe(200); + expect(emailSet().accountId).toBe('team'); + }); + + it('answers 404 for a message that is gone', async () => { + emailSetResult = { notUpdated: { e1: { type: 'notFound' } } }; + expect((await call(await markRead())).status).toBe(404); + }); + + it('answers 502 when the server refuses', async () => { + emailSetResult = { notUpdated: { e1: { type: 'forbidden' } } }; + expect((await call(await markRead())).status).toBe(502); + }); +}); + +describe('Archive', () => { + it('moves the message into the Archive folder', async () => { + expect((await call(await archive())).status).toBe(200); + expect(emailSet().update.e1).toEqual({ mailboxIds: { arch: true } }); + expect(mailboxSet()).toBeUndefined(); + }); + + it('finds an Archive by name when no folder has the role, as the app does', async () => { + mailboxes = [INBOX, { id: 'named', name: 'archive' }]; + await call(await archive()); + expect(emailSet().update.e1).toEqual({ mailboxIds: { named: true } }); + }); + + it('creates the Archive folder when the account has none, in the same request', async () => { + mailboxes = [INBOX]; + expect((await call(await archive())).status).toBe(200); + expect(mailboxSet()?.create).toEqual({ archive: { name: 'Archive', parentId: null, role: 'archive' } }); + expect(emailSet().update.e1).toEqual({ mailboxIds: { '#archive': true } }); + }); + + it("doesn't try when the account may not create one", async () => { + mailboxes = [INBOX]; + session.accounts = { a: { accountCapabilities: { 'urn:ietf:params:jmap:mail': { mayCreateTopLevelMailbox: false } } } }; + expect((await call(await archive())).status).toBe(403); + expect(writes).toHaveLength(0); + }); + + it("doesn't try an Archive that takes no mail", async () => { + mailboxes = [INBOX, { ...ARCHIVE, myRights: { mayAddItems: false } }]; + expect((await call(await archive())).status).toBe(403); + expect(writes).toHaveLength(0); + }); + + it('files by year into an existing year folder', async () => { + mailboxes = [INBOX, ARCHIVE, { id: 'y26', name: '2026', parentId: 'arch' }]; + await call(await archive({ archiveMode: 'year', timeZone: 'UTC' })); + expect(emailSet().update.e1).toEqual({ mailboxIds: { y26: true } }); + expect(mailboxSet()).toBeUndefined(); + }); + + it('ignores a folder named like the year elsewhere', async () => { + mailboxes = [INBOX, ARCHIVE, { id: 'other', name: '2026', parentId: 'inbox' }]; + await call(await archive({ archiveMode: 'year', timeZone: 'UTC' })); + expect(mailboxSet()?.create).toEqual({ year: { name: '2026', parentId: 'arch' } }); + expect(emailSet().update.e1).toEqual({ mailboxIds: { '#year': true } }); + }); + + it("files by month in the user's time zone, not the server's", async () => { + mailboxes = [INBOX, ARCHIVE, { id: 'y26', name: '2026', parentId: 'arch' }, { id: 'm03', name: '03', parentId: 'y26' }]; + // 23:30 UTC on 31 March is 1 April in Berlin. + await call(await archive({ archiveMode: 'month', timeZone: 'Europe/Berlin' })); + expect(mailboxSet()?.create).toEqual({ month: { name: '04', parentId: 'y26' } }); + expect(emailSet().update.e1).toEqual({ mailboxIds: { '#month': true } }); + + writes.length = 0; + await call(await archive({ archiveMode: 'month', timeZone: 'UTC' })); + expect(emailSet().update.e1).toEqual({ mailboxIds: { m03: true } }); + }); + + it('creates the whole path in month mode on an account without an Archive', async () => { + mailboxes = [INBOX]; + await call(await archive({ archiveMode: 'month', timeZone: 'UTC' })); + expect(mailboxSet()?.create).toEqual({ + archive: { name: 'Archive', parentId: null, role: 'archive' }, + year: { name: '2026', parentId: '#archive' }, + month: { name: '03', parentId: '#year' }, + }); + expect(emailSet().update.e1).toEqual({ mailboxIds: { '#month': true } }); + }); + + it('survives a time zone that is no time zone', async () => { + mailboxes = [INBOX, ARCHIVE, { id: 'y26', name: '2026', parentId: 'arch' }]; + expect((await call(await archive({ archiveMode: 'year', timeZone: 'Mars/Olympus' }))).status).toBe(200); + expect(emailSet().update.e1).toEqual({ mailboxIds: { y26: true } }); + }); + + it('answers 404 for a message that is gone', async () => { + email = null; + expect((await call(await archive())).status).toBe(404); + expect(writes).toHaveLength(0); + }); + + it('files into a folder another client made first, on one more try', async () => { + mailboxes = [INBOX, ARCHIVE]; + mailboxesOnSecondRead = [INBOX, ARCHIVE, { id: 'y26', name: '2026', parentId: 'arch' }]; + mailboxSetResults = [{ notCreated: { year: { type: 'alreadyExists' } } }]; + emailSetResults = [{ notUpdated: { e1: { type: 'invalidProperties' } } }]; + expect((await call(await archive({ archiveMode: 'year', timeZone: 'UTC' }))).status).toBe(200); + expect(mailboxReads).toBe(2); + expect(writes).toHaveLength(2); + expect(emailSet().update.e1).toEqual({ mailboxIds: { y26: true } }); + expect(destroyed()).toBeUndefined(); + }); + + it('tries only once more, and stops when the second try fails too', async () => { + mailboxes = [INBOX, ARCHIVE]; + mailboxSetResults = [ + { notCreated: { year: { type: 'alreadyExists' } } }, + { notCreated: { year: { type: 'alreadyExists' } } }, + ]; + emailSetResult = { notUpdated: { e1: { type: 'invalidProperties' } } }; + expect((await call(await archive({ archiveMode: 'year', timeZone: 'UTC' }))).status).toBe(502); + expect(writes).toHaveLength(2); + }); + + it('leaves no empty folder when the message then is refused, deepest first', async () => { + mailboxes = [INBOX]; + emailSetResult = { notUpdated: { e1: { type: 'forbidden' } } }; + expect((await call(await archive({ archiveMode: 'month', timeZone: 'UTC' }))).status).toBe(502); + expect(destroyed()).toEqual(['new-month', 'new-year', 'new-archive']); + }); + + it('keeps the folders it made when the message went in', async () => { + mailboxes = [INBOX]; + expect((await call(await archive({ archiveMode: 'month', timeZone: 'UTC' }))).status).toBe(200); + expect(destroyed()).toBeUndefined(); + }); + + it("files into an Archive shared with others, as the app's own button does", async () => { + mailboxes = [INBOX, { ...ARCHIVE, shareWith: { u2: { mayReadItems: true } } }]; + expect((await call(await archive())).status).toBe(200); + expect(emailSet().update.e1).toEqual({ mailboxIds: { arch: true } }); + }); + + describe('the admin gate', () => { + it('refuses when the admin turned Archive off, whatever the worker believes', async () => { + policy = { features: { archiveActionEnabled: false } }; + expect((await call(await archive())).status).toBe(403); + expect(writes).toHaveLength(0); + }); + + it('follows the scope this login is in', async () => { + policy = { scopes: [{ id: 'team', label: 'Team', match: { domains: ['example.org'] }, policy: { features: { archiveActionEnabled: false } } }] }; + expect((await call(await archive())).status).toBe(403); + expect(writes).toHaveLength(0); + }); + + + it("refuses when the login's scopes can't be worked out", async () => { + subject = null; + expect((await call(await archive())).status).toBe(401); + expect(writes).toHaveLength(0); + }); + + it('leaves Mark read alone', async () => { + policy = { features: { archiveActionEnabled: false } }; + expect((await call(await markRead())).status).toBe(200); + }); + }); +}); diff --git a/lib/__tests__/push-preview-route.test.ts b/lib/__tests__/push-preview-route.test.ts index 3880595cb..a7abae5ca 100644 --- a/lib/__tests__/push-preview-route.test.ts +++ b/lib/__tests__/push-preview-route.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { endlessResponse } from './helpers/endless-body'; +import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint'; vi.mock('next/server', () => ({ NextResponse: { @@ -112,6 +113,11 @@ describe('push preview route account resolution', () => { expect(mailboxQuery?.methodCalls[0][1].accountId).toBe('g'); }); + it('names the login for a message, as the action route knows it', async () => { + const { body } = await callRoute('a'); + expect(body.login).toBe(await loginFingerprint('https://mail.example.com', undefined)); + }); + it('names the login (cookie slot) the account belongs to', async () => { // The worker only knows the JMAP account id, which can repeat across // servers; the click must open the message in this login. @@ -186,7 +192,7 @@ describe('push preview JMAP failures', () => { ['Email/get', { list: [{ id: 'd', threadId: 'td' }] }, 'delivered'], ] })); expect(await callRoute('a', 'd')).toEqual({ - status: 200, body: { email: { id: 'd', threadId: 'td' }, unreadTotal: 1, slot: 0 }, + status: 200, body: { email: { id: 'd', threadId: 'td' }, unreadTotal: 1, slot: 0, login: expect.any(String) }, }); }); }); @@ -202,7 +208,7 @@ it('previews a delivered message outside an empty Inbox', async () => { ['Email/get', { list: [{ id: 'filed', threadId: 't2' }] }, 'delivered'], ] })); expect(await callRoute('a', 'filed')).toEqual({ - status: 200, body: { email: { id: 'filed', threadId: 't2' }, unreadTotal: 1, slot: 0 }, + status: 200, body: { email: { id: 'filed', threadId: 't2' }, unreadTotal: 1, slot: 0, login: expect.any(String) }, }); }); diff --git a/lib/__tests__/sign-out-cleanup.test.ts b/lib/__tests__/sign-out-cleanup.test.ts index 270fc1606..355f623e6 100644 --- a/lib/__tests__/sign-out-cleanup.test.ts +++ b/lib/__tests__/sign-out-cleanup.test.ts @@ -6,6 +6,7 @@ vi.mock('@/lib/plugin-storage', () => ({ import { fileStorage } from '@/lib/plugin-storage'; import { broadcastSignOut, onSignedOutElsewhere, purgeSignedOutData } from '@/lib/sign-out-cleanup'; +import { handOffFiles, pendingHandoffs } from '@/lib/launch-handoff'; // A full sign-out left search history, open tabs (with subjects), Files // favourites and staged attachment bytes behind for the next person at the @@ -14,6 +15,13 @@ import { broadcastSignOut, onSignedOutElsewhere, purgeSignedOutData } from '@/li beforeEach(() => localStorage.clear()); describe('purgeSignedOutData', () => { + it('forgets files and links on their way into an account', () => { + handOffFiles([new File(['x'], 'a.ics')], 'calendar'); + expect(pendingHandoffs()).toHaveLength(1); + purgeSignedOutData(); + expect(pendingHandoffs()).toHaveLength(0); + }); + it("removes the accounts' leftovers and keeps device preferences", () => { for (const key of [ 'email-snapshot', 'pro-tabs', 'search-history-storage', 'calendar-storage', diff --git a/lib/__tests__/sw-harness.ts b/lib/__tests__/sw-harness.ts index d54631a62..c9a20f1ea 100644 --- a/lib/__tests__/sw-harness.ts +++ b/lib/__tests__/sw-harness.ts @@ -29,17 +29,41 @@ export class FakeCaches { } } -export function loadWorker(opts: { buildId: string; caches: FakeCaches; clients: Array<{ id: string }>; fetch?: (input: unknown) => Promise }) { +export interface ShownNotification { + title: string; + options: Record; +} + +export function loadWorker(opts: { + buildId: string; + caches: FakeCaches; + clients: Array<{ id: string }>; + fetch?: (input: unknown, init?: RequestInit) => Promise; + /** More globals of a real worker: Notification, NotificationEvent, MessageChannel. */ + globals?: Record; + openWindow?: (url: string) => Promise; + /** What showNotification does, after it was recorded in `shown`. */ + showNotification?: (title: string, options: Record) => Promise; + notifications?: Array>; + location?: string; +}) { const listeners = new Map void>(); + const shown: ShownNotification[] = []; let skipped = 0; const self = { - location: { href: `https://mail.example.com/sw.js?v=${opts.buildId}`, origin: 'https://mail.example.com' }, + location: { href: opts.location ?? `https://mail.example.com/sw.js?v=${opts.buildId}`, origin: 'https://mail.example.com' }, addEventListener: (type: string, fn: (event: unknown) => void) => listeners.set(type, fn), skipWaiting: () => { skipped++; }, - clients: { claim: async () => {}, matchAll: async () => opts.clients }, - registration: { showNotification: async () => {}, getNotifications: async () => [] }, + clients: { claim: async () => {}, matchAll: async () => opts.clients, openWindow: opts.openWindow }, + registration: { + showNotification: async (title: string, options: Record) => { + shown.push({ title, options }); + await opts.showNotification?.(title, options); + }, + getNotifications: async () => opts.notifications ?? [], + }, }; - const context = vm.createContext({ self, caches: opts.caches, fetch: opts.fetch ?? (async () => { throw new TypeError('offline'); }), URL, URLSearchParams, Request, Response, Headers, Date, console, AbortController, setTimeout, clearTimeout }); + const context = vm.createContext({ self, caches: opts.caches, fetch: opts.fetch ?? (async () => { throw new TypeError('offline'); }), URL, URLSearchParams, Request, Response, Headers, Date, console, AbortController, setTimeout, clearTimeout, crypto: globalThis.crypto, TextEncoder, btoa, ...opts.globals }); vm.runInContext(SW_SOURCE, context); async function dispatch(type: string, event: Record = {}) { const waits: Promise[] = []; @@ -52,5 +76,5 @@ export function loadWorker(opts: { buildId: string; caches: FakeCaches; clients: listeners.get('fetch')?.({ request: { method: 'GET', mode: 'cors', ...req }, respondWith: (p: Promise) => { answer = p; } }); return answer ? await answer : null; } - return { dispatch, listeners, request, skipped: () => skipped }; + return { dispatch, listeners, request, shown, skipped: () => skipped }; } diff --git a/lib/__tests__/sw-notification-actions.test.ts b/lib/__tests__/sw-notification-actions.test.ts new file mode 100644 index 000000000..96e94949f --- /dev/null +++ b/lib/__tests__/sw-notification-actions.test.ts @@ -0,0 +1,446 @@ +import { describe, expect, it, vi } from 'vitest'; +import { FakeCaches, loadWorker } from './sw-harness'; + +// A notification's Archive, Mark read and Reply buttons (public/sw.js). The +// worker has no login: an open tab goes first, then the cookie route, then the +// message opens in the app. + +const ORIGIN = 'https://mail.example.com'; +const PUSH_CACHE = 'bulwark-push-state-v1'; +const PREFS = { + archiveEnabled: true, + archiveMode: 'month', + pluginsMayVeto: false, + labels: { archive: 'Archivieren', markRead: 'Als gelesen markieren', reply: 'Antworten', replyPlaceholder: 'Antwort schreiben' }, +}; +const ONE_MESSAGE = { kind: 'email', emailId: 'e1', threadId: 't1', accountId: 'a', slot: 2, login: 'fp' }; +const MESSAGE_URL = '/mail/message/e1?slot=2&owner=a'; + +async function cachesWith(prefs?: unknown) { + const caches = new FakeCaches(); + if (prefs) { + const cache = await caches.open(PUSH_CACHE); + await cache.put(`${ORIGIN}/__push-state/actions`, new Response(JSON.stringify(prefs))); + } + return caches; +} + +/** A window client that answers a posted action with the given statuses. */ +function tab(id: string, statuses: string[], extra: Record = {}) { + const messages: Array> = []; + const client = { + id, + url: `${ORIGIN}/mail/folder/inbox`, + messages, + focus: vi.fn(async function focus(this: unknown) { return client; }), + navigate: vi.fn(async () => client), + postMessage(message: Record, ports?: MessagePort[]) { + messages.push(message); + if (message.kind !== 'notification-action') return; + const port = ports?.[0]; + for (const status of statuses) port?.postMessage({ status }); + }, + ...extra, + }; + return client; +} + +function worker(opts: { + caches: FakeCaches; + clients?: Array<{ id: string }>; + fetch?: (input: unknown, init?: RequestInit) => Promise; + max?: number | null; + inline?: boolean; + showNotification?: (title: string, options: Record) => Promise; + notifications?: Array>; +}) { + const openWindow = vi.fn(async () => null); + const fetch = vi.fn(opts.fetch ?? (async () => new Response('{}', { status: 200 }))); + const max = opts.max === undefined ? 3 : opts.max; + const loaded = loadWorker({ + buildId: 'b1', + caches: opts.caches, + clients: opts.clients ?? [], + fetch, + openWindow, + showNotification: opts.showNotification, + notifications: opts.notifications, + globals: { + MessageChannel, + ...(max === null ? {} : { Notification: { maxActions: max } }), + ...(opts.inline ? { NotificationEvent: class { get reply() { return null; } } } : {}), + }, + }); + const click = (action: string, extra: Record = {}, data: Record = ONE_MESSAGE) => + loaded.dispatch('notificationclick', { action, ...extra, notification: { data, tag: 'bulwark-mail:a', close() {} } }); + const posts = () => fetch.mock.calls.filter(([, init]) => init?.method === 'POST'); + return { ...loaded, click, openWindow, fetch, posts }; +} + +const pushFor = (preview: Record) => async (w: ReturnType) => { + await w.dispatch('push', { data: { json: () => ({ kind: 'jmap-email-push', accountId: 'a', emailIds: ['e1'] }) } }); + return preview; +}; + +const previewOf = (unreadTotal: number) => JSON.stringify({ + unreadTotal, + slot: 2, + login: 'fp', + email: { id: 'e1', threadId: 't1', subject: 'Hello', from: [{ name: 'Bob' }] }, +}); + +describe('the buttons a notification gets', () => { + const buttonsFor = async (opts: { prefs?: unknown; max?: number | null; inline?: boolean; unreadTotal?: number }) => { + const w = worker({ + caches: await cachesWith(opts.prefs), + max: opts.max, + inline: opts.inline, + fetch: async () => new Response(previewOf(opts.unreadTotal ?? 1), { status: 200 }), + }); + await pushFor({})(w); + expect(w.shown).toHaveLength(1); + return w.shown[0].options.actions as Array> | undefined; + }; + + it('offers the three in the labels the page left, on one message', async () => { + expect(await buttonsFor({ prefs: PREFS })).toEqual([ + { action: 'archive', title: 'Archivieren' }, + { action: 'mark-read', title: 'Als gelesen markieren' }, + { action: 'reply', title: 'Antworten' }, + ]); + }); + + it('keeps Archive and Reply where the browser has two buttons', async () => { + const buttons = await buttonsFor({ prefs: PREFS, max: 2 }); + expect(buttons?.map((b) => b.action)).toEqual(['archive', 'reply']); + }); + + it('leaves Archive out when the admin turned it off', async () => { + const buttons = await buttonsFor({ prefs: { ...PREFS, archiveEnabled: false }, max: 2 }); + expect(buttons?.map((b) => b.action)).toEqual(['mark-read', 'reply']); + }); + + it('offers no Archive, in English, before any page has spoken', async () => { + expect(await buttonsFor({})).toEqual([ + { action: 'mark-read', title: 'Mark as read' }, + { action: 'reply', title: 'Reply' }, + ]); + }); + + it('adds nothing in a browser without notification buttons', async () => { + expect(await buttonsFor({ prefs: PREFS, max: null })).toBeUndefined(); + expect(await buttonsFor({ prefs: PREFS, max: 0 })).toBeUndefined(); + }); + + it('gives the group notification none: it names no message', async () => { + expect(await buttonsFor({ prefs: PREFS, unreadTotal: 4 })).toBeUndefined(); + }); + + it('gives a message without its login none', async () => { + const w = worker({ + caches: await cachesWith(PREFS), + fetch: async () => new Response(JSON.stringify({ unreadTotal: 1, slot: 2, email: { id: 'e1', threadId: 't1' } }), { status: 200 }), + }); + await pushFor({})(w); + expect(w.shown[0].options.actions).toBeUndefined(); + }); + + it('makes Reply a text field only where the browser can hand the text back', async () => { + const buttons = await buttonsFor({ prefs: PREFS, inline: true }); + expect(buttons?.find((b) => b.action === 'reply')).toEqual({ + action: 'reply', title: 'Antworten', type: 'text', placeholder: 'Antwort schreiben', + }); + }); + + it('still shows the notification when the browser refuses the buttons', async () => { + const w = worker({ + caches: await cachesWith(PREFS), + fetch: async () => new Response(previewOf(1), { status: 200 }), + showNotification: async (_title, options) => { + if (options.actions) throw new TypeError('bad action'); + }, + }); + await pushFor({})(w); + expect(w.shown).toHaveLength(2); + expect(w.shown[1].options.actions).toBeUndefined(); + expect(w.shown[1].options.data).toMatchObject({ emailId: 'e1', login: 'fp' }); + }); + + it('keeps the buttons when a redelivered push shows the same notification again', async () => { + const existing = { title: 'Bob', body: 'Hello', data: ONE_MESSAGE }; + const caches = await cachesWith(PREFS); + const cache = await caches.open(PUSH_CACHE); + await cache.put(`${ORIGIN}/__push-state/notified/a`, new Response(JSON.stringify({ ids: ['e1'] }))); + const w = worker({ caches, notifications: [existing] }); + await pushFor({})(w); + expect(w.shown).toHaveLength(1); + expect((w.shown[0].options.actions as unknown[]).length).toBe(3); + }); +}); + +describe('a click', () => { + it('on the body works as before: it opens the message and runs nothing', async () => { + const client = tab('t', ['done']); + const w = worker({ caches: await cachesWith(PREFS), clients: [client] }); + await w.click(''); + expect(client.navigate).toHaveBeenCalledWith(MESSAGE_URL); + expect(client.messages.map((m) => m.kind)).toEqual(['notificationclick']); + expect(w.posts()).toHaveLength(0); + }); + + it('on an unknown button counts as a click on the body', async () => { + const client = tab('t', ['done']); + const w = worker({ caches: await cachesWith(PREFS), clients: [client] }); + await w.click('explode'); + expect(client.navigate).toHaveBeenCalledWith(MESSAGE_URL); + expect(w.posts()).toHaveLength(0); + }); + + it('on a button of a group notification opens the inbox and runs nothing', async () => { + const client = tab('t', ['done']); + const w = worker({ caches: await cachesWith(PREFS), clients: [client] }); + await w.click('archive', {}, { kind: 'mail-list', accountId: 'a', slot: 2 }); + expect(client.navigate).toHaveBeenCalledWith('/mail/folder/inbox?slot=2&owner=a'); + expect(w.posts()).toHaveLength(0); + }); +}); + +describe('Mark read and Archive', () => { + it('go to an open tab first, which leaves nothing else to do', async () => { + const client = tab('t', ['accepted', 'done']); + const w = worker({ caches: await cachesWith(PREFS), clients: [client] }); + await w.click('mark-read'); + expect(client.messages.find((m) => m.kind === 'notification-action')).toEqual({ + kind: 'notification-action', action: 'mark-read', emailId: 'e1', threadId: 't1', accountId: 'a', slot: 2, login: 'fp', at: expect.any(Number), + }); + expect(w.posts()).toHaveLength(0); + expect(client.navigate).not.toHaveBeenCalled(); + expect(client.focus).not.toHaveBeenCalled(); + expect(w.openWindow).not.toHaveBeenCalled(); + }); + + it('go to the tab in front first', async () => { + const hidden = tab('hidden', ['done'], { visibilityState: 'hidden' }); + const front = tab('front', ['done'], { focused: true, visibilityState: 'visible' }); + const w = worker({ caches: await cachesWith(PREFS), clients: [hidden, front] }); + await w.click('mark-read'); + expect(front.messages.some((m) => m.kind === 'notification-action')).toBe(true); + expect(hidden.messages.some((m) => m.kind === 'notification-action')).toBe(false); + }); + + it('try the next tab when one declines', async () => { + const other = tab('other', ['declined']); + const right = tab('right', ['done']); + const w = worker({ caches: await cachesWith(PREFS), clients: [other, right] }); + await w.click('mark-read'); + expect(right.messages.some((m) => m.kind === 'notification-action')).toBe(true); + expect(w.posts()).toHaveLength(0); + }); + + it('use the route with no tab open, and nothing opens when it works', async () => { + const w = worker({ caches: await cachesWith(PREFS) }); + await w.click('mark-read'); + expect(w.posts()).toHaveLength(1); + const [url, init] = w.posts()[0]; + expect(url).toBe('/api/push/action'); + expect(init?.credentials).toBe('include'); + expect(JSON.parse(String(init?.body))).toEqual({ action: 'mark-read', accountId: 'a', emailId: 'e1', login: 'fp' }); + expect(w.openWindow).not.toHaveBeenCalled(); + }); + + it('send the mirrored archive mode and the time zone with Archive', async () => { + const w = worker({ caches: await cachesWith(PREFS) }); + await w.click('archive'); + const body = JSON.parse(String(w.posts()[0][1]?.body)); + expect(body).toMatchObject({ action: 'archive', archiveMode: 'month', accountId: 'a', emailId: 'e1', login: 'fp' }); + expect(typeof body.timeZone).toBe('string'); + expect(body.timeZone).not.toBe(''); + }); + + it('send when they asked, so a tab that thaws later can tell', async () => { + const client = tab('t', ['done']); + const before = Date.now(); + const w = worker({ caches: await cachesWith(PREFS), clients: [client] }); + await w.click('mark-read'); + const at = client.messages.find((m) => m.kind === 'notification-action')?.at as number; + expect(at).toBeGreaterThanOrEqual(before); + expect(at).toBeLessThanOrEqual(Date.now()); + }); + + it('leave the route alone while an enabled plugin may stop them, and open the message', async () => { + const w = worker({ caches: await cachesWith({ ...PREFS, pluginsMayVeto: true }) }); + await w.click('mark-read'); + expect(w.posts()).toHaveLength(0); + expect(w.openWindow).toHaveBeenCalledWith(MESSAGE_URL); + }); + + it('leave the route alone before any page has said what plugins there are', async () => { + const w = worker({ caches: await cachesWith() }); + await w.click('mark-read'); + expect(w.posts()).toHaveLength(0); + expect(w.openWindow).toHaveBeenCalledWith(MESSAGE_URL); + }); + + it('open the message when a tab holding this login stays silent, without the route', async () => { + const silent = tab('held', []); + const w = worker({ caches: await cachesWith(PREFS), clients: [silent] }); + await w.dispatch('message', { data: { type: 'client-logins', logins: ['fp'] }, source: { id: 'held' } }); + await w.click('archive'); + expect(w.posts()).toHaveLength(0); + expect(silent.focus).toHaveBeenCalled(); + expect(silent.navigate).toHaveBeenCalledWith(MESSAGE_URL); + }); + + it('give a busy tab holding this login longer than the others', async () => { + const busy = tab('held', []); + const post = busy.postMessage; + busy.postMessage = (message: Record, ports?: MessagePort[]) => { + post(message, ports); + if (message.kind === 'notification-action') setTimeout(() => ports?.[0]?.postMessage({ status: 'done' }), 1_500); + }; + const w = worker({ caches: await cachesWith(PREFS), clients: [busy] }); + await w.dispatch('message', { data: { type: 'client-logins', logins: ['fp'] }, source: { id: 'held' } }); + await w.click('mark-read'); + expect(busy.navigate).not.toHaveBeenCalled(); + expect(w.posts()).toHaveLength(0); + expect(w.openWindow).not.toHaveBeenCalled(); + }); + + it('take another tab that answers over a silent one holding this login', async () => { + const silent = tab('held', [], { visibilityState: 'visible', focused: true }); + const alive = tab('alive', ['done']); + const w = worker({ caches: await cachesWith(PREFS), clients: [silent, alive] }); + await w.dispatch('message', { data: { type: 'client-logins', logins: ['fp'] }, source: { id: 'held' } }); + await w.click('mark-read'); + expect(alive.messages.some((m) => m.kind === 'notification-action')).toBe(true); + expect(silent.navigate).not.toHaveBeenCalled(); + expect(w.posts()).toHaveLength(0); + }); + + it('forget tabs that are gone when another reports its logins', async () => { + const caches = await cachesWith(PREFS); + const clients = [tab('one', []), tab('two', [])]; + const w = worker({ caches, clients }); + await w.dispatch('message', { data: { type: 'client-logins', logins: ['fp'] }, source: { id: 'one' } }); + clients.shift(); + await w.dispatch('message', { data: { type: 'client-logins', logins: ['other'] }, source: { id: 'two' } }); + const cache = await caches.open(PUSH_CACHE); + expect(await (await cache.match(`${ORIGIN}/__push-state/client-logins`))!.json()).toEqual({ two: ['other'] }); + }); + + it('use the route when a tab never answers, so a frozen tab costs a moment only', async () => { + const frozen = tab('frozen', []); + const w = worker({ caches: await cachesWith(PREFS), clients: [frozen] }); + await w.click('mark-read'); + expect(w.posts()).toHaveLength(1); + expect(frozen.navigate).not.toHaveBeenCalled(); + }); + + it.each([401, 403, 404, 409, 502])('open the message when the route answers %i', async (status) => { + const w = worker({ caches: await cachesWith(PREFS), fetch: async () => new Response('{}', { status }) }); + await w.click('archive'); + expect(w.posts()).toHaveLength(1); + expect(w.openWindow).toHaveBeenCalledWith(MESSAGE_URL); + }); + + it('open the message when the route is out of reach', async () => { + const w = worker({ caches: await cachesWith(PREFS), fetch: async () => { throw new TypeError('offline'); } }); + await w.click('mark-read'); + expect(w.openWindow).toHaveBeenCalledWith(MESSAGE_URL); + }); + + it('open the message when a tab with a live login tried and failed, without the route', async () => { + const client = tab('t', ['accepted', 'failed']); + const w = worker({ caches: await cachesWith(PREFS), clients: [client] }); + await w.click('archive'); + expect(w.posts()).toHaveLength(0); + expect(client.focus).toHaveBeenCalled(); + expect(client.navigate).toHaveBeenCalledWith(MESSAGE_URL); + }); + + it('never post an Archive the mirror says is off', async () => { + const w = worker({ caches: await cachesWith({ ...PREFS, archiveEnabled: false }) }); + await w.click('archive'); + expect(w.posts()).toHaveLength(0); + expect(w.openWindow).toHaveBeenCalledWith(MESSAGE_URL); + }); +}); + +describe('Reply', () => { + it('opens the composer in an open tab and brings it to the front', async () => { + const client = tab('t', ['done']); + const w = worker({ caches: await cachesWith(PREFS), clients: [client] }); + await w.click('reply', { reply: ' Thanks! ' }); + expect(client.messages.find((m) => m.kind === 'notification-action')).toMatchObject({ action: 'reply', emailId: 'e1', text: 'Thanks!' }); + expect(client.focus).toHaveBeenCalled(); + expect(client.navigate).not.toHaveBeenCalled(); + expect(w.posts()).toHaveLength(0); + }); + + it('navigates a tab that has no mail page to the reply link', async () => { + const client = tab('t', ['declined']); + const w = worker({ caches: await cachesWith(PREFS), clients: [client] }); + await w.click('reply'); + expect(client.focus).toHaveBeenCalled(); + expect(client.navigate).toHaveBeenCalledWith(`${MESSAGE_URL}&reply=1`); + }); + + it('opens a window on the reply link with no tab open, never through the route', async () => { + const w = worker({ caches: await cachesWith(PREFS) }); + await w.click('reply'); + expect(w.openWindow).toHaveBeenCalledWith(`${MESSAGE_URL}&reply=1`); + expect(w.posts()).toHaveLength(0); + }); + + it('keeps typed text in the store for the page, out of the URL', async () => { + const caches = await cachesWith(PREFS); + const w = worker({ caches }); + await w.click('reply', { reply: 'Private words' }); + expect(w.openWindow).toHaveBeenCalledWith(`${MESSAGE_URL}&reply=1`); + const cache = await caches.open(PUSH_CACHE); + const kept = await (await cache.match(`${ORIGIN}/__push-state/pending-reply`))!.json(); + expect(kept).toMatchObject({ emailId: 'e1', text: 'Private words' }); + }); + + it('keeps typed text and the reply link when a tab holding this login stays silent', async () => { + const caches = await cachesWith(PREFS); + const silent = tab('held', []); + const w = worker({ caches, clients: [silent] }); + await w.dispatch('message', { data: { type: 'client-logins', logins: ['fp'] }, source: { id: 'held' } }); + await w.click('reply', { reply: 'Private words' }); + expect(silent.navigate).toHaveBeenCalledWith(`${MESSAGE_URL}&reply=1`); + const cache = await caches.open(PUSH_CACHE); + expect(await (await cache.match(`${ORIGIN}/__push-state/pending-reply`))!.json()).toMatchObject({ text: 'Private words' }); + }); + + it('leaves nothing in the store without typed text', async () => { + const caches = await cachesWith(PREFS); + const w = worker({ caches }); + await w.click('reply', { reply: ' ' }); + const cache = await caches.open(PUSH_CACHE); + expect(await cache.match(`${ORIGIN}/__push-state/pending-reply`)).toBeUndefined(); + }); +}); + +describe('Background Sync', () => { + it('tells every open tab to flush the outbox', async () => { + const one = tab('one', []); + const two = tab('two', []); + const w = worker({ caches: await cachesWith(), clients: [one, two] }); + await w.dispatch('sync', { tag: 'bulwark-outbox' }); + expect(one.messages).toEqual([{ kind: 'outbox-flush' }]); + expect(two.messages).toEqual([{ kind: 'outbox-flush' }]); + }); + + it('ignores other tags', async () => { + const one = tab('one', []); + const w = worker({ caches: await cachesWith(), clients: [one] }); + await w.dispatch('sync', { tag: 'something-else' }); + expect(one.messages).toEqual([]); + }); + + it('settles with no tab open', async () => { + const w = worker({ caches: await cachesWith(), clients: [] }); + await expect(w.dispatch('sync', { tag: 'bulwark-outbox' })).resolves.toBeUndefined(); + }); +}); diff --git a/lib/__tests__/sw-share-target.test.ts b/lib/__tests__/sw-share-target.test.ts new file mode 100644 index 000000000..57509a55f --- /dev/null +++ b/lib/__tests__/sw-share-target.test.ts @@ -0,0 +1,283 @@ +// @vitest-environment node +import { describe, expect, it, vi } from 'vitest'; +import { FakeCaches, loadWorker } from './sw-harness'; + +// The manifest's share_target POST, in the service worker, and a mailto: +// launch while offline. Any site can POST here, signed in or not, so what the +// worker keeps is limited and sealed. Before: a POST went to the server when +// offline mode was off, and files sat readable in the cache with no limit. + +const ORIGIN = 'https://mail.example.com'; +const CACHE = 'bulwark-launch-handoff-v1'; +const TTL = 15 * 60 * 1000; +const settle = () => new Promise((r) => setTimeout(r, 0)); + +function shareRequest(form: FormData, overrides: Record = {}) { + return { url: `${ORIGIN}/protocol/share`, method: 'POST', mode: 'navigate', destination: 'document', formData: async () => form, ...overrides } as never; +} + +function sharedForm(): FormData { + const form = new FormData(); + form.set('title', 'A link'); + form.set('text', 'Look'); + form.set('url', 'https://example.org/'); + form.append('files', new File(['secret bytes'], 'photo.png', { type: 'image/png' })); + return form; +} + +const landingOf = (answer: Response | null) => { + const url = new URL(answer!.headers.get('location')!); + return { url, fragment: new URLSearchParams(url.hash.slice(1)) }; +}; + +async function shellOn(caches: FakeCaches, on: boolean) { + const cache = await caches.open('bulwark-sw-state-v1'); + await cache.put('/__bulwark/offline-shell', new Response(JSON.stringify({ on }))); +} + +async function plantEntry(caches: FakeCaches, id: string, createdAt: number, bytes = 0) { + const store = await caches.open(CACHE); + await store.put(`/__bulwark/handoff/${id}/meta`, new Response(JSON.stringify({ kind: 'compose', createdAt, bytes }))); + await store.put(`/__bulwark/handoff/${id}/sealed`, new Response('x')); +} + +async function entryIds(caches: FakeCaches): Promise { + const store = await caches.open(CACHE); + return (await store.keys()).filter((key) => key.url.endsWith('/meta')).map((key) => key.url.split('/').slice(-2)[0]); +} + +function htmlPage(body: string) { + const res = new Response(body, { status: 200, headers: { 'Content-Type': 'text/html' } }); + Object.defineProperty(res, 'type', { value: 'basic' }); + return res; +} + +describe('share target', () => { + it.each([[false], [true]])('takes the POST with offline mode on=%s, keeps the files sealed and goes to the app', async (on) => { + const caches = new FakeCaches(); + await shellOn(caches, on); + const fetchMock = vi.fn(); + const worker = loadWorker({ buildId: 'a', caches, clients: [], fetch: fetchMock }); + await settle(); + + const answer = await worker.request(shareRequest(sharedForm())); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(answer?.status).toBe(303); + const { url, fragment } = landingOf(answer); + expect(`${url.origin}${url.pathname}`).toBe(`${ORIGIN}/`); + // Id and key in the fragment: neither ever reaches the server. + expect(url.search).toBe(''); + const id = fragment.get('share')!; + expect(id).toMatch(/^[\w-]+$/); + expect(fragment.get('key')).toMatch(/^[\w-]{43}$/); + + const store = await caches.open(CACHE); + const meta = (await (await store.match(`/__bulwark/handoff/${id}/meta`))!.json()) as Record; + expect(meta).toMatchObject({ kind: 'compose', bytes: 12 }); + // Only what a sweep needs is readable. + expect(Object.keys(meta).sort()).toEqual(['bytes', 'createdAt', 'kind']); + for (const part of ['meta', 'sealed', '0']) { + const text = await (await store.match(`/__bulwark/handoff/${id}/${part}`))!.text(); + for (const secret of ['secret bytes', 'photo.png', 'A link', 'example.org']) expect(text).not.toContain(secret); + } + }); + + it('answers while the worker has only just started and has not read its state', async () => { + const worker = loadWorker({ buildId: 'a', caches: new FakeCaches(), clients: [] }); + const answer = await worker.request(shareRequest(sharedForm())); + expect(answer?.status).toBe(303); + }); + + it('works under a subpath', async () => { + const worker = loadWorker({ buildId: 'a', caches: new FakeCaches(), clients: [], location: `${ORIGIN}/webmail/sw.js?v=a` }); + expect(await worker.request(shareRequest(sharedForm()))).toBeNull(); + const answer = await worker.request(shareRequest(sharedForm(), { url: `${ORIGIN}/webmail/protocol/share` })); + expect(answer!.headers.get('location')).toContain(`${ORIGIN}/webmail/#share=`); + }); + + it('skips empty file fields', async () => { + const caches = new FakeCaches(); + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + const form = new FormData(); + form.set('url', 'https://example.org/'); + form.append('files', new File([], '')); + const answer = await worker.request(shareRequest(form)); + const id = landingOf(answer).fragment.get('share')!; + const store = await caches.open(CACHE); + const meta = (await (await store.match(`/__bulwark/handoff/${id}/meta`))!.json()) as { bytes: number }; + expect(meta.bytes).toBe(0); + expect(await store.match(`/__bulwark/handoff/${id}/0`)).toBeUndefined(); + }); + + it('keeps the text and says the files are lost when there is no room', async () => { + const caches = new FakeCaches(); + const original = caches.open.bind(caches); + const store = await original(CACHE); + vi.spyOn(caches, 'open').mockImplementation(async (name: string) => (name === CACHE + ? { ...store, put: async () => { throw new Error('QuotaExceededError'); } } + : original(name))); + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + + const { fragment } = landingOf(await worker.request(shareRequest(sharedForm()))); + + expect(fragment.get('share')).toBe('unavailable'); + expect(fragment.get('share_files')).toBe('1'); + expect(fragment.get('share_title')).toBe('A link'); + expect(fragment.get('share_url')).toBe('https://example.org/'); + expect((await store.keys()).length).toBe(0); + }); + + it('keeps that address short too, and says it cut', async () => { + const caches = new FakeCaches(); + const original = caches.open.bind(caches); + const store = await original(CACHE); + vi.spyOn(caches, 'open').mockImplementation(async (name: string) => (name === CACHE + ? { ...store, put: async () => { throw new Error('QuotaExceededError'); } } + : original(name))); + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + const form = new FormData(); + form.set('text', 'あ'.repeat(3000)); + + const answer = await worker.request(shareRequest(form)); + + expect(answer!.headers.get('location')!.length).toBeLessThan(4000); + expect(landingOf(answer).fragment.get('share_cut')).toBe('1'); + }); + + it('says so when the form cannot be read', async () => { + const worker = loadWorker({ buildId: 'a', caches: new FakeCaches(), clients: [] }); + const answer = await worker.request(shareRequest(new FormData(), { formData: async () => { throw new TypeError('bad body'); } })); + const { fragment } = landingOf(answer); + expect(fragment.get('share')).toBe('unavailable'); + expect(fragment.get('share_files')).toBe('1'); + }); + + it('leaves other requests alone', async () => { + const caches = new FakeCaches(); + await shellOn(caches, false); + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + await settle(); + // A fetch from a page, a POST elsewhere, another origin, and a plain GET. + expect(await worker.request(shareRequest(sharedForm(), { mode: 'cors' }))).toBeNull(); + expect(await worker.request(shareRequest(sharedForm(), { url: `${ORIGIN}/api/share` }))).toBeNull(); + expect(await worker.request(shareRequest(sharedForm(), { url: 'https://other.example/protocol/share' }))).toBeNull(); + expect(await worker.request({ url: `${ORIGIN}/protocol/share`, mode: 'navigate', destination: 'document' } as never)).toBeNull(); + expect(await caches.has(CACHE)).toBe(false); + }); + + it('leaves a POST into a hidden frame to the network: it stores nothing', async () => { + const caches = new FakeCaches(); + await shellOn(caches, false); + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + await settle(); + expect(await worker.request(shareRequest(sharedForm(), { destination: 'iframe' }))).toBeNull(); + expect(await worker.request(shareRequest(sharedForm(), { destination: 'embed' }))).toBeNull(); + expect(await caches.has(CACHE)).toBe(false); + }); +}); + +describe('limits on what the worker keeps', () => { + it('drops the oldest entries over the entry limit, on every share', async () => { + const caches = new FakeCaches(); + const now = Date.now(); + for (let n = 0; n < 5; n += 1) await plantEntry(caches, `old-${n}`, now - 1000 * (5 - n)); + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + + const answer = await worker.request(shareRequest(sharedForm())); + + const ids = await entryIds(caches); + expect(ids).toHaveLength(5); + expect(ids).not.toContain('old-0'); + expect(ids).toContain('old-1'); + expect(ids).toContain(landingOf(answer).fragment.get('share')); + // Everything of the dropped one goes, not only its list. + expect(await (await caches.open(CACHE)).match('/__bulwark/handoff/old-0/sealed')).toBeUndefined(); + }); + + it('drops the oldest entries over the byte limit', async () => { + const caches = new FakeCaches(); + const now = Date.now(); + await plantEntry(caches, 'big', now - 2000, 100 * 1024 * 1024 - 5); + await plantEntry(caches, 'small', now - 1000, 2); + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + + await worker.request(shareRequest(sharedForm())); + + const ids = await entryIds(caches); + expect(ids).not.toContain('big'); + expect(ids).toContain('small'); + }); + + it('stores nothing from a share over the byte limit, says the files are lost, and keeps the rest', async () => { + const caches = new FakeCaches(); + await plantEntry(caches, 'kept', Date.now() - 1000, 10); + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + const big = new File(['x'], 'huge.bin'); + Object.defineProperty(big, 'size', { value: 100 * 1024 * 1024 + 1 }); + const fields = new Map([['title', 'T']]); + const form = { get: (name: string) => fields.get(name) ?? null, getAll: () => [big] } as unknown as FormData; + + const { fragment } = landingOf(await worker.request(shareRequest(form))); + + expect(fragment.get('share')).toBe('unavailable'); + expect(fragment.get('share_files')).toBe('1'); + expect(await entryIds(caches)).toEqual(['kept']); + }); + + it('sweeps what is too old on every share', async () => { + const caches = new FakeCaches(); + await plantEntry(caches, 'stale', Date.now() - TTL - 1); + await plantEntry(caches, 'fresh', Date.now() - 1000); + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + await worker.request(shareRequest(sharedForm())); + const ids = await entryIds(caches); + expect(ids).not.toContain('stale'); + expect(ids).toContain('fresh'); + }); + + it('sweeps what is too old when it activates', async () => { + const caches = new FakeCaches(); + await plantEntry(caches, 'stale', Date.now() - TTL - 1); + await plantEntry(caches, 'fresh', Date.now() - 1000); + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + await worker.dispatch('activate'); + expect(await entryIds(caches)).toEqual(['fresh']); + }); + + it('creates no cache when it activates with nothing kept', async () => { + const caches = new FakeCaches(); + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + await worker.dispatch('activate'); + expect(await caches.has(CACHE)).toBe(false); + }); +}); + +describe('a mailto: launch without a connection', () => { + it('gets the kept handler page instead of a network error', async () => { + const caches = new FakeCaches(); + await shellOn(caches, true); + const online = vi.fn(async () => htmlPage('mailto page')); + const worker = loadWorker({ buildId: 'a', caches, clients: [], fetch: online }); + await settle(); + + await worker.dispatch('message', { data: { type: 'offline-shell', on: true, urls: ['/protocol/mailto'], buildId: 'a' } }); + await settle(); + + online.mockImplementation(async () => { throw new TypeError('offline'); }); + const answer = await worker.request({ url: `${ORIGIN}/protocol/mailto?url=mailto%3Aa%40example.org`, mode: 'navigate' }); + expect(await answer!.text()).toBe('mailto page'); + }); + + it('falls back to the start page when that page was never kept', async () => { + const caches = new FakeCaches(); + await shellOn(caches, true); + const shell = await caches.open('bulwark-shell-a'); + await shell.put(ORIGIN, htmlPage('start')); + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + await settle(); + + const answer = await worker.request({ url: `${ORIGIN}/protocol/webcal?url=x`, mode: 'navigate' }); + expect(await answer!.text()).toBe('start'); + }); +}); diff --git a/lib/calendar-alerts.ts b/lib/calendar-alerts.ts index bef0151e1..2c5e8ff96 100644 --- a/lib/calendar-alerts.ts +++ b/lib/calendar-alerts.ts @@ -7,7 +7,7 @@ import type { Calendar, CalendarTask, } from '@/lib/jmap/types'; -import { parseDuration } from '@/components/calendar/event-card'; +import { parseDuration } from '@/lib/calendar-event-normalization'; export interface PendingAlert { eventId: string; diff --git a/lib/calendar-event-normalization.ts b/lib/calendar-event-normalization.ts index 6dd31bf83..5305faa8e 100644 --- a/lib/calendar-event-normalization.ts +++ b/lib/calendar-event-normalization.ts @@ -39,6 +39,21 @@ export function parseDurationSeconds(duration: string | undefined): number | nul return (((weeks * 7 + days) * 24 + hours) * 60 + minutes) * 60 + seconds; } +/** A duration in whole minutes, 0 when there is none. */ +export function parseDuration(duration: string | undefined): number { + if (!duration) return 0; + let totalMinutes = 0; + const weekMatch = duration.match(/(\d+)W/); + const hourMatch = duration.match(/(\d+)H/); + const minMatch = duration.match(/(\d+)M/); + const dayMatch = duration.match(/(\d+)D/); + if (weekMatch) totalMinutes += parseInt(weekMatch[1]) * 7 * 24 * 60; + if (dayMatch) totalMinutes += parseInt(dayMatch[1]) * 24 * 60; + if (hourMatch) totalMinutes += parseInt(hourMatch[1]) * 60; + if (minMatch) totalMinutes += parseInt(minMatch[1]); + return totalMinutes; +} + export function normalizeAllDayDurationValue(duration: string | undefined): string | undefined { const totalSeconds = parseDurationSeconds(duration); if (totalSeconds === null || totalSeconds < 86400 || totalSeconds % 86400 !== 0) { diff --git a/lib/calendar-utils.ts b/lib/calendar-utils.ts index 2299fb54c..faff279c4 100644 --- a/lib/calendar-utils.ts +++ b/lib/calendar-utils.ts @@ -1,5 +1,5 @@ import { addDays, differenceInCalendarDays, parseISO, startOfDay, subMilliseconds } from "date-fns"; -import { parseDuration } from "@/components/calendar/event-card"; +import { parseDuration } from "@/lib/calendar-event-normalization"; import type { CalendarEvent } from "@/lib/jmap/types"; import { fromWallClock, isValidTimeZone, parseLocalDateTime, toDisplayDate } from "@/lib/timezone"; diff --git a/lib/deep-links.ts b/lib/deep-links.ts index 483ad33a9..9e890e58e 100644 --- a/lib/deep-links.ts +++ b/lib/deep-links.ts @@ -137,7 +137,7 @@ const ALIASED_ROLES = new Set(['inbox', 'sent', 'drafts', 'trash', 'archive', 'j */ export type MailDeepLink = | { kind: 'folder'; ref: string; accountId?: string; slot?: number; ownerAccountId?: string } - | { kind: 'message'; id: string; accountId?: string; slot?: number; fullscreen?: boolean; ownerAccountId?: string } + | { kind: 'message'; id: string; accountId?: string; slot?: number; fullscreen?: boolean; ownerAccountId?: string; reply?: boolean } | { kind: 'thread'; id: string; accountId?: string; slot?: number; ownerAccountId?: string }; export interface MailLinkState { @@ -286,12 +286,15 @@ export function parseMailPath( // opens message links as fullscreen email tabs and ignores the flag. const fullscreen = search?.get('view') === 'fullscreen' || undefined; const ownerAccountId = search?.get('owner') || undefined; + // `?reply=1`, a notification's Reply: opens the reply composer once. Only + // opens it: nothing a link says can send or change mail. + const reply = search?.get('reply') === '1' || undefined; const [kind, value] = segments; if (kind && value) { const id = decodeSegment(value); if (id) { - if (kind === 'message') return { kind: 'message', id, accountId, slot, fullscreen, ownerAccountId }; + if (kind === 'message') return { kind: 'message', id, accountId, slot, fullscreen, ownerAccountId, ...(reply ? { reply } : {}) }; if (kind === 'thread') return { kind: 'thread', id, accountId, slot, ownerAccountId }; if (kind === 'folder') return { kind: 'folder', ref: id, accountId, slot, ownerAccountId }; } diff --git a/lib/global-search/__tests__/providers.test.ts b/lib/global-search/__tests__/providers.test.ts index 111bf736c..fe54a5f7e 100644 --- a/lib/global-search/__tests__/providers.test.ts +++ b/lib/global-search/__tests__/providers.test.ts @@ -163,7 +163,7 @@ describe('mail provider', () => { const result = await mailProvider.remote(parseSearchQuery('zebra'), account('login-a'), { limit: 25, signal }); expect(buildUnifiedAccountClients).toHaveBeenCalledWith({ includeGroup: true, scopeToClientAccountId: 'login-a' }); // The stub login has no trash/junk role folders, so no exclusion is added. - expect(advancedSearchEmails).toHaveBeenCalledWith({ text: 'zebra' }, undefined, 25, 0); + expect(advancedSearchEmails).toHaveBeenCalledWith({ text: 'zebra' }, undefined, 25, 0, { palette: true }); expect(result.hasMore).toBe(true); expect(result.hits[0]).toMatchObject({ id: 'm9', localAccountId: 'login-a', jmapAccountId: 'jmap-a', source: 'remote', subtitle: 'Inbox', diff --git a/lib/global-search/providers/mail.ts b/lib/global-search/providers/mail.ts index 14850795c..d52db2eab 100644 --- a/lib/global-search/providers/mail.ts +++ b/lib/global-search/providers/mail.ts @@ -119,6 +119,7 @@ export const mailProvider: SearchProvider = { entry.isShared ? entry.jmapAccountId : undefined, limit, position, + { palette: true }, ); if (result.hasMore) hasMore = true; for (const raw of result.emails) { diff --git a/lib/jmap/__tests__/search-merge.test.ts b/lib/jmap/__tests__/search-merge.test.ts new file mode 100644 index 000000000..4ef295281 --- /dev/null +++ b/lib/jmap/__tests__/search-merge.test.ts @@ -0,0 +1,903 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_SCOPE } from '@/lib/store/area'; +import { AccountEngine } from '@/lib/store/engine/account-engine'; +import { mailArea } from '@/lib/store/mail'; +import { FAKE_SESSION, FakeJmap, JMAP_ACCOUNT } from '@/lib/store/__tests__/helpers/fake-jmap'; +import { openMemoryStore } from '@/lib/store/__tests__/helpers/node-adapter'; +import { JMAPClient } from '../client'; +import type { SearchEmailsResult } from '../client-interface'; +import { setOfflineHooks, type OfflineHooks } from '../offline-hooks'; +import { filterHasTextTerms, LocalSearchMerge, mailboxesFitFilter } from '../search-merge'; +import type { Email } from '../types'; + +function mail(id: string, minute: number, extra: Partial = {}): Email { + return { id, receivedAt: new Date(Date.UTC(2026, 9, 1, 0, minute)).toISOString(), ...extra } as Email; +} + +/** A side of the search: the whole result list, answered a page at a time, newest first. */ +function side(rows: Email[], fixedTotal?: number) { + const asked: number[] = []; + const answer = async (position: number, limit: number): Promise => { + asked.push(position); + const total = fixedTotal ?? rows.length; + const emails = rows.slice(position, position + limit); + return { emails, total, hasMore: position + emails.length < total }; + }; + return { asked, answer }; +} + +function pager(server: ReturnType, local: ReturnType | null, limit: number) { + const merge = new LocalSearchMerge(); + return (position: number) => merge.run({ + key: 'search', + limit, + position, + server: (at) => server.answer(at, limit), + local: async (at) => (local ? local.answer(at, limit) : null), + }); +} + +const ids = (result: SearchEmailsResult) => result.emails.map((email) => email.id); + +describe('merging one page', () => { + it('sorts newest first and has no duplicates', async () => { + const server = side([mail('s4', 40), mail('both3', 30), mail('s1', 10)]); + const local = side([mail('l5', 50), mail('both3', 30), mail('l2', 20)]); + const result = await pager(server, local, 50)(0); + expect(ids(result)).toEqual(['l5', 's4', 'both3', 'l2', 's1']); + expect(result.hasMore).toBe(false); + expect(result.total).toBe(5); + }); + + it('keeps the server copy when both have a message', async () => { + const server = side([mail('both', 10, { subject: 'server copy' })]); + const local = side([mail('both', 10, { subject: 'local copy' })]); + const result = await pager(server, local, 50)(0); + expect(result.emails.map((email) => email.subject)).toEqual(['server copy']); + expect(result.total).toBe(1); + }); + + it('keeps the server copy when the local row sorts earlier than it', async () => { + // The local copy is out of date and looks newer: still one row, the server's. + const server = side([mail('s2', 20), mail('both', 10, { subject: 'server copy' })]); + const local = side([mail('both', 30, { subject: 'local copy' })]); + const result = await pager(server, local, 50)(0); + expect(ids(result)).toEqual(['s2', 'both']); + expect(result.emails[1].subject).toBe('server copy'); + }); + + it('passes on what the server added to its result, such as its deferred snippets', async () => { + const snippets = Promise.resolve([]); + const merge = new LocalSearchMerge(); + const result = await merge.run({ + key: 'k', limit: 10, position: 0, + server: async () => ({ emails: [mail('s', 1)], total: 1, hasMore: false, snippets }), + local: async () => ({ emails: [mail('l', 2)], total: 1, hasMore: false }), + }); + expect(result.snippets).toBe(snippets); + }); +}); + +describe('paging', () => { + it('walks both lists with no row lost or repeated, counting by merged rows', async () => { + const serverRows = Array.from({ length: 25 }, (_, i) => mail(`s${i}`, 1000 - i * 10)); + const localOnly = [mail('l-a', 995), mail('l-b', 905), mail('l-c', 805), mail('l-d', 500)]; + const server = side(serverRows); + const local = side([...localOnly, serverRows[1], serverRows[12]].sort((a, b) => Date.parse(b.receivedAt) - Date.parse(a.receivedAt))); + const page = pager(server, local, 10); + + const seen: string[] = []; + let result = await page(0); + seen.push(...ids(result)); + expect(result.emails.length).toBe(10); + expect(result.hasMore).toBe(true); + let guard = 0; + while (result.hasMore && guard < 10) { + // As the store does: the next position is how many rows it holds. + result = await page(seen.length); + seen.push(...ids(result)); + guard += 1; + } + expect(seen.length).toBe(29); + expect(new Set(seen).size).toBe(29); + expect(seen).toEqual(expect.arrayContaining([...serverRows.map((e) => e.id), ...localOnly.map((e) => e.id)])); + const times = [...serverRows, ...localOnly].sort((a, b) => Date.parse(b.receivedAt) - Date.parse(a.receivedAt)).map((e) => e.id); + expect(seen).toEqual(times); + expect(result.total).toBe(29); + }); + + it('asks the server for rows a local row pushed off the page', async () => { + const server = side([mail('s3', 30), mail('s2', 20), mail('s1', 10)]); + const local = side([mail('l4', 40)]); + const page = pager(server, local, 2); + expect(ids(await page(0))).toEqual(['l4', 's3']); + expect(server.asked).toEqual([0]); + // Merged position 2, but the server has only given one row so far. + expect(ids(await page(2))).toEqual(['s2', 's1']); + expect(server.asked).toEqual([0, 1]); + }); + + it('holds back a local row older than what the server has sent, in case the server has it later', async () => { + const serverRows = [mail('s4', 40), mail('s3', 30), mail('both', 20), mail('s1', 10)]; + const server = side(serverRows); + const local = side([mail('both', 20)]); + const page = pager(server, local, 2); + const first = await page(0); + expect(ids(first)).toEqual(['s4', 's3']); + const second = await page(2); + expect(ids(second)).toEqual(['both', 's1']); + expect(second.hasMore).toBe(false); + expect(second.total).toBe(4); + }); + + it('keeps paging through local rows when the server has none', async () => { + const server = side([]); + const local = side([mail('l3', 30), mail('l2', 20), mail('l1', 10)]); + const page = pager(server, local, 2); + const first = await page(0); + expect(ids(first)).toEqual(['l3', 'l2']); + expect(first.hasMore).toBe(true); + expect(first.total).toBe(3); + const second = await page(2); + expect(ids(second)).toEqual(['l1']); + expect(second.hasMore).toBe(false); + expect(second.total).toBe(3); + }); + + it('keeps total at or above what has been returned and what either side knows of', async () => { + const server = side([mail('s2', 20), mail('s1', 10)], 40); + const local = side([mail('l3', 30)], 50); + const result = await pager(server, local, 2)(0); + expect(result.total).toBe(50); + const noLocalLead = await pager(side([mail('s2', 20)], 40), side([mail('l3', 30)], 1), 2)(0); + expect(noLocalLead.total).toBe(41); + }); + + it('never repeats a local row the server later returns', async () => { + // Same time on both sides, split by the page edge: it shows once. + const server = side([mail('s', 20), mail('tie', 20)]); + const local = side([mail('tie', 20), mail('l', 5)]); + const page = pager(server, local, 1); + const seen = [...ids(await page(0))]; + seen.push(...ids(await page(seen.length))); + seen.push(...ids(await page(seen.length))); + expect(seen).toEqual(['s', 'tie', 'l']); + }); + + it('drops a row the server returns later if the store already showed it, and holds older local rows until the server catches up', async () => { + // The local copy of x looks newer than the server's, so it goes out first. + const server = side([mail('a', 30), mail('b', 25), mail('x', 20), mail('c', 10)]); + const local = side([mail('x', 40), mail('l', 5)]); + const page = pager(server, local, 2); + const first = await page(0); + expect(ids(first)).toEqual(['x', 'a']); + // The server's page is b and x. x has been shown, and the server has more, + // so l can't be placed yet: it may be on the server's next page. + const second = await page(2); + expect(ids(second)).toEqual(['b']); + expect(second.hasMore).toBe(true); + const third = await page(3); + expect(ids(third)).toEqual(['c', 'l']); + expect(third.hasMore).toBe(false); + }); + + it('follows a caller whose position has moved since the last page', async () => { + const server = side([mail('s4', 40), mail('s3', 30), mail('s2', 20), mail('s1', 10)]); + const local = side([mail('l5', 50)]); + const page = pager(server, local, 2); + await page(0); + // The caller added a row of its own to the list, as plugins can: one past. + await page(3); + expect(server.asked).toEqual([0, 2]); + }); + + it('starts over at position 0', async () => { + const server = side([mail('s2', 20), mail('s1', 10)]); + const local = side([mail('l3', 30)]); + const page = pager(server, local, 2); + await page(0); + expect(ids(await page(0))).toEqual(['l3', 's2']); + }); + + it('walks a search again when it has no cursor, so no server row is skipped', async () => { + const server = side([mail('s4', 40), mail('s3', 30), mail('s2', 20), mail('s1', 10)]); + const local = side([mail('l5', 50), mail('l25', 25)]); + const merge = new LocalSearchMerge(); + const ask = (key: string, position: number) => merge.run({ + key, limit: 2, position, + server: (at) => server.answer(at, 2), + local: (at) => local.answer(at, 2), + }); + // The caller holds l5, s4, s3. As a merged position 3 would skip s2 on the server. + const result = await ask('evicted', 3); + expect(ids(result)).toEqual(['l25', 's2']); + }); + + it('sends a position with no cursor straight to the server when the store is off', async () => { + const server = side([mail('s2', 20), mail('s1', 10)]); + const result = await pager(server, null, 2)(1); + expect(ids(result)).toEqual(['s1']); + expect(server.asked).toEqual([0, 1]); + }); + + it('ends the list, and never guesses, past how far it will walk again', async () => { + const rows = Array.from({ length: 100 }, (_, i) => mail(`s${i}`, 1000 - i)); + const server = side(rows); + const local = side([mail('l', 2000)]); + const result = await pager(server, local, 2)(90); + expect(result.emails).toEqual([]); + expect(result.hasMore).toBe(false); + expect(server.asked).not.toContain(90); + }); + + describe('a refresh of a longer list', () => { + function paged() { + const serverRows = [mail('s6', 60), mail('s5', 50), mail('s4', 40), mail('s3', 30), mail('s2', 20), mail('s1', 10)]; + const server = side(serverRows); + const local = side([mail('l7', 70)]); + return { server, local, page: pager(server, local, 2) }; + } + + it('leaves the next load-more where the list had got to', async () => { + const { page } = paged(); + await page(0); + await page(2); + const refreshed = await page(0); + expect(ids(refreshed)).toEqual(['l7', 's6']); + const more = await page(4); + expect(ids(more)).toEqual(['s3', 's2']); + }); + + it('keeps total the same when nothing changed', async () => { + const { page } = paged(); + const first = await page(0); + expect(first.total).toBe(7); + await page(2); + expect((await page(0)).total).toBe(7); + }); + + it('does not forget a local row that was on a later page', async () => { + const server = side([mail('s6', 60), mail('s5', 50), mail('s4', 40), mail('s3', 30), mail('s2', 20), mail('s1', 10)]); + const local = side([mail('l45', 45)]); + const page = pager(server, local, 2); + expect(ids(await page(0))).toEqual(['s6', 's5']); + expect(ids(await page(2))).toEqual(['l45', 's4']); + // This first page has no local row, so it knows nothing of l45 by itself. + const refreshed = await page(0); + expect(ids(refreshed)).toEqual(['s6', 's5']); + expect(refreshed.total).toBe(7); + // The list holds four rows, three of them the server's: s3 is next. + const more = await page(4); + expect(ids(more)).toEqual(['s3', 's2']); + expect(more.total).toBe(7); + }); + + it('still lets a new search of the same words page from its own start', async () => { + const { page } = paged(); + await page(0); + await page(2); + await page(0); + // The list was replaced by that page, so the next one starts right after it. + expect(ids(await page(2))).toEqual(['s5', 's4']); + }); + + it('moves on past a new row the refresh brought in', async () => { + const serverRows = [mail('s4', 40), mail('s3', 30), mail('s2', 20), mail('s1', 10)]; + const server = side(serverRows); + const local = side([mail('l5', 50)]); + const page = pager(server, local, 2); + await page(0); + await page(2); + serverRows.unshift(mail('new', 60)); + const refreshed = await page(0); + expect(refreshed.total).toBe(6); + // The caller's list grew by one: l5 s4 s3 s2 plus the new row. + expect(ids(await page(5))).toEqual(['s1']); + }); + }); + + describe('how long a cursor lasts', () => { + function search(merge: LocalSearchMerge, key: string, position: number, rows: ReturnType, local: ReturnType) { + return merge.run({ key, limit: 1, position, server: (at) => rows.answer(at, 1), local: (at) => local.answer(at, 1) }); + } + const other = () => [side([mail('o', 1)]), side([])] as const; + + it('keeps the search in use when many others come and go', async () => { + const merge = new LocalSearchMerge(); + const server = side([mail('s2', 20), mail('s1', 10)]); + const local = side([mail('l3', 30)]); + await search(merge, 'mine', 0, server, local); + for (let i = 0; i < 60; i += 1) await search(merge, `other-${i}`, 0, ...other()); + await search(merge, 'mine', 1, server, local); + for (let i = 60; i < 120; i += 1) await search(merge, `other-${i}`, 0, ...other()); + server.asked.length = 0; + expect(ids(await search(merge, 'mine', 2, server, local))).toEqual(['s1']); + // The cursor was there: no walking from the start again. + expect(server.asked).toEqual([1]); + }); + + it('still returns the right rows once a cursor is gone', async () => { + const merge = new LocalSearchMerge(); + const server = side([mail('s2', 20), mail('s1', 10)]); + const local = side([mail('l3', 30)]); + await search(merge, 'mine', 0, server, local); + for (let i = 0; i < 70; i += 1) await search(merge, `other-${i}`, 0, ...other()); + expect(ids(await search(merge, 'mine', 1, server, local))).toEqual(['s2']); + }); + }); + + it('counts a row both sides have once in total', async () => { + const server = side([mail('s1', 10)]); + const local = side([mail('s1', 10)]); + const result = await pager(server, local, 5)(0); + expect(result.total).toBe(1); + }); + + it('keeps searches apart by key', async () => { + const merge = new LocalSearchMerge(); + const server = (rows: Email[]) => async (at: number) => ({ emails: rows.slice(at, at + 1), total: rows.length, hasMore: at + 1 < rows.length }); + const a = { key: 'a', limit: 1, server: server([mail('a2', 20), mail('a1', 10)]), local: async () => ({ emails: [mail('la', 30)], total: 1, hasMore: false }) }; + const b = { key: 'b', limit: 1, server: server([mail('b1', 10)]), local: async () => null }; + expect(ids(await merge.run({ ...a, position: 0 }))).toEqual(['la']); + expect(ids(await merge.run({ ...b, position: 0 }))).toEqual(['b1']); + expect(ids(await merge.run({ ...a, position: 1 }))).toEqual(['a2']); + }); +}); + +describe('rows only the store has', () => { + function run( + serverRows: Email[], + localRows: Email[], + verify: (rows: Email[]) => Promise, + limit = 5, + ) { + const server = side(serverRows); + const local = side(localRows); + return new LocalSearchMerge().run({ + key: 'k', limit, position: 0, + server: (at) => server.answer(at, limit), + local: (at) => local.answer(at, limit), + verify, + }); + } + + it('drops what the server no longer has, and fills the page with the rest', async () => { + const verify = vi.fn(async (rows: Email[]) => rows.filter((row) => row.id !== 'deleted')); + const result = await run([mail('s1', 10)], [mail('deleted', 30), mail('kept', 20)], verify); + expect(ids(result)).toEqual(['kept', 's1']); + expect(result.total).toBe(2); + expect(verify.mock.calls[0][0].map((row) => row.id)).toEqual(['deleted', 'kept']); + }); + + it('shows the current copy of what the server still has', async () => { + const result = await run([], [mail('kept', 20, { subject: 'old', keywords: {} })], async () => [mail('kept', 20, { subject: 'current', keywords: { $seen: true } })]); + expect(result.emails[0].subject).toBe('current'); + expect(result.emails[0].keywords).toEqual({ $seen: true }); + }); + + it('marks them, so nothing takes them for new mail', async () => { + const result = await run([mail('s', 30)], [mail('s', 30), mail('kept', 20)], async (rows) => rows); + expect(result.emails.map((email) => email.foundLocally ?? false)).toEqual([false, true]); + }); + + it('does not ask when the store has nothing of its own', async () => { + const verify = vi.fn(async (rows: Email[]) => rows); + await run([mail('both', 10)], [mail('both', 10)], verify); + expect(verify).not.toHaveBeenCalled(); + }); + + it('does not ask about a row too old for this page', async () => { + const verify = vi.fn(async (rows: Email[]) => rows); + await run([mail('s3', 30), mail('s2', 20), mail('s1', 10)], [mail('old', 5)], verify, 2); + expect(verify).not.toHaveBeenCalled(); + }); + + it('keeps them when the check fails', async () => { + const result = await run([mail('s1', 10)], [mail('kept', 20)], async () => { throw new Error('offline again'); }); + expect(ids(result)).toEqual(['kept', 's1']); + }); + + it('keeps them, unchecked, when the search itself failed', async () => { + const verify = vi.fn(async () => []); + const result = await new LocalSearchMerge().run({ + key: 'k', limit: 5, position: 0, + server: async () => { throw new Error('server broke'); }, + local: async () => ({ emails: [mail('kept', 20)], total: 1, hasMore: false }), + verify, + }); + expect(ids(result)).toEqual(['kept']); + expect(verify).not.toHaveBeenCalled(); + }); +}); + +describe('mailboxesFitFilter', () => { + const inbox = { inbox: true }; + it.each([ + [{ inMailbox: 'inbox' }, inbox, true], + [{ inMailbox: 'inbox' }, { trash: true }, false], + [{ inMailbox: 'inbox' }, { inbox: false }, false], + [{ inMailbox: 'inbox' }, undefined, false], + [{ inMailboxOtherThan: ['trash', 'junk'] }, inbox, true], + [{ inMailboxOtherThan: ['trash', 'junk'] }, { trash: true }, false], + [{ inMailboxOtherThan: ['trash', 'junk'] }, { trash: true, inbox: true }, true], + [{ text: 'x' }, { trash: true }, true], + [{ operator: 'AND', conditions: [{ inMailbox: 'inbox' }, { text: 'x' }] }, { trash: true }, false], + [{ operator: 'AND', conditions: [{ inMailbox: 'inbox' }, { text: 'x' }] }, inbox, true], + [{ operator: 'OR', conditions: [{ inMailbox: 'a' }, { inMailbox: 'b' }] }, { b: true }, true], + [{ operator: 'OR', conditions: [{ inMailbox: 'a' }, { inMailbox: 'b' }] }, inbox, false], + [{ operator: 'OR', conditions: [{ inMailbox: 'a' }, { text: 'x' }] }, inbox, true], + [{ operator: 'NOT', conditions: [{ inMailbox: 'trash' }] }, inbox, true], + [{ operator: 'NOT', conditions: [{ inMailbox: 'trash' }] }, { trash: true }, false], + [{ operator: 'NOT', conditions: [{ text: 'x' }] }, inbox, true], + [{ inMailbox: 'trash', text: 'x' }, inbox, false], + ])('%j with %j is %s', (filter, boxes, expected) => { + expect(mailboxesFitFilter(filter, boxes as Record | undefined)).toBe(expected); + }); +}); + +describe('errors', () => { + it('leaves no unhandled rejection when the store fails while the server is still working', async () => { + const unhandled: unknown[] = []; + const note = (reason: unknown) => { unhandled.push(reason); }; + process.on('unhandledRejection', note); + try { + const result = await new LocalSearchMerge().run({ + key: 'k', limit: 10, position: 0, + server: async () => { + await new Promise((resolve) => setTimeout(resolve, 30)); + return { emails: [mail('s', 1)], total: 1, hasMore: false }; + }, + local: async () => { throw new Error('store broke'); }, + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(ids(result)).toEqual(['s']); + expect(unhandled).toEqual([]); + } finally { + process.off('unhandledRejection', note); + } + }); + + it('keeps the list open when the server fails on a later page', async () => { + let fail = false; + const rows = side([mail('s4', 40), mail('s3', 30), mail('s2', 20), mail('s1', 10)]); + const local = side([mail('l5', 50), mail('l25', 25)]); + const merge = new LocalSearchMerge(); + const run = (position: number) => merge.run({ + key: 'k', limit: 2, position, + server: async (at) => { + if (fail) throw new Error('server broke'); + return rows.answer(at, 2); + }, + local: (at) => local.answer(at, 2), + }); + expect((await run(0)).hasMore).toBe(true); + fail = true; + const during = await run(2); + expect(ids(during)).toEqual(['l25']); + // Local rows ran out, but the server said it had more: the list isn't over. + expect(during.hasMore).toBe(true); + fail = false; + expect(ids(await run(3))).toEqual(['s3', 's2']); + }); + + it('keeps the server result when the store fails', async () => { + const merge = new LocalSearchMerge(); + const result = await merge.run({ + key: 'k', limit: 10, position: 0, + server: async () => ({ emails: [mail('s', 1)], total: 1, hasMore: false }), + local: async () => { throw new Error('store broke'); }, + }); + expect(ids(result)).toEqual(['s']); + }); + + it('survives a store that fails before it can return a promise', async () => { + const merge = new LocalSearchMerge(); + const result = await merge.run({ + key: 'k', limit: 10, position: 0, + server: async () => ({ emails: [mail('s', 1)], total: 1, hasMore: false }), + local: () => { throw new Error('store broke'); }, + }); + expect(ids(result)).toEqual(['s']); + }); + + it('keeps the local result when the server fails', async () => { + const merge = new LocalSearchMerge(); + const result = await merge.run({ + key: 'k', limit: 10, position: 0, + server: async () => { throw new Error('server broke'); }, + local: async () => ({ emails: [mail('l', 1)], total: 1, hasMore: false }), + }); + expect(ids(result)).toEqual(['l']); + expect(result.total).toBe(1); + expect(result.hasMore).toBe(false); + }); + + it('asks the server again on the next page after it failed', async () => { + let fail = true; + const server = side([mail('s1', 10)]); + const merge = new LocalSearchMerge(); + const run = (position: number) => merge.run({ + key: 'k', limit: 1, position, + server: async (at) => { + if (fail) throw new Error('server broke'); + return server.answer(at, 1); + }, + local: async (at) => side([mail('l2', 20), mail('l1', 5)]).answer(at, 1), + }); + expect(ids(await run(0))).toEqual(['l2']); + fail = false; + expect(ids(await run(1))).toEqual(['s1']); + }); + + it('keeps the server error when the store has nothing to show', async () => { + const failing = async () => { throw new Error('server broke'); }; + await expect(new LocalSearchMerge().run({ + key: 'k', limit: 10, position: 0, server: failing, local: async () => ({ emails: [], total: 0, hasMore: false }), + })).rejects.toThrow('server broke'); + await expect(new LocalSearchMerge().run({ + key: 'k', limit: 10, position: 0, server: failing, local: async () => null, + })).rejects.toThrow('server broke'); + await expect(new LocalSearchMerge().run({ + key: 'k', limit: 10, position: 0, server: failing, local: async () => { throw new Error('store broke too'); }, + })).rejects.toThrow('server broke'); + }); + + it('keeps the error of a later page, so the user can try again', async () => { + let fail = false; + const merge = new LocalSearchMerge(); + const run = (position: number) => merge.run({ + key: 'k', limit: 1, position, + server: async (at) => { + if (fail) throw new Error('server broke'); + return side([mail('s2', 20), mail('s1', 10)]).answer(at, 1); + }, + local: async (at) => side([mail('l3', 30)]).answer(at, 1), + }); + expect(ids(await run(0))).toEqual(['l3']); + fail = true; + await expect(run(1)).rejects.toThrow('server broke'); + fail = false; + expect(ids(await run(1))).toEqual(['s2']); + }); + + describe('with a store that never answers', () => { + beforeEach(() => { vi.useFakeTimers(); }); + afterEach(() => { vi.useRealTimers(); }); + + it('counts the grace period from when the server answered', async () => { + const merge = new LocalSearchMerge(); + let local: (value: SearchEmailsResult) => void = () => {}; + const answer = merge.run({ + key: 'k', limit: 10, position: 0, + server: async () => { + await new Promise((resolve) => setTimeout(resolve, 5_000)); + return { emails: [mail('s', 1)], total: 1, hasMore: false }; + }, + local: () => new Promise((resolve) => { local = resolve; }), + }); + await vi.advanceTimersByTimeAsync(5_000); + // The store answers soon after a slow server, and is still in time. + await vi.advanceTimersByTimeAsync(500); + local({ emails: [mail('l', 2)], total: 1, hasMore: false }); + expect(ids(await answer)).toEqual(['l', 's']); + }); + + it('waits only as long as it was told to', async () => { + const merge = new LocalSearchMerge({ graceMs: 200 }); + let done = false; + const answer = merge.run({ + key: 'k', limit: 10, position: 0, + server: async () => ({ emails: [mail('s', 1)], total: 1, hasMore: false }), + local: () => new Promise(() => {}), + }).then((result) => { + done = true; + return result; + }); + await vi.advanceTimersByTimeAsync(199); + expect(done).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(ids(await answer)).toEqual(['s']); + }); + + it('does not hold the server result back for long', async () => { + const merge = new LocalSearchMerge(); + const answer = merge.run({ + key: 'k', limit: 10, position: 0, + server: async () => ({ emails: [mail('s', 1)], total: 1, hasMore: false }), + local: () => new Promise(() => {}), + }); + await vi.advanceTimersByTimeAsync(1_000); + expect(ids(await answer)).toEqual(['s']); + }); + + it('gives the server error once it has waited for the store', async () => { + const merge = new LocalSearchMerge(); + const answer = merge.run({ + key: 'k', limit: 10, position: 0, + server: async () => { throw new Error('server broke'); }, + local: () => new Promise(() => {}), + }); + const failed = expect(answer).rejects.toThrow('server broke'); + await vi.advanceTimersByTimeAsync(5_000); + await failed; + }); + }); +}); + +describe('filterHasTextTerms', () => { + it('is true for searches for text, in any shape', () => { + for (const filter of [ + { text: 'x' }, { from: 'x' }, { to: 'x' }, { cc: 'x' }, { bcc: 'x' }, { subject: 'x' }, { body: 'x' }, + { operator: 'AND', conditions: [{ inMailbox: 'i' }, { text: 'x' }] }, + { operator: 'OR', conditions: [{ to: 'x' }, { cc: 'x' }] }, + { operator: 'AND', conditions: [{ operator: 'OR', conditions: [{ hasKeyword: 'a' }, { body: 'x' }] }] }, + ]) expect(filterHasTextTerms(filter), JSON.stringify(filter)).toBe(true); + }); + + it('is false when nothing is searched for', () => { + for (const filter of [ + {}, { inMailbox: 'i' }, { hasKeyword: '$seen' }, { text: '' }, { text: ' ' }, + { operator: 'AND', conditions: [{ inMailbox: 'i' }, { hasAttachment: true }] }, + { operator: 'NOT', conditions: [{ text: 'x' }] }, + { operator: 'AND', conditions: [] }, + ]) expect(filterHasTextTerms(filter), JSON.stringify(filter)).toBe(false); + }); +}); + +describe('through the client, with a real store', () => { + const SERVER = 'https://mail.example.org'; + const SESSION = { + apiUrl: `${SERVER}/jmap/`, + downloadUrl: `${SERVER}/jmap/download/{accountId}/{blobId}/{name}?accept={type}`, + uploadUrl: `${SERVER}/jmap/upload/{accountId}/`, + accounts: { [JMAP_ACCOUNT]: { name: 'alice', isPersonal: true, accountCapabilities: {} } }, + primaryAccounts: { 'urn:ietf:params:jmap:mail': JMAP_ACCOUNT }, + capabilities: { 'urn:ietf:params:jmap:core': { maxObjectsInGet: 500 } }, + username: 'alice@example.org', + }; + + let serverRows: Array>; + let serverFails: boolean; + let requests: Array, string]>>; + /** What Email/get by id finds on the server, past the search's own rows. */ + let serverKnows: Array>; + + function fakeServerFetch() { + return vi.fn(async (url: string, init?: RequestInit) => { + if (String(url).endsWith('/.well-known/jmap')) return new Response(JSON.stringify(SESSION), { status: 200 }); + const { methodCalls } = JSON.parse(String(init?.body)) as { methodCalls: Array<[string, Record, string]> }; + requests.push(methodCalls); + if (serverFails) return new Response('broken', { status: 500 }); + const [firstName, firstArgs, firstId] = methodCalls[0]; + if (firstName === 'Email/get' && Array.isArray(firstArgs.ids)) { + const wanted = firstArgs.ids as string[]; + const list = [...serverRows, ...serverKnows].filter((row) => wanted.includes(row.id as string)); + return new Response(JSON.stringify({ + methodResponses: [[firstName, { list, notFound: wanted.filter((id) => !list.some((row) => row.id === id)) }, firstId]], + }), { status: 200 }); + } + const position = (methodCalls[0][1].position as number) ?? 0; + const limit = methodCalls[0][1].limit as number; + const page = serverRows.slice(position, position + limit); + return new Response(JSON.stringify({ + methodResponses: methodCalls.map(([name, , id]) => { + if (name === 'Email/query') return [name, { ids: page.map((row) => row.id), total: serverRows.length, position }, id]; + if (name === 'Email/get') return [name, { list: page, notFound: [] }, id]; + return [name, { list: [], notFound: [] }, id]; + }), + }), { status: 200 }); + }); + } + + async function storeEngine() { + const fake = new FakeJmap(); + fake.addEmail({ id: 'both', subject: 'Stromrechnung', receivedAt: '2026-10-05T00:00:00Z' }); + fake.addEmail({ id: 'only-local', subject: 'Zahlungserinnerungsrechnung', receivedAt: '2026-10-03T00:00:00Z', mailboxIds: { inbox: true } }); + fake.addEmail({ id: 'not-a-hit', subject: 'Hallo', receivedAt: '2026-10-02T00:00:00Z' }); + const engine = new AccountEngine({ + account: 'a@fake.test', + db: openMemoryStore(), + session: FAKE_SESSION, + transport: fake, + areas: [mailArea], + scope: { ...DEFAULT_SCOPE, days: null }, + now: () => Date.UTC(2026, 10, 3), + sleep: async () => {}, + }); + await engine.requestSync(); + return engine; + } + + function hooksWith(searchLocal: OfflineHooks['searchLocal'], skipNetwork = false): OfflineHooks { + return { + skipNetwork: () => skipNetwork, + answer: async () => null, + record: () => {}, + noteNetwork: () => {}, + serverGone: async () => false, + blob: async () => null, + recordBlob: () => {}, + holdUpload: async () => null, + searchLocal, + }; + } + + async function connected(): Promise { + const client = new JMAPClient(SERVER, 'alice@example.org', 'pw'); + await client.connect(); + return client; + } + + beforeEach(() => { + serverFails = false; + requests = []; + serverKnows = [ + { id: 'only-local', subject: 'Zahlungserinnerungsrechnung', receivedAt: '2026-10-03T00:00:00Z', mailboxIds: { inbox: true } }, + ]; + serverRows = [ + { id: 'newer', subject: 'Rechnung', receivedAt: '2026-10-06T00:00:00Z', mailboxIds: { inbox: true } }, + { id: 'both', subject: 'Stromrechnung, server copy', receivedAt: '2026-10-05T00:00:00Z', mailboxIds: { inbox: true } }, + ]; + vi.stubGlobal('fetch', fakeServerFetch()); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + setOfflineHooks(null); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('merges what the store finds into searchEmails, with its highlights', async () => { + const engine = await storeEngine(); + setOfflineHooks(hooksWith((_client, calls) => engine.answer(calls))); + const client = await connected(); + const result = await client.searchEmails('rechnung'); + expect(result.emails.map((email) => email.id)).toEqual(['newer', 'both', 'only-local']); + expect(result.emails[1].subject).toBe('Stromrechnung, server copy'); + expect(result.emails[2].searchSnippet?.subject).toBe('Zahlungserinnerungsrechnung'); + expect(result.total).toBe(3); + expect(result.hasMore).toBe(false); + }); + + it('drops a row deleted on the server since the store last synced', async () => { + const engine = await storeEngine(); + setOfflineHooks(hooksWith((_client, calls) => engine.answer(calls))); + serverKnows = []; + const client = await connected(); + const result = await client.searchEmails('rechnung'); + expect(result.emails.map((email) => email.id)).toEqual(['newer', 'both']); + expect(result.total).toBe(2); + }); + + it('drops a row moved out of the folder searched', async () => { + const engine = await storeEngine(); + setOfflineHooks(hooksWith((_client, calls) => engine.answer(calls))); + serverKnows = [{ id: 'only-local', subject: 'x', receivedAt: '2026-10-03T00:00:00Z', mailboxIds: { trash: true } }]; + const client = await connected(); + const inFolder = await client.searchEmails('rechnung', 'inbox'); + expect(inFolder.emails.map((email) => email.id)).toEqual(['newer', 'both']); + const everywhere = await client.searchEmails('rechnung'); + expect(everywhere.emails.map((email) => email.id)).toEqual(['newer', 'both', 'only-local']); + const notInTrash = await client.advancedSearchEmails({ operator: 'AND', conditions: [{ text: 'rechnung' }, { inMailboxOtherThan: ['trash'] }] }); + expect(notInTrash.emails.map((email) => email.id)).toEqual(['newer', 'both']); + }); + + it('searches the store without snippets, and asks only for those of its own rows', async () => { + const engine = await storeEngine(); + const asked: string[][] = []; + const snippetIds: unknown[] = []; + setOfflineHooks(hooksWith((_client, calls) => { + asked.push(calls.map(([name]) => name)); + for (const [name, args] of calls) if (name === 'SearchSnippet/get') snippetIds.push(args.emailIds); + return engine.answer(calls); + })); + const client = await connected(); + await client.searchEmails('rechnung'); + expect(asked).toEqual([['Email/query', 'Email/get'], ['SearchSnippet/get']]); + expect(snippetIds).toEqual([['only-local']]); + }); + + it('adds the snippets of its own rows to the deferred ones', async () => { + const engine = await storeEngine(); + setOfflineHooks(hooksWith((_client, calls) => engine.answer(calls))); + const client = await connected(); + const result = await client.searchEmails('rechnung', undefined, undefined, 50, 0, { deferSnippets: true }); + expect(result.emails.every((email) => !email.searchSnippet)).toBe(true); + const snippets = await result.snippets; + expect(snippets?.find((s) => s.emailId === 'only-local')?.subject).toBe('Zahlungserinnerungsrechnung'); + }); + + it('keeps the palette from pushing out the paging of the mail list', async () => { + const engine = await storeEngine(); + setOfflineHooks(hooksWith((_client, calls) => engine.answer(calls))); + const client = await connected(); + await client.searchEmails('rechnung', undefined, undefined, 2); + for (let i = 0; i < 100; i += 1) await client.searchEmails(`wort${i}`, undefined, undefined, 2, 0, { palette: true }); + requests = []; + await client.searchEmails('rechnung', undefined, undefined, 2, 2); + // With its cursor, the next page starts where the server left off, and nothing is walked again. + const queries = requests.flatMap((calls) => calls.filter(([name]) => name === 'Email/query')); + expect(queries.map(([, args]) => args.position)).toEqual([2]); + }); + + it('waits less for the store in the palette', async () => { + setOfflineHooks(hooksWith(() => new Promise(() => {}))); + const client = await connected(); + const started = Date.now(); + const result = await client.searchEmails('rechnung', undefined, undefined, 50, 0, { palette: true }); + expect(result.emails.length).toBe(2); + expect(Date.now() - started).toBeLessThan(900); + }); + + it('merges into advancedSearchEmails too, and leaves the server request as it was', async () => { + const engine = await storeEngine(); + setOfflineHooks(hooksWith((_client, calls) => engine.answer(calls))); + const client = await connected(); + const filter = { operator: 'AND', conditions: [{ inMailbox: 'inbox' }, { text: 'rechnung' }] }; + const result = await client.advancedSearchEmails(filter); + expect(result.emails.map((email) => email.id)).toEqual(['newer', 'both', 'only-local']); + const query = requests[0][0]; + expect(query[0]).toBe('Email/query'); + expect(query[1]).toMatchObject({ filter, limit: 50, position: 0, calculateTotal: true, sort: [{ property: 'receivedAt', isAscending: false }] }); + }); + + it('shows what the store found when the server fails', async () => { + const engine = await storeEngine(); + setOfflineHooks(hooksWith((_client, calls) => engine.answer(calls))); + const client = await connected(); + serverFails = true; + const result = await client.searchEmails('rechnung'); + expect(result.emails.map((email) => email.id).sort()).toEqual(['both', 'only-local']); + }); + + it('shows what the server found when the store fails', async () => { + setOfflineHooks(hooksWith(async () => { throw new Error('store broke'); })); + const client = await connected(); + const result = await client.searchEmails('rechnung'); + expect(result.emails.map((email) => email.id)).toEqual(['newer', 'both']); + }); + + it('changes nothing when the store is off for the account', async () => { + const searchLocal = vi.fn(async () => null); + setOfflineHooks(hooksWith(searchLocal)); + const client = await connected(); + const result = await client.searchEmails('rechnung'); + expect(searchLocal).toHaveBeenCalledTimes(1); + expect(result).toEqual({ emails: expect.any(Array), total: 2, hasMore: false }); + expect(result.emails.map((email) => email.id)).toEqual(['newer', 'both']); + expect(requests.length).toBe(1); + }); + + it('changes nothing without a store', async () => { + setOfflineHooks(hooksWith(undefined)); + const client = await connected(); + const result = await client.searchEmails('rechnung', undefined, undefined, 50, 7); + expect(requests[0][0][1].position).toBe(7); + expect(result.total).toBe(2); + }); + + it('leaves filters without text to the server', async () => { + const searchLocal = vi.fn(async () => null); + setOfflineHooks(hooksWith(searchLocal)); + const client = await connected(); + await client.advancedSearchEmails({ inMailbox: 'inbox' }); + await client.advancedSearchEmails({}); + expect(searchLocal).not.toHaveBeenCalled(); + }); + + it('does not ask twice when the device is answering anyway', async () => { + const searchLocal = vi.fn(async () => null); + const hooks = hooksWith(searchLocal, true); + hooks.answer = async (_client, calls) => ({ + methodResponses: calls.map(([name, , id]) => (name === 'Email/query' + ? [name, { ids: [], total: 0, position: 0 }, id] + : [name, { list: [], notFound: [] }, id])), + }); + setOfflineHooks(hooks); + const client = await connected(); + await client.searchEmails('rechnung'); + expect(searchLocal).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 377e61525..9b28a08eb 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -94,6 +94,11 @@ export interface SearchRequestOptions { * where the list must not wait for the highlighting. */ deferSnippets?: boolean; + /** + * For the search palette. It searches on every few keystrokes, so it keeps its + * paging apart from the mail list's, and waits less for the local store. + */ + palette?: boolean; } export interface SearchEmailsResult { @@ -635,6 +640,8 @@ export interface IJMAPClient { options?: CalendarEventUpdateOptions, ): Promise; deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise; + /** Whether the last request was answered from this device instead of the server. */ + answeredFromDevice?(): boolean; batchDeleteCalendarEvents(eventIds: string[], targetAccountId?: string): Promise<{ destroyed: string[]; notDestroyed: Record }>; queryCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number, targetAccountId?: string, options?: CalendarQueryOptions): Promise; queryAllCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number, options?: CalendarQueryOptions): Promise; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 5536ac710..1327f0aec 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -4,11 +4,13 @@ import { IncompleteDeleteError } from "./delete-error"; import { downloadAllowed, saveBlobAs, type DownloadKind } from "@/lib/download-guard"; import { ArchiveEmailsError } from "./archive-error"; import { generateUUID } from '@/lib/utils'; +import { standInsIn, withRealIds, withStandInsBack } from './stand-in-ids'; import type { Attachment, Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, ShareNotification, BusyPeriod, CalendarParticipantIdentity, CalendarEventNotification, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarComponentType, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, CreateCalendarOptions, FileNode, FileNodeFilter, FileNodeRights, Principal, PrincipalAvailability, PrincipalListing, PushSubscription, EmailPushConfig, DeliveryStatus, EmailSubmission, RejectedRecipient, ScheduledEmail, SendEmailResult, SharedAccount } from "./types"; import type { SieveScript, SieveCapabilities } from "./sieve-types"; import type { BlobBatch, CalendarEventUpdateOptions, CalendarQueryOptions, FileNodeListings, IJMAPClient, KeywordDiscoveryResult, KeywordInfo, KeywordMigration, SearchEmailsResult, SearchRequestOptions, UploadedEmailImport, UploadedEmailImportResult } from "./client-interface"; import { attachSearchSnippets, filterHasSnippetTerms, snippetFilterFor, type SearchSnippetResult } from "@/lib/search-snippet"; import { textConditions, textFilter } from "./search-utils"; +import { filterHasTextTerms, LocalSearchMerge, mailboxesFitFilter } from "./search-merge"; import { batched, batchedBySize, itemsPerRequest } from "./request-limits"; import { keywordPointer, pointerToken } from "./patch-pointer"; import { FirstTouchGate } from "./first-touch-gate"; @@ -1638,6 +1640,8 @@ export class JMAPClient implements IJMAPClient { * can be reached again (a reload signs in as usual). */ private offlineOnly = false; + private readonly searchMerge = new LocalSearchMerge(); + private readonly paletteSearchMerge = new LocalSearchMerge({ graceMs: 200 }); constructor(serverUrl: string, username: string, password: string) { this.serverUrl = serverUrl.replace(/\/$/, ''); @@ -2214,9 +2218,30 @@ export class JMAPClient implements IJMAPClient { const offline = getOfflineHooks(); if (this.offlineOnly || offline?.skipNetwork(this)) { const local = offline ? await offline.answer(this, methodCalls) : null; - if (local) return local as JMAPResponse; + if (local) { + this.lastAnswerFromDevice = true; + return local as JMAPResponse; + } throw new OfflineUnavailableError(); } + // A stand-in id from an event created offline, still held by an open dialog + // or a list: the server knows it by its real id, or not yet at all. + const standIns = offline?.resolveStandIns ? standInsIn(methodCalls) : []; + let realIds: Record | null = null; + if (standIns.length > 0) { + const resolved = await offline!.resolveStandIns!(this, standIns); + if (standIns.some((id) => !resolved[id])) { + const local = await offline!.answer(this, methodCalls); + if (local) { + this.lastAnswerFromDevice = true; + return local as JMAPResponse; + } + throw new OfflineUnavailableError(); + } + realIds = resolved as Record; + methodCalls = withRealIds(methodCalls, realIds); + } + const asAsked = (response: JMAPResponse) => (realIds ? withStandInsBack(response, realIds) : response); let answer: JMAPResponse; try { answer = await this.networkRequest(methodCalls, using, opts); @@ -2225,15 +2250,25 @@ export class JMAPClient implements IJMAPClient { // headers looks the same to the browser and must stay an error. if (offline && isNetworkFailure(error) && await offline.serverGone(this)) { const local = await offline.answer(this, methodCalls); - if (local) return local as JMAPResponse; + if (local) { + this.lastAnswerFromDevice = true; + return asAsked(local as JMAPResponse); + } } throw error; } + this.lastAnswerFromDevice = false; if (offline) { offline.noteNetwork(this, true); if (!opts?.skipOfflineRecord) offline.record(this, methodCalls, answer); } - return answer; + return asAsked(answer); + } + + private lastAnswerFromDevice = false; + + answeredFromDevice(): boolean { + return this.lastAnswerFromDevice; } private async networkRequest(methodCalls: JMAPMethodCall[], using?: string[], opts?: { transfer?: boolean }): Promise { @@ -4411,61 +4446,144 @@ export class JMAPClient implements IJMAPClient { } async searchEmails(query: string, mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, options: SearchRequestOptions = {}): Promise { + // The JMAP "text" filter searches from, to, cc, bcc, subject and body. + // Quoted phrases and `word*` prefixes go out as conditions of their + // own (see splitSearchText). inMailbox joins them in the same AND: a + // FilterOperator cannot also carry property keys. + const filter: Record = mailboxId + ? { operator: "AND", conditions: [{ inMailbox: mailboxId }, ...textConditions(query)] } + : textFilter(query); + return this.searchWithLocal(filter, accountId, limit, position, options); + } + + /** + * A search on the server, merged with what the local store finds (see + * search-merge.ts). Without a store for this client, or when the request + * would be answered on this device anyway, it's the server's answer alone. + */ + private searchWithLocal( + filter: Record, + accountId: string | undefined, + limit: number, + position: number, + options: SearchRequestOptions, + ): Promise { + const server = (at: number) => this.serverSearch(filter, accountId, limit, at, options); + const hooks = getOfflineHooks(); + const searchLocal = hooks?.searchLocal; + if (!searchLocal || this.offlineOnly || hooks.skipNetwork(this) || !filterHasTextTerms(filter)) { + return server(position); + } + const targetAccountId = accountId || this.accountId; + const merge = options.palette ? this.paletteSearchMerge : this.searchMerge; + return merge.run({ + key: JSON.stringify([accountId ?? null, filter]), + limit, + position, + server, + local: async (at) => { + // No snippets here: excerpting every hit is the slow part, and most of + // them are the server's to show. See localSnippets. + const answer = await searchLocal.call(hooks, this, this.searchCalls(targetAccountId, filter, limit, at, true)); + return answer ? this.searchResultFrom(answer as JMAPResponse, accountId, limit, at) : null; + }, + verify: (rows) => this.currentSearchRows(rows, filter, accountId), + localSnippets: async (ids) => { + if (!filterHasSnippetTerms(filter)) return null; + const answer = await searchLocal.call(hooks, this, [ + ["SearchSnippet/get", { accountId: targetAccountId, filter: snippetFilterFor(filter), emailIds: ids }, "snippets"], + ]); + return answer ? this.searchSnippetsFrom(answer as JMAPResponse) ?? null : null; + }, + deferSnippets: options.deferSnippets, + }); + } + + /** + * The server's copies of rows only the local store found, without those it + * no longer has or has outside the filter's folders. The filter is checked + * before the ids get their shared-account namespace, since it has bare ones. + */ + private async currentSearchRows(rows: Email[], filter: Record, accountId: string | undefined): Promise { + const response = await this.request([ + ["Email/get", { + accountId: accountId || this.accountId, + ids: rows.map((row) => row.id), + properties: [...EMAIL_LIST_PROPERTIES], + }, "0"], + ]); + const [name, result] = response.methodResponses?.[0] ?? []; + if (name !== "Email/get" || !result) throw new Error(methodErrorMessage(response, 'Failed to get emails')); + const list = applyListAuthenticationResults((result.list || []) as Email[]) + .filter((email) => mailboxesFitFilter(filter, email.mailboxIds)); + if (accountId && accountId !== this.accountId) namespaceMailboxIds(list, accountId); + return list; + } + + private searchCalls( + targetAccountId: string, + filter: Record, + limit: number, + position: number, + deferSnippets: boolean | undefined, + ): JMAPMethodCall[] { + // Searching all folders with no criteria yields an empty FilterCondition, + // which servers reject; omit the key entirely to mean "no filter". + const hasFilter = Object.keys(filter).length > 0; + return [ + ["Email/query", { + accountId: targetAccountId, + ...(hasFilter ? { filter } : {}), + sort: [{ property: "receivedAt", isAscending: false }], + limit, + position, + calculateTotal: true, + }, "0"], + ["Email/get", { + accountId: targetAccountId, + "#ids": { resultOf: "0", name: "Email/query", path: "/ids" }, + properties: [...EMAIL_LIST_PROPERTIES], + }, "1"], + ...(deferSnippets ? [] : [this.searchSnippetCall(targetAccountId, hasFilter ? filter : undefined)]), + ]; + } + + private searchResultFrom(response: JMAPResponse, accountId: string | undefined, limit: number, position: number): SearchEmailsResult { + assertQuerySucceeded(response, 'Search'); + + const queryResponse = response.methodResponses?.[0]?.[1]; + const emails = applyListAuthenticationResults((response.methodResponses?.[1]?.[1]?.list || []) as Email[]); + emails.sort((a: Email, b: Email) => + new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime() + ); + const total = queryResponse?.total || 0; + const hasMore = computeHasMore(position, emails.length, total, limit); + attachSearchSnippets(emails, this.searchSnippetsFrom(response)); + + // Mirror getEmails: emails fetched from a delegated/shared account carry + // bare owner mailbox ids; namespace them to `${ownerId}:${id}` so they line + // up with the namespaced ids the store holds for shared mailboxes. The + // cross-account views (All mail / Unread / Starred) browse via + // advancedSearchEmails, so they need this too. (#281 V3) + if (accountId && accountId !== this.accountId) { + namespaceMailboxIds(emails, accountId); + } + return { emails, hasMore, total }; + } + + private async serverSearch( + filter: Record, + accountId: string | undefined, + limit: number, + position: number, + options: SearchRequestOptions, + ): Promise { try { const targetAccountId = accountId || this.accountId; - - // The JMAP "text" filter searches from, to, cc, bcc, subject and body. - // Quoted phrases and `word*` prefixes go out as conditions of their - // own (see splitSearchText). inMailbox joins them in the same AND: a - // FilterOperator cannot also carry property keys. - let filter: Record; - if (mailboxId) { - filter = { - operator: "AND", - conditions: [ - { inMailbox: mailboxId }, - ...textConditions(query), - ], - }; - } else { - filter = textFilter(query); - } - - const response = await this.request([ - ["Email/query", { - accountId: targetAccountId, - filter, - sort: [{ property: "receivedAt", isAscending: false }], - limit, - position, - calculateTotal: true, - }, "0"], - ["Email/get", { - accountId: targetAccountId, - "#ids": { resultOf: "0", name: "Email/query", path: "/ids" }, - properties: [...EMAIL_LIST_PROPERTIES], - }, "1"], - ...(options.deferSnippets ? [] : [this.searchSnippetCall(targetAccountId, filter)]), - ]); - assertQuerySucceeded(response, 'Search'); - - const queryResponse = response.methodResponses?.[0]?.[1]; - const emails = applyListAuthenticationResults((response.methodResponses?.[1]?.[1]?.list || []) as Email[]); - emails.sort((a: Email, b: Email) => - new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime() - ); - const total = queryResponse?.total || 0; - const hasMore = computeHasMore(position, emails.length, total, limit); - attachSearchSnippets(emails, this.searchSnippetsFrom(response)); - - // Mirror getEmails: emails fetched from a delegated/shared account carry - // bare owner mailbox ids; namespace them to `${ownerId}:${id}` so they line - // up with the namespaced ids the store holds for shared mailboxes. (#281 V3) - if (accountId && accountId !== this.accountId) { - namespaceMailboxIds(emails, accountId); - } - - return { emails, hasMore, total, ...this.deferredSnippets(options, targetAccountId, filter, emails) }; + const hasFilter = Object.keys(filter).length > 0; + const response = await this.request(this.searchCalls(targetAccountId, filter, limit, position, options.deferSnippets)); + const result = this.searchResultFrom(response, accountId, limit, position); + return { ...result, ...this.deferredSnippets(options, targetAccountId, hasFilter ? filter : undefined, result.emails) }; } catch (error) { // Rethrow: an empty result would read as "No results found". console.error('Search failed:', error); @@ -4537,52 +4655,7 @@ export class JMAPClient implements IJMAPClient { position: number = 0, options: SearchRequestOptions = {}, ): Promise { - try { - const targetAccountId = accountId || this.accountId; - - // Searching all folders with no criteria yields an empty FilterCondition, - // which servers reject; omit the key entirely to mean "no filter". - const hasFilter = Object.keys(filter).length > 0; - - const response = await this.request([ - ["Email/query", { - accountId: targetAccountId, - ...(hasFilter ? { filter } : {}), - sort: [{ property: "receivedAt", isAscending: false }], - limit, - position, - calculateTotal: true, - }, "0"], - ["Email/get", { - accountId: targetAccountId, - "#ids": { resultOf: "0", name: "Email/query", path: "/ids" }, - properties: [...EMAIL_LIST_PROPERTIES], - }, "1"], - ...(options.deferSnippets ? [] : [this.searchSnippetCall(targetAccountId, hasFilter ? filter : undefined)]), - ]); - assertQuerySucceeded(response, 'Search'); - - const queryResponse = response.methodResponses?.[0]?.[1]; - const emails = applyListAuthenticationResults((response.methodResponses?.[1]?.[1]?.list || []) as Email[]); - emails.sort((a: Email, b: Email) => - new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime() - ); - const total = queryResponse?.total || 0; - const hasMore = computeHasMore(position, emails.length, total, limit); - attachSearchSnippets(emails, this.searchSnippetsFrom(response)); - - // Namespace shared/delegated-account mailbox ids (see searchEmails). The - // cross-account views (All mail / Unread / Starred) browse via this method, - // so without it shared emails would carry bare owner ids there. (#281 V3) - if (accountId && accountId !== this.accountId) { - namespaceMailboxIds(emails, accountId); - } - - return { emails, hasMore, total, ...this.deferredSnippets(options, targetAccountId, hasFilter ? filter : undefined, emails) }; - } catch (error) { - console.error('Advanced search failed:', error); - throw error; - } + return this.searchWithLocal(filter, accountId, limit, position, options); } async getEmailFields( @@ -9437,6 +9510,8 @@ export class JMAPClient implements IJMAPClient { // Strip client-only shared fields before sending to JMAP const { originalId: _oi, baseEventId: _be, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanEvent } = event as CalendarEvent; cleanRecurrenceRules(cleanEvent as unknown as Record); + // Chosen here, not by the server: if the answer is lost, this finds the event again. + cleanEvent.uid ||= generateUUID(); debug.group('CalendarEvent/create', 'calendar'); debug.log('calendar', 'CalendarEvent/create outgoing payload', { @@ -9543,6 +9618,7 @@ export class JMAPClient implements IJMAPClient { for (const { event, index } of batch) { const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...clean } = event as CalendarEvent; cleanRecurrenceRules(clean as unknown as Record); + clean.uid ||= generateUUID(); createMap[`new-${index}`] = clean; } @@ -9967,7 +10043,7 @@ export class JMAPClient implements IJMAPClient { accountId = resolved.accountId; taskData.calendarIds = resolved.calendarIds; } - const cleanTask = { ...completeBareTaskDates(taskData), '@type': 'Task' }; + const cleanTask = { ...completeBareTaskDates(taskData), '@type': 'Task', uid: taskData.uid || generateUUID() }; debug.group('CalendarTask/create', 'tasks'); debug.log('tasks', 'CalendarTask/create accountId', accountId); diff --git a/lib/jmap/offline-hooks.ts b/lib/jmap/offline-hooks.ts index ab0477fc9..ab746f3bc 100644 --- a/lib/jmap/offline-hooks.ts +++ b/lib/jmap/offline-hooks.ts @@ -45,6 +45,18 @@ export interface OfflineHooks { recordBlob(client: OfflineClientRef, blobId: string, accountId: string, blob: Blob): void; /** Keep a file to upload later; the stand-in blob id, or null when offline mode doesn't cover the client. */ holdUpload(client: OfflineClientRef, file: File, accountId: string): Promise<{ blobId: string; size: number; type: string } | null>; + /** + * Search calls answered from the local store while the server is asked too + * (lib/jmap/search-merge.ts). Null when the store doesn't cover the client. + * Left out when there is no store, and searches then go to the server alone. + */ + searchLocal?(client: OfflineClientRef, methodCalls: OfflineMethodCall[]): Promise; + /** + * Real ids for stand-in ids the page still holds (lib/jmap/stand-in-ids.ts), or + * null for one that hasn't reached the server yet. Without it the client sends + * ids as they are. + */ + resolveStandIns?(client: OfflineClientRef, ids: string[]): Promise>; } const KEY = '__bulwarkOfflineHooks'; diff --git a/lib/jmap/search-merge.ts b/lib/jmap/search-merge.ts new file mode 100644 index 000000000..a7200b0f9 --- /dev/null +++ b/lib/jmap/search-merge.ts @@ -0,0 +1,382 @@ +/** + * Online, a search also asks the local store and merges the two answers. The + * store finds what the server's index can't, such as a word inside a German + * compound. This module knows nothing about the store: it merges two lists. + * + * The merge walks both lists newest first, as a search sorts them. When both + * have a message, the server's copy is kept. A page never holds more than + * `limit` rows, so a local row can push a server row to the next page. + * + * A row only the store has may be stale: deleted or moved since the store last + * synced. So the server is asked for those rows before they're shown. What it + * no longer has, or has outside the filter's folders, is dropped, and what it + * still has is shown as its current copy. It takes a request of its own, since + * these ids are only known once both answers are in. If that request fails, or + * the search itself did, the rows stay: an error must not lose results. + * + * Callers page by counting the rows they have, so `position` counts merged + * rows and means nothing to the server once a local row is among them. A cursor + * for each search remembers how far each side was read: + * - Position 0 can be a new search or a refresh of a longer list, and only the + * next page tells which. So it runs on a cursor of its own next to the one + * already there. The next page takes whichever ended closest to its position. + * - With no cursor (it was evicted), the search is walked again from the start + * up to the position. A merged position never goes to the server as it is. + * - When the store has nothing to say for this search, no merged row exists and + * positions are the server's own, so requests pass straight through. + * + * `hasMore` is true while either side has rows this search hasn't returned. + * A local list made only of rows the server also has can therefore cost one + * extra page that comes back empty, and then it's false. When the server fails + * on a later page, what it last said about having more still counts. + * + * `total` is the server's total plus the local-only rows returned so far, and + * never less than the local total or the rows already returned. It can grow + * while paging, because how many local rows the server also has only shows + * when the lists are read. + * + * Local rows older than the last row the server sent wait for the next page: + * they may still be on the server's next page. + * + * An error on one side keeps the other side's rows. When the server fails and + * the store has nothing, the server's error stands, since an empty list would + * read as "no results". A slow store holds up the server's answer for at most + * the grace period, counted from when the server answered. Snippets for local + * rows are asked for separately, so excerpting bodies in the worker doesn't + * hold up the list either. + */ + +import { debug } from '@/lib/debug'; +import { attachSearchSnippets, type SearchSnippetResult } from '@/lib/search-snippet'; +import type { SearchEmailsResult } from './client-interface'; +import type { Email } from './types'; + +const LOCAL_GRACE_MS = 1_000; +const LOCAL_ALONE_MS = 5_000; +const MAX_CURSORS = 64; +/** How far back an evicted search is walked again. Past this, the list ends. */ +const MAX_WALK_PAGES = 20; + +const TEXT_FILTER_KEYS = ['text', 'from', 'to', 'cc', 'bcc', 'subject', 'body']; + +/** + * Whether a filter searches for text. Other filters (a folder, a flag, a date) + * have the same answer on both sides, so only a stale local row could come of + * merging them. Terms under NOT are searched to be absent, so they don't count. + */ +export function filterHasTextTerms(filter: Record): boolean { + if (typeof filter.operator === 'string') { + if (filter.operator === 'NOT' || !Array.isArray(filter.conditions)) return false; + return (filter.conditions as Array>).some(filterHasTextTerms); + } + return TEXT_FILTER_KEYS.some((key) => typeof filter[key] === 'string' && (filter[key] as string).trim().length > 0); +} + +/** True, false, or null when the filter's answer depends on more than the folders. */ +function mailboxAnswer(filter: Record, boxes: readonly string[]): boolean | null { + if (typeof filter.operator === 'string') { + const answers = (Array.isArray(filter.conditions) ? filter.conditions as Array> : []) + .map((condition) => mailboxAnswer(condition, boxes)); + const unknown = answers.includes(null); + if (filter.operator === 'AND') return answers.includes(false) ? false : unknown ? null : true; + const any = answers.includes(true) ? true : unknown ? null : false; + if (filter.operator === 'OR') return any; + if (filter.operator === 'NOT') return any === null ? null : !any; + return null; + } + const answers: boolean[] = []; + if (typeof filter.inMailbox === 'string') answers.push(boxes.includes(filter.inMailbox)); + if (Array.isArray(filter.inMailboxOtherThan)) { + const excluded = filter.inMailboxOtherThan as string[]; + answers.push(boxes.some((box) => !excluded.includes(box))); + } + if (answers.includes(false)) return false; + return answers.length > 0 && Object.keys(filter).length === answers.length ? true : null; +} + +/** Whether a message's folders still fit the filter. Conditions on anything else don't count against it. */ +export function mailboxesFitFilter(filter: Record, mailboxIds: Record | undefined): boolean { + const boxes = Object.entries(mailboxIds ?? {}).filter(([, on]) => on).map(([id]) => id); + return mailboxAnswer(filter, boxes) !== false; +} + +interface MergeCursor { + /** Merged rows returned so far, which is where the next page should ask from. */ + next: number; + serverPosition: number; + localPosition: number; + serverTotal: number; + serverHasMore: boolean; + /** Local-only rows returned so far. */ + extras: number; + /** Local rows the server said are gone. */ + rejected: number; + /** Every row returned so far, so none appears twice. */ + shown: Set; + /** The store has nothing for this search: positions are the server's. */ + passthrough: boolean; +} + +function newCursor(): MergeCursor { + return { + next: 0, serverPosition: 0, localPosition: 0, serverTotal: 0, serverHasMore: false, + extras: 0, rejected: 0, shown: new Set(), passthrough: false, + }; +} + +interface CursorEntry { + cursor: MergeCursor; + /** A position 0 that came after later pages: a new search or a refresh. */ + restarted: MergeCursor | null; +} + +function receivedTime(email: Email): number { + return Date.parse(email.receivedAt) || 0; +} + +interface PageMerge { + emails: Email[]; + serverRead: number; + localRead: number; + extraIds: string[]; +} + +/** One merged page. Rows are added to `shown`. */ +export function mergeSearchPages( + server: readonly Email[], + serverHasMore: boolean, + local: readonly Email[], + limit: number, + shown: Set, +): PageMerge { + const serverIds = new Set(server.map((email) => email.id)); + const emails: Email[] = []; + const extraIds: string[] = []; + let s = 0; + let l = 0; + while (emails.length < limit) { + const fromServer = server[s]; + const fromLocal = local[l]; + if (fromLocal && (serverIds.has(fromLocal.id) || shown.has(fromLocal.id))) { + l += 1; + continue; + } + if (fromServer && shown.has(fromServer.id)) { + s += 1; + continue; + } + if (!fromServer && (!fromLocal || serverHasMore)) break; + if (fromServer && (!fromLocal || receivedTime(fromLocal) <= receivedTime(fromServer))) { + emails.push(fromServer); + shown.add(fromServer.id); + s += 1; + } else { + emails.push({ ...fromLocal, foundLocally: true }); + shown.add(fromLocal.id); + extraIds.push(fromLocal.id); + l += 1; + } + } + return { emails, serverRead: s, localRead: l, extraIds }; +} + +/** The promise's value, or null if it takes longer than `ms`. The promise must not reject. */ +function within(promise: Promise, ms: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => resolve(null), ms); + void promise.then((value) => { + clearTimeout(timer); + resolve(value); + }); + }); +} + +/** Runs a store call with its failure handled right away, so it can't go unhandled while the server is awaited. */ +function safely(what: string, call: () => Promise): Promise { + return Promise.resolve().then(call).catch((error: unknown) => { + debug.warn('jmap', `${what} failed:`, error); + return null; + }); +} + +export interface MergedSearchRequest { + /** The same for every page of one search. */ + key: string; + limit: number; + position: number; + server(position: number): Promise; + /** Null when the store doesn't cover this account. */ + local(position: number): Promise; + /** The server's current copies of these rows, leaving out what's gone or no longer fits the search. */ + verify?(rows: Email[]): Promise; + /** Highlights for rows only the store has. */ + localSnippets?(ids: string[]): Promise; + /** Hand local snippets back as `snippets` instead of waiting for them. */ + deferSnippets?: boolean; +} + +export interface LocalSearchMergeOptions { + /** How long a slow store may hold up the server's answer. */ + graceMs?: number; +} + +export class LocalSearchMerge { + private readonly cursors = new Map(); + private readonly graceMs: number; + + constructor(options: LocalSearchMergeOptions = {}) { + this.graceMs = options.graceMs ?? LOCAL_GRACE_MS; + } + + /** Least recently used goes first, so a search being paged through stays. */ + private remember(key: string, entry: CursorEntry): void { + this.cursors.delete(key); + this.cursors.set(key, entry); + if (this.cursors.size > MAX_CURSORS) this.cursors.delete(this.cursors.keys().next().value as string); + } + + async run(request: MergedSearchRequest): Promise { + const { key, limit, position } = request; + const entry = this.cursors.get(key); + if (position === 0) { + const fresh = newCursor(); + const alongside = entry && entry.cursor.next > 0 ? entry.cursor : null; + this.remember(key, alongside ? { cursor: alongside, restarted: fresh } : { cursor: fresh, restarted: null }); + return this.page(fresh, request, 0, limit, { alongside }); + } + if (!entry) return this.walkTo(request); + this.remember(key, entry); + const cursor = this.choose(entry, position); + if (cursor.passthrough) return request.server(position); + return this.page(cursor, request, position, limit, {}); + } + + /** Which cursor the page after a position 0 continues from. Only that one is kept from then on. */ + private choose(entry: CursorEntry, position: number): MergeCursor { + const { cursor, restarted } = entry; + if (!restarted) return cursor; + entry.restarted = null; + const restartedIsCloser = Math.abs(position - restarted.next) <= Math.abs(position - cursor.next); + if (cursor.passthrough || restartedIsCloser) { + entry.cursor = restarted; + return restarted; + } + for (const id of restarted.shown) cursor.shown.add(id); + return cursor; + } + + /** No cursor: read the search again from the start, so the rows the caller holds are accounted for. */ + private async walkTo(request: MergedSearchRequest): Promise { + const { key, limit, position } = request; + const cursor = newCursor(); + let last: SearchEmailsResult | null = null; + for (let page = 0; cursor.next < position && !cursor.passthrough; page += 1) { + if (page === MAX_WALK_PAGES) return { emails: [], hasMore: false, total: last?.total ?? position }; + last = await this.page(cursor, request, cursor.next, Math.min(limit, position - cursor.next), { walking: true }); + if (last.emails.length === 0 || !last.hasMore) break; + } + this.remember(key, { cursor, restarted: null }); + if (cursor.passthrough) return request.server(position); + // The search ended before the position: the caller holds rows that are gone. + cursor.next = position; + return this.page(cursor, request, position, limit, {}); + } + + private async page( + cursor: MergeCursor, + request: MergedSearchRequest, + position: number, + limit: number, + { alongside = null, walking = false }: { alongside?: MergeCursor | null; walking?: boolean }, + ): Promise { + // A caller that removed rows or added its own asks from a little off + // where the last page ended, and the server's side moves with it. + const serverPosition = Math.max(0, cursor.serverPosition + position - cursor.next); + const localAnswer = safely('Local search', () => request.local(cursor.localPosition)); + + let serverPage: SearchEmailsResult | null = null; + let serverError: unknown = null; + try { + serverPage = await request.server(serverPosition); + } catch (error) { + serverError = error; + } + const localPage = await within(localAnswer, serverPage ? this.graceMs : LOCAL_ALONE_MS); + + if (!serverPage && !localPage?.emails.length) throw serverError; + if (serverPage && !localPage && cursor.extras === 0) { + // Nothing local in this search: the server's answer, untouched. + cursor.passthrough = true; + cursor.next = position + serverPage.emails.length; + return serverPage; + } + + const server = serverPage?.emails ?? []; + const serverHasMore = serverPage?.hasMore ?? false; + const local = serverPage + ? await this.verified(request, cursor, server, serverHasMore, localPage?.emails ?? []) + : localPage?.emails ?? []; + const merged = mergeSearchPages(server, serverHasMore, local, limit, cursor.shown); + + cursor.serverPosition = serverPosition + merged.serverRead; + cursor.localPosition += merged.localRead; + cursor.next = position + merged.emails.length; + cursor.extras += merged.extraIds.length; + if (serverPage) { + cursor.serverTotal = serverPage.total; + cursor.serverHasMore = merged.serverRead < server.length || serverHasMore; + } + + const localLeft = localPage ? merged.localRead < local.length || localPage.hasMore : false; + // A refresh of a longer list counts the local rows already in that list. + const extras = Math.max(cursor.extras, alongside?.extras ?? 0); + const rejected = Math.max(cursor.rejected, alongside?.rejected ?? 0); + const result: SearchEmailsResult = { + ...serverPage, + emails: merged.emails, + hasMore: cursor.serverHasMore || localLeft, + total: Math.max(cursor.serverTotal + extras, (localPage?.total ?? 0) - rejected, cursor.next), + }; + if (!walking && merged.extraIds.length > 0 && request.localSnippets) await this.addLocalSnippets(request, merged, result); + return result; + } + + /** The local rows, with what the server no longer has marked as read past, and the rest as its current copies. */ + private async verified( + request: MergedSearchRequest, + cursor: MergeCursor, + server: readonly Email[], + serverHasMore: boolean, + local: Email[], + ): Promise { + if (!request.verify) return local; + const serverIds = new Set(server.map((email) => email.id)); + const oldestPlaceable = serverHasMore && server.length > 0 ? receivedTime(server[server.length - 1]) : -Infinity; + const candidates = local.filter((row) => !serverIds.has(row.id) && !cursor.shown.has(row.id) && receivedTime(row) >= oldestPlaceable); + if (candidates.length === 0) return local; + let current: Email[]; + try { + current = await request.verify(candidates); + } catch (error) { + debug.warn('jmap', 'Checking local search results failed:', error); + return local; + } + const byId = new Map(current.map((row) => [row.id, row])); + for (const row of candidates) { + if (byId.has(row.id)) continue; + cursor.shown.add(row.id); + cursor.rejected += 1; + } + return local.map((row) => byId.get(row.id) ?? row); + } + + private async addLocalSnippets(request: MergedSearchRequest, merged: PageMerge, result: SearchEmailsResult): Promise { + const asked = safely('Local snippets', () => (request.localSnippets as NonNullable)(merged.extraIds)); + if (!request.deferSnippets) { + attachSearchSnippets(result.emails, (await within(asked, this.graceMs)) ?? undefined); + return; + } + const fromServer = result.snippets; + result.snippets = Promise.all([fromServer, asked]).then(([a, b]) => (a || b ? [...(a ?? []), ...(b ?? [])] : undefined)); + } +} diff --git a/lib/jmap/stand-in-ids.ts b/lib/jmap/stand-in-ids.ts new file mode 100644 index 000000000..00da263e6 --- /dev/null +++ b/lib/jmap/stand-in-ids.ts @@ -0,0 +1,76 @@ +/** + * Stand-in ids: what the local store hands out for an event created offline. + * + * A page can hold one long after the event reached the server: an open dialog, + * a list not refetched yet, a plugin. So the client swaps them for the real ids + * on the way to the network, and swaps them back in the answer, because callers + * look up the outcome under the id they asked about. + */ + +import type { OfflineMethodCall, OfflineResponse } from './offline-hooks'; + +const STAND_IN_PREFIX = 'local-'; + +export function isStandInId(id: string): boolean { + return id.startsWith(STAND_IN_PREFIX); +} + +export function newStandInId(): string { + return `${STAND_IN_PREFIX}${crypto.randomUUID()}`; +} + +/** The stand-in ids a request names. */ +export function standInsIn(calls: readonly OfflineMethodCall[]): string[] { + const found = new Set(); + for (const [name, args] of calls) { + if (name === 'CalendarEvent/set') { + for (const id of Object.keys((args.update ?? {}) as object)) if (isStandInId(id)) found.add(id); + for (const id of (args.destroy ?? []) as string[]) if (isStandInId(id)) found.add(id); + } else if (name === 'CalendarEvent/get') { + for (const id of (args.ids ?? []) as string[]) if (isStandInId(id)) found.add(id); + } + } + return [...found]; +} + +function renamedKeys(map: Record, rename: (id: string) => string): Record { + return Object.fromEntries(Object.entries(map).map(([id, value]) => [rename(id), value])); +} + +/** The request with every stand-in swapped for its real id. */ +export function withRealIds(calls: readonly T[], real: Readonly>): T[] { + const rename = (id: string) => real[id] ?? id; + return calls.map((call) => { + const [name, args, callId] = call; + if (name === 'CalendarEvent/set') { + const next = { ...args }; + if (args.update) next.update = renamedKeys(args.update as Record, rename); + if (args.destroy) next.destroy = (args.destroy as string[]).map(rename); + return [name, next, callId] as unknown as T; + } + if (name === 'CalendarEvent/get' && args.ids) { + return [name, { ...args, ids: (args.ids as string[]).map(rename) }, callId] as unknown as T; + } + return call; + }); +} + +/** The answer as if the caller's ids had gone out. The objects in a get's list keep their real id. */ +export function withStandInsBack(response: T, real: Readonly>): T { + const back = new Map(Object.entries(real).map(([standIn, id]) => [id, standIn])); + const rename = (id: string) => back.get(id) ?? id; + return { + ...response, + methodResponses: response.methodResponses.map(([name, result, callId]) => { + if (name !== 'CalendarEvent/set' && name !== 'CalendarEvent/get') return [name, result, callId]; + const next = { ...result }; + for (const key of ['updated', 'notUpdated', 'notDestroyed']) { + if (next[key]) next[key] = renamedKeys(next[key], rename); + } + for (const key of ['destroyed', 'notFound']) { + if (next[key]) next[key] = next[key].map(rename); + } + return [name, next, callId]; + }), + }; +} diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index 647a57309..6a19f659f 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -51,6 +51,8 @@ export interface Email { // subject / body excerpt with the matched terms in , set on search // hits only. See lib/search-snippet.ts. searchSnippet?: { subject: string | null; preview: string | null }; + /** A search row only the local store found. It's no sign of new mail, whatever its date (lib/jmap/search-merge.ts). */ + foundLocally?: boolean; textBody?: EmailBodyPart[]; htmlBody?: EmailBodyPart[]; bodyValues?: Record; diff --git a/lib/launch-handoff.ts b/lib/launch-handoff.ts new file mode 100644 index 000000000..8f5be39ec --- /dev/null +++ b/lib/launch-handoff.ts @@ -0,0 +1,587 @@ +/** + * Files and links that arrive from outside the app, on their way to the part + * of the app that handles them: a .eml to the viewer, a .ics to the calendar + * import, a .vcf to the contacts import, shared files and links to a new + * draft. + * + * Three things feed it: the launchQueue consumer + * (components/protocol/protocol-launch-handler-provider.tsx), the service + * worker's share target (public/sw.js) and, later, the desktop app's bridge, + * because launchQueue never fires in Electron. A bridge only has to call + * handOffFiles, handOffDraft or handOffNewMessage. + * + * The pending mailto next to it (lib/protocol-handlers/session.ts) is JSON in + * sessionStorage, which can't hold a File. So a handoff lives in this module's + * memory, and everything except a .eml also goes into the Cache API: signing + * in with SSO, or after a session ended, leaves the page, and the file has to + * still be there afterwards. + * + * What is stored is sealed with a key of its own, and the key stays in this + * tab's sessionStorage, so a second window can't take it. The worker passes + * its key in the landing address's fragment. What is stored goes when it is + * taken, after HANDOFF_TTL_MS, when newer handoffs need the room, at sign-out + * and when offline mode wipes. + * + * Anyone can POST to the share target, or link to a landing address, so what + * arrives that way is untrusted: the provider asks the user before it gets + * here (peekStoredHandoff, then adoptStoredHandoff or discardStoredHandoff). + * Chromium's link capturing can bring a cross-site form POST into an installed + * app's window, so that goes for app windows too. Only a bridge skips it. + */ + +import { stripBidiControls } from '@/lib/bidi'; +import { matchSurface } from '@/lib/deep-links'; +import { cleanMailtoBody, cleanMailtoSubject } from '@/lib/protocol-handlers/mailto'; + +export type HandoffKind = 'compose' | 'eml' | 'calendar' | 'contacts'; + +export interface Handoff { + id: string; + kind: HandoffKind; + files: File[]; + /** New draft only. */ + subject?: string; + body?: string; + createdAt: number; +} + +/** The service worker writes this cache too, in the same layout and with the same limits. */ +export const HANDOFF_CACHE = 'bulwark-launch-handoff-v1'; +export const HANDOFF_TTL_MS = 15 * 60 * 1000; +export const HANDOFF_MAX_ENTRIES = 5; +export const HANDOFF_MAX_BYTES = 100 * 1024 * 1024; +const STORE_PREFIX = '/__bulwark/handoff/'; +const TAB_KEYS_KEY = 'bulwark:launch-handoffs'; +/** A launch can arrive twice: as launchQueue's target and as the page's own address. */ +const NEW_MESSAGE_REPEAT_MS = 2000; + +type Listener = (kind: HandoffKind) => void; +export type HandoffLoss = 'expired' | 'missing'; + +const queue: Handoff[] = []; +const stored = new Map>(); +const kindListeners = new Map>(); +const anyListeners = new Set(); +const lossListeners = new Set<(loss: HandoffLoss) => void>(); +let lastNewMessageAt = 0; +let expiryTimer: ReturnType | null = null; +/** Bumped by clearHandoffs: a store still under way when it ran takes itself back. */ +let clearCount = 0; + +function newId(): string { + return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; +} + +function cacheApi(): CacheStorage | null { + return typeof caches !== 'undefined' && caches && typeof caches.open === 'function' ? caches : null; +} + +function readTabKeys(): Record { + try { + const parsed: unknown = JSON.parse(sessionStorage.getItem(TAB_KEYS_KEY) ?? '{}'); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : {}; + } catch { + return {}; + } +} + +function writeTabKeys(keys: Record): void { + try { + if (Object.keys(keys).length > 0) sessionStorage.setItem(TAB_KEYS_KEY, JSON.stringify(keys)); + else sessionStorage.removeItem(TAB_KEYS_KEY); + } catch { + // Hardened or private modes: the handoff stays in memory only. + } +} + +function setTabKey(id: string, key: string | null): void { + const keys = readTabKeys(); + if (key) keys[id] = key; + else delete keys[id]; + writeTabKeys(keys); +} + +function toBase64Url(bytes: Uint8Array): string { + return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function fromBase64Url(text: string): Uint8Array { + const binary = atob(text.replace(/-/g, '+').replace(/_/g, '/')); + const bytes = new Uint8Array(new ArrayBuffer(binary.length)); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return bytes; +} + +/** A 12-byte IV, then the AES-GCM ciphertext. */ +async function seal(key: CryptoKey, data: BufferSource): Promise> { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const body = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data)); + const sealed = new Uint8Array(new ArrayBuffer(iv.length + body.length)); + sealed.set(iv); + sealed.set(body, iv.length); + return sealed; +} + +async function unseal(key: CryptoKey, sealed: ArrayBuffer): Promise { + return crypto.subtle.decrypt({ name: 'AES-GCM', iv: sealed.slice(0, 12) }, key, sealed.slice(12)); +} + +function canSeal(): boolean { + return typeof crypto !== 'undefined' && !!crypto.subtle; +} + +function entryPrefix(id: string): string { + return `${STORE_PREFIX}${id}/`; +} + +function pathOf(key: { url: string }): string { + return new URL(key.url, 'https://x.invalid').pathname; +} + +async function deleteEntry(cache: Cache, prefix: string): Promise { + const keys = await cache.keys(); + const own = keys.filter((key) => pathOf(key).startsWith(prefix)); + // The list last: the sweep goes by it. + await Promise.all(own.filter((key) => !pathOf(key).endsWith('/meta')).map((key) => cache.delete(key))); + await cache.delete(`${prefix}meta`); +} + +/** + * Drops what is too old and, when `incoming` wants in, the oldest entries that + * are in the way. Returns when the oldest remaining entry was stored. + */ +async function sweep(cache: Cache, incoming: { bytes: number } | null): Promise { + const entries: Array<{ prefix: string; createdAt: number; bytes: number }> = []; + for (const key of await cache.keys()) { + const path = pathOf(key); + if (!path.endsWith('/meta')) continue; + const response = await cache.match(key); + const meta = response ? ((await response.json().catch(() => null)) as { createdAt?: unknown; bytes?: unknown } | null) : null; + entries.push({ + prefix: path.slice(0, -'meta'.length), + createdAt: typeof meta?.createdAt === 'number' ? meta.createdAt : 0, + bytes: typeof meta?.bytes === 'number' ? meta.bytes : 0, + }); + } + entries.sort((a, b) => a.createdAt - b.createdAt); + const now = Date.now(); + let total = entries.reduce((sum, entry) => sum + entry.bytes, 0); + while (entries.length > 0) { + const tooOld = now - entries[0].createdAt > HANDOFF_TTL_MS; + const inTheWay = incoming !== null + && (entries.length >= HANDOFF_MAX_ENTRIES || total + incoming.bytes > HANDOFF_MAX_BYTES); + if (!tooOld && !inTheWay) break; + const [oldest] = entries.splice(0, 1); + total -= oldest.bytes; + await deleteEntry(cache, oldest.prefix); + } + return entries[0]?.createdAt ?? null; +} + +async function writeStored(handoff: Handoff): Promise { + const api = cacheApi(); + if (!api || !canSeal()) return; + const bytes = handoff.files.reduce((sum, file) => sum + file.size, 0); + if (bytes > HANDOFF_MAX_BYTES) return; + const prefix = entryPrefix(handoff.id); + const startedAt = clearCount; + try { + const cache = await api.open(HANDOFF_CACHE); + await sweep(cache, { bytes }); + const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); + const rawKey = new Uint8Array(await crypto.subtle.exportKey('raw', key)); + // The list first: the sweep goes by it, so no file can stay behind unlisted. + await cache.put(`${prefix}meta`, new Response(JSON.stringify({ kind: handoff.kind, createdAt: handoff.createdAt, bytes }))); + const contents = JSON.stringify({ + title: handoff.subject ?? '', + text: handoff.body ?? '', + url: '', + files: handoff.files.map((file) => ({ name: file.name, type: file.type })), + }); + await cache.put(`${prefix}sealed`, new Response(await seal(key, new TextEncoder().encode(contents)))); + for (const [index, file] of handoff.files.entries()) { + await cache.put(`${prefix}${index}`, new Response(await seal(key, await file.arrayBuffer()))); + } + if (startedAt !== clearCount) { + await deleteEntry(cache, prefix); + return; + } + setTabKey(handoff.id, toBase64Url(rawKey)); + } catch { + // Quota, or no Cache API here: the handoff stays in memory only. + await deleteStored(handoff.id); + } +} + +async function deleteStored(id: string): Promise { + setTabKey(id, null); + const api = cacheApi(); + if (!api) return; + try { + if (!(await api.has(HANDOFF_CACHE))) return; + await deleteEntry(await api.open(HANDOFF_CACHE), entryPrefix(id)); + } catch { + // The sweep gets it. + } +} + +function notify(kind: HandoffKind): void { + // The app first, so it can go to the right page while the handoff is still + // pending, then whoever takes it. + for (const listener of [...anyListeners]) listener(kind); + for (const listener of [...(kindListeners.get(kind) ?? [])]) listener(kind); +} + +function reportLoss(loss: HandoffLoss): void { + for (const listener of [...lossListeners]) listener(loss); +} + +function dropFromQueue(index: number): Handoff { + const [handoff] = queue.splice(index, 1); + void (stored.get(handoff.id) ?? Promise.resolve()).then(() => deleteStored(handoff.id)); + stored.delete(handoff.id); + return handoff; +} + +function armExpiry(oldestStored: number | null): void { + if (expiryTimer !== null) clearTimeout(expiryTimer); + expiryTimer = null; + const times = [...queue.map((handoff) => handoff.createdAt), ...(oldestStored === null ? [] : [oldestStored])]; + if (times.length === 0) return; + const delay = Math.max(0, Math.min(...times) + HANDOFF_TTL_MS - Date.now()) + 1000; + expiryTimer = setTimeout(() => { void expireHandoffs(); }, delay); +} + +/** What waited too long goes, in memory and stored. Also runs on a timer while this page is open. */ +export async function expireHandoffs(): Promise { + const now = Date.now(); + let lost = false; + for (let index = queue.length - 1; index >= 0; index -= 1) { + if (now - queue[index].createdAt <= HANDOFF_TTL_MS) continue; + dropFromQueue(index); + lost = true; + } + if (lost) reportLoss('expired'); + await sweepStoredHandoffs(); +} + +async function sweepStoredHandoffs(): Promise { + let oldest: number | null = null; + const api = cacheApi(); + try { + if (api && (await api.has(HANDOFF_CACHE))) oldest = await sweep(await api.open(HANDOFF_CACHE), null); + } catch { + // Tried again at the next page load. + } + armExpiry(oldest); +} + +function enqueue(handoff: Handoff, persist: boolean): void { + queue.push(handoff); + armExpiry(null); + if (persist) stored.set(handoff.id, writeStored(handoff)); + notify(handoff.kind); +} + +/** The kind a file from the OS goes to; null when nothing here handles it. */ +export function kindForFile(file: { name: string; type?: string }): HandoffKind | null { + const dot = file.name.lastIndexOf('.'); + const extension = dot >= 0 ? file.name.slice(dot).toLowerCase() : ''; + if (extension === '.eml') return 'eml'; + if (extension === '.ics' || extension === '.ical') return 'calendar'; + if (extension === '.vcf' || extension === '.vcard') return 'contacts'; + const type = (file.type ?? '').toLowerCase(); + if (type === 'message/rfc822') return 'eml'; + if (type === 'text/calendar') return 'calendar'; + if (type === 'text/vcard' || type === 'text/x-vcard') return 'contacts'; + return null; +} + +/** Hands files to a part of the app, which opens when they arrive. */ +export function handOffFiles(files: File[], kind: HandoffKind): void { + if (files.length === 0) return; + // A .eml opens at once and needs no sign-in, so it never waits for one. + enqueue({ id: newId(), kind, files, createdAt: Date.now() }, kind !== 'eml'); +} + +/** Opens a new draft with this text and these files. Trusted: what came from outside was asked about first. */ +export function handOffDraft(draft: { subject?: string; body?: string; files?: File[] }): void { + const subject = cleanMailtoSubject(draft.subject ?? ''); + const body = cleanMailtoBody(draft.body ?? ''); + enqueue({ + id: newId(), + kind: 'compose', + files: draft.files ?? [], + ...(subject ? { subject } : {}), + ...(body ? { body } : {}), + createdAt: Date.now(), + }, true); +} + +/** Opens an empty new message. */ +export function handOffNewMessage(): void { + const now = Date.now(); + if (now - lastNewMessageAt < NEW_MESSAGE_REPEAT_MS) return; + lastNewMessageAt = now; + enqueue({ id: newId(), kind: 'compose', files: [], createdAt: now }, false); +} + +/** + * Routes files from a launch by their type. Returns the names nothing handles + * and how many couldn't be read. + */ +export async function handOffLaunchFiles( + handles: ReadonlyArray<{ kind?: string; getFile?: () => Promise }>, +): Promise<{ unsupported: string[]; failed: number }> { + const byKind = new Map(); + const unsupported: string[] = []; + let failed = 0; + for (const handle of handles) { + if (typeof handle.getFile !== 'function') continue; + try { + const file = await handle.getFile(); + const kind = kindForFile(file); + if (!kind) { + unsupported.push(file.name); + continue; + } + byKind.set(kind, [...(byKind.get(kind) ?? []), file]); + } catch { + failed += 1; + } + } + for (const [kind, files] of byKind) handOffFiles(files, kind); + return { unsupported, failed }; +} + +/** The oldest handoff of this kind; it is gone from here after this. */ +export function takeHandoff(kind: HandoffKind): Handoff | null { + const now = Date.now(); + let lost = false; + let found: Handoff | null = null; + for (let index = 0; index < queue.length && !found; index += 1) { + if (queue[index].kind !== kind) continue; + const handoff = dropFromQueue(index); + index -= 1; + if (now - handoff.createdAt <= HANDOFF_TTL_MS) found = handoff; + else lost = true; + } + if (lost) reportLoss('expired'); + return found; +} + +/** What is waiting, oldest first. Nothing is taken. */ +export function pendingHandoffs(): readonly Handoff[] { + return queue; +} + +/** `kind` "any" hears every handoff, before those listening for that kind. */ +export function subscribeHandoff(kind: HandoffKind | 'any', listener: Listener): () => void { + if (kind === 'any') { + anyListeners.add(listener); + return () => { anyListeners.delete(listener); }; + } + const set = kindListeners.get(kind) ?? new Set(); + kindListeners.set(kind, set); + set.add(listener); + return () => { set.delete(listener); }; +} + +/** Hears about a handoff that went without arriving, so the user can be told. */ +export function subscribeHandoffLost(listener: (loss: HandoffLoss) => void): () => void { + lossListeners.add(listener); + return () => { lossListeners.delete(listener); }; +} + +/** Subject and body of a draft from what the OS shared, under the same rules as a mailto: link. */ +export function shareDraft(shared: { title?: string; text?: string; url?: string }): { subject: string; body: string } { + const text = cleanMailtoBody(shared.text ?? '').trim(); + const url = cleanMailtoSubject(shared.url ?? '').trim(); + // Android often puts the link in the text as well. + const body = url && !text.includes(url) ? [text, url].filter(Boolean).join('\n\n') : text; + return { subject: cleanMailtoSubject(shared.title ?? '').trim(), body: cleanMailtoBody(body) }; +} + +/** A name from outside, as it may be shown and attached. */ +function cleanFileName(name: string): string { + // eslint-disable-next-line no-control-regex + return stripBidiControls(name.replace(/[\u0000-\u001F\u007F]/g, '')).slice(0, 255) || 'file'; +} + +interface StoredContents { + meta: { kind: HandoffKind; createdAt: number }; + subject: string; + body: string; + files: Array<{ name: string; type: string }>; + key: CryptoKey; + cache: Cache; +} + +async function openStored(id: string, encodedKey: string): Promise { + const api = cacheApi(); + if (!api || !canSeal() || !encodedKey) return null; + const prefix = entryPrefix(id); + try { + if (!(await api.has(HANDOFF_CACHE))) return null; + const cache = await api.open(HANDOFF_CACHE); + const metaResponse = await cache.match(`${prefix}meta`); + if (!metaResponse) return null; + const meta = (await metaResponse.json()) as StoredContents['meta']; + if (Date.now() - meta.createdAt > HANDOFF_TTL_MS) { + await deleteStored(id); + return null; + } + const sealedResponse = await cache.match(`${prefix}sealed`); + if (!sealedResponse) return null; + const key = await crypto.subtle.importKey('raw', fromBase64Url(encodedKey), 'AES-GCM', false, ['decrypt']); + const contents = JSON.parse(new TextDecoder().decode(await unseal(key, await sealedResponse.arrayBuffer()))) as { + title?: string; text?: string; url?: string; files?: Array<{ name: string; type?: string }>; + }; + return { + meta, + ...shareDraft(contents), + files: (contents.files ?? []).map((file) => ({ name: cleanFileName(file.name), type: file.type ?? '' })), + key, + cache, + }; + } catch { + return null; + } +} + +/** What a stored handoff holds, to ask the user about. Null when it is gone or can't be read. */ +export async function peekStoredHandoff(id: string, encodedKey: string): Promise<{ subject: string; body: string; files: string[] } | null> { + const contents = await openStored(id, encodedKey); + return contents && { subject: contents.subject, body: contents.body, files: contents.files.map((file) => file.name) }; +} + +/** The user refused it. */ +export function discardStoredHandoff(id: string): Promise { + return deleteStored(id); +} + +/** Brings a stored handoff into this tab. False when it is gone. Without `encodedKey`: this tab's own key. */ +export async function adoptStoredHandoff(id: string, encodedKey?: string): Promise { + if (queue.some((handoff) => handoff.id === id)) return true; + const key = encodedKey ?? readTabKeys()[id] ?? ''; + const contents = await openStored(id, key); + if (!contents) return false; + const prefix = entryPrefix(id); + const files: File[] = []; + let missing = false; + for (const [index, entry] of contents.files.entries()) { + try { + const response = await contents.cache.match(`${prefix}${index}`); + if (!response) throw new Error('gone'); + const bytes = await unseal(contents.key, await response.arrayBuffer()); + files.push(new File([bytes], entry.name, { type: entry.type })); + } catch { + missing = true; + } + } + // The awaits above: another caller may have got here first. + if (queue.some((handoff) => handoff.id === id)) return true; + setTabKey(id, key); + enqueue({ + id, + kind: contents.meta.kind, + files, + ...(contents.subject ? { subject: contents.subject } : {}), + ...(contents.body ? { body: contents.body } : {}), + createdAt: contents.meta.createdAt, + }, false); + if (missing) reportLoss('missing'); + return true; +} + +/** After a page load: this tab's stored handoffs come back, what is too old goes. */ +export async function restoreStoredHandoffs(): Promise { + let lost = false; + await Promise.all(Object.keys(readTabKeys()).map(async (id) => { + if (await adoptStoredHandoff(id)) return; + setTabKey(id, null); + lost = true; + })); + if (lost) reportLoss('expired'); + await sweepStoredHandoffs(); +} + +/** Sign-out and offline wipes: nothing that was on its way into an account stays. */ +export function clearHandoffs(): void { + clearCount += 1; + queue.length = 0; + stored.clear(); + writeTabKeys({}); + lastNewMessageAt = 0; + if (expiryTimer !== null) clearTimeout(expiryTimer); + expiryTimer = null; + const api = cacheApi(); + if (api) void api.delete(HANDOFF_CACHE).catch(() => {}); +} + +export type LaunchLanding = + | { type: 'new-message' } + /** The service worker stored what was shared. */ + | { type: 'stored'; id: string; key: string } + /** It couldn't be stored, or no worker ran: the text came along in the address. */ + | { type: 'inline'; subject: string; body: string; filesLost: boolean; cut: boolean }; + +const LANDING_HASH_PARAMS = ['share', 'key', 'share_title', 'share_text', 'share_url', 'share_files', 'share_cut']; + +/** What the page's own address asks for, once. See stripLandingParams. */ +export function parseLaunchLanding(search: string, hash: string): LaunchLanding | null { + if (new URLSearchParams(search).get('compose') === '1') return { type: 'new-message' }; + const params = new URLSearchParams(hash.replace(/^#/, '')); + const share = params.get('share'); + if (!share) return null; + if (share === 'unavailable') { + return { + type: 'inline', + ...shareDraft({ + title: params.get('share_title') ?? '', + text: params.get('share_text') ?? '', + url: params.get('share_url') ?? '', + }), + filesLost: params.get('share_files') === '1', + cut: params.get('share_cut') === '1', + }; + } + return /^[\w-]{1,64}$/.test(share) ? { type: 'stored', id: share, key: params.get('key') ?? '' } : null; +} + +/** The address without what parseLaunchLanding read, so a reload doesn't do it again. */ +export function stripLandingParams(search: string, hash: string): { search: string; hash: string } { + const query = new URLSearchParams(search); + query.delete('compose'); + const rest = query.toString(); + let leftHash = hash; + const fragment = new URLSearchParams(hash.replace(/^#/, '')); + if (fragment.has('share')) { + for (const name of LANDING_HASH_PARAMS) fragment.delete(name); + const left = fragment.toString(); + leftHash = left ? `#${left}` : ''; + } + return { search: rest ? `?${rest}` : '', hash: leftHash }; +} + +/** + * What a launch's address asks of a window that is already open. A shortcut + * with launch_handler focus-existing only focuses that window and passes the + * address here. Anything else, the start page included, stays as it is. + */ +export function shortcutFromTargetUrl(targetURL: string, origin: string): 'new-message' | 'calendar' | 'contacts' | null { + let url: URL; + try { + url = new URL(targetURL, origin); + } catch { + return null; + } + if (url.origin !== origin) return null; + const match = matchSurface(url.pathname); + if (!match || match.segments.length > 0) return null; + if (match.surface === 'mail') return url.searchParams.get('compose') === '1' ? 'new-message' : null; + if (match.surface === 'calendar' || match.surface === 'contacts') return match.surface; + return null; +} diff --git a/lib/offline/__tests__/handoff-wipes.test.ts b/lib/offline/__tests__/handoff-wipes.test.ts new file mode 100644 index 000000000..84c58ba08 --- /dev/null +++ b/lib/offline/__tests__/handoff-wipes.test.ts @@ -0,0 +1,64 @@ +import 'fake-indexeddb/auto'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAccountStore } from '@/stores/account-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { handOffFiles, pendingHandoffs } from '@/lib/launch-handoff'; +import { disableOffline, enforcePolicy, wipeEverything } from '../offline-mode'; +import { forgetIndexes } from '../cache'; +import { forgetDeviceKey } from '../crypto'; +import { allowOfflineDbCreation, deleteOfflineDb } from '../db'; +import { useOfflineStore, writeEnabled } from '../state'; + +// Offline mode's wipes take what was on its way into an account along. + +function waiting() { + handOffFiles([new File(['x'], 'a.ics')], 'calendar'); + expect(pendingHandoffs()).toHaveLength(1); +} + +beforeEach(async () => { + forgetIndexes(); + forgetDeviceKey(); + await deleteOfflineDb(); + allowOfflineDbCreation(); + localStorage.clear(); + useOfflineStore.setState({ enabled: [], outbox: [], pendingChanges: 0, browserOffline: false, downServers: [], startedOffline: false }); + usePolicyStore.setState({ loaded: true, subject: null, subjectPending: false, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } } as never); + useAccountStore.setState({ accounts: [{ id: 'acc', label: 'alice', serverUrl: 'https://mail.example.org', username: 'alice@example.org' } as never] }); +}); + +afterEach(async () => { + await wipeEverything(); +}); + +describe('offline wipes and handoffs', () => { + it('wipeEverything clears them', async () => { + waiting(); + await wipeEverything(); + expect(pendingHandoffs()).toHaveLength(0); + }); + + it('disableOffline clears them', async () => { + writeEnabled(['acc']); + waiting(); + await disableOffline('acc'); + expect(pendingHandoffs()).toHaveLength(0); + }); + + it('enforcePolicy clears them when the organisation forbids offline mode', async () => { + writeEnabled(['acc']); + waiting(); + usePolicyStore.setState({ policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, noOffline: true } } } }); + enforcePolicy(); + await vi.waitFor(() => expect(useOfflineStore.getState().enabled).toEqual([])); + expect(pendingHandoffs()).toHaveLength(0); + }); + + it('enforcePolicy leaves them while offline mode is allowed', () => { + writeEnabled(['acc']); + waiting(); + enforcePolicy(); + expect(pendingHandoffs()).toHaveLength(1); + }); +}); diff --git a/lib/offline/__tests__/queue-sync.test.ts b/lib/offline/__tests__/queue-sync.test.ts new file mode 100644 index 000000000..a31f5616e --- /dev/null +++ b/lib/offline/__tests__/queue-sync.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { queuePatch, queueSend } from '../queue'; + +// Whatever goes into the queue asks for a Background Sync, once it's stored. + +const requestOutboxSync = vi.fn(); +const stored: unknown[] = []; +vi.mock('@/lib/pwa-notify/outbox-sync', () => ({ requestOutboxSync: () => requestOutboxSync(stored.length) })); +vi.mock('../db', () => ({ + idbAdd: vi.fn(async (_store: string, record: unknown) => { stored.push(record); return stored.length; }), + idbDeleteAccount: vi.fn(), idbGet: vi.fn(), idbGetAll: vi.fn(), idbGetAllByAccount: vi.fn(), idbUpdate: vi.fn(), idbWrite: vi.fn(), +})); +vi.mock('../crypto', () => ({ + sealJson: vi.fn(async () => ({ iv: 'i', data: 'd' })), + openJson: vi.fn(), openBytes: vi.fn(), sealBytes: vi.fn(), +})); + +beforeEach(() => { + stored.length = 0; + requestOutboxSync.mockClear(); +}); + +describe('the offline queue', () => { + it('asks for a sync after storing a change', async () => { + await queuePatch('acc', 'jmap', {} as never); + expect(requestOutboxSync).toHaveBeenCalledTimes(1); + expect(requestOutboxSync).toHaveBeenCalledWith(1); + }); + + it('asks for a sync after storing a message, and still returns its id', async () => { + const id = await queueSend('acc', {} as never, {} as never); + expect(id).toBe(1); + expect(requestOutboxSync).toHaveBeenCalledWith(1); + }); +}); diff --git a/lib/offline/__tests__/shell-pages.test.ts b/lib/offline/__tests__/shell-pages.test.ts new file mode 100644 index 000000000..20a8e3716 --- /dev/null +++ b/lib/offline/__tests__/shell-pages.test.ts @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// A mailto: or webcal: launch without a connection needs its handler page in +// the app shell. Its address is what opens the draft; the start page alone +// doesn't read it outside an installed app. + +vi.mock('@/lib/browser-navigation', () => ({ + apiFetch: async () => new Response(JSON.stringify({ buildId: 'build-1', files: ['/_next/static/a.js'] })), + withBasePath: (path: string) => path, +})); +vi.mock('@/lib/app-update', () => ({ currentBuildId: () => 'build-1', SW_SCOPE: '/' })); + +const { tellServiceWorker } = await import('../shell'); + +afterEach(() => vi.unstubAllGlobals()); + +describe('app shell pages', () => { + it('includes the protocol handler pages', async () => { + const postMessage = vi.fn(); + vi.stubGlobal('navigator', { serviceWorker: { getRegistration: async () => ({ active: { postMessage } }) } }); + + await tellServiceWorker(true); + + expect(postMessage.mock.calls[0][0].urls).toEqual(expect.arrayContaining(['/', '/protocol/mailto', '/protocol/webcal'])); + }); +}); diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index 0152db852..edc755b96 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -66,6 +66,7 @@ import { type OutboxSummary, type PolicyFingerprint, } from './queue'; +import { clearHandoffs } from '@/lib/launch-handoff'; import { isOfflineNow, OFFLINE_ACCOUNTS_KEY, readEnabledFrom, serverKey, setServerDown, useOfflineStore, writeEnabled, type OutboxRow } from './state'; import { flushQueue, settleStaleSends } from './flush'; import { syncAccount } from './sync'; @@ -696,6 +697,7 @@ export async function enableOffline(account: string): Promise { /** Switches offline mode off for an account (the user confirmed losing unsent mail) and removes everything it kept here. */ export async function disableOffline(account: string): Promise { confirmLoss(account); + clearHandoffs(); writeEnabled(useOfflineStore.getState().enabled.filter((id) => id !== account)); await wipeAccount(account); } @@ -779,6 +781,7 @@ export async function wipeEverything(): Promise { await deleteOfflineDb(); await tellServiceWorker(false); await deleteShellCaches(); + clearHandoffs(); confirmed.clear(); named.clear(); useOfflineStore.setState({ outbox: [], pendingChanges: 0, usageBytes: 0, syncing: [], lastSyncedAt: {} }); @@ -801,6 +804,7 @@ export function enforcePolicy(): void { for (const account of useOfflineStore.getState().enabled) { if (!policyDecided(account)) continue; if (!offlineAllowed(account)) { + clearHandoffs(); void wipeAccount(account); notify({ kind: 'forbidden' }); } diff --git a/lib/offline/queue.ts b/lib/offline/queue.ts index 61e53091a..4da2d5bf0 100644 --- a/lib/offline/queue.ts +++ b/lib/offline/queue.ts @@ -18,6 +18,7 @@ import { idbAdd, idbDeleteAccount, idbGet, idbGetAll, idbGetAllByAccount, idbUpdate, idbWrite } from './db'; import { openBytes, openJson, sealBytes, sealJson, type Sealed } from './crypto'; import { generateUUID } from '@/lib/utils'; +import { requestOutboxSync } from '@/lib/pwa-notify/outbox-sync'; export interface PatchItem { kind: 'patch'; @@ -104,11 +105,14 @@ interface SendPayload { export async function queuePatch(account: string, jmap: string, update: PatchItem['update']): Promise { const item: PatchItem = { kind: 'patch', jmap, update }; await idbAdd('queue', { account, kind: 'patch', createdAt: Date.now(), ...(await sealJson(item)) } satisfies QueueRecord); + requestOutboxSync(); } export async function queueSend(account: string, summary: OutboxSummary, item: SendItem, fingerprint?: PolicyFingerprint): Promise { const payload: SendPayload = { summary, item, fingerprint }; - return Number(await idbAdd('queue', { account, kind: 'send', createdAt: Date.now(), status: 'waiting', ...(await sealJson(payload)) } satisfies QueueRecord)); + const id = Number(await idbAdd('queue', { account, kind: 'send', createdAt: Date.now(), status: 'waiting', ...(await sealJson(payload)) } satisfies QueueRecord)); + requestOutboxSync(); + return id; } export interface QueuedPatch { id: number; account: string; item: PatchItem } diff --git a/lib/offline/shell.ts b/lib/offline/shell.ts index 7059ff739..fed8fa099 100644 --- a/lib/offline/shell.ts +++ b/lib/offline/shell.ts @@ -8,7 +8,7 @@ import { apiFetch, withBasePath } from '@/lib/browser-navigation'; import { currentBuildId, SW_SCOPE } from '@/lib/app-update'; /** The app's pages kept for a start without the network. */ -const SHELL_PAGES = ['/', '/mail', '/settings', '/popout/compose', '/popout/message/_']; +const SHELL_PAGES = ['/', '/mail', '/settings', '/protocol/mailto', '/protocol/webcal', '/popout/compose', '/popout/message/_']; /** * The files of this tab's build. After a deploy the server lists the new diff --git a/lib/protocol-handlers/mailto.ts b/lib/protocol-handlers/mailto.ts index 8cb217dd8..e58385708 100644 --- a/lib/protocol-handlers/mailto.ts +++ b/lib/protocol-handlers/mailto.ts @@ -39,6 +39,16 @@ function stripBodyControlChars(value: string): string { .replace(CONTROL_CHARS_EXCEPT_LINE_BREAKS, ""); } +/** A subject from outside the app: no control characters, no longer than a mail header allows. */ +export function cleanMailtoSubject(value: string): string { + return stripControlChars(value).slice(0, MAX_SUBJECT_LENGTH); +} + +/** A body from outside the app: line breaks kept, other control characters gone, limited in length. */ +export function cleanMailtoBody(value: string): string { + return stripBodyControlChars(value).slice(0, MAX_BODY_LENGTH); +} + function splitRecipients(value: string): string[] { // Quote/angle-aware split so a `"Doe, John" ` display name with // an embedded comma stays a single recipient instead of being torn in two. @@ -125,8 +135,8 @@ export function parseMailto(raw: string): ParsedMailto | null { to, cc, bcc, - subject: stripControlChars(getQueryValue(searchParams, "subject")).slice(0, MAX_SUBJECT_LENGTH), - body: stripBodyControlChars(getQueryValue(searchParams, "body")).slice(0, MAX_BODY_LENGTH), + subject: cleanMailtoSubject(getQueryValue(searchParams, "subject")), + body: cleanMailtoBody(getQueryValue(searchParams, "body")), }; } diff --git a/lib/pwa-notify/__tests__/login-fingerprint.test.ts b/lib/pwa-notify/__tests__/login-fingerprint.test.ts new file mode 100644 index 000000000..82cab2ad3 --- /dev/null +++ b/lib/pwa-notify/__tests__/login-fingerprint.test.ts @@ -0,0 +1,42 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { loginFingerprint } from '../login-fingerprint'; + +// The registry keeps the server address as typed, the context cookie as the +// login route normalised it. Both must name the same login. +describe('loginFingerprint', () => { + const base = 'https://mail.example.org'; + const USER = 'alice@example.org'; + + it.each([ + 'https://Mail.Example.org', + 'https://MAIL.EXAMPLE.ORG/', + 'https://mail.example.org:443', + 'https://mail.example.org:443/', + 'https://mail.example.org//', + ])('reads %s as the same server', async (raw) => { + expect(await loginFingerprint(raw, USER)).toBe(await loginFingerprint(base, USER)); + }); + + it('drops the default port of http too', async () => { + expect(await loginFingerprint('http://mail.example.org:80', USER)).toBe(await loginFingerprint('http://mail.example.org', USER)); + }); + + it('ignores the case of the username and stray spaces', async () => { + expect(await loginFingerprint(base, ' Alice@Example.org ')).toBe(await loginFingerprint(base, USER)); + }); + + it.each([ + ['another host', 'https://other.example.org', USER], + ['another port', 'https://mail.example.org:8443', USER], + ['another scheme', 'http://mail.example.org', USER], + ['another path', 'https://mail.example.org/jmap', USER], + ['another user', base, 'bob@example.org'], + ])('tells %s apart', async (_name, server, user) => { + expect(await loginFingerprint(server, user)).not.toBe(await loginFingerprint(base, USER)); + }); + + it('keeps going on an address that is no URL', async () => { + expect(await loginFingerprint('not a url/', USER)).toBe(await loginFingerprint('not a url', USER)); + }); +}); diff --git a/lib/pwa-notify/__tests__/outbox-sync.test.ts b/lib/pwa-notify/__tests__/outbox-sync.test.ts new file mode 100644 index 000000000..0e2ae151d --- /dev/null +++ b/lib/pwa-notify/__tests__/outbox-sync.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { requestOutboxSync } from '../outbox-sync'; + +// Background Sync where the browser has it. Elsewhere, and in Electron where +// register rejects, nothing changes and nothing throws. + +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function browserWith(opts: { syncManager?: boolean; registration?: unknown; worker?: boolean }) { + if (opts.syncManager) vi.stubGlobal('SyncManager', class {}); + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: opts.worker === false ? undefined : { getRegistration: async () => opts.registration }, + }); +} + +afterEach(() => { + vi.unstubAllGlobals(); + Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: undefined }); +}); + +describe('requestOutboxSync', () => { + it('registers the tag the worker listens for', async () => { + const register = vi.fn(async () => {}); + browserWith({ syncManager: true, registration: { sync: { register } } }); + requestOutboxSync(); + await settle(); + expect(register).toHaveBeenCalledWith('bulwark-outbox'); + }); + + it('does nothing in a browser without Background Sync', async () => { + const register = vi.fn(async () => {}); + browserWith({ syncManager: false, registration: { sync: { register } } }); + requestOutboxSync(); + await settle(); + expect(register).not.toHaveBeenCalled(); + }); + + it('does nothing with no worker registered', async () => { + browserWith({ syncManager: true, registration: undefined }); + expect(() => requestOutboxSync()).not.toThrow(); + await settle(); + }); + + it('does nothing without service workers', async () => { + browserWith({ syncManager: true, worker: false }); + expect(() => requestOutboxSync()).not.toThrow(); + await settle(); + }); + + it('swallows the rejection Electron answers with', async () => { + const rejected = vi.fn(); + process.on('unhandledRejection', rejected); + const register = vi.fn(async () => { throw new DOMException('Background Sync is disabled', 'UnknownError'); }); + browserWith({ syncManager: true, registration: { sync: { register } } }); + requestOutboxSync(); + await settle(); + await settle(); + process.off('unhandledRejection', rejected); + expect(register).toHaveBeenCalled(); + expect(rejected).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/pwa-notify/__tests__/page-actions.test.ts b/lib/pwa-notify/__tests__/page-actions.test.ts new file mode 100644 index 000000000..39287cd2f --- /dev/null +++ b/lib/pwa-notify/__tests__/page-actions.test.ts @@ -0,0 +1,280 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { loginFingerprint } from '../login-fingerprint'; +import { isNotificationActionRequest, runNotificationAction, setNotificationReplyOpener, type NotificationActionRequest, type NotificationActionStatus } from '../page-actions'; + +// A notification's button in an open tab: only for the login it was shown for, +// through the store actions, with the tab's answer telling the worker what's next. + +const accounts = [ + { id: 'alice@one', cookieSlot: 0, serverUrl: 'https://one.example', username: 'alice@example.org' }, + { id: 'bob@two', cookieSlot: 1, serverUrl: 'https://two.example', username: 'bob@example.org' }, +]; +const makeClient = (accountId: string) => ({ + getAccountId: () => accountId, + getEmail: vi.fn(async (id: string) => (id === 'gone' ? null : { id, receivedAt: '2026-01-01T00:00:00Z', mailboxIds: { [id === 'binned' ? 'bin' : 'inbox']: true } })), + getMailboxes: vi.fn(async () => [{ id: 'inbox', role: 'inbox' }, { id: 'x', originalId: 'bin', role: 'trash' }]), +}); +let clients: Record>; +let activeAccountId = 'alice@one'; +const gateOff = new Set(); +let hooksRegistered = false; +const batchMarkAsRead = vi.fn(async (..._args: unknown[]) => true); +const batchArchive = vi.fn(async (..._args: unknown[]) => true); + +vi.mock('@/stores/account-store', () => ({ useAccountStore: { getState: () => ({ accounts }) } })); +vi.mock('@/stores/auth-store', () => ({ + useAuthStore: { + getState: () => ({ + activeAccountId, + client: clients[activeAccountId] ?? null, + getClientForAccount: (id: string) => clients[id], + }), + }, +})); +vi.mock('@/stores/email-store', () => ({ useEmailStore: { getState: () => ({ batchMarkAsRead, batchArchive }) } })); +// Each login's own subject, and a policy that reads it: the gate of the login +// the notification is for, not of the one that's active here. +vi.mock('@/lib/org/account-subjects', () => ({ subjectForAccount: (id: string) => ({ email: id, groups: [] }) })); +vi.mock('@/stores/policy-store', () => ({ + usePolicyStore: { + getState: () => ({ + resolveFor: (subject: { email: string }) => ({ features: { archiveActionEnabled: !gateOff.has(subject.email) } }), + }), + }, +})); +vi.mock('../plugin-veto', () => ({ vetoHooksRegistered: () => hooksRegistered })); + +async function run(over: Partial = {}) { + const request: NotificationActionRequest = { + action: 'mark-read', + emailId: 'e1', + accountId: 'a', + slot: 0, + login: await loginFingerprint('https://one.example', 'alice@example.org'), + at: Date.now(), + ...over, + }; + const answers: NotificationActionStatus[] = []; + await runNotificationAction(request, (status) => answers.push(status)); + return answers; +} + +beforeEach(() => { + vi.clearAllMocks(); + clients = { 'alice@one': makeClient('a'), 'bob@two': makeClient('b') }; + activeAccountId = 'alice@one'; + gateOff.clear(); + hooksRegistered = false; + batchMarkAsRead.mockResolvedValue(true); + batchArchive.mockResolvedValue(true); +}); + +describe('which notifications a tab takes', () => { + it('declines when the slot holds someone else now', async () => { + const answers = await run({ login: await loginFingerprint('https://other.example', 'alice@example.org') }); + expect(answers).toEqual(['declined']); + expect(batchMarkAsRead).not.toHaveBeenCalled(); + }); + + it('takes a notification whose server address was written differently', async () => { + const answers = await run({ login: await loginFingerprint('https://ONE.example:443/', 'alice@example.org') }); + expect(answers).toEqual(['accepted', 'done']); + }); + + it('declines a slot it has no login for', async () => { + expect(await run({ slot: 7 })).toEqual(['declined']); + }); + + it('declines without a login to compare', async () => { + expect(await run({ login: undefined })).toEqual(['declined']); + expect(batchMarkAsRead).not.toHaveBeenCalled(); + }); + + it('declines when that login is not connected in this tab', async () => { + delete clients['alice@one']; + activeAccountId = 'bob@two'; + expect(await run()).toEqual(['declined']); + }); + + it('declines a click from long ago: a tab that thawed has lost it', async () => { + expect(await run({ at: Date.now() - 6_000 })).toEqual(['declined']); + expect(batchMarkAsRead).not.toHaveBeenCalled(); + const opener = vi.fn(); + const remove = setNotificationReplyOpener(opener); + expect(await run({ action: 'reply', at: Date.now() - 6_000 })).toEqual(['declined']); + expect(opener).not.toHaveBeenCalled(); + remove(); + }); + + it('takes one from a moment ago', async () => { + expect(await run({ at: Date.now() - 2_000 })).toEqual(['accepted', 'done']); + }); + + it('checks a message from the worker for the shape it needs', () => { + const at = 1; + expect(isNotificationActionRequest({ action: 'archive', emailId: 'e', accountId: 'a', at })).toBe(true); + expect(isNotificationActionRequest({ action: 'archive', emailId: 'e', accountId: 'a' })).toBe(false); + expect(isNotificationActionRequest({ action: 'delete', emailId: 'e', accountId: 'a', at })).toBe(false); + expect(isNotificationActionRequest({ action: 'archive', emailId: '', accountId: 'a', at })).toBe(false); + expect(isNotificationActionRequest({ action: 'archive', emailId: 'e', at })).toBe(false); + expect(isNotificationActionRequest(null)).toBe(false); + }); +}); + +describe('with plugins listening on a veto hook', () => { + beforeEach(() => { hooksRegistered = true; }); + + it("doesn't pass on a shared account's Archive to the route, which has no plugins", async () => { + expect(await run({ action: 'archive', accountId: 'team' })).toEqual(['failed']); + expect(batchArchive).not.toHaveBeenCalled(); + }); + + it("doesn't pass on a login that isn't connected here", async () => { + delete clients['alice@one']; + activeAccountId = 'bob@two'; + expect(await run()).toEqual(['failed']); + }); + + it('still declines a notification of a login this tab does not hold', async () => { + expect(await run({ slot: 7 })).toEqual(['declined']); + }); + + it('runs what it can itself, through the store, where the hooks are', async () => { + expect(await run()).toEqual(['accepted', 'done']); + expect(batchMarkAsRead).toHaveBeenCalled(); + }); +}); + +describe('Mark read', () => { + it('goes through the store, with the login of the notification', async () => { + expect(await run()).toEqual(['accepted', 'done']); + const [active, read, target] = batchMarkAsRead.mock.calls[0]; + expect(active).toBe(clients['alice@one']); + expect(read).toBe(true); + expect(target).toEqual({ emailIds: ['e1'], client: clients['alice@one'], accountId: undefined }); + }); + + it('reaches a login that is not the active one through its own client', async () => { + const answers = await run({ slot: 1, accountId: 'b', login: await loginFingerprint('https://two.example', 'bob@example.org') }); + expect(answers).toEqual(['accepted', 'done']); + const [active, , target] = batchMarkAsRead.mock.calls[0]; + expect(active).toBe(clients['alice@one']); + expect((target as { client: unknown }).client).toBe(clients['bob@two']); + }); + + it('names a shared account', async () => { + await run({ accountId: 'team' }); + expect((batchMarkAsRead.mock.calls[0][2] as { accountId?: string }).accountId).toBe('team'); + expect(clients['alice@one'].getEmail).toHaveBeenCalledWith('e1', 'team'); + }); + + it('reports a failure, so the worker opens the message', async () => { + batchMarkAsRead.mockRejectedValue(new Error('forbidden')); + expect(await run()).toEqual(['accepted', 'failed']); + }); + + it('reports a plugin stopping it', async () => { + batchMarkAsRead.mockResolvedValue(false); + expect(await run()).toEqual(['accepted', 'failed']); + }); + + it('still queues with the mail server out of reach, like the app\'s own button', async () => { + clients['alice@one'].getEmail.mockRejectedValue(new Error('offline')); + clients['alice@one'].getMailboxes.mockRejectedValue(new Error('offline')); + expect(await run()).toEqual(['accepted', 'done']); + expect(batchMarkAsRead).toHaveBeenCalled(); + }); + + it('leaves a message since moved to Trash alone, by the server id of the folder', async () => { + expect(await run({ emailId: 'binned' })).toEqual(['accepted', 'failed']); + expect(batchMarkAsRead).not.toHaveBeenCalled(); + }); +}); + +describe('Archive', () => { + it('goes through the store with the message, which it files by date', async () => { + expect(await run({ action: 'archive' })).toEqual(['accepted', 'done']); + const [active, target] = batchArchive.mock.calls[0]; + expect(active).toBe(clients['alice@one']); + expect(target).toEqual({ + emailIds: ['e1'], + client: clients['alice@one'], + emails: [{ id: 'e1', receivedAt: '2026-01-01T00:00:00Z', mailboxIds: { inbox: true } }], + userArchive: true, + }); + }); + + it('fails when the admin turned it off for this login, and the message opens', async () => { + gateOff.add('alice@one'); + expect(await run({ action: 'archive' })).toEqual(['failed']); + expect(batchArchive).not.toHaveBeenCalled(); + }); + + it("goes by the notification's login, not the active one", async () => { + const bob = { slot: 1, accountId: 'b', login: await loginFingerprint('https://two.example', 'bob@example.org') }; + gateOff.add('bob@two'); + expect(await run({ action: 'archive', ...bob })).toEqual(['failed']); + expect(batchArchive).not.toHaveBeenCalled(); + + gateOff.clear(); + gateOff.add('alice@one'); + expect(await run({ action: 'archive', ...bob })).toEqual(['accepted', 'done']); + }); + + it("leaves a shared account's Archive to the route: the store reads own folders only", async () => { + expect(await run({ action: 'archive', accountId: 'team' })).toEqual(['declined']); + expect(batchArchive).not.toHaveBeenCalled(); + }); + + it('reports a message that is gone', async () => { + expect(await run({ action: 'archive', emailId: 'gone' })).toEqual(['accepted', 'failed']); + expect(batchArchive).not.toHaveBeenCalled(); + }); + + it('reports a message it can\'t read with the mail server out of reach', async () => { + clients['alice@one'].getEmail.mockRejectedValue(new Error('offline')); + expect(await run({ action: 'archive' })).toEqual(['accepted', 'failed']); + expect(batchArchive).not.toHaveBeenCalled(); + }); + + it('leaves a message since moved to Trash alone', async () => { + expect(await run({ action: 'archive', emailId: 'binned' })).toEqual(['accepted', 'failed']); + expect(batchArchive).not.toHaveBeenCalled(); + }); + + it('reports a plugin or the store stopping it', async () => { + batchArchive.mockResolvedValue(false); + expect(await run({ action: 'archive' })).toEqual(['accepted', 'failed']); + }); +}); + +describe('Reply', () => { + it('declines on a page without the mail app', async () => { + expect(await run({ action: 'reply' })).toEqual(['declined']); + }); + + it('declines on such a page with plugins too: a link opens the composer, nothing is skipped', async () => { + hooksRegistered = true; + expect(await run({ action: 'reply' })).toEqual(['declined']); + }); + + it('hands the request to the mail page, typed text included', async () => { + const opener = vi.fn(); + const remove = setNotificationReplyOpener(opener); + expect(await run({ action: 'reply', text: 'Thanks' })).toEqual(['done']); + expect(opener).toHaveBeenCalledWith({ emailId: 'e1', accountId: 'a', slot: 0, text: 'Thanks' }); + remove(); + expect(await run({ action: 'reply' })).toEqual(['declined']); + expect(opener).toHaveBeenCalledTimes(1); + }); + + it('keeps a newer opener when an older one is removed', async () => { + const remove = setNotificationReplyOpener(vi.fn()); + const current = vi.fn(); + const removeCurrent = setNotificationReplyOpener(current); + remove(); + await run({ action: 'reply' }); + expect(current).toHaveBeenCalled(); + removeCurrent(); + }); +}); diff --git a/lib/pwa-notify/__tests__/plugin-veto.test.ts b/lib/pwa-notify/__tests__/plugin-veto.test.ts new file mode 100644 index 000000000..1dfcf9292 --- /dev/null +++ b/lib/pwa-notify/__tests__/plugin-veto.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { hookSpec } from '@/lib/plugin-hook-catalog'; +import { emailHooks } from '@/lib/plugin-hooks'; +import { pluginsMayVeto, vetoHooksRegistered } from '../plugin-veto'; + +describe('pluginsMayVeto', () => { + it('reads the permission off the catalogue, which is email:write for all three today', () => { + for (const name of ['onBeforeEmailMove', 'onBeforeEmailArchive', 'onBeforeEmailReadStateChange']) { + expect(hookSpec(name)?.permission).toBe('email:write'); + } + }); + + it('is true for an enabled plugin with that permission', () => { + expect(pluginsMayVeto([{ enabled: true, permissions: ['email:read', 'email:write'] }])).toBe(true); + }); + + it('is false for a disabled one, for other permissions and for none', () => { + expect(pluginsMayVeto([{ enabled: false, permissions: ['email:write'] }])).toBe(false); + expect(pluginsMayVeto([{ enabled: true, permissions: ['email:read'] }])).toBe(false); + expect(pluginsMayVeto([{ enabled: true }])).toBe(false); + expect(pluginsMayVeto([])).toBe(false); + }); +}); + +describe('vetoHooksRegistered', () => { + const stops: Array<() => void> = []; + afterEach(() => { stops.splice(0).forEach((stop) => stop()); }); + + it.each(['onBeforeEmailMove', 'onBeforeEmailArchive', 'onBeforeEmailReadStateChange'] as const)('sees a listener on %s', (name) => { + expect(vetoHooksRegistered()).toBe(false); + const bus = emailHooks[name] as unknown as { register(id: string, fn: () => boolean): { dispose(): void } }; + const handle = bus.register('test-plugin', () => true); + stops.push(() => handle.dispose()); + expect(vetoHooksRegistered()).toBe(true); + }); +}); diff --git a/lib/pwa-notify/__tests__/tab-reply.test.ts b/lib/pwa-notify/__tests__/tab-reply.test.ts new file mode 100644 index 000000000..99f35a448 --- /dev/null +++ b/lib/pwa-notify/__tests__/tab-reply.test.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { requestTabReply, takeTabReply, useNotificationTabReply } from '../tab-reply'; + +afterEach(() => { + vi.useRealTimers(); + useNotificationTabReply.setState({ pending: null }); +}); + +describe('a notification reply waiting for its Pro tab', () => { + it('reaches that message once, with the text', () => { + requestTabReply('e1', 'Thanks'); + expect(takeTabReply('e1')).toBe('Thanks'); + expect(takeTabReply('e1')).toBeNull(); + }); + + it('keeps an empty reply apart from none', () => { + requestTabReply('e1', ''); + expect(takeTabReply('e1')).toBe(''); + }); + + it('stays for its own message when another tab loads', () => { + requestTabReply('e1', 'Thanks'); + expect(takeTabReply('e2')).toBeNull(); + expect(takeTabReply('e1')).toBe('Thanks'); + }); + + it("doesn't open a composer minutes later", () => { + vi.useFakeTimers(); + requestTabReply('e1', 'Thanks'); + vi.advanceTimersByTime(3 * 60 * 1000); + expect(takeTabReply('e1')).toBeNull(); + }); +}); diff --git a/lib/pwa-notify/__tests__/worker-state.test.ts b/lib/pwa-notify/__tests__/worker-state.test.ts new file mode 100644 index 000000000..69e384f09 --- /dev/null +++ b/lib/pwa-notify/__tests__/worker-state.test.ts @@ -0,0 +1,70 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FakeCaches } from '@/lib/__tests__/sw-harness'; +import { PUSH_STATE_CACHE } from '@/lib/push-preferences'; +import { consumePendingReplyText, notificationActionPrefsKey, pendingReplyKey, writeNotificationActionPrefs } from '../worker-state'; + +let caches: FakeCaches; + +const keep = async (value: unknown) => { + const cache = await caches.open(PUSH_STATE_CACHE); + await cache.put(pendingReplyKey(window.location.origin), new Response(JSON.stringify(value))); +}; + +beforeEach(() => { + caches = new FakeCaches(); + vi.stubGlobal('caches', caches); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('the worker store', () => { + it('holds the prefs where sw.js reads them', async () => { + const prefs = { archiveEnabled: true, archiveMode: 'year' as const, pluginsMayVeto: true, labels: { archive: 'A', markRead: 'M', reply: 'R', replyPlaceholder: 'P' } }; + await writeNotificationActionPrefs(prefs); + const cache = await caches.open(PUSH_STATE_CACHE); + const stored = await cache.match(notificationActionPrefsKey(window.location.origin)); + expect(await stored!.json()).toEqual(prefs); + }); + + it('uses the same keys and cache as sw.js, which can import nothing', () => { + const worker = readFileSync(join(process.cwd(), 'public', 'sw.js'), 'utf8'); + expect(worker).toContain(`const PUSH_STATE_CACHE = "${PUSH_STATE_CACHE}"`); + expect(worker).toContain('/__push-state/actions`'); + expect(worker).toContain('/__push-state/pending-reply`'); + expect(notificationActionPrefsKey('https://x')).toBe('https://x/__push-state/actions'); + expect(pendingReplyKey('https://x')).toBe('https://x/__push-state/pending-reply'); + }); + + it('survives a browser without the Cache API', async () => { + vi.stubGlobal('caches', undefined); + await expect(writeNotificationActionPrefs({ archiveEnabled: false, archiveMode: 'single', pluginsMayVeto: false, labels: { archive: '', markRead: '', reply: '', replyPlaceholder: '' } })).resolves.toBeUndefined(); + await expect(consumePendingReplyText('e1')).resolves.toBe(''); + }); +}); + +describe('a reply typed on a notification', () => { + it('reaches the page once', async () => { + await keep({ emailId: 'e1', text: 'Thanks', at: Date.now() }); + expect(await consumePendingReplyText('e1')).toBe('Thanks'); + expect(await consumePendingReplyText('e1')).toBe(''); + }); + + it("doesn't go into a reply to another message, and is gone after", async () => { + await keep({ emailId: 'e1', text: 'Thanks', at: Date.now() }); + expect(await consumePendingReplyText('e2')).toBe(''); + expect(await consumePendingReplyText('e1')).toBe(''); + }); + + it("doesn't go into a reply much later", async () => { + await keep({ emailId: 'e1', text: 'Thanks', at: Date.now() - 6 * 60 * 1000 }); + expect(await consumePendingReplyText('e1')).toBe(''); + }); + + it('is empty when there is none', async () => { + expect(await consumePendingReplyText('e1')).toBe(''); + }); +}); diff --git a/lib/pwa-notify/login-fingerprint.ts b/lib/pwa-notify/login-fingerprint.ts new file mode 100644 index 000000000..13eeaa0f4 --- /dev/null +++ b/lib/pwa-notify/login-fingerprint.ts @@ -0,0 +1,25 @@ +/** + * Names a login (server and user) in a notification's data, without naming + * either. A cookie slot goes to whoever signs in next, and JMAP account ids + * repeat across servers, so neither says that an action on a notification + * from yesterday still means the same mailbox. + * + * Runs in the page and on the server: both ends compare the result. + */ +function canonicalServerUrl(serverUrl: string): string { + // The registry keeps what the user typed, the cookie what the login route + // normalised: `https://Mail.Example.org:443/` and `https://mail.example.org` + // are the same server. + try { + const url = new URL(serverUrl); + return `${url.origin}${url.pathname}`.replace(/\/+$/, ''); + } catch { + return serverUrl.replace(/\/+$/, ''); + } +} + +export async function loginFingerprint(serverUrl: string, username: string | undefined): Promise { + const text = `${canonicalServerUrl(serverUrl)}\n${(username ?? '').trim().toLowerCase()}`; + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)); + return Array.from(new Uint8Array(digest).slice(0, 16), (byte) => byte.toString(16).padStart(2, '0')).join(''); +} diff --git a/lib/pwa-notify/outbox-sync.ts b/lib/pwa-notify/outbox-sync.ts new file mode 100644 index 000000000..4e2fd8db4 --- /dev/null +++ b/lib/pwa-notify/outbox-sync.ts @@ -0,0 +1,24 @@ +/** Same tag as sw.js's OUTBOX_SYNC_TAG. */ +const OUTBOX_SYNC_TAG = 'bulwark-outbox'; + +/** + * Something went into the outbox: asks the browser to wake the service worker + * when the network is back, which tells the open tabs to flush. Only a tab + * can flush (the worker has no login), so this helps a tab that missed the + * `online` event. + * + * Chromium only. Without it, or without a worker (Lite, development), or in + * Electron, where register rejects, the outbox flushes as it always did. + */ +export function requestOutboxSync(): void { + void (async () => { + try { + if (typeof window === 'undefined' || !('SyncManager' in window) || !navigator.serviceWorker) return; + const registration = await navigator.serviceWorker.getRegistration(); + const sync = (registration as (ServiceWorkerRegistration & { sync?: { register(tag: string): Promise } }) | undefined)?.sync; + await sync?.register(OUTBOX_SYNC_TAG); + } catch { + // Nothing to do: the outbox doesn't depend on this. + } + })(); +} diff --git a/lib/pwa-notify/page-actions.ts b/lib/pwa-notify/page-actions.ts new file mode 100644 index 000000000..96b1db101 --- /dev/null +++ b/lib/pwa-notify/page-actions.ts @@ -0,0 +1,159 @@ +import { subjectForAccount } from '@/lib/org/account-subjects'; +import { useAccountStore } from '@/stores/account-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { loginFingerprint } from './login-fingerprint'; +import { vetoHooksRegistered } from './plugin-veto'; + +/** What the service worker posts when a notification's button is clicked (public/sw.js). */ +export interface NotificationActionRequest { + action: 'archive' | 'mark-read' | 'reply'; + emailId: string; + accountId: string; + /** When the worker asked. */ + at: number; + threadId?: string; + slot?: number; + login?: string; + /** A reply typed on the notification, where the browser has a text field there. */ + text?: string; +} + +/** + * `declined`: not for this tab, the worker tries the next tab or the route. + * `accepted`: this tab is on it. `done` and `failed`: how it ended; on + * `failed` the message opens, and the route isn't tried. + */ +export type NotificationActionStatus = 'declined' | 'accepted' | 'done' | 'failed'; + +export interface NotificationReplyRequest { + emailId: string; + accountId: string; + slot: number; + text?: string; +} + +/** + * A tab that was frozen and thaws after this has lost the click: the worker has + * moved on, maybe to the route or to opening the message. Past every wait in + * sw.js for a tab to take an action. + */ +const REQUEST_MAX_AGE_MS = 5_000; + +type ReplyOpener = (request: NotificationReplyRequest) => void; + +let replyOpener: ReplyOpener | null = null; + +/** The mail page opens replies. On other pages the worker navigates to it instead. */ +export function setNotificationReplyOpener(opener: ReplyOpener): () => void { + replyOpener = opener; + return () => { + if (replyOpener === opener) replyOpener = null; + }; +} + +export function isNotificationActionRequest(value: unknown): value is NotificationActionRequest { + const request = value as Partial | null; + return !!request + && (request.action === 'archive' || request.action === 'mark-read' || request.action === 'reply') + && typeof request.emailId === 'string' && !!request.emailId + && typeof request.accountId === 'string' && !!request.accountId + && typeof request.at === 'number'; +} + +/** The account of this tab's registry the notification is for, if it still means that login. */ +async function loginOf(request: NotificationActionRequest) { + const entry = useAccountStore.getState().accounts.find((account) => account.cookieSlot === request.slot); + if (!entry || !request.login) return null; + return (await loginFingerprint(entry.serverUrl, entry.username)) === request.login ? entry : null; +} + +/** The Archive gate as it applies to this login, whichever login is active here. */ +function archiveEnabledFor(accountEntryId: string): boolean { + return usePolicyStore.getState().resolveFor(subjectForAccount(accountEntryId)).features?.archiveActionEnabled !== false; +} + +/** + * Runs a notification's button in this tab, through the same store actions as + * the app's own buttons: plugin hooks, archive mode and the Archive gate all + * apply, and the list on screen follows. What this tab can't do while plugins + * listen ends in `failed`, not `declined`: the route has no plugins. + */ +export async function runNotificationAction( + request: NotificationActionRequest, + answer: (status: NotificationActionStatus) => void, +): Promise { + try { + if (Date.now() - request.at > REQUEST_MAX_AGE_MS) { + answer('declined'); + return; + } + const entry = await loginOf(request); + if (!entry) { + answer('declined'); + return; + } + const passOn = () => answer(vetoHooksRegistered() ? 'failed' : 'declined'); + const auth = useAuthStore.getState(); + const client = auth.getClientForAccount(entry.id) ?? (entry.id === auth.activeAccountId ? auth.client : null); + const activeClient = auth.client; + if (request.action === 'reply') { + if (!client || !replyOpener || request.slot === undefined) { + answer('declined'); + return; + } + replyOpener({ emailId: request.emailId, accountId: request.accountId, slot: request.slot, text: request.text }); + answer('done'); + return; + } + if (!client || !activeClient) { + passOn(); + return; + } + + const own = request.accountId === client.getAccountId(); + if (request.action === 'archive') { + // The route would refuse it too. + if (!archiveEnabledFor(entry.id)) { + answer('failed'); + return; + } + // The store's targeted archive reads a login's own folders only. A shared + // account's Archive is the route's to find. + if (!own) { + passOn(); + return; + } + } + + answer('accepted'); + const accountId = own ? undefined : request.accountId; + // Unknown with the mail server out of reach. Mark read then queues like the + // app's own button; Archive files by date and can't go without the message. + const [email, mailboxes] = await Promise.all([ + client.getEmail(request.emailId, accountId).catch(() => null), + client.getMailboxes(accountId).catch(() => []), + ]); + if (request.action === 'archive' && !email) { + answer('failed'); + return; + } + // Since the notification it may have gone to Trash or Junk: not for a toast + // to bring back or to mark. The app opens it. + const inBin = Object.keys(email?.mailboxIds ?? {}).some((id) => { + const mailbox = mailboxes.find((m) => m.id === id || m.originalId === id); + return mailbox?.role === 'trash' || mailbox?.role === 'junk'; + }); + if (inBin) { + answer('failed'); + return; + } + const done = request.action === 'mark-read' + ? await useEmailStore.getState().batchMarkAsRead(activeClient, true, { emailIds: [request.emailId], client, accountId }) + : await useEmailStore.getState().batchArchive(activeClient, { emailIds: [request.emailId], client, emails: email ? [email] : [], userArchive: true }); + answer(done ? 'done' : 'failed'); + } catch { + answer('failed'); + } +} diff --git a/lib/pwa-notify/plugin-veto.ts b/lib/pwa-notify/plugin-veto.ts new file mode 100644 index 000000000..48f1f2b52 --- /dev/null +++ b/lib/pwa-notify/plugin-veto.ts @@ -0,0 +1,16 @@ +import { hookSpec } from '@/lib/plugin-hook-catalog'; +import { emailHooks } from '@/lib/plugin-hooks'; + +/** The hooks that can stop what a notification's buttons do. */ +const VETO_HOOKS = ['onBeforeEmailMove', 'onBeforeEmailArchive', 'onBeforeEmailReadStateChange'] as const; + +/** Whether one of these plugins could register one of those hooks, today or after a page load. */ +export function pluginsMayVeto(plugins: ReadonlyArray<{ enabled?: boolean; permissions?: readonly unknown[] }>): boolean { + const needed = new Set(VETO_HOOKS.map((name) => hookSpec(name)?.permission)); + return plugins.some((plugin) => plugin.enabled && plugin.permissions?.some((permission) => needed.has(permission as never))); +} + +/** Whether a plugin in this tab listens on one of those hooks now. */ +export function vetoHooksRegistered(): boolean { + return VETO_HOOKS.some((name) => emailHooks[name].size > 0); +} diff --git a/lib/pwa-notify/tab-reply.ts b/lib/pwa-notify/tab-reply.ts new file mode 100644 index 000000000..ad8ff34eb --- /dev/null +++ b/lib/pwa-notify/tab-reply.ts @@ -0,0 +1,27 @@ +import { create } from 'zustand'; + +/** A tab that hasn't got the message by then has been left behind. */ +const MAX_AGE_MS = 2 * 60 * 1000; + +interface TabReplyState { + pending: { emailId: string; text: string; at: number } | null; +} + +/** + * A notification's Reply in the Pro shell, where a message lives in a tab of + * its own that fetches it. Waits here, not in the tab store: that one is kept + * in localStorage, and a reply being typed is not for keeping. + */ +export const useNotificationTabReply = create(() => ({ pending: null })); + +export function requestTabReply(emailId: string, text: string): void { + useNotificationTabReply.setState({ pending: { emailId, text, at: Date.now() } }); +} + +/** The text for a reply to this message, once; null when none waits. */ +export function takeTabReply(emailId: string): string | null { + const { pending } = useNotificationTabReply.getState(); + if (!pending || pending.emailId !== emailId) return null; + useNotificationTabReply.setState({ pending: null }); + return Date.now() - pending.at < MAX_AGE_MS ? pending.text : null; +} diff --git a/lib/pwa-notify/worker-state.ts b/lib/pwa-notify/worker-state.ts new file mode 100644 index 000000000..161f3885c --- /dev/null +++ b/lib/pwa-notify/worker-state.ts @@ -0,0 +1,60 @@ +import { PUSH_STATE_CACHE } from '@/lib/push-preferences'; + +/** + * What the service worker needs for a notification's buttons. It has no + * settings store and no translations, so the page mirrors these into the + * worker's Cache store, as it does the sound setting (lib/push-preferences.ts). + * Signing out deletes the store with the rest of the push state. + */ +export interface NotificationActionPrefs { + archiveEnabled: boolean; + archiveMode: 'single' | 'year' | 'month'; + /** An enabled plugin may stop or watch these actions: the worker leaves the route alone. */ + pluginsMayVeto: boolean; + labels: { archive: string; markRead: string; reply: string; replyPlaceholder: string }; +} + +const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/+$/, ''); + +/** A reply typed on a notification waits this long for the page it opened. */ +const PENDING_REPLY_MAX_AGE_MS = 5 * 60 * 1000; + +/** The cache key sw.js reads the prefs from. */ +export function notificationActionPrefsKey(origin: string): string { + return `${origin}${BASE_PATH}/__push-state/actions`; +} + +/** The cache key sw.js keeps a reply typed on a notification under. */ +export function pendingReplyKey(origin: string): string { + return `${origin}${BASE_PATH}/__push-state/pending-reply`; +} + +export async function writeNotificationActionPrefs(prefs: NotificationActionPrefs): Promise { + try { + if (typeof caches === 'undefined') return; + const cache = await caches.open(PUSH_STATE_CACHE); + await cache.put( + notificationActionPrefsKey(window.location.origin), + new Response(JSON.stringify(prefs), { headers: { 'Content-Type': 'application/json' } }), + ); + } catch { + // Without the store the worker offers no Archive, in English. + } +} + +/** The text typed on this message's notification, once. */ +export async function consumePendingReplyText(emailId: string): Promise { + try { + if (typeof caches === 'undefined') return ''; + const cache = await caches.open(PUSH_STATE_CACHE); + const key = pendingReplyKey(window.location.origin); + const res = await cache.match(key); + if (!res) return ''; + const pending = (await res.json()) as { emailId?: unknown; text?: unknown; at?: unknown }; + await cache.delete(key); + const fresh = typeof pending.at === 'number' && Date.now() - pending.at < PENDING_REPLY_MAX_AGE_MS; + return fresh && pending.emailId === emailId && typeof pending.text === 'string' ? pending.text : ''; + } catch { + return ''; + } +} diff --git a/lib/recurrence-expansion.ts b/lib/recurrence-expansion.ts index 90897c253..f8e0f4a33 100644 --- a/lib/recurrence-expansion.ts +++ b/lib/recurrence-expansion.ts @@ -210,7 +210,7 @@ function validDate(value: string | null | undefined): Date | null { } /** The wall-clock fields of a parsed LocalDateTime, read as UTC. */ -function wallAsUtc(wall: Date): number { +export function wallClockFieldsAsUtc(wall: Date): number { return Date.UTC( wall.getFullYear(), wall.getMonth(), wall.getDate(), wall.getHours(), wall.getMinutes(), wall.getSeconds(), @@ -236,7 +236,7 @@ function plausibleInstants( ): { start: Date; end: Date | null } | null { const wall = validDate(event.start); if (!wall) return null; - const wallStart = wallAsUtc(wall); + const wallStart = wallClockFieldsAsUtc(wall); const start = instantNear(event.utcStart, wallStart); if (!start) return null; const end = instantNear(event.utcEnd, wallStart + (parseDurationSeconds(event.duration) ?? 0) * 1000); @@ -288,10 +288,10 @@ function wallClockAsUtcTimestamp(formatter: Intl.DateTimeFormat, instant: number * and return the corresponding UTC instant. Two fixup iterations converge * for all real offsets, including across DST transitions. */ -function zonedWallTimeToUtc(wall: Date, timeZone: string): Date | null { +export function zonedWallTimeToUtc(wall: Date, timeZone: string): Date | null { const formatter = getTzFormatter(timeZone); if (!formatter) return null; - const wallMs = wallAsUtc(wall); + const wallMs = wallClockFieldsAsUtc(wall); let guess = wallMs; for (let i = 0; i < 2; i++) { guess = wallMs - (wallClockAsUtcTimestamp(formatter, guess) - guess); diff --git a/lib/recurrence-instances.ts b/lib/recurrence-instances.ts index 715895d11..562e7b9e7 100644 --- a/lib/recurrence-instances.ts +++ b/lib/recurrence-instances.ts @@ -341,7 +341,7 @@ export function applyOverridePatch>(event: T, p * replaces whatever the entry patches beneath it, and one beneath a value the * entry sets goes into that value. */ -function mergeOverrideEntry(entry: Record, updates: Record): Record { +export function mergeOverrideEntry(entry: Record, updates: Record): Record { const merged = { ...entry }; for (const [key, value] of Object.entries(updates)) { for (const existing of Object.keys(merged)) { diff --git a/lib/recurrence-scope-edit.ts b/lib/recurrence-scope-edit.ts index 4de698447..7c45c9db5 100644 --- a/lib/recurrence-scope-edit.ts +++ b/lib/recurrence-scope-edit.ts @@ -645,6 +645,19 @@ function raisesSequences(series: Partial, master: CalendarEvent, */ export type OccurrenceOutcome = boolean | 'stopped'; +/** + * "This and all following" splits a series: ends the old one, creates the rest as + * a new one. Queued on a device these are two writes the server takes or refuses + * apart, with no one there to put the series back when only one goes through, so + * it waits for the server. + */ +export class SeriesSplitNeedsServerError extends Error { + constructor() { + super('Splitting a series needs the server'); + this.name = 'SeriesSplitNeedsServerError'; + } +} + /** The store actions resolve to false when a plugin stopped the write. */ function stopped(result: boolean | void): boolean { return result === false; @@ -1035,6 +1048,11 @@ export async function saveOccurrenceEdit( // From the first occurrence on is the whole series; ended before its // first occurrence, the series would be left with none. if (isFirstOccurrence(event, master)) return saveOccurrenceEdit('all', edit, actions); + if (client.answeredFromDevice) { + // Whether the server is there right now: the last answer may be minutes old. + await client.getCalendarEvent(master.originalId ?? master.id, master.accountId); + if (client.answeredFromDevice()) throw new SeriesSplitNeedsServerError(); + } const truncated = await truncateSeries(master, event, actions, { sendScheduling, userEmails: edit.userEmails }); if (!truncated) return 'stopped'; const { restore, announced, removed } = truncated; diff --git a/lib/sign-out-cleanup.ts b/lib/sign-out-cleanup.ts index 2b00aaf51..fbdd44507 100644 --- a/lib/sign-out-cleanup.ts +++ b/lib/sign-out-cleanup.ts @@ -1,4 +1,5 @@ import { fileStorage } from '@/lib/plugin-storage'; +import { clearHandoffs } from '@/lib/launch-handoff'; import { clearAllCheckpoints } from '@/lib/mail-import/checkpoints'; import { sweepPrivateExports } from '@/lib/mail-export/sinks'; @@ -114,6 +115,8 @@ export function purgeSignedOutData(): void { /* storage unavailable */ } forgetOfflineShell(); + // Files and links on their way into a draft or an import. + clearHandoffs(); for (const cleanup of signedOutCleanups) { try { cleanup(); diff --git a/lib/store/__tests__/calendar-client-modes.test.ts b/lib/store/__tests__/calendar-client-modes.test.ts new file mode 100644 index 000000000..35cb3ef7a --- /dev/null +++ b/lib/store/__tests__/calendar-client-modes.test.ts @@ -0,0 +1,212 @@ +// @vitest-environment node +import { afterEach, describe, expect, it } from 'vitest'; +import type { JMAPClient } from '@/lib/jmap/client'; +import type { CalendarEvent } from '@/lib/jmap/types'; +import { expandRecurringEvents } from '@/lib/recurrence-expansion'; +import { isBrowserExpandedOccurrence, isServerRecurrenceInstance } from '@/lib/recurrence-instances'; +import { calendarArea } from '../calendar'; +import { FakeCalendarJmap, type StalwartVersion } from './helpers/fake-calendar'; +import { wire, type Wire } from './helpers/client-wire'; + +const NOW = Date.UTC(2026, 10, 3, 12); +const RANGE = { after: '2026-11-02T00:00:00', before: '2026-11-23T00:00:00' }; + +let current: Wire | null = null; + +afterEach(() => { + current?.restore(); + current = null; +}); + +function seeded(version: StalwartVersion): Wire { + const server = new FakeCalendarJmap(version); + server.addEvent({ + id: 'series', + title: 'Weekly', + start: '2026-01-05T10:00:00', + recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly' }, + recurrenceOverrides: { + '2026-11-09T10:00:00': { title: 'Special' }, + '2026-11-16T10:00:00': { excluded: true }, + }, + }); + server.addEvent({ id: 'single', title: 'One off', start: '2026-11-10T09:00:00' }); + current = wire(server, [calendarArea], NOW); + return current; +} + +/** What the calendar grid shows for a range: the client's query, then the store's expansion. */ +async function grid(client: JMAPClient, range = RANGE): Promise { + const raw = await client.queryAllCalendarEvents(range); + return expandRecurringEvents(raw, range.after, range.before); +} + +function summary(events: CalendarEvent[]): Array<[string, string]> { + return events.map((event): [string, string] => [event.start, event.title]).sort(); +} + +const SHOWN = [ + ['2026-11-02T10:00:00', 'Weekly'], + ['2026-11-09T10:00:00', 'Special'], + ['2026-11-10T09:00:00', 'One off'], +]; + +const expandedAsked = (w: Wire) => w.sent.some(([name, args]) => name === 'CalendarEvent/query' && args.expandRecurrences === true); + +describe('a client that probed online and keeps its mode', () => { + for (const version of ['1.0', '0.16'] as const) { + it(`synthetic-id mode on ${version}: the expanded query gets a correct grid`, async () => { + const w = seeded(version); + await w.engine.requestSync(); + const client = await w.newClient(); + await client.getCalendars(); + expect(await client.supportsSyntheticCalendarIds()).toBe(true); + const online = await grid(client); + expect(online.some(isServerRecurrenceInstance)).toBe(true); + expect(summary(online)).toEqual(SHOWN); + + w.online = false; + w.sent.length = 0; + const offline = await grid(client); + expect(expandedAsked(w)).toBe(true); + expect(summary(offline)).toEqual(SHOWN); + // Occurrences the calendar store can write back as overrides on the master. + const occurrences = offline.filter((event) => event.recurrenceId); + expect(occurrences.length).toBe(2); + for (const occurrence of occurrences) { + expect(isBrowserExpandedOccurrence(occurrence)).toBe(true); + expect(occurrence.originalId).toBe('series'); + } + // Back online, the same client still asks the server to expand. + w.online = true; + expect((await grid(client)).some(isServerRecurrenceInstance)).toBe(true); + }); + } + + it('client-expansion mode on an old server: never asks to expand, same grid', async () => { + const w = seeded('0.16.19'); + await w.engine.requestSync(); + const client = await w.newClient(); + await client.getCalendars(); + expect(await client.supportsSyntheticCalendarIds()).toBe(false); + w.online = false; + w.sent.length = 0; + expect(summary(await grid(client))).toEqual(SHOWN); + expect(expandedAsked(w)).toBe(false); + // The second get with the overrides went out, as the online path sends it. + const gets = w.sent.filter(([name]) => name === 'CalendarEvent/get'); + expect(gets.some(([, args]) => (args.properties as string[]).join() === 'id,recurrenceOverrides')).toBe(true); + }); + + it('gives the grid across a range longer than one expanded query may span', async () => { + const w = seeded('1.0'); + await w.engine.requestSync(); + const client = await w.newClient(); + await client.getCalendars(); + w.online = false; + w.sent.length = 0; + const range = { after: '2026-11-02T00:00:00', before: '2028-11-02T00:00:00' }; + const events = await grid(client, range); + const queries = w.sent.filter(([name]) => name === 'CalendarEvent/query'); + expect(queries.length).toBeGreaterThan(1); + expect(new Set(events.map((event) => event.id)).size).toBe(events.length); + expect(events.filter((event) => event.title === 'Weekly').length).toBeGreaterThan(90); + expect(events.filter((event) => event.title === 'Special')).toHaveLength(1); + }); +}); + +describe('a client started offline', () => { + it('takes the mode the server had at the last sync, so it behaves the same once the server is back', async () => { + const w = seeded('1.0'); + await w.engine.requestSync(); + w.online = false; + const client = await w.newClient(); + await client.getCalendars(); + expect(await client.supportsSyntheticCalendarIds()).toBe(true); + expect(summary(await grid(client))).toEqual(SHOWN); + w.online = true; + expect((await grid(client)).some(isServerRecurrenceInstance)).toBe(true); + }); + + it('does not ask to expand on a server that had no synthetic ids', async () => { + const w = seeded('0.16.19'); + await w.engine.requestSync(); + w.online = false; + w.sent.length = 0; + const client = await w.newClient(); + await client.getCalendars(); + expect(await client.supportsSyntheticCalendarIds()).toBe(false); + expect(summary(await grid(client))).toEqual(SHOWN); + expect(expandedAsked(w)).toBe(false); + }); + + it('uses client-side expansion before any sync, and a failed read does not look like an empty calendar', async () => { + const w = seeded('1.0'); + w.online = false; + const client = await w.newClient(); + expect(await client.supportsSyntheticCalendarIds()).toBe(false); + await expect(client.queryAllCalendarEvents(RANGE)).rejects.toThrow(); + }); +}); + +describe('reads and writes through the real client', () => { + it('reads back a created event under its stand-in id, then edits and deletes it', async () => { + const w = seeded('1.0'); + await w.engine.requestSync(); + const client = await w.newClient(); + await client.getCalendars(); + w.online = false; + + const created = await client.createCalendarEvent({ + title: 'Offline', + start: '2026-11-11T08:00:00', + duration: 'PT1H', + timeZone: 'Europe/Berlin', + calendarIds: { cal1: true }, + } as Partial); + expect(created.id).toMatch(/^local-/); + expect(created.title).toBe('Offline'); + expect(created.utcStart).toBe('2026-11-11T07:00:00Z'); + expect(summary(await grid(client))).toContainEqual(['2026-11-11T08:00:00', 'Offline']); + + await client.updateCalendarEvent(created.id, { title: 'Renamed' }); + expect(summary(await grid(client))).toContainEqual(['2026-11-11T08:00:00', 'Renamed']); + + await client.deleteCalendarEvent(created.id); + expect(summary(await grid(client))).toEqual(SHOWN); + expect(await client.getCalendarEvent(created.id)).toBeNull(); + expect(w.server.events.size).toBe(2); + }); + + it('fetches overrides with its one-event read', async () => { + const w = seeded('1.0'); + await w.engine.requestSync(); + const client = await w.newClient(); + w.online = false; + const event = await client.getCalendarEvent('series'); + expect(event?.recurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'weekly' }]); + expect(Object.keys(event?.recurrenceOverrides ?? {})).toHaveLength(2); + expect(event?.utcStart).toBe('2026-01-05T09:00:00Z'); + }); + + it('lists tasks through the shape fetchTaskObjects reads', async () => { + const w = seeded('1.0'); + w.server.addEvent({ id: 'task', '@type': 'Task', title: 'To do', start: undefined, due: '2026-11-12T10:00:00', progress: 'needs-action' }); + await w.engine.requestSync(); + const client = await w.newClient(); + w.online = false; + const tasks = await client.getCalendarTasks(); + expect(tasks.map((task) => [task.id, task.title, task.due])).toEqual([['task', 'To do', '2026-11-12T10:00:00']]); + expect(summary(await grid(client)).map(([, title]) => title)).not.toContain('To do'); + }); + + it('marks tasks-only calendars and reads identities from the last sync', async () => { + const w = seeded('1.0'); + await w.engine.requestSync(); + const client = await w.newClient(); + w.online = false; + const calendars = await client.getCalendars(); + expect(calendars.map((calendar) => [calendar.id, calendar.isTasksOnly])).toEqual([['cal1', false]]); + expect((await client.getParticipantIdentities())[0].calendarAddress).toBe('mailto:a@example.org'); + }); +}); diff --git a/lib/store/__tests__/calendar-no-dom.test.ts b/lib/store/__tests__/calendar-no-dom.test.ts new file mode 100644 index 000000000..8cd3d10a1 --- /dev/null +++ b/lib/store/__tests__/calendar-no-dom.test.ts @@ -0,0 +1,32 @@ +// @vitest-environment node +import path from 'node:path'; +import { build } from 'esbuild'; +import { describe, expect, it } from 'vitest'; + +const root = path.resolve(__dirname, '../../..'); + +async function inputsOf(entry: string): Promise { + const result = await build({ + entryPoints: [path.join(root, entry)], + bundle: true, + write: false, + format: 'esm', + platform: 'browser', + alias: { '@': root }, + metafile: true, + logLevel: 'silent', + // Stay away from the packages' own graph: only our files matter here. + packages: 'external', + }); + return Object.keys(result.metafile.inputs); +} + +describe('what the worker pulls in', () => { + for (const entry of ['lib/store/calendar/index.ts', 'lib/store/calendar/reminders.ts']) { + it(`${entry} reaches no UI code`, async () => { + const inputs = await inputsOf(entry); + expect(inputs.length).toBeGreaterThan(3); + expect(inputs.filter((file) => /(^|\/)(components|stores|hooks|app)\//.test(file))).toEqual([]); + }, 30_000); + } +}); diff --git a/lib/store/__tests__/calendar-sync.test.ts b/lib/store/__tests__/calendar-sync.test.ts new file mode 100644 index 000000000..2b3e90c65 --- /dev/null +++ b/lib/store/__tests__/calendar-sync.test.ts @@ -0,0 +1,948 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { DEFAULT_SCOPE, type StoreEvent } from '../area'; +import { calendarArea } from '../calendar'; +import { listUpcomingAlerts } from '../calendar/reminders'; +import { AccountEngine } from '../engine/account-engine'; +import { TransportError, type MethodCall } from '../engine/transport'; +import type { CalendarConflict } from '../calendar/writes'; +import { CAL, CALENDAR_SESSION, FakeCalendarJmap, type StalwartVersion } from './helpers/fake-calendar'; +import { JMAP_ACCOUNT } from './helpers/fake-jmap'; +import { openMemoryStore } from './helpers/node-adapter'; + +const NOW = Date.UTC(2026, 10, 3, 12); +const WEEKLY = { '@type': 'RecurrenceRule', frequency: 'weekly' }; + +function setup(version: StalwartVersion = '1.0', server = new FakeCalendarJmap(version), session = CALENDAR_SESSION) { + const db = openMemoryStore(); + const events: StoreEvent[] = []; + const engine = new AccountEngine({ + account: 'a@fake.test', + db, + session, + transport: server, + areas: [calendarArea], + scope: DEFAULT_SCOPE, + emit: (event) => events.push(event), + now: () => NOW, + sleep: async () => {}, + }); + return { server, db, engine, events }; +} + +type Setup = ReturnType; + +async function call(s: Setup, name: string, args: Record) { + // A companion call: the engine answers null when nothing at all could be answered. + const response = await s.engine.answer([ + [name, { accountId: JMAP_ACCOUNT, ...args }, 'x'], + ['CalendarEventNotification/get', { accountId: JMAP_ACCOUNT, ids: [] }, 'y'], + ]); + return response!.methodResponses[0]; +} + +async function queryIds(s: Setup, args: Record): Promise { + const [name, result] = await call(s, 'CalendarEvent/query', args); + expect(name, JSON.stringify(result)).toBe('CalendarEvent/query'); + return result.ids; +} + +async function getOne(s: Setup, id: string, properties?: string[], timeZone?: string): Promise | undefined> { + const [, result] = await call(s, 'CalendarEvent/get', { ids: [id], ...(properties ? { properties } : {}), ...(timeZone ? { timeZone } : {}) }); + return result.list[0]; +} + +async function setEvent(s: Setup, args: Record) { + const [name, result] = await call(s, 'CalendarEvent/set', args); + expect(name).toBe('CalendarEvent/set'); + return result; +} + +const flush = (s: Setup) => s.engine.areaCall('calendar.flush', undefined) as Promise<{ sent: number; conflicts: number; pending: number }>; +const conflicts = (s: Setup) => s.engine.areaCall('calendar.conflicts', undefined) as Promise; +const pendingRows = (s: Setup) => s.db.all<{ kind: string; patch: string; record_id: string }>('SELECT kind, patch, record_id FROM calendar_pending'); +const setCalls = (server: FakeCalendarJmap): MethodCall[] => server.calls.filter(([name]) => name === 'CalendarEvent/set') + .filter(([, args]) => !(args.update && 'h333333' in (args.update as object))); + +describe('calendar sync', () => { + it('keeps a recurring event as its master across sync, with its overrides', async () => { + const s = setup(); + s.server.addEvent({ + id: 'series', + recurrenceRule: WEEKLY, + start: '2026-01-05T10:00:00', + recurrenceOverrides: { '2026-11-09T10:00:00': { title: 'Moved' }, '2026-11-16T10:00:00': { excluded: true } }, + }); + await s.engine.requestSync(); + expect(s.db.all('SELECT id FROM calendar_event').map((row) => row.id)).toEqual(['series']); + const stored = await getOne(s, 'series'); + expect(stored?.recurrenceRule).toEqual(WEEKLY); + expect(stored?.recurrenceOverrides).toEqual({ '2026-11-09T10:00:00': { title: 'Moved' }, '2026-11-16T10:00:00': { excluded: true } }); + }); + + it('asks for overrides without the utc properties, which 1.0 refuses next to them', async () => { + const s = setup('1.0'); + s.server.addEvent({ id: 'series', recurrenceRule: WEEKLY, recurrenceOverrides: { '2026-11-17T10:00:00': { title: 'x' } } }); + await s.engine.requestSync(); + const events = s.server.calls.filter(([name]) => name === 'CalendarEvent/get'); + const withOverrides = events.filter(([, args]) => (args.properties as string[]).includes('recurrenceOverrides')); + expect(withOverrides.length).toBeGreaterThan(0); + for (const [, args] of withOverrides) expect(args.properties).not.toContain('utcStart'); + expect(await getOne(s, 'series')).toMatchObject({ recurrenceOverrides: { '2026-11-17T10:00:00': { title: 'x' } } }); + }); + + it('never runs an expanded query or stores a synthetic id', async () => { + const s = setup('0.16'); + s.server.addEvent({ id: 'series', recurrenceRule: WEEKLY }); + s.server.addEvent({ id: 'single' }); + await s.engine.requestSync(); + expect(s.server.calls.filter(([, args]) => args.expandRecurrences)).toEqual([]); + expect(s.db.all('SELECT id FROM calendar_event ORDER BY id').map((row) => row.id)).toEqual(['series', 'single']); + }); + + it('pages past 500 objects', async () => { + const s = setup(); + for (let i = 0; i < 1234; i += 1) s.server.addEvent(); + await s.engine.requestSync(); + expect(Number(s.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM calendar_event')?.n)).toBe(1234); + expect(s.server.calls.filter(([name]) => name === 'CalendarEvent/query').length).toBe(3); + }); + + it('takes an edit of one occurrence from /changes as an update of the master', async () => { + const s = setup('1.0'); + s.server.addEvent({ id: 'series', recurrenceRule: WEEKLY, start: '2026-11-02T10:00:00' }); + await s.engine.requestSync(); + // Someone edits one occurrence through its synthetic id. + const [, expanded] = (await s.server.request([['CalendarEvent/query', { + accountId: JMAP_ACCOUNT, filter: { after: '2026-11-08T00:00:00', before: '2026-11-10T00:00:00' }, expandRecurrences: true, + }, 'q']])).methodResponses[0]; + expect(expanded.canCalculateChanges).toBe(false); + await s.server.request([['CalendarEvent/set', { accountId: JMAP_ACCOUNT, update: { [expanded.ids[0]]: { title: 'One off' } } }, 'u']]); + await s.engine.requestSync(); + expect(s.db.all('SELECT id FROM calendar_event').map((row) => row.id)).toEqual(['series']); + expect(await getOne(s, 'series')).toMatchObject({ + recurrenceOverrides: { '2026-11-09T10:00:00': { title: 'One off' } }, + }); + }); + + it('keeps old single events out and old series and tasks in, whatever their start', async () => { + const s = setup(); + s.server.addEvent({ id: 'ancient', start: '2020-01-01T10:00:00' }); + s.server.addEvent({ id: 'recent', start: '2025-06-01T10:00:00' }); + s.server.addEvent({ id: 'future', start: '2040-01-01T10:00:00' }); + s.server.addEvent({ id: 'old-series', start: '2015-01-01T10:00:00', recurrenceRule: WEEKLY }); + s.server.addEvent({ id: 'old-task', '@type': 'Task', start: undefined, due: '2015-01-01T10:00:00' }); + await s.engine.requestSync(); + expect(s.db.all('SELECT id FROM calendar_event ORDER BY id').map((row) => row.id)) + .toEqual(['future', 'old-series', 'old-task', 'recent']); + }); + + it('starts over when the log is gone, and drops what the server no longer has', async () => { + const s = setup(); + s.server.addEvent({ id: 'stays' }); + s.server.addEvent({ id: 'goes' }); + await s.engine.requestSync(); + s.server.events.delete('goes'); + s.server.eventLog.push('destroyed', 'goes'); + s.server.eventLog.truncatedBelow = s.server.eventLog.log.length; + await s.engine.requestSync(); + expect(s.db.all('SELECT id FROM calendar_event').map((row) => row.id)).toEqual(['stays']); + }); + + it('keeps ids of two JMAP accounts apart', async () => { + const s = setup('1.0', undefined, { + ...CALENDAR_SESSION, + accounts: { ...CALENDAR_SESSION.accounts, other: { name: 'other', isPersonal: false, accountCapabilities: { [CAL]: {} } } }, + }); + s.server.addEvent({ id: 'same', title: 'Mine' }); + await s.engine.requestSync(); + await call(s, 'CalendarEvent/set', { accountId: 'other', update: { same: { title: 'Theirs' } } }); + expect((await getOne(s, 'same'))?.title).toBe('Mine'); + const row = s.db.get<{ json: string }>("SELECT json FROM calendar_event WHERE jmap = 'other' AND id = 'same'"); + expect(JSON.parse(row!.json).title).toBe('Theirs'); + }); + + it('stores calendars and participant identities and answers them with the real state', async () => { + const s = setup(); + await s.engine.requestSync(); + const [, calendars] = await call(s, 'Calendar/get', { properties: ['id', 'name'] }); + expect(calendars.list).toEqual([{ id: 'cal1', name: 'Personal' }]); + expect(calendars.state).toBe('cs1'); + const [, identities] = await call(s, 'ParticipantIdentity/get', {}); + expect(identities.list[0].calendarAddress).toBe('mailto:a@example.org'); + }); + + it('refuses to read before anything was kept, instead of saying there is nothing', async () => { + const s = setup(); + const [name, result] = await call(s, 'CalendarEvent/query', {}); + expect(name).toBe('error'); + expect(result.type).toBe('serverUnavailable'); + expect((await call(s, 'Calendar/get', {}))[0]).toBe('error'); + }); +}); + +describe('queries from the store', () => { + async function seeded() { + const s = setup(); + s.server.addEvent({ id: 'late', title: 'Late meeting', start: '2026-11-10T23:30:00', duration: 'PT1H', timeZone: 'Europe/Berlin' }); + s.server.addEvent({ + id: 'lunch', + title: 'Lünch with Bob', + description: 'Bring the slides', + start: '2026-11-11T12:00:00', + calendarIds: { cal2: true }, + locations: { l: { '@type': 'Location', name: 'Room Seven' } }, + organizerCalendarAddress: 'mailto:owner@example.org', + participants: { + p1: { '@type': 'Participant', name: 'Bob', calendarAddress: 'mailto:bob@example.org', roles: { attendee: true }, participationStatus: 'accepted' }, + p2: { '@type': 'Participant', name: 'Olga', calendarAddress: 'mailto:olga@example.org', roles: { owner: true }, participationStatus: 'declined' }, + }, + }); + s.server.addEvent({ id: 'series', title: 'Weekly', start: '2026-01-05T10:00:00', recurrenceRule: WEEKLY }); + s.server.addEvent({ id: 'ended', title: 'Ended', start: '2026-01-05T10:00:00', recurrenceRule: { ...WEEKLY, count: 2 } }); + s.server.addEvent({ id: 'task', '@type': 'Task', title: 'Task', start: undefined, due: '2026-11-12T10:00:00' }); + await s.engine.requestSync(); + return s; + } + + const ids = async (s: Setup, filter: unknown, extra: Record = {}) => (await queryIds(s, { filter, ...extra })).sort(); + + it('answers each filter the client can send', async () => { + const s = await seeded(); + expect(await ids(s, { inCalendar: 'cal2' })).toEqual(['lunch']); + expect(await ids(s, { inCalendars: ['cal2', 'nope'] })).toEqual(['lunch']); + expect(await ids(s, { title: 'lunch' })).toEqual(['lunch']); + expect(await ids(s, { description: 'slides' })).toEqual(['lunch']); + expect(await ids(s, { location: 'seven' })).toEqual(['lunch']); + expect(await ids(s, { owner: 'olga' })).toEqual(['lunch']); + expect(await ids(s, { owner: 'bob' })).toEqual([]); + expect(await ids(s, { attendee: 'bob' })).toEqual(['lunch']); + expect(await ids(s, { attendee: 'olga' })).toEqual([]); + expect(await ids(s, { participationStatus: 'declined' })).toEqual(['lunch']); + expect(await ids(s, { attendee: 'bob', participationStatus: 'declined' })).toEqual([]); + expect(await ids(s, { uid: 'lunch@example.org' })).toEqual(['lunch']); + expect(await ids(s, { uid: 'LUNCH@example.org' })).toEqual([]); + expect(await ids(s, { types: ['Task'] })).toEqual(['task']); + expect(await ids(s, { text: 'BOB' })).toEqual(['lunch']); + expect(await ids(s, { text: 'lunch bob' })).toEqual(['lunch']); + expect(await ids(s, { text: 'lunch nothing' })).toEqual([]); + }); + + it('answers operators', async () => { + const s = await seeded(); + expect(await ids(s, { operator: 'OR', conditions: [{ uid: 'late@example.org' }, { uid: 'task@example.org' }] })).toEqual(['late', 'task']); + expect(await ids(s, { operator: 'AND', conditions: [{ text: 'bob' }, { inCalendar: 'cal2' }] })).toEqual(['lunch']); + expect(await ids(s, { operator: 'NOT', conditions: [{ title: 'weekly' }, { title: 'ended' }, { title: 'late' }, { types: ['Task'] }] })).toEqual(['lunch']); + }); + + it('gives a per-call error for a filter it does not know', async () => { + const s = await seeded(); + const [name, result] = await call(s, 'CalendarEvent/query', { filter: { nonsense: 1 } }); + expect(name).toBe('error'); + expect(result.type).toBe('unsupportedFilter'); + }); + + it('reads bounds as wall clock in the request zone', async () => { + const s = await seeded(); + // 23:30 in Berlin on the 10th is 17:30 in New York, and 22:30 UTC. + const day10 = { after: '2026-11-10T00:00:00', before: '2026-11-11T00:00:00' }; + expect(await ids(s, day10, { timeZone: 'Europe/Berlin' })).toContain('late'); + const night = { after: '2026-11-10T22:45:00', before: '2026-11-10T23:00:00' }; + expect(await ids(s, night, { timeZone: 'Europe/Berlin' })).not.toContain('late'); + expect(await ids(s, night)).toContain('late'); + // A Z says nothing: Stalwart drops it and reads the wall clock. + expect(await ids(s, { after: '2026-11-10T22:45:00Z', before: '2026-11-10T23:00:00Z' }, { timeZone: 'Europe/Berlin' })).not.toContain('late'); + expect(await ids(s, { after: '2026-11-10T17:00:00', before: '2026-11-10T18:00:00' }, { timeZone: 'America/New_York' })).toContain('late'); + }); + + it('matches a series by any occurrence, once, and not when it has none in range', async () => { + const s = await seeded(); + const later = { after: '2026-11-16T00:00:00', before: '2026-11-17T00:00:00' }; + expect(await ids(s, later)).toEqual(['series']); + const bare = { operator: 'AND', conditions: [{ after: later.after }, { before: later.before }] }; + expect(await ids(s, bare)).toEqual(['series']); + // `ended` has two occurrences in January and none after. + expect(await ids(s, { after: '2026-01-05T00:00:00', before: '2026-01-06T00:00:00' })).toEqual(['ended', 'series']); + expect(await ids(s, { after: '2026-02-02T00:00:00', before: '2026-02-03T00:00:00' })).toEqual(['series']); + }); + + it('sorts, pages and counts', async () => { + const s = await seeded(); + const sort = [{ property: 'start', isAscending: false }]; + const [, first] = await call(s, 'CalendarEvent/query', { filter: { types: ['Event'] }, sort, limit: 2, calculateTotal: true }); + expect(first.ids).toEqual(['lunch', 'late']); + expect(first.total).toBe(4); + const [, second] = await call(s, 'CalendarEvent/query', { filter: { types: ['Event'] }, sort, limit: 2, position: 2 }); + expect(second.ids.sort()).toEqual(['ended', 'series']); + expect(first.canCalculateChanges).toBe(false); + }); + + it('returns utcStart for the request zone, and reads a floating event in it', async () => { + const s = setup(); + s.server.addEvent({ id: 'floating', start: '2026-11-10T10:00:00', timeZone: null, duration: 'PT1H' }); + s.server.addEvent({ id: 'zoned', start: '2026-11-10T10:00:00', timeZone: 'Europe/Berlin', duration: 'PT1H' }); + await s.engine.requestSync(); + expect(await getOne(s, 'floating', ['utcStart', 'utcEnd'], 'America/New_York')).toEqual({ + id: 'floating', utcStart: '2026-11-10T15:00:00Z', utcEnd: '2026-11-10T16:00:00Z', + }); + expect((await getOne(s, 'zoned', ['utcStart'], 'America/New_York'))?.utcStart).toBe('2026-11-10T09:00:00Z'); + }); + + it('answers the task list like the client reads it', async () => { + const s = await seeded(); + const list = await ids(s, undefined, { limit: 1000 }); + expect(list).toContain('task'); + const task = await getOne(s, 'task', ['id', '@type', 'due', 'progress', 'title']); + expect(task).toMatchObject({ '@type': 'Task', due: '2026-11-12T10:00:00', title: 'Task' }); + }); + + it('answers notifications with nothing rather than failing', async () => { + const s = await seeded(); + const response = await s.engine.answer([ + ['CalendarEventNotification/query', { accountId: JMAP_ACCOUNT, sort: [] }, '0'], + ['CalendarEventNotification/get', { accountId: JMAP_ACCOUNT, '#ids': { resultOf: '0', name: 'CalendarEventNotification/query', path: '/ids' } }, '1'], + ]); + expect(response!.methodResponses[1][1].list).toEqual([]); + }); + + it('refuses an expanded query without both bounds, as both servers do', async () => { + const s = await seeded(); + const [name, result] = await call(s, 'CalendarEvent/query', { filter: { after: '2026-11-16T00:00:00' }, expandRecurrences: true }); + expect(name).toBe('error'); + expect(result.type).toBe('invalidArguments'); + }); +}); + +describe('writes while offline', () => { + async function synced(version: StalwartVersion = '1.0') { + const s = setup(version); + s.server.addEvent({ + id: 'e1', + title: 'Meeting', + participants: { + me: { '@type': 'Participant', calendarAddress: 'mailto:a@example.org', participationStatus: 'needs-action', roles: { attendee: true } }, + bob: { '@type': 'Participant', calendarAddress: 'mailto:bob@example.org', participationStatus: 'accepted', roles: { attendee: true } }, + }, + }); + await s.engine.requestSync(); + s.server.calls.length = 0; + return s; + } + + it('merges edits of one event into one patch, and shows them at once', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'First' } } }); + await setEvent(s, { update: { e1: { description: 'Added' } } }); + await setEvent(s, { update: { e1: { title: 'Second' } } }); + expect(pendingRows(s)).toHaveLength(1); + expect(await getOne(s, 'e1')).toMatchObject({ title: 'Second', description: 'Added' }); + + await flush(s); + const sent = setCalls(s.server); + expect(sent).toHaveLength(1); + expect((sent[0][1].update as Record).e1).toEqual({ title: 'Second', description: 'Added' }); + expect(s.server.events.get('e1')).toMatchObject({ title: 'Second', description: 'Added' }); + expect(pendingRows(s)).toEqual([]); + }); + + it('merges a pointer into a value set whole, and a whole value over pointers beneath it', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { locations: { a: { name: 'One' } } } } }); + await setEvent(s, { update: { e1: { 'locations/b': { name: 'Two' } } } }); + expect(JSON.parse(pendingRows(s)[0].patch)).toEqual({ locations: { a: { name: 'One' }, b: { name: 'Two' } } }); + + const other = await synced(); + await setEvent(other, { update: { e1: { 'participants/me/participationStatus': 'accepted' } } }); + await setEvent(other, { update: { e1: { participants: { only: { name: 'Solo' } } } } }); + expect(JSON.parse(pendingRows(other)[0].patch)).toEqual({ participants: { only: { name: 'Solo' } } }); + }); + + it('queues an RSVP as a participant patch and sends it with scheduling on', async () => { + const s = await synced(); + const answer = await setEvent(s, { + update: { e1: { 'participants/me/participationStatus': 'accepted' } }, + sendSchedulingMessages: true, + }); + expect(answer.updated).toEqual({ e1: null }); + expect(s.server.events.get('e1')?.participants).toMatchObject({ me: { participationStatus: 'needs-action' } }); + const shown = await getOne(s, 'e1') as { participants: Record }; + expect(shown.participants.me.participationStatus).toBe('accepted'); + expect(shown.participants.bob.participationStatus).toBe('accepted'); + + await flush(s); + const [, args] = setCalls(s.server)[0]; + expect(args.sendSchedulingMessages).toBe(true); + expect(args.update).toEqual({ e1: { 'participants/me/participationStatus': 'accepted' } }); + expect(s.server.events.get('e1')?.participants).toMatchObject({ me: { participationStatus: 'accepted' }, bob: { participationStatus: 'accepted' } }); + }); + + it('survives a sync while pending: the server copy moves on underneath, the edit stays on top', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Mine' } } }); + s.server.patchEvent('e1', { description: 'Theirs' }); + // The delta gets through, the write doesn't. + const realRequest = s.server.request.bind(s.server); + let refuseWrites = true; + s.server.request = async (calls: MethodCall[]) => { + if (refuseWrites && calls.some(([name, args]) => name === 'CalendarEvent/set' && args.update && 'e1' in (args.update as object))) { + throw new TransportError('network', 'Failed to fetch'); + } + return realRequest(calls); + }; + await s.engine.requestSync(); + expect(pendingRows(s)).toHaveLength(1); + expect(s.server.events.get('e1')).toMatchObject({ title: 'Meeting', description: 'Theirs' }); + expect(await getOne(s, 'e1')).toMatchObject({ title: 'Mine', description: 'Theirs' }); + + refuseWrites = false; + await s.engine.requestSync(); + expect(await conflicts(s)).toEqual([]); + expect(s.server.events.get('e1')).toMatchObject({ title: 'Mine', description: 'Theirs' }); + expect(await getOne(s, 'e1')).toMatchObject({ title: 'Mine', description: 'Theirs' }); + }); + + it('sends an edit that came in during a send, and takes its own first write for its own', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'One' } } }); + const realRequest = s.server.request.bind(s.server); + let once = true; + s.server.request = async (calls: MethodCall[]) => { + const response = await realRequest(calls); + if (once && calls.some(([name]) => name === 'CalendarEvent/set')) { + once = false; + await setEvent(s, { update: { e1: { title: 'Two', description: 'During' } } }); + } + return response; + }; + await flush(s); + expect(await conflicts(s)).toEqual([]); + expect(s.server.events.get('e1')).toMatchObject({ title: 'Two', description: 'During' }); + expect(pendingRows(s)).toEqual([]); + }); + + it('does not take an edit whose answer got lost for a conflict when it comes back through sync', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Sent once' } } }); + s.server.loseNextSetResponse = true; + expect(await flush(s)).toMatchObject({ sent: 0, pending: 1 }); + expect(s.server.events.get('e1')?.title).toBe('Sent once'); + // The next sync reads it back, so there's nothing left to send. + expect(await flush(s)).toMatchObject({ pending: 0 }); + expect(await conflicts(s)).toEqual([]); + }); + + it('gives out a stand-in id on create that later edits and deletes can use', async () => { + const s = await synced(); + const result = await setEvent(s, { create: { 'new-event': { title: 'Fresh', calendarIds: { cal1: true }, start: '2026-11-20T09:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin' } } }); + const id = result.created['new-event'].id as string; + expect(await getOne(s, id, ['title', 'utcStart', 'uid'])).toMatchObject({ title: 'Fresh', utcStart: '2026-11-20T08:00:00Z' }); + expect(await queryIds(s, { filter: { after: '2026-11-20T00:00:00', before: '2026-11-21T00:00:00' } })).toEqual([id]); + + await setEvent(s, { update: { [id]: { title: 'Fresher', 'locations/a': { name: 'Here' } } } }); + expect(pendingRows(s)).toHaveLength(1); + expect(pendingRows(s)[0].kind).toBe('create'); + expect(await getOne(s, id)).toMatchObject({ title: 'Fresher', locations: { a: { name: 'Here' } } }); + + await flush(s); + const creates = setCalls(s.server).filter(([, args]) => args.create); + expect(creates).toHaveLength(1); + expect(s.server.events.size).toBe(2); + const onServer = [...s.server.events.values()].find((event) => event.title === 'Fresher'); + expect(onServer?.locations).toEqual({ a: { name: 'Here' } }); + expect(await getOne(s, id)).toMatchObject({ id: onServer?.id, title: 'Fresher' }); + expect(s.db.get('SELECT 1 AS x FROM calendar_event WHERE id = ?', [id])).toBeUndefined(); + }); + + it('sends nothing for a create deleted again before it left', async () => { + const s = await synced(); + const result = await setEvent(s, { create: { c: { title: 'Gone', start: '2026-11-20T09:00:00', calendarIds: { cal1: true } } } }); + const id = result.created.c.id as string; + const gone = await setEvent(s, { destroy: [id] }); + expect(gone.destroyed).toEqual([id]); + expect(await getOne(s, id)).toBeUndefined(); + await flush(s); + expect(setCalls(s.server)).toEqual([]); + expect(s.server.events.size).toBe(1); + }); + + it('sends an edit then delete as just the delete, and hides it meanwhile', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Doomed' } } }); + await setEvent(s, { destroy: ['e1'] }); + expect(pendingRows(s)[0].kind).toBe('destroy'); + expect(await getOne(s, 'e1')).toBeUndefined(); + expect(await queryIds(s, {})).toEqual([]); + await flush(s); + expect(setCalls(s.server).map(([, args]) => args.destroy)).toEqual([['e1']]); + expect(s.server.events.has('e1')).toBe(false); + expect(s.db.get('SELECT 1 AS x FROM calendar_event WHERE id = ?', ['e1'])).toBeUndefined(); + }); + + it('takes a delete of an event already gone as done', async () => { + const s = await synced(); + await setEvent(s, { destroy: ['e1'] }); + s.server.removeEvent('e1'); + await flush(s); + expect(await conflicts(s)).toEqual([]); + expect(pendingRows(s)).toEqual([]); + }); + + it('keeps writes across a run without a network and sends them after', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Later' } } }); + s.server.offline = true; + const offline = await flush(s); + expect(offline).toMatchObject({ sent: 0, pending: 1 }); + expect(await getOne(s, 'e1')).toMatchObject({ title: 'Later' }); + s.server.offline = false; + expect(await flush(s)).toMatchObject({ sent: 1, pending: 0 }); + expect(s.server.events.get('e1')?.title).toBe('Later'); + }); + + it('flushes during a normal sync pass too', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'By sync' } } }); + await s.engine.requestSync(); + expect(s.server.events.get('e1')?.title).toBe('By sync'); + expect(pendingRows(s)).toEqual([]); + }); + + it('tells the page something changed', async () => { + const s = await synced(); + s.events.length = 0; + await setEvent(s, { update: { e1: { title: 'x' } } }); + expect(s.events).toContainEqual({ type: 'changed', account: 'a@fake.test', collections: ['CalendarEvent'] }); + }); +}); + +describe('a create and a lost response', () => { + const NEW = { title: 'Once', calendarIds: { cal1: true }, start: '2026-11-20T09:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin' }; + + async function queued() { + const s = setup(); + await s.engine.requestSync(); + s.server.calls.length = 0; + const result = await setEvent(s, { create: { c: NEW } }); + return { s, id: result.created.c.id as string, uid: result.created.c.uid as string }; + } + + it('gives the event a uid of its own before it goes out', async () => { + const { s, uid } = await queued(); + expect(uid).toBeTruthy(); + await flush(s); + expect([...s.server.events.values()][0].uid).toBe(uid); + }); + + it('keeps a uid the client chose', async () => { + const s = setup(); + await s.engine.requestSync(); + const result = await setEvent(s, { create: { c: { ...NEW, uid: 'mine@example.org' } } }); + expect(result.created.c.uid).toBe('mine@example.org'); + }); + + it('finds the event by uid instead of creating it again', async () => { + const { s, id, uid } = await queued(); + s.server.loseNextSetResponse = true; + expect(await flush(s)).toMatchObject({ sent: 0, pending: 1 }); + expect(s.server.events.size).toBe(1); + + expect(await flush(s)).toMatchObject({ pending: 0 }); + expect(s.server.events.size).toBe(1); + expect(setCalls(s.server).filter(([, args]) => args.create)).toHaveLength(1); + expect(uid).toBe([...s.server.events.values()][0].uid); + const real = [...s.server.events.keys()][0]; + expect(await queryIds(s, {})).toEqual([real]); + expect(await getOne(s, id)).toMatchObject({ id: real }); + expect(await conflicts(s)).toEqual([]); + }); + + it('shows one event when a sync finds the created event before the flush does', async () => { + const { s } = await queued(); + s.server.loseNextSetResponse = true; + await flush(s); + await s.engine.requestSync(); + expect((await queryIds(s, {}))).toHaveLength(1); + expect(s.server.events.size).toBe(1); + }); + + it('creates it after all when the request never reached the server', async () => { + const { s } = await queued(); + // The attempt is marked, the network drops before it arrives. + const realRequest = s.server.request.bind(s.server); + let dropped = false; + s.server.request = async (calls: MethodCall[]) => { + if (!dropped && calls.some(([name, args]) => name === 'CalendarEvent/set' && args.create)) { + dropped = true; + throw new TransportError('network', 'Failed to fetch'); + } + return realRequest(calls); + }; + await flush(s); + expect(s.server.events.size).toBe(0); + expect(await flush(s)).toMatchObject({ sent: 1, pending: 0 }); + expect(s.server.events.size).toBe(1); + }); + + it('treats the uid refusal as "it exists already", should the mark have been lost', async () => { + const { s, uid } = await queued(); + s.db.run('UPDATE calendar_pending SET attempted = 0'); + s.server.addEvent({ ...NEW, uid, id: 'already' }); + await flush(s); + expect(s.server.events.size).toBe(1); + expect(await conflicts(s)).toEqual([]); + expect(await queryIds(s, {})).toEqual(['already']); + }); + + it('follows a create with its delete when it went out before the delete came', async () => { + const { s, id } = await queued(); + s.server.loseNextSetResponse = true; + await flush(s); + expect(s.server.events.size).toBe(1); + await setEvent(s, { destroy: [id] }); + expect(await queryIds(s, {})).toEqual([]); + await flush(s); + expect(s.server.events.size).toBe(0); + expect(pendingRows(s)).toEqual([]); + }); + + it('applies a write that came in during the send as a write of its own', async () => { + const { s, id } = await queued(); + const realRequest = s.server.request.bind(s.server); + s.server.request = async (calls: MethodCall[]) => { + const response = await realRequest(calls); + if (calls.some(([name, args]) => name === 'CalendarEvent/set' && args.create)) { + await setEvent(s, { update: { [id]: { title: 'Changed meanwhile' } } }); + } + return response; + }; + await flush(s); + expect([...s.server.events.values()][0].title).toBe('Changed meanwhile'); + expect(pendingRows(s)).toEqual([]); + }); +}); + +describe('conflicts', () => { + async function synced(version: StalwartVersion = '1.0') { + const s = setup(version); + s.server.addEvent({ id: 'e1', title: 'Meeting', recurrenceRule: WEEKLY }); + await s.engine.requestSync(); + s.server.calls.length = 0; + return s; + } + + it('lists a patch on a value the server changed too, and does not send it', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Mine' } } }); + s.server.patchEvent('e1', { title: 'Theirs' }); + const result = await flush(s); + expect(setCalls(s.server)).toEqual([]); + expect(result).toMatchObject({ sent: 0, pending: 0 }); + const [conflict] = await conflicts(s); + expect(conflict).toMatchObject({ recordId: 'e1', kind: 'update', reason: 'changedOnServer', local: { title: 'Mine' } }); + expect(conflict.server).toMatchObject({ title: 'Theirs' }); + expect(s.server.events.get('e1')?.title).toBe('Theirs'); + expect(await getOne(s, 'e1')).toMatchObject({ title: 'Theirs' }); + }); + + it('lets an edit through when the server changed something else', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Mine' } } }); + s.server.patchEvent('e1', { description: 'Theirs' }); + await flush(s); + expect(await conflicts(s)).toEqual([]); + expect(s.server.events.get('e1')).toMatchObject({ title: 'Mine', description: 'Theirs' }); + }); + + it('does not take the same value on both sides for a conflict', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Same' } } }); + s.server.patchEvent('e1', { title: 'Same' }); + await flush(s); + expect(await conflicts(s)).toEqual([]); + }); + + it('lets an RSVP through when the organizer changed the title', async () => { + const s = setup(); + s.server.addEvent({ id: 'e1', participants: { me: { participationStatus: 'needs-action' } } }); + await s.engine.requestSync(); + await setEvent(s, { update: { e1: { 'participants/me/participationStatus': 'accepted' } } }); + s.server.patchEvent('e1', { title: 'New title' }); + await flush(s); + expect(await conflicts(s)).toEqual([]); + expect(s.server.events.get('e1')).toMatchObject({ title: 'New title', participants: { me: { participationStatus: 'accepted' } } }); + }); + + it('lists a patch the server refuses, with its reason, and goes on with the others', async () => { + const s = await synced(); + s.server.addEvent({ id: 'e2', title: 'Other' }); + await s.engine.requestSync(); + await setEvent(s, { update: { e1: { title: 'Refused' } } }); + await setEvent(s, { update: { e2: { title: 'Fine' } } }); + s.server.refuse.e1 = { type: 'forbidden', description: 'No rights' }; + const result = await flush(s); + expect(result).toMatchObject({ sent: 1, conflicts: 1, pending: 0 }); + const [conflict] = await conflicts(s); + expect(conflict).toMatchObject({ recordId: 'e1', reason: 'refused', errorType: 'forbidden', description: 'No rights', local: { title: 'Refused' } }); + expect(await getOne(s, 'e1')).toMatchObject({ title: 'Meeting' }); + expect(s.server.events.get('e2')?.title).toBe('Fine'); + }); + + it('lists an edit of an event the server deleted, and drops the event', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Too late' } } }); + s.server.removeEvent('e1'); + await flush(s); + const [conflict] = await conflicts(s); + expect(conflict).toMatchObject({ reason: 'destroyedOnServer', local: { title: 'Too late' }, server: null }); + expect(await getOne(s, 'e1')).toBeUndefined(); + }); + + it('lists a delete of an event the server changed, and brings the event back', async () => { + const s = await synced(); + await setEvent(s, { destroy: ['e1'] }); + s.server.patchEvent('e1', { title: 'Still wanted' }); + await flush(s); + expect(s.server.events.has('e1')).toBe(true); + expect((await conflicts(s))[0]).toMatchObject({ kind: 'destroy', reason: 'changedOnServer' }); + expect(await getOne(s, 'e1')).toMatchObject({ title: 'Still wanted' }); + }); + + it('lists a create the server refuses and takes the stand-in away', async () => { + const s = await synced(); + const result = await setEvent(s, { create: { c: { title: 'Bad', calendarIds: { nope: true }, start: '2026-11-20T09:00:00' } } }); + s.server.refuse.c = { type: 'invalidProperties', description: 'calendarIds', properties: ['calendarIds'] }; + await flush(s); + const [conflict] = await conflicts(s); + expect(conflict).toMatchObject({ kind: 'create', reason: 'refused', local: { title: 'Bad' } }); + expect(await getOne(s, result.created.c.id)).toBeUndefined(); + expect(pendingRows(s)).toEqual([]); + }); + + it('retries without series keys when 1.0 refuses them inside an override, instead of a conflict', async () => { + const s = setup('1.0'); + s.server.addEvent({ id: 'e1', recurrenceRule: WEEKLY, privacy: 'private' }); + await s.engine.requestSync(); + s.server.calls.length = 0; + await setEvent(s, { update: { e1: { recurrenceOverrides: { '2026-11-17T10:00:00': { title: 'One', privacy: 'private' } } } } }); + await flush(s); + expect(await conflicts(s)).toEqual([]); + expect(setCalls(s.server)).toHaveLength(2); + expect(s.server.events.get('e1')?.recurrenceOverrides).toEqual({ '2026-11-17T10:00:00': { title: 'One' } }); + }); + + it('keeps a write for later when the server has a bad moment', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Soon' } } }); + const realRequest = s.server.request.bind(s.server); + let failed = false; + s.server.request = async (calls: MethodCall[]) => { + if (!failed && calls.some(([name]) => name === 'CalendarEvent/set')) { + failed = true; + return { methodResponses: [['error', { type: 'serverFail' }, 's']] }; + } + return realRequest(calls); + }; + expect(await flush(s)).toMatchObject({ sent: 0, conflicts: 0, pending: 1 }); + expect(await conflicts(s)).toEqual([]); + expect(await flush(s)).toMatchObject({ sent: 1, pending: 0 }); + }); + + it('lists a method error that means the write will not work', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'x' } } }); + const realRequest = s.server.request.bind(s.server); + s.server.request = async (calls: MethodCall[]) => { + if (calls.some(([name]) => name === 'CalendarEvent/set')) return { methodResponses: [['error', { type: 'accountReadOnly' }, 's']] }; + return realRequest(calls); + }; + expect(await flush(s)).toMatchObject({ conflicts: 1, pending: 0 }); + expect((await conflicts(s))[0].errorType).toBe('accountReadOnly'); + }); + + it('can dismiss a conflict', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Mine' } } }); + s.server.patchEvent('e1', { title: 'Theirs' }); + await flush(s); + const [conflict] = await conflicts(s); + await s.engine.areaCall('calendar.dismissConflict', { id: conflict.id }); + expect(await conflicts(s)).toEqual([]); + }); + + it('keeps a pending edit through a resync, and lists it when the event is gone', async () => { + const s = await synced(); + s.server.addEvent({ id: 'e2', title: 'Stays' }); + await s.engine.requestSync(); + await setEvent(s, { update: { e1: { title: 'Mine' } } }); + await setEvent(s, { update: { e2: { title: 'Mine too' } } }); + s.server.events.delete('e1'); + s.server.eventLog.push('destroyed', 'e1'); + s.server.eventLog.truncatedBelow = s.server.eventLog.log.length; + await s.engine.requestSync(); + const [conflict] = await conflicts(s); + expect(conflict).toMatchObject({ recordId: 'e1', reason: 'destroyedOnServer' }); + expect(s.server.events.get('e2')?.title).toBe('Mine too'); + }); +}); + +describe('the synthetic-id probe', () => { + const probe = { update: { h333333: {} } }; + + it('tells a client what the server said at the last sync', async () => { + for (const version of ['1.0', '0.16'] as const) { + const s = setup(version); + await s.engine.requestSync(); + expect((await setEvent(s, probe)).notUpdated.h333333.type).toBe('notFound'); + } + const old = setup('0.16.19'); + await old.engine.requestSync(); + const refusal = (await setEvent(old, probe)).notUpdated.h333333; + expect(refusal.type).toBe('invalidProperties'); + expect(refusal.description).toMatch(/synthetic ids is not yet supported/); + }); + + it('says not supported before any sync, because client-side expansion works on every server', async () => { + const s = setup(); + const refusal = (await setEvent(s, probe)).notUpdated.h333333; + expect(refusal.type).toBe('invalidProperties'); + }); + + it('queues nothing for the probe', async () => { + const s = setup(); + await s.engine.requestSync(); + await setEvent(s, probe); + expect(pendingRows(s)).toEqual([]); + }); + + it('runs the probe on the sync request, and follows a server that was upgraded', async () => { + const s = setup('0.16.19'); + await s.engine.requestSync(); + expect(s.server.requests).toBeGreaterThan(0); + expect((await setEvent(s, probe)).notUpdated.h333333.type).toBe('invalidProperties'); + Object.defineProperty(s.server, 'version', { value: '1.0' }); + await s.engine.requestSync(); + expect((await setEvent(s, probe)).notUpdated.h333333.type).toBe('notFound'); + }); +}); + +describe('occurrence writes for ids the store does not hold', () => { + it('refuses them the way an old server does, so the client writes on the base event', async () => { + const s = setup(); + s.server.addEvent({ id: 'series', recurrenceRule: WEEKLY }); + await s.engine.requestSync(); + const update = await setEvent(s, { update: { h20261110series: { title: 'x' } } }); + expect(update.notUpdated.h20261110series.description).toMatch(/synthetic ids?\b.*\bnot (?:yet )?supported/i); + const destroy = await setEvent(s, { destroy: ['h20261110series'] }); + expect(destroy.notDestroyed.h20261110series.description).toMatch(/synthetic ids?\b.*\bnot (?:yet )?supported/i); + expect(pendingRows(s)).toEqual([]); + }); +}); + +describe('reminders from stored events', () => { + const ALERT = { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } }; + const FROM = Date.UTC(2026, 10, 10); + const TO = Date.UTC(2026, 10, 11); + const OPTIONS = { from: FROM, to: TO, timeZone: 'Europe/Berlin' }; + + it('lists an occurrence of a series, keyed by the master, with the time from its own zone', async () => { + const s = setup(); + s.server.addEvent({ id: 'series', start: '2026-01-06T10:00:00', recurrenceRule: WEEKLY, alerts: { a: ALERT } }); + await s.engine.requestSync(); + const alerts = listUpcomingAlerts(s.db, OPTIONS); + expect(alerts).toHaveLength(1); + const fire = Date.UTC(2026, 10, 10, 8, 45); + expect(alerts[0]).toMatchObject({ + baseEventId: 'series', alertId: 'a', fireTimeMs: fire, recurrenceId: '2026-11-10T10:00:00', key: `${JMAP_ACCOUNT}:series@example.org:a:${fire}`, kind: 'event', jmap: JMAP_ACCOUNT, + }); + }); + + it('follows a moved occurrence and skips an excluded one', async () => { + const s = setup(); + s.server.addEvent({ + id: 'moved', + start: '2026-01-06T10:00:00', + recurrenceRule: WEEKLY, + alerts: { a: ALERT }, + recurrenceOverrides: { '2026-11-10T10:00:00': { start: '2026-11-10T15:00:00' } }, + }); + s.server.addEvent({ + id: 'excluded', + start: '2026-01-06T10:00:00', + recurrenceRule: WEEKLY, + alerts: { a: ALERT }, + recurrenceOverrides: { '2026-11-10T10:00:00': { excluded: true } }, + }); + await s.engine.requestSync(); + const alerts = listUpcomingAlerts(s.db, OPTIONS); + expect(alerts.map((alert) => [alert.baseEventId, alert.fireTimeMs])).toEqual([['moved', Date.UTC(2026, 10, 10, 13, 45)]]); + }); + + it('finds an alert days before its occurrence, and a fixed-time alert of a series', async () => { + const s = setup(); + s.server.addEvent({ + id: 'days', + start: '2026-11-13T10:00:00', + alerts: { a: { ...ALERT, trigger: { '@type': 'OffsetTrigger', offset: '-P3D' } } }, + }); + s.server.addEvent({ + id: 'days-series', + start: '2026-01-06T10:00:00', + recurrenceRule: { frequency: 'yearly', byMonth: ['11'], byMonthDay: [13] }, + alerts: { a: { ...ALERT, trigger: { '@type': 'OffsetTrigger', offset: '-P3D' } } }, + }); + s.server.addEvent({ + id: 'fixed', + start: '2026-01-06T10:00:00', + recurrenceRule: WEEKLY, + alerts: { a: { ...ALERT, trigger: { '@type': 'AbsoluteTrigger', when: '2026-11-10T06:00:00Z' } } }, + }); + await s.engine.requestSync(); + const alerts = listUpcomingAlerts(s.db, OPTIONS); + expect(alerts.map((alert) => alert.baseEventId).sort()).toEqual(['days', 'days-series', 'fixed']); + expect(alerts.filter((alert) => alert.baseEventId === 'fixed')).toHaveLength(1); + }); + + it('uses the calendar defaults while the event does, and reads a floating event in the given zone', async () => { + const s = setup(); + s.server.addEvent({ id: 'def', start: '2026-11-10T10:00:00', timeZone: null, useDefaultAlerts: true }); + await s.engine.requestSync(); + const [alert] = listUpcomingAlerts(s.db, OPTIONS); + expect(alert).toMatchObject({ baseEventId: 'def', alertId: 'd1', calendarName: 'Personal', fireTimeMs: Date.UTC(2026, 10, 10, 8, 45) }); + expect(listUpcomingAlerts(s.db, { ...OPTIONS, timeZone: 'UTC' })[0].fireTimeMs).toBe(Date.UTC(2026, 10, 10, 9, 45)); + }); + + it('leaves out what should not fire, and what is outside the window', async () => { + const s = setup(); + s.server.addEvent({ id: 'email', alerts: { a: { ...ALERT, action: 'email' } } }); + s.server.addEvent({ id: 'seen', alerts: { a: { ...ALERT, acknowledged: '2026-11-01T00:00:00Z' } } }); + s.server.addEvent({ id: 'off', status: 'cancelled', alerts: { a: ALERT } }); + s.server.addEvent({ id: 'later', start: '2026-12-10T10:00:00', alerts: { a: ALERT } }); + s.server.addEvent({ id: 'task-done', '@type': 'Task', start: undefined, due: '2026-11-10T10:00:00', progress: 'completed', alerts: { a: ALERT } }); + await s.engine.requestSync(); + expect(listUpcomingAlerts(s.db, OPTIONS)).toEqual([]); + }); + + it('lists a task, and an edit or a delete made offline', async () => { + const s = setup(); + s.server.addEvent({ id: 'task', '@type': 'Task', start: undefined, due: '2026-11-10T10:00:00Z', alerts: { a: ALERT } }); + s.server.addEvent({ id: 'moved', start: '2026-12-10T10:00:00', alerts: { a: ALERT } }); + s.server.addEvent({ id: 'dropped', start: '2026-11-10T11:00:00', alerts: { a: ALERT } }); + await s.engine.requestSync(); + await setEvent(s, { update: { moved: { start: '2026-11-10T09:00:00' } } }); + await setEvent(s, { destroy: ['dropped'] }); + expect(listUpcomingAlerts(s.db, OPTIONS).map((alert) => [alert.baseEventId, alert.kind])).toEqual([['moved', 'event'], ['task', 'task']]); + }); +}); + +it('uses only capabilities the session lists', async () => { + const s = setup(); + s.server.addEvent(); + const usings: string[][] = []; + const realRequest = s.server.request.bind(s.server) as (calls: MethodCall[]) => ReturnType; + s.server.request = ((calls: MethodCall[], using: string[]) => { + usings.push(using); + return realRequest(calls); + }) as FakeCalendarJmap['request']; + await s.engine.requestSync(); + for (const using of usings) expect(using).toEqual(['urn:ietf:params:jmap:core', CAL]); +}); diff --git a/lib/store/__tests__/calendar-writes-review.test.ts b/lib/store/__tests__/calendar-writes-review.test.ts new file mode 100644 index 000000000..9ab9cfc37 --- /dev/null +++ b/lib/store/__tests__/calendar-writes-review.test.ts @@ -0,0 +1,520 @@ +// @vitest-environment node +import { afterEach, describe, expect, it } from 'vitest'; +import type { CalendarEvent } from '@/lib/jmap/types'; +import { DEFAULT_SCOPE } from '../area'; +import { calendarArea } from '../calendar'; +import { listUpcomingAlerts } from '../calendar/reminders'; +import type { CalendarConflict } from '../calendar/writes'; +import { AccountEngine } from '../engine/account-engine'; +import { TransportError, type JmapResponse, type MethodCall, type StoreSession } from '../engine/transport'; +import { wire, type Wire } from './helpers/client-wire'; +import { CAL, CALENDAR_SESSION, FakeCalendarJmap, type StalwartVersion } from './helpers/fake-calendar'; +import { JMAP_ACCOUNT } from './helpers/fake-jmap'; +import { openMemoryStore } from './helpers/node-adapter'; + +const NOW = Date.UTC(2026, 10, 3, 12); +const WEEKLY = { '@type': 'RecurrenceRule', frequency: 'weekly' }; +const K1 = '2026-11-17T10:00:00'; +const K2 = '2026-11-24T10:00:00'; + +function setup(version: StalwartVersion = '1.0', session: StoreSession = CALENDAR_SESSION) { + const server = new FakeCalendarJmap(version); + const db = openMemoryStore(); + const engine = new AccountEngine({ + account: 'a@fake.test', db, session, transport: server, areas: [calendarArea], scope: DEFAULT_SCOPE, now: () => NOW, sleep: async () => {}, + }); + return { server, db, engine }; +} +type Setup = ReturnType; + +async function write(s: Setup, args: Record, jmap = JMAP_ACCOUNT) { + const response = await s.engine.answer([['CalendarEvent/set', { accountId: jmap, ...args }, 'x']]); + return response!.methodResponses[0][1]; +} + +async function read(s: Setup, id: string, properties?: string[]) { + const response = await s.engine.answer([['CalendarEvent/get', { accountId: JMAP_ACCOUNT, ids: [id], ...(properties ? { properties } : {}) }, 'x']]); + return response!.methodResponses[0][1].list[0] as Record | undefined; +} + +async function query(s: Setup, args: Record = {}) { + const response = await s.engine.answer([['CalendarEvent/query', { accountId: JMAP_ACCOUNT, ...args }, 'x']]); + return response!.methodResponses[0][1].ids as string[]; +} + +const flush = (s: Pick) => s.engine.areaCall('calendar.flush', undefined) as Promise<{ sent: number; conflicts: number; pending: number }>; +const conflicts = (s: Pick) => s.engine.areaCall('calendar.conflicts', undefined) as Promise; +const sets = (server: FakeCalendarJmap) => server.calls.filter(([name, args]) => name === 'CalendarEvent/set' && !(args.update && 'h333333' in (args.update as object))); +const pending = (s: Pick) => s.db.all<{ kind: string; patch: string; record_id: string; send_scheduling: number | null }>('SELECT kind, patch, record_id, send_scheduling FROM calendar_pending ORDER BY seq'); +const NEW = { title: 'Fresh', calendarIds: { cal1: true }, start: '2026-11-20T09:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin' }; + +async function synced(version: StalwartVersion = '1.0', add?: (server: FakeCalendarJmap) => void) { + const s = setup(version); + s.server.addEvent({ id: 'e1', title: 'Meeting', description: 'Old' }); + add?.(s.server); + await s.engine.requestSync(); + s.server.calls.length = 0; + return s; +} + +function failSets(s: Setup, decide: (args: Record) => JmapResponse | Error | null) { + const real = s.server.request.bind(s.server); + s.server.request = async (calls: MethodCall[]) => { + for (const [name, args] of calls) { + if (name !== 'CalendarEvent/set' || (args.update && 'h333333' in (args.update as object))) continue; + const outcome = decide(args); + if (outcome instanceof Error) throw outcome; + if (outcome) return outcome; + } + return real(calls); + }; +} + +describe('an edit after a create whose answer got lost', () => { + it('reaches the server as an update, without a second create', async () => { + const s = await synced(); + const id = (await write(s, { create: { c: NEW } })).created.c.id as string; + s.server.loseNextSetResponse = true; + await flush(s); + await write(s, { update: { [id]: { title: 'Edited after the attempt' } } }); + expect(await flush(s)).toMatchObject({ pending: 0 }); + expect(s.server.events.size).toBe(2); + expect(sets(s.server).filter(([, args]) => args.create)).toHaveLength(1); + const onServer = [...s.server.events.values()].find((event) => event.uid !== 'e1@example.org'); + expect(onServer?.title).toBe('Edited after the attempt'); + expect(await read(s, onServer!.id)).toMatchObject({ title: 'Edited after the attempt' }); + }); + + it('also removes what the edit removed', async () => { + const s = await synced(); + const id = (await write(s, { create: { c: { ...NEW, description: 'To go' } } })).created.c.id as string; + s.server.loseNextSetResponse = true; + await flush(s); + await write(s, { update: { [id]: { description: null } } }); + await flush(s); + const onServer = [...s.server.events.values()].find((event) => event.title === 'Fresh'); + expect(onServer?.description ?? null).toBeNull(); + }); + + it('applies an import whose uid the server already has as an update', async () => { + const s = await synced(); + await write(s, { create: { c: { ...NEW, uid: 'e1@example.org', title: 'Imported', description: 'Old' } } }); + await flush(s); + expect(sets(s.server).filter(([, args]) => args.create)).toEqual([]); + expect(s.server.events.size).toBe(1); + expect(s.server.events.get('e1')).toMatchObject({ title: 'Imported', description: 'Old' }); + expect(await query(s)).toEqual(['e1']); + }); + + it('sends no update when the server already holds the same event', async () => { + const s = await synced(); + const held = s.server.events.get('e1')!; + await write(s, { create: { c: { uid: 'e1@example.org', title: held.title, calendarIds: held.calendarIds, start: held.start, duration: held.duration, timeZone: held.timeZone, '@type': 'Event' } } }); + await flush(s); + expect(sets(s.server)).toEqual([]); + expect(pending(s)).toEqual([]); + }); +}); + +describe('writes with different scheduling flags', () => { + it('stay separate and go out in order', async () => { + const s = await synced(); + await write(s, { update: { e1: { title: 'Quiet edit' } }, sendSchedulingMessages: false }); + await write(s, { update: { e1: { 'participants/me/participationStatus': 'accepted' } }, sendSchedulingMessages: true }); + expect(pending(s).map((row) => row.send_scheduling)).toEqual([0, 1]); + expect(await read(s, 'e1')).toMatchObject({ title: 'Quiet edit', participants: { me: { participationStatus: 'accepted' } } }); + + await flush(s); + const sent = sets(s.server); + expect(sent.map(([, args]) => args.sendSchedulingMessages)).toEqual([false, true]); + expect((sent[0][1].update as Record).e1).toEqual({ title: 'Quiet edit' }); + expect((sent[1][1].update as Record).e1).toEqual({ 'participants/me/participationStatus': 'accepted' }); + }); + + it('still merge when the flags are the same', async () => { + const s = await synced(); + await write(s, { update: { e1: { title: 'One' } }, sendSchedulingMessages: true }); + await write(s, { update: { e1: { description: 'Two' } }, sendSchedulingMessages: true }); + expect(pending(s)).toHaveLength(1); + }); + + it('create and a later edit that notifies go out as two writes, the second under the real id', async () => { + const s = await synced(); + const id = (await write(s, { create: { c: NEW }, sendSchedulingMessages: false })).created.c.id as string; + await write(s, { update: { [id]: { title: 'Told everyone' } }, sendSchedulingMessages: true }); + expect(pending(s).map((row) => row.kind)).toEqual(['create', 'update']); + await flush(s); + const sent = sets(s.server); + expect(sent).toHaveLength(2); + expect(sent[0][1].sendSchedulingMessages).toBe(false); + const real = Object.keys(sent[1][1].update as object)[0]; + expect(s.server.events.get(real)).toMatchObject({ title: 'Told everyone' }); + expect(sent[1][1].sendSchedulingMessages).toBe(true); + expect(await flush(s)).toMatchObject({ pending: 0 }); + }); + + it('a create deleted before it left takes its later rows with it', async () => { + const s = await synced(); + const id = (await write(s, { create: { c: NEW }, sendSchedulingMessages: true })).created.c.id as string; + await write(s, { update: { [id]: { title: 'Quiet' } }, sendSchedulingMessages: false }); + await write(s, { destroy: [id] }); + expect(pending(s)).toEqual([]); + await flush(s); + expect(sets(s.server)).toEqual([]); + }); + + it('a conflict on the first takes the ones built on it along', async () => { + const s = await synced(); + await write(s, { update: { e1: { title: 'Mine' } }, sendSchedulingMessages: false }); + await write(s, { update: { e1: { description: 'Mine too' } }, sendSchedulingMessages: true }); + s.server.patchEvent('e1', { title: 'Theirs' }); + await flush(s); + expect(sets(s.server)).toEqual([]); + expect((await conflicts(s)).map((conflict) => conflict.local)).toEqual([{ title: 'Mine' }, { description: 'Mine too' }]); + expect(await read(s, 'e1')).toMatchObject({ title: 'Theirs', description: 'Old' }); + }); +}); + +describe('a whole recurrenceOverrides map from stale state', () => { + const series = (server: FakeCalendarJmap) => server.addEvent({ id: 's', title: 'Weekly', start: '2026-11-03T10:00:00', recurrenceRule: WEEKLY }); + + it('does not erase an override someone else added', async () => { + const s = await synced('1.0', series); + await write(s, { update: { s: { recurrenceOverrides: { [K1]: { title: 'Mine' } } } } }); + s.server.patchEvent('s', { recurrenceOverrides: { [K2]: { title: 'Theirs' } } }); + await flush(s); + expect(await conflicts(s)).toEqual([]); + expect(s.server.events.get('s')?.recurrenceOverrides).toEqual({ [K1]: { title: 'Mine' }, [K2]: { title: 'Theirs' } }); + }); + + it('goes out as a whole map only when the server has none', async () => { + const s = await synced('1.0', series); + await write(s, { update: { s: { recurrenceOverrides: { [K1]: { title: 'Mine' } } } } }); + await flush(s); + expect((sets(s.server)[0][1].update as Record).s).toEqual({ recurrenceOverrides: { [K1]: { title: 'Mine' } } }); + expect(s.server.events.get('s')?.recurrenceOverrides).toEqual({ [K1]: { title: 'Mine' } }); + }); + + it('removes only the entries the sender knew and left out', async () => { + const s = await synced('1.0', (server) => { + series(server); + server.patchEvent('s', { recurrenceOverrides: { [K1]: { title: 'Dropped' } } }); + }); + await write(s, { update: { s: { recurrenceOverrides: { [K2]: { title: 'Kept' } } } } }); + s.server.patchEvent('s', { [`recurrenceOverrides/2026-12-01T10:00:00`]: { title: 'Theirs' } }); + await flush(s); + expect(s.server.events.get('s')?.recurrenceOverrides).toEqual({ [K2]: { title: 'Kept' }, '2026-12-01T10:00:00': { title: 'Theirs' } }); + }); + + it('collides on the same entry', async () => { + const s = await synced('1.0', series); + await write(s, { update: { s: { recurrenceOverrides: { [K1]: { title: 'Mine' } } } } }); + s.server.patchEvent('s', { recurrenceOverrides: { [K1]: { title: 'Theirs' } } }); + await flush(s); + expect((await conflicts(s))[0]).toMatchObject({ reason: 'changedOnServer' }); + }); +}); + +describe('one bad write', () => { + it('a 4xx refuses that write and the next one still goes', async () => { + const s = await synced('1.0', (server) => server.addEvent({ id: 'e2', title: 'Other' })); + await write(s, { update: { e1: { title: 'Bad' } } }); + await write(s, { update: { e2: { title: 'Good' } } }); + failSets(s, (args) => (args.update && 'e1' in (args.update as object) ? new TransportError('http', 'Bad request', 400) : null)); + expect(await flush(s)).toMatchObject({ sent: 1, conflicts: 1, pending: 0 }); + expect((await conflicts(s))[0]).toMatchObject({ recordId: 'e1', reason: 'refused' }); + expect(s.server.events.get('e2')?.title).toBe('Good'); + expect(await read(s, 'e1')).toMatchObject({ title: 'Meeting' }); + }); + + it('401, 429 and a 5xx stay pending', async () => { + for (const error of [new TransportError('auth', 'x', 401), new TransportError('rate', 'x', 429), new TransportError('http', 'x', 500)]) { + const s = await synced(); + await write(s, { update: { e1: { title: 'Later' } } }); + failSets(s, () => error); + expect(await flush(s)).toMatchObject({ conflicts: 0, pending: 1 }); + expect(await conflicts(s)).toEqual([]); + } + }); + + it('an answer without a verdict, or a passing refusal, retries instead of deleting or finishing', async () => { + const s = await synced(); + const id = (await write(s, { create: { c: NEW } })).created.c.id as string; + await write(s, { update: { e1: { title: 'Later' } } }); + let round = 0; + failSets(s, (args) => { + if (round > 3) return null; + if (args.create) { + const notCreated = round === 1 ? { c: { type: 'serverFail' } } : round === 2 ? { c: { type: 'rateLimit' } } : undefined; + return { methodResponses: [['CalendarEvent/set', notCreated ? { notCreated } : {}, 's']] }; + } + return { methodResponses: [['CalendarEvent/set', {}, 's']] }; + }); + for (round = 1; round <= 3; round += 1) { + await flush(s); + expect(await conflicts(s)).toEqual([]); + expect(pending(s)).toHaveLength(2); + expect(await read(s, id)).toMatchObject({ title: 'Fresh' }); + } + expect(await flush(s)).toMatchObject({ pending: 0 }); + expect(s.server.events.get('e1')?.title).toBe('Later'); + expect([...s.server.events.values()].filter((event) => event.title === 'Fresh')).toHaveLength(1); + }); +}); + +describe('a lost answer on an edit', () => { + const series = (server: FakeCalendarJmap) => server.addEvent({ + id: 's', + title: 'Weekly', + description: 'Series', + start: '2026-11-03T10:00:00', + recurrenceRule: WEEKLY, + participants: { + me: { '@type': 'Participant', calendarAddress: 'mailto:a@example.org', participationStatus: 'needs-action', roles: { attendee: true } }, + boss: { '@type': 'Participant', calendarAddress: 'mailto:boss@example.org', participationStatus: 'accepted', roles: { owner: true } }, + }, + }); + + for (const version of ['1.0', '0.16'] as const) { + it(`an occurrence answer that read back changed on ${version} is not a conflict, and isn't sent twice`, async () => { + const s = await synced(version, series); + const master = s.server.events.get('s') as unknown as { participants: Record> } & Record; + const entry = { + title: 'Weekly', + description: 'Series', + start: K1, + duration: 'PT1H', + sequence: 0, + participants: { ...master.participants, me: { ...master.participants.me, participationStatus: 'accepted' } }, + }; + await write(s, { update: { s: { recurrenceOverrides: { [K1]: entry } } }, sendSchedulingMessages: true }); + s.server.loseNextSetResponse = true; + expect(await flush(s)).toMatchObject({ pending: 1 }); + expect(await flush(s)).toMatchObject({ conflicts: 0, pending: 0 }); + expect(await conflicts(s)).toEqual([]); + expect(sets(s.server)).toHaveLength(1); + }); + + it(`a change to another override on ${version} is not a conflict on this one`, async () => { + const s = await synced(version, (server) => { + series(server); + server.patchEvent('s', { recurrenceOverrides: { [K1]: { title: 'One' }, [K2]: { title: 'Two' } } }); + }); + await write(s, { update: { s: { [`recurrenceOverrides/${K1}`]: { title: 'Mine' } } } }); + s.server.patchEvent('s', { [`recurrenceOverrides/${K2}`]: { title: 'Theirs' } }); + await flush(s); + expect(await conflicts(s)).toEqual([]); + expect((s.server.events.get('s')?.recurrenceOverrides as Record)[K1].title).toBe('Mine'); + }); + } + + it('an edit made after the lost one reads the lost value as ours, and goes out', async () => { + const s = await synced(); + await write(s, { update: { e1: { title: 'First' } } }); + s.server.loseNextSetResponse = true; + await flush(s); + await write(s, { update: { e1: { title: 'Second' } } }); + await flush(s); + expect(await conflicts(s)).toEqual([]); + expect(s.server.events.get('e1')?.title).toBe('Second'); + }); + + it('drops an RSVP whose value the server already holds, without sending', async () => { + const s = await synced('1.0', (server) => server.addEvent({ id: 'r', participants: { me: { participationStatus: 'accepted' } } })); + await write(s, { update: { r: { 'participants/me/participationStatus': 'accepted' } }, sendSchedulingMessages: true }); + await flush(s); + expect(sets(s.server)).toEqual([]); + expect(pending(s)).toEqual([]); + expect(await conflicts(s)).toEqual([]); + }); + + it('drops it during sync too when someone answered the same from elsewhere', async () => { + const s = await synced('1.0', (server) => server.addEvent({ id: 'r', participants: { me: { participationStatus: 'needs-action' } } })); + await write(s, { update: { r: { 'participants/me/participationStatus': 'accepted' } }, sendSchedulingMessages: true }); + s.server.patchEvent('r', { 'participants/me/participationStatus': 'accepted' }); + await flush(s); + expect(sets(s.server)).toEqual([]); + }); +}); + +describe('sync finding a created event', () => { + it('adopts the stand-in by uid during the delta, without a flush', async () => { + const s = await synced(); + const id = (await write(s, { create: { c: NEW } })).created.c.id as string; + s.server.loseNextSetResponse = true; + await flush(s); + expect(await query(s)).toHaveLength(2); + // Only the delta gets through: no lookup by uid, no writes. + const real = s.server.request.bind(s.server); + s.server.request = async (calls: MethodCall[]) => { + if (calls.some(([name, args]) => (name === 'CalendarEvent/set' && !(args.update && 'h333333' in (args.update as object))) + || (name === 'CalendarEvent/query' && (args.filter as { uid?: string } | undefined)?.uid))) { + throw new TransportError('network', 'Failed to fetch'); + } + return real(calls); + }; + await s.engine.requestSync(); + const ids = await query(s); + expect(ids).toHaveLength(2); + expect(ids.filter((value) => value.startsWith('local-'))).toEqual([]); + expect(pending(s)).toEqual([]); + expect((await read(s, id))?.id).toBe(ids.find((value) => value !== 'e1')); + }); +}); + +describe('a full list', () => { + it('keeps a live event the paging skipped, and drops only what is really gone', async () => { + const s = setup(); + for (let i = 0; i < 620; i += 1) s.server.addEvent({ id: `e${String(i).padStart(3, '0')}` }); + await s.engine.requestSync(); + // The stored state can't be used, so this pass lists everything again. + s.db.run("UPDATE sync_state SET state = 'not usable!' WHERE type = 'CalendarEvent'"); + let pages = 0; + const real = s.server.request.bind(s.server); + s.server.request = async (calls: MethodCall[]) => { + const answer = await real(calls); + if (calls.some(([name]) => name === 'CalendarEvent/query') && (pages += 1) === 1) { + // A delete before the page boundary moves one event across it. + s.server.removeEvent('e000'); + } + return answer; + }; + await s.engine.requestSync(); + const ids = new Set(s.db.all<{ id: string }>('SELECT id FROM calendar_event').map((row) => row.id)); + expect(ids.has('e000')).toBe(false); + expect(ids.has('e500')).toBe(true); + expect(ids.size).toBe(619); + }); +}); + +describe('flush around other accounts', () => { + const session: StoreSession = { + ...CALENDAR_SESSION, + accounts: { + ...CALENDAR_SESSION.accounts, + shared: { name: 'shared', isPersonal: false, accountCapabilities: { [CAL]: {} } }, + }, + }; + + it('does not touch the network for a never-synced account once stopped', async () => { + const s = setup(); + await write(s, { create: { c: NEW } }); + s.engine.setOnline(false); + const before = s.server.requests; + await flush(s); + expect(s.server.requests).toBe(before); + expect(pending(s)).toHaveLength(1); + }); + + it('goes on with the next account after one answers accountNotFound', async () => { + const s = setup('1.0', session); + s.server.addEvent({ id: 'e1', title: 'Meeting' }); + await s.engine.requestSync(); + // The primary account comes first and fails, the shared one must still go. + await write(s, { update: { e1: { title: 'Primary' } } }); + await write(s, { update: { e1: { title: 'Shared' } } }, 'shared'); + const real = s.server.request.bind(s.server); + s.server.request = async (calls: MethodCall[]) => { + if (calls[0][1].accountId === JMAP_ACCOUNT && calls[0][0] === 'CalendarEvent/changes') { + return { methodResponses: [['error', { type: 'accountNotFound' }, calls[0][2]]] }; + } + return real(calls); + }; + await flush(s); + expect(s.server.events.get('e1')?.title).toBe('Shared'); + expect(pending(s).map((row) => JSON.parse(row.patch))).toEqual([{ title: 'Primary' }]); + }); + + it('forgets an account the session no longer lists, and its reminders', async () => { + const s = setup('1.0', session); + s.server.addEvent({ + id: 'e1', + start: '2026-11-10T10:00:00', + alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } }, + }); + await s.engine.requestSync(); + const options = { from: Date.UTC(2026, 10, 10), to: Date.UTC(2026, 10, 11), timeZone: 'Europe/Berlin' }; + expect(listUpcomingAlerts(s.db, options).map((alert) => alert.jmap).sort()).toEqual([JMAP_ACCOUNT, 'shared'].sort()); + s.engine.setSession(CALENDAR_SESSION); + await s.engine.requestSync(); + expect(s.db.all("SELECT 1 FROM calendar_event WHERE jmap = 'shared'")).toEqual([]); + expect(s.db.all("SELECT 1 FROM object WHERE jmap = 'shared'")).toEqual([]); + expect(listUpcomingAlerts(s.db, options).map((alert) => alert.jmap)).toEqual([JMAP_ACCOUNT]); + }); +}); + +describe('reminder keys', () => { + it('stay the same when a stand-in becomes real', async () => { + const s = await synced(); + await write(s, { create: { c: { ...NEW, alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } } } } }); + const options = { from: Date.UTC(2026, 10, 20), to: Date.UTC(2026, 10, 21), timeZone: 'Europe/Berlin' }; + const [before] = listUpcomingAlerts(s.db, options); + await flush(s); + const [after] = listUpcomingAlerts(s.db, options); + expect(before.baseEventId).toMatch(/^local-/); + expect(after.baseEventId).not.toMatch(/^local-/); + expect(after.key).toBe(before.key); + }); + + it('differ between two accounts holding the same id', async () => { + const s = setup('1.0', { + ...CALENDAR_SESSION, + accounts: { ...CALENDAR_SESSION.accounts, shared: { name: 'shared', isPersonal: false, accountCapabilities: { [CAL]: {} } } }, + }); + s.server.addEvent({ id: 'e1', start: '2026-11-10T10:00:00', alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } } }); + await s.engine.requestSync(); + expect(listUpcomingAlerts(s.db, { from: Date.UTC(2026, 10, 10), to: Date.UTC(2026, 10, 11), timeZone: 'Europe/Berlin' })).toHaveLength(2); + }); +}); + +describe('creates through the real client', () => { + let w: Wire; + afterEach(() => w?.restore()); + + async function online() { + w = wire(new FakeCalendarJmap('1.0'), [calendarArea], NOW); + await w.engine.requestSync(); + return w.newClient(); + } + const created = () => w.sent.flatMap(([name, args]) => (name === 'CalendarEvent/set' ? Object.values((args.create ?? {}) as Record) : [])); + + it('createCalendarEvent has a uid on its first send, and a lost answer does not duplicate', async () => { + const client = await online(); + w.server.loseNextSetResponse = true; + const event = await client.createCalendarEvent({ ...NEW } as Partial); + expect(created()[0].uid).toBeTruthy(); + expect(event.id).toMatch(/^local-/); + expect(w.server.events.size).toBe(1); + await flush(w); + expect(w.server.events.size).toBe(1); + expect(await conflicts(w)).toEqual([]); + expect(pending(w)).toEqual([]); + }); + + it('batchCreateCalendarEvents does the same, which covers import', async () => { + const client = await online(); + w.server.loseNextSetResponse = true; + const result = await client.batchCreateCalendarEvents([{ ...NEW } as Partial, { ...NEW, title: 'Two' } as Partial]); + expect(result.created).toHaveLength(2); + expect(created().every((event) => event.uid)).toBe(true); + expect(w.server.events.size).toBe(2); + await flush(w); + expect(w.server.events.size).toBe(2); + expect(await conflicts(w)).toEqual([]); + }); + + it('createCalendarTask does the same', async () => { + const client = await online(); + w.server.loseNextSetResponse = true; + await client.createCalendarTask({ title: 'Task', calendarIds: { cal1: true }, due: '2026-12-01T10:00:00' }); + expect(created()[0].uid).toBeTruthy(); + await flush(w); + expect(w.server.events.size).toBe(1); + }); + + it('keeps a uid the caller chose', async () => { + const client = await online(); + await client.createCalendarEvent({ ...NEW, uid: 'chosen@example.org' } as Partial); + expect(created()[0].uid).toBe('chosen@example.org'); + }); +}); diff --git a/lib/store/__tests__/helpers/client-wire.ts b/lib/store/__tests__/helpers/client-wire.ts new file mode 100644 index 000000000..f46fb343f --- /dev/null +++ b/lib/store/__tests__/helpers/client-wire.ts @@ -0,0 +1,101 @@ +/** + * A real JMAPClient talking to a FakeCalendarJmap while "online" and to the + * store's answers while "offline", through a stubbed fetch. + */ + +import { vi } from 'vitest'; +import { JMAPClient } from '@/lib/jmap/client'; +import { setOfflineHooks, type OfflineMethodCall, type OfflineResponse } from '@/lib/jmap/offline-hooks'; +import { DEFAULT_SCOPE, type StoreArea } from '../../area'; +import { AccountEngine } from '../../engine/account-engine'; +import { TransportError, type MethodCall } from '../../engine/transport'; +import { CALENDAR_SESSION, FakeCalendarJmap } from './fake-calendar'; +import { JMAP_ACCOUNT } from './fake-jmap'; +import { openMemoryStore } from './node-adapter'; + +const CLIENT_SESSION = { + ...CALENDAR_SESSION, + primaryAccounts: { ...CALENDAR_SESSION.primaryAccounts, 'urn:ietf:params:jmap:mail': JMAP_ACCOUNT }, + accounts: { + [JMAP_ACCOUNT]: { + name: 'a@example.org', + isPersonal: true, + accountCapabilities: { ...CALENDAR_SESSION.accounts[JMAP_ACCOUNT].accountCapabilities, 'urn:ietf:params:jmap:mail': {} }, + }, + }, + capabilities: { ...CALENDAR_SESSION.capabilities, 'urn:ietf:params:jmap:mail': {} }, + eventSourceUrl: '', +}; + +function json(body: unknown): Response { + return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }); +} + +export interface Wire { + server: FakeCalendarJmap; + engine: AccountEngine; + db: ReturnType; + /** Everything the client sent, wherever it went. */ + sent: MethodCall[]; + online: boolean; + newClient(): Promise; + restore(): void; +} + +export function wire(server: FakeCalendarJmap, areas: readonly StoreArea[], now: number): Wire { + const db = openMemoryStore(); + const engine = new AccountEngine({ + account: 'a@fake.test', + db, + session: CALENDAR_SESSION, + transport: server, + areas, + scope: DEFAULT_SCOPE, + now: () => now, + sleep: async () => {}, + }); + const spy = vi.spyOn(globalThis, 'fetch'); + const state: Wire = { + server, + engine, + db, + sent: [], + online: true, + newClient: async () => { + const client = new JMAPClient('https://fake.test', 'a@example.org', 'pw'); + await client.connect(); + return client; + }, + restore: () => { + spy.mockRestore(); + setOfflineHooks(null); + }, + }; + spy.mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => { + if (!init?.body) return json(CLIENT_SESSION); + const calls = (JSON.parse(String(init.body)) as { methodCalls: MethodCall[] }).methodCalls; + state.sent.push(...calls); + if (!state.online) throw new TypeError('Failed to fetch'); + try { + return json(await server.request(calls)); + } catch (error) { + if (error instanceof TransportError && error.kind === 'network') throw new TypeError('Failed to fetch'); + throw error; + } + }); + setOfflineHooks({ + skipNetwork: () => !state.online, + answer: async (_client, calls: OfflineMethodCall[]) => { + state.sent.push(...calls); + return engine.answer(calls) as Promise; + }, + record: () => {}, + noteNetwork: () => {}, + serverGone: async () => true, + blob: async () => null, + recordBlob: () => {}, + holdUpload: async () => null, + resolveStandIns: (_client, ids) => engine.areaCall('calendar.resolveStandIns', { ids }) as Promise>, + }); + return state; +} diff --git a/lib/store/__tests__/helpers/fake-calendar.ts b/lib/store/__tests__/helpers/fake-calendar.ts new file mode 100644 index 000000000..795f42b50 --- /dev/null +++ b/lib/store/__tests__/helpers/fake-calendar.ts @@ -0,0 +1,408 @@ +/** + * FakeJmap with calendars, modelled on what was measured on both Stalwarts + * (local-data/desktop-offline-facts-parts/stalwart-changes-prf.md section 7, and + * the probe run in desktop-offline-decisions.md): + * + * - /changes lists masters only. An edit through a synthetic id gives + * `updated: [master]`. + * - A plain query returns masters. An expanded one needs both bounds, says + * canCalculateChanges false on 1.0 and true on 0.16, refuses more than P52W1D + * on 1.0, and gives a single event a synthetic id on 0.16 only. + * - 1.0 refuses recurrenceOverrides next to utcStart in one get, and series keys + * inside an override. `recurrenceRules` is an invalid property on both. + * - A pointer into overrides fails while a series has none. + * - A second event with one uid is refused, uid filters match exactly. + * - Overrides read back differently from what was written. 1.0 keeps only + * what differs from the series, a participant as a pointer key. 0.16 keeps + * the entry, adds `updated` to every entry on any change of the series, and + * drops an attendee-only `roles` from participants. + */ + +import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization'; +import { applyPatchObject } from '@/lib/jmap/patch-object'; +import type { CalendarEvent } from '@/lib/jmap/types'; +import { expandRecurringEvents } from '@/lib/recurrence-expansion'; +import { SYNTHETIC_ID_PROBE } from '@/lib/recurrence-instances'; +import { TransportError, type JmapResponse, type MethodCall, type MethodResponse, type StoreSession } from '../../engine/transport'; +import { Collection, FakeJmap, JMAP_ACCOUNT } from './fake-jmap'; + +export const CAL = 'urn:ietf:params:jmap:calendars'; + +export const CALENDAR_SESSION: StoreSession = { + apiUrl: 'https://fake.test/jmap/', + downloadUrl: 'https://fake.test/d/{accountId}/{blobId}/{name}?type={type}', + uploadUrl: 'https://fake.test/u/{accountId}', + primaryAccounts: { [CAL]: JMAP_ACCOUNT }, + accounts: { + [JMAP_ACCOUNT]: { name: 'a@example.org', isPersonal: true, accountCapabilities: { [CAL]: { maxExpandedQueryDuration: 'P52W1D' } } }, + }, + capabilities: { 'urn:ietf:params:jmap:core': {}, [CAL]: {} }, +}; + +export type FakeEvent = Record & { id: string }; +export type StalwartVersion = '1.0' | '0.16' | '0.16.19'; + +/** One override as the server hands it back (measured on both, 2026-11-03). */ +function readBack(version: StalwartVersion, master: FakeEvent, key: string, entry: Record, tick: number): Record { + if (entry.excluded) return entry; + if (version === '1.0') { + const out: Record = {}; + for (const [name, value] of Object.entries(entry)) { + if (name === 'updated') continue; + if (name === 'start' && value === key) continue; + if (name === 'participants') { + const series = (master.participants ?? {}) as Record>; + for (const [id, person] of Object.entries(value as Record>)) { + for (const [field, fieldValue] of Object.entries(person)) { + if (JSON.stringify(series[id]?.[field]) !== JSON.stringify(fieldValue)) out[`participants/${id}/${field}`] = fieldValue; + } + } + continue; + } + if (name !== 'sequence' && JSON.stringify(master[name]) === JSON.stringify(value)) continue; + out[name] = value; + } + return out; + } + const out: Record = { ...entry, updated: `2026-11-03T00:00:${String(tick % 60).padStart(2, '0')}Z` }; + if (out.participants) { + out.participants = Object.fromEntries(Object.entries(out.participants as Record>).map(([id, person]) => { + const { roles, ...rest } = person; + const attendeeOnly = roles && Object.keys(roles).length === 1 && (roles as Record).attendee; + return [id, attendeeOnly ? rest : person]; + })); + } + return out; +} + +function jmapError(type: string, description?: string): Error { + return Object.assign(new Error(type), { jmapError: type, description }); +} + +const SERIES_KEYS_IN_OVERRIDE = ['privacy', 'organizerCalendarAddress']; + +interface Instance { + id: string; + master: FakeEvent; + recurrenceId: string | null; +} + +export class FakeCalendarJmap extends FakeJmap { + readonly events = new Map(); + readonly calendars = new Map & { id: string }>(); + readonly identities: Array> = [{ id: 'pi1', name: 'A', calendarAddress: 'mailto:a@example.org', isDefault: true }]; + readonly eventLog = new Collection(); + private readonly instances = new Map(); + private nextEvent = 1; + /** The next create or update reaches the server, but its answer doesn't reach the client. */ + loseNextSetResponse = false; + /** The next CalendarEvent/set refuses this id like a server would. */ + refuse: Record = {}; + + constructor(readonly version: StalwartVersion = '1.0') { + super(); + this.calendars.set('cal1', { + id: 'cal1', name: 'Personal', isDefault: true, myRights: { mayWriteAll: true }, + defaultAlertsWithTime: { d1: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } }, + }); + } + + addEvent(partial: Partial = {}): FakeEvent { + const id = partial.id ?? `ev${this.nextEvent++}`; + const event: FakeEvent = { + '@type': 'Event', + uid: `${id}@example.org`, + calendarIds: { cal1: true }, + title: `Event ${id}`, + start: '2026-11-10T10:00:00', + duration: 'PT1H', + timeZone: 'Europe/Berlin', + baseEventId: null, + isOrigin: true, + useDefaultAlerts: false, + ...partial, + id, + }; + this.events.set(id, event); + this.eventLog.push('created', id); + return event; + } + + /** Someone else changes an event. */ + patchEvent(id: string, patch: Record): void { + const next = applyPatchObject(this.events.get(id) as FakeEvent, patch); + if (next.recurrenceOverrides) { + const tick = this.eventLog.log.length; + next.recurrenceOverrides = Object.fromEntries(Object.entries(next.recurrenceOverrides as Record>) + .map(([key, entry]) => [key, readBack(this.version, next, key, entry, tick)])); + } + this.events.set(id, next); + this.eventLog.push('updated', id); + } + + removeEvent(id: string): void { + this.events.delete(id); + this.eventLog.push('destroyed', id); + } + + private expanded(after: string, before: string): Instance[] { + const out: Instance[] = []; + for (const master of this.events.values()) { + const normal = normalizeCalendarEventLike(master as unknown as CalendarEvent); + if (!normal.recurrenceRules?.length) { + const start = String(master.start); + if (start < after || start >= before) continue; + out.push({ id: this.version === '1.0' ? master.id : `e${master.id}`, master, recurrenceId: null }); + continue; + } + for (const occurrence of expandRecurringEvents([normal], after, before)) { + out.push({ + id: `h${String(occurrence.recurrenceId).replace(/\D/g, '')}${master.id}`, + master, + recurrenceId: occurrence.recurrenceId as string, + }); + } + } + for (const instance of out) this.instances.set(instance.id, instance); + return out; + } + + private instanceView(instance: Instance): FakeEvent { + const { master, recurrenceId } = instance; + if (!recurrenceId) return { ...master, id: instance.id, baseEventId: this.version === '1.0' ? null : master.id }; + const override = ((master.recurrenceOverrides ?? {}) as Record>)[recurrenceId] ?? {}; + const { recurrenceRule: _r, recurrenceOverrides: _o, ...rest } = master; + return { + ...rest, + start: recurrenceId, + ...override, + id: instance.id, + baseEventId: master.id, + recurrenceId, + recurrenceRule: null, + }; + } + + private query(args: Record): Record { + const filter = (args.filter ?? {}) as { uid?: string; after?: string; before?: string; inCalendar?: string; operator?: string }; + const position = Number(args.position ?? 0); + const limit = Number(args.limit ?? 5000) || 5000; + const page = (ids: string[], canCalculateChanges: boolean) => ({ + accountId: args.accountId, + queryState: this.eventLog.state(), + canCalculateChanges, + position, + ids: ids.slice(position, position + limit), + }); + if (args.expandRecurrences) { + if (!filter.after || !filter.before) throw jmapError('invalidArguments', 'Expansion needs after and before'); + const days = (Date.parse(`${filter.before}Z`) - Date.parse(`${filter.after}Z`)) / 86_400_000; + if (this.version === '1.0' && days > 365) throw jmapError('expandDurationTooLarge'); + const list = this.expanded(filter.after, filter.before); + return page(list.map((instance) => instance.id), this.version !== '1.0'); + } + const masters = [...this.events.values()].filter((event) => { + if (filter.uid !== undefined && event.uid !== filter.uid) return false; + if (filter.inCalendar && !(event.calendarIds as Record)[filter.inCalendar]) return false; + return true; + }); + return page(masters.map((event) => event.id), true); + } + + private get(args: Record): Record { + const properties = args.properties as string[] | undefined; + if (properties?.includes('recurrenceRules')) throw jmapError('invalidArguments', 'recurrenceRules'); + if (this.version === '1.0' && properties?.includes('recurrenceOverrides') + && (properties.includes('utcStart') || properties.includes('utcEnd'))) { + throw jmapError('invalidArguments', 'recurrenceOverrides cannot be requested together with utcStart or utcEnd'); + } + const ids = (args.ids as string[] | null) ?? [...this.events.keys()].slice(0, 500); + if (ids.length > 500) throw jmapError('requestTooLarge'); + const list: Record[] = []; + const notFound: string[] = []; + for (const id of ids) { + const instance = this.instances.get(id); + const event = instance && this.events.has(instance.master.id) + ? this.instanceView({ ...instance, master: this.events.get(instance.master.id) as FakeEvent }) + : this.events.get(id); + if (!event) { + notFound.push(id); + continue; + } + const full = { ...event, utcStart: `${String(event.start)}Z`, utcEnd: `${String(event.start)}Z` }; + if (!properties) { + list.push({ ...event }); + continue; + } + const out: Record = { id }; + for (const name of properties) if (name in full) out[name] = (full as Record)[name]; + list.push(out); + } + return { accountId: args.accountId, state: this.eventLog.state(), list, notFound }; + } + + private seriesKeyInOverride(patch: Record): string | null { + if (this.version !== '1.0') return null; + for (const [key, value] of Object.entries(patch)) { + if (key !== 'recurrenceOverrides' && !key.startsWith('recurrenceOverrides/')) continue; + const entries = key === 'recurrenceOverrides' ? Object.values(value as object) : [value]; + for (const entry of entries) { + for (const name of SERIES_KEYS_IN_OVERRIDE) { + if (entry && typeof entry === 'object' && name in entry) return name; + } + } + } + return null; + } + + private update(id: string, patch: Record): { type: string; description?: string; properties?: string[] } | null { + if (this.refuse[id]) { + const refusal = this.refuse[id]; + delete this.refuse[id]; + return refusal; + } + if (id === SYNTHETIC_ID_PROBE) { + return this.version === '0.16.19' + ? { type: 'invalidProperties', description: 'Updating synthetic ids is not yet supported.', properties: ['id'] } + : { type: 'notFound' }; + } + const instance = this.instances.get(id); + if (instance?.recurrenceId) { + if (this.version === '0.16.19') { + return { type: 'invalidProperties', description: 'Updating synthetic ids is not yet supported.', properties: ['id'] }; + } + const master = this.events.get(instance.master.id); + if (!master) return { type: 'notFound' }; + const overrides = { ...(master.recurrenceOverrides as object | undefined) } as Record>; + overrides[instance.recurrenceId] = { ...overrides[instance.recurrenceId], ...patch }; + this.patchEvent(master.id, { recurrenceOverrides: overrides }); + return null; + } + const event = this.events.get(id); + if (!event) return { type: 'notFound' }; + const seriesKey = this.seriesKeyInOverride(patch); + if (seriesKey) { + return { type: 'invalidProperties', description: `${seriesKey} cannot be set inside recurrenceOverrides.`, properties: ['recurrenceOverrides'] }; + } + const pointsIntoNothing = Object.keys(patch).some((key) => key.startsWith('recurrenceOverrides/')) && !event.recurrenceOverrides; + if (pointsIntoNothing) return { type: this.version === '1.0' ? 'invalidPatch' : 'invalidProperties', description: 'Patch operation failed.' }; + this.patchEvent(id, patch); + return null; + } + + private set(args: Record): Record { + const out: Record = { accountId: args.accountId, oldState: this.eventLog.state() }; + const created: Record = {}; + const notCreated: Record = {}; + for (const [key, event] of Object.entries((args.create ?? {}) as Record>)) { + const seriesKey = this.seriesKeyInOverride(event); + if (seriesKey) { + notCreated[key] = { type: 'invalidProperties', description: `${seriesKey} cannot be set inside recurrenceOverrides.`, properties: ['recurrenceOverrides'] }; + } else if (typeof event.uid === 'string' && [...this.events.values()].some((other) => other.uid === event.uid)) { + notCreated[key] = { type: 'invalidProperties', description: `An event with UID ${event.uid} already exists.`, properties: ['uid'] }; + } else if (this.refuse[key]) { + notCreated[key] = this.refuse[key]; + delete this.refuse[key]; + } else { + created[key] = { id: this.addEvent(event).id }; + } + } + const updated: Record = {}; + const notUpdated: Record = {}; + for (const [id, patch] of Object.entries((args.update ?? {}) as Record>)) { + const refusal = this.update(id, patch); + if (refusal) notUpdated[id] = refusal; + else updated[id] = null; + } + const destroyed: string[] = []; + const notDestroyed: Record = {}; + for (const id of (args.destroy ?? []) as string[]) { + const instance = this.instances.get(id); + if (instance?.recurrenceId && this.events.has(instance.master.id)) { + const master = this.events.get(instance.master.id) as FakeEvent; + this.patchEvent(master.id, { + recurrenceOverrides: { ...(master.recurrenceOverrides as object | undefined), [instance.recurrenceId]: { excluded: true } }, + }); + destroyed.push(id); + } else if (this.refuse[id]) { + notDestroyed[id] = this.refuse[id]; + delete this.refuse[id]; + } else if (this.events.has(id)) { + this.removeEvent(id); + destroyed.push(id); + } else { + notDestroyed[id] = { type: 'notFound' }; + } + } + if (Object.keys(created).length > 0) out.created = created; + if (Object.keys(notCreated).length > 0) out.notCreated = notCreated; + if (Object.keys(updated).length > 0) out.updated = updated; + if (Object.keys(notUpdated).length > 0) out.notUpdated = notUpdated; + if (destroyed.length > 0) out.destroyed = destroyed; + if (Object.keys(notDestroyed).length > 0) out.notDestroyed = notDestroyed; + out.newState = this.eventLog.state(); + return out; + } + + private answerCalendarCall(name: string, args: Record): Record { + switch (name) { + case 'Calendar/get': + return { accountId: args.accountId, state: 'cs1', list: [...this.calendars.values()], notFound: [] }; + case 'ParticipantIdentity/get': + return { accountId: args.accountId, list: this.identities, notFound: [] }; + case 'CalendarEvent/query': + return this.query(args); + case 'CalendarEvent/get': + return this.get(args); + case 'CalendarEvent/set': + return this.set(args); + case 'CalendarEvent/changes': { + const since = String(args.sinceState); + if (!/^s\d+$/.test(since)) throw jmapError('invalidArguments'); + const from = Number(since.slice(1)); + if (from < this.eventLog.truncatedBelow || from > this.eventLog.log.length) throw jmapError('cannotCalculateChanges'); + const maxChanges = Math.min(Number(args.maxChanges ?? 5000) || 5000, 5000); + return { accountId: args.accountId, oldState: since, ...this.eventLog.changes(since, maxChanges) }; + } + default: + throw jmapError('unknownMethod'); + } + } + + override async request(calls: MethodCall[]): Promise { + if (!calls.every(([name]) => /^(Calendar|CalendarEvent|ParticipantIdentity)\//.test(name))) return super.request(calls); + this.requests += 1; + this.beforeRequest?.(this.requests); + if (this.offline) throw new TransportError('network', 'Failed to fetch'); + if (calls.length > 16) throw new TransportError('http', 'too many calls', 400, 'urn:ietf:params:jmap:error:limit'); + const done: MethodResponse[] = []; + let lose = false; + for (const call of calls) { + this.calls.push(call); + const [name, raw, id] = call; + const args: Record = {}; + for (const [key, value] of Object.entries(raw)) { + if (key.startsWith('#')) { + const ref = value as { resultOf: string; path: string }; + const source = done.find(([, , callId]) => callId === ref.resultOf); + args[key.slice(1)] = source?.[1][ref.path.slice(1)]; + } else { + args[key] = value; + } + } + try { + done.push([name, this.answerCalendarCall(name, args), id]); + if (name === 'CalendarEvent/set' && (raw.create || raw.update) && this.loseNextSetResponse) lose = true; + } catch (error) { + const type = (error as { jmapError?: string }).jmapError; + if (!type) throw error; + done.push(['error', { type, description: (error as { description?: string }).description }, id]); + } + } + if (lose) { + this.loseNextSetResponse = false; + throw new TransportError('network', 'The connection dropped before the answer came'); + } + return { methodResponses: done }; + } +} diff --git a/lib/store/__tests__/helpers/fake-jmap.ts b/lib/store/__tests__/helpers/fake-jmap.ts new file mode 100644 index 000000000..bac572400 --- /dev/null +++ b/lib/store/__tests__/helpers/fake-jmap.ts @@ -0,0 +1,285 @@ +/** + * A small JMAP server for engine tests, modelled on how Stalwart really + * behaves (local-data/desktop-offline-facts-parts/stalwart-changes-prf.md): + * a 5000 cap on /changes pages, fixed log ranges, the three kinds of state + * refusal, 500 ids per Email/get, 16 calls per request, `ids: null` giving the + * first 500. + */ + +import { TransportError, type JmapResponse, type JmapTransport, type MethodCall, type MethodResponse, type StoreSession } from '../../engine/transport'; + +export type FakeEmail = Record & { id: string }; + +export const JMAP_ACCOUNT = 'acc1'; + +export const FAKE_SESSION: StoreSession = { + apiUrl: 'https://fake.test/jmap/', + downloadUrl: 'https://fake.test/d/{accountId}/{blobId}/{name}?type={type}', + uploadUrl: 'https://fake.test/u/{accountId}', + primaryAccounts: { 'urn:ietf:params:jmap:mail': JMAP_ACCOUNT }, + accounts: { [JMAP_ACCOUNT]: { name: 'a@example.org', isPersonal: true, accountCapabilities: { 'urn:ietf:params:jmap:mail': {} } } }, + capabilities: { 'urn:ietf:params:jmap:mail': {} }, +}; + +type ChangeKind = 'created' | 'updated' | 'destroyed'; + +interface Log { kind: ChangeKind; id: string } + +export class Collection { + log: Log[] = []; + /** Below this log position, states are refused as truncated. */ + truncatedBelow = 0; + + state(): string { + return `s${this.log.length}`; + } + + push(kind: ChangeKind, id: string): void { + this.log.push({ kind, id }); + } + + changes(since: string, maxChanges: number): { created: string[]; updated: string[]; destroyed: string[]; newState: string; hasMoreChanges: boolean } { + const from = Number(since.slice(1)); + const slice = this.log.slice(from, from + maxChanges); + const created = new Set(); + const updated = new Set(); + const destroyed = new Set(); + for (const { kind, id } of slice) { + if (kind === 'created') created.add(id); + else if (kind === 'updated') { + if (!created.has(id)) updated.add(id); + } else if (created.delete(id)) { + // Created and destroyed in the window: nowhere. + } else { + updated.delete(id); + destroyed.add(id); + } + } + const end = from + slice.length; + return { + created: [...created], + updated: [...updated], + destroyed: [...destroyed], + newState: `s${end}`, + hasMoreChanges: end < this.log.length, + }; + } +} + +export interface FakeOptions { + /** Ten mailboxes by default, a single Inbox here unless given. */ + mailboxes?: Array & { id: string }>; +} + +export class FakeJmap implements JmapTransport { + readonly emails = new Map(); + readonly mailboxes = new Map & { id: string }>(); + readonly threads = new Map(); + readonly coll = { Email: new Collection(), Mailbox: new Collection(), Thread: new Collection() }; + readonly calls: MethodCall[] = []; + requests = 0; + /** Set to make every request fail like a network drop. */ + offline = false; + /** Fails the next N requests as busy. */ + busyNext = 0; + /** Called before each request; can change data while a sync is running. */ + beforeRequest: ((n: number) => void) | null = null; + nextId = 1; + + constructor(options: FakeOptions = {}) { + for (const box of options.mailboxes ?? [{ id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 0, unreadEmails: 0 }]) { + this.mailboxes.set(box.id, box); + this.coll.Mailbox.push('created', box.id); + } + } + + addEmail(partial: Partial = {}): FakeEmail { + const n = this.nextId++; + const id = partial.id ?? `e${n}`; + const threadId = (partial.threadId as string) ?? `t${n}`; + const email: FakeEmail = { + id, + threadId, + mailboxIds: { inbox: true }, + keywords: {}, + size: 100, + receivedAt: new Date(Date.UTC(2026, 9, 1) + n * 1000).toISOString(), + sentAt: new Date(Date.UTC(2026, 9, 1) + n * 1000).toISOString(), + from: [{ name: `Sender ${n}`, email: `s${n}@example.org` }], + to: [{ name: 'A', email: 'a@example.org' }], + cc: [], + subject: `Subject ${n}`, + preview: `Preview ${n}`, + hasAttachment: false, + blobId: `b${n}`, + textBody: [{ partId: '1', type: 'text/plain' }], + htmlBody: [{ partId: '1', type: 'text/plain' }], + bodyValues: { 1: { value: `Body of message ${n}`, isTruncated: false, isEncodingProblem: false } }, + attachments: [], + messageId: [`m${n}@example.org`], + headers: [], + bodyStructure: { partId: '1', type: 'text/plain' }, + ...partial, + }; + this.emails.set(id, email); + this.coll.Email.push('created', id); + const members = this.threads.get(threadId); + if (members) { + members.push(id); + this.coll.Thread.push('updated', threadId); + } else { + this.threads.set(threadId, [id]); + this.coll.Thread.push('created', threadId); + } + return email; + } + + updateEmail(id: string, patch: Partial): void { + Object.assign(this.emails.get(id) as FakeEmail, patch); + this.coll.Email.push('updated', id); + } + + destroyEmail(id: string): void { + const email = this.emails.get(id); + if (!email) return; + this.emails.delete(id); + this.coll.Email.push('destroyed', id); + const threadId = email.threadId as string; + const members = (this.threads.get(threadId) ?? []).filter((m) => m !== id); + if (members.length > 0) { + this.threads.set(threadId, members); + this.coll.Thread.push('updated', threadId); + } else { + this.threads.delete(threadId); + this.coll.Thread.push('destroyed', threadId); + } + } + + /** The next /changes from before this point gets cannotCalculateChanges. */ + truncateLog(type: 'Email' | 'Mailbox' | 'Thread'): void { + this.coll[type].truncatedBelow = this.coll[type].log.length; + } + + private queryIds(args: Record): string[] { + const filter = args.filter as { after?: string; inMailbox?: string } | undefined; + let list = [...this.emails.values()]; + if (filter?.after) list = list.filter((e) => Date.parse(String(e.receivedAt)) >= Date.parse(filter.after as string)); + if (filter?.inMailbox) list = list.filter((e) => (e.mailboxIds as Record)[filter.inMailbox as string]); + list.sort((a, b) => Date.parse(String(b.receivedAt)) - Date.parse(String(a.receivedAt))); + return list.map((e) => e.id); + } + + private handle(name: string, args: Record): Record { + switch (name) { + case 'Mailbox/get': { + const ids = args.ids as string[] | null; + const all = [...this.mailboxes.values()]; + const list = ids ? all.filter((b) => ids.includes(b.id)) : all; + return { accountId: args.accountId, state: this.coll.Mailbox.state(), list, notFound: [] }; + } + case 'Email/query': { + const ids = this.queryIds(args); + const position = Number(args.position ?? 0); + const limit = Number(args.limit ?? 5000) || 5000; + return { + accountId: args.accountId, + queryState: this.coll.Email.state(), + canCalculateChanges: true, + position, + ids: ids.slice(position, position + Math.min(limit, 5000)), + ...(args.calculateTotal ? { total: ids.length } : {}), + }; + } + case 'Email/get': { + const ids = args.ids as string[] | null; + if (ids && ids.length > 500) throw Object.assign(new Error('requestTooLarge'), { jmapError: 'requestTooLarge' }); + const chosen = ids ?? [...this.emails.keys()].slice(0, 500); + const properties = args.properties as string[] | undefined; + const list: Record[] = []; + const notFound: string[] = []; + for (const id of chosen) { + const email = this.emails.get(id); + if (!email) { + notFound.push(id); + continue; + } + if (!properties) { + list.push({ ...email }); + continue; + } + const out: Record = { id }; + for (const p of properties) if (p in email) out[p] = email[p]; + list.push(out); + } + return { accountId: args.accountId, state: this.coll.Email.state(), list, notFound }; + } + case 'Thread/get': { + const ids = (args.ids ?? []) as string[]; + return { + accountId: args.accountId, + state: this.coll.Thread.state(), + list: ids.filter((id) => this.threads.has(id)).map((id) => ({ id, emailIds: this.threads.get(id) })), + notFound: ids.filter((id) => !this.threads.has(id)), + }; + } + case 'Email/changes': + case 'Mailbox/changes': + case 'Thread/changes': { + const type = name.split('/')[0] as 'Email' | 'Mailbox' | 'Thread'; + const coll = this.coll[type]; + const since = String(args.sinceState); + if (!/^s\d+$/.test(since)) throw Object.assign(new Error('bad state'), { jmapError: 'invalidArguments' }); + const from = Number(since.slice(1)); + if (from < coll.truncatedBelow || from > coll.log.length) { + throw Object.assign(new Error('log truncated'), { jmapError: 'cannotCalculateChanges' }); + } + const maxChanges = Math.min(Number(args.maxChanges ?? 5000) || 5000, 5000); + return { accountId: args.accountId, oldState: since, ...coll.changes(since, maxChanges) }; + } + default: + throw Object.assign(new Error(name), { jmapError: 'unknownMethod' }); + } + } + + async request(calls: MethodCall[]): Promise { + this.requests += 1; + this.beforeRequest?.(this.requests); + if (this.offline) throw new TransportError('network', 'Failed to fetch'); + if (this.busyNext > 0) { + this.busyNext -= 1; + throw new TransportError('rate', 'Server busy', 429, undefined, 1); + } + if (calls.length > 16) throw new TransportError('http', 'too many calls', 400, 'urn:ietf:params:jmap:error:limit'); + const done: MethodResponse[] = []; + for (const call of calls) { + this.calls.push(call); + const [name, raw, id] = call; + const args: Record = {}; + for (const [key, value] of Object.entries(raw)) { + if (key.startsWith('#')) { + const ref = value as { resultOf: string; path: string }; + const source = done.find(([, , callId]) => callId === ref.resultOf); + args[key.slice(1)] = source?.[1][ref.path.slice(1)]; + } else { + args[key] = value; + } + } + try { + done.push([name, this.handle(name, args), id]); + } catch (error) { + const type = (error as { jmapError?: string }).jmapError; + if (!type) throw error; + done.push(['error', { type }, id]); + } + } + return { methodResponses: done }; + } + + async download(): Promise { + return new Uint8Array(); + } + + async upload(): Promise<{ blobId: string; type: string; size: number }> { + return { blobId: 'uploaded', type: 'application/octet-stream', size: 0 }; + } +} diff --git a/lib/store/__tests__/helpers/node-adapter.ts b/lib/store/__tests__/helpers/node-adapter.ts new file mode 100644 index 000000000..421324cfa --- /dev/null +++ b/lib/store/__tests__/helpers/node-adapter.ts @@ -0,0 +1,36 @@ +/** The storage adapter for tests: real SQLite with FTS5 through node:sqlite. */ + +import { DatabaseSync, type SQLInputValue } from 'node:sqlite'; +import { runTransaction, type SqlRow, type SqlValue, type StorageAdapter } from '../../storage'; + +export function openMemoryStore(): StorageAdapter { + return adapterFor(new DatabaseSync(':memory:')); +} + +export function openFileStore(file: string): StorageAdapter { + return adapterFor(new DatabaseSync(file)); +} + +function adapterFor(db: DatabaseSync): StorageAdapter { + const tx = { depth: 0 }; + const prepared = new Map>(); + const statement = (sql: string) => { + let stmt = prepared.get(sql); + if (!stmt) { + stmt = db.prepare(sql); + prepared.set(sql, stmt); + } + return stmt; + }; + return { + exec: (sql) => db.exec(sql), + run: (sql, params = []) => { + const result = statement(sql).run(...(params as SQLInputValue[])); + return { changes: Number(result.changes), lastInsertRowid: Number(result.lastInsertRowid) }; + }, + all: (sql: string, params: readonly SqlValue[] = []) => statement(sql).all(...(params as SQLInputValue[])) as T[], + get: (sql: string, params: readonly SqlValue[] = []) => statement(sql).get(...(params as SQLInputValue[])) as T | undefined, + transaction: (fn) => runTransaction(tx, (sql) => db.exec(sql), fn), + close: () => db.close(), + }; +} diff --git a/lib/store/__tests__/mail-sync.test.ts b/lib/store/__tests__/mail-sync.test.ts new file mode 100644 index 000000000..a5ddae149 --- /dev/null +++ b/lib/store/__tests__/mail-sync.test.ts @@ -0,0 +1,463 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { DEFAULT_SCOPE, type StoreContext, type StoreEvent } from '../area'; +import { AccountEngine } from '../engine/account-engine'; +import { runChangesLoop } from '../engine/changes'; +import { answerRequest, buildMethodTable } from '../engine/responder'; +import type { SqlValue } from '../storage'; +import { TransportError, type MethodCall } from '../engine/transport'; +import { mailArea } from '../mail'; +import { FAKE_SESSION, FakeJmap, JMAP_ACCOUNT } from './helpers/fake-jmap'; +import { openFileStore, openMemoryStore } from './helpers/node-adapter'; + +const EVERYTHING = { ...DEFAULT_SCOPE, days: null }; +const INBOX = { inMailbox: 'inbox' }; + +function engineFor(server: FakeJmap, db = openMemoryStore(), emit?: (e: StoreEvent) => void) { + return new AccountEngine({ + account: 'a@fake.test', + db, + session: FAKE_SESSION, + transport: server, + areas: [mailArea], + scope: EVERYTHING, + emit, + now: () => Date.UTC(2026, 10, 3), + sleep: async () => {}, + }); +} + +function count(db: ReturnType, table: string): number { + return Number(db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM ${table}`)?.n); +} + +function callsNamed(server: FakeJmap, name: string): MethodCall[] { + return server.calls.filter(([n]) => n === name); +} + +describe('full sync', () => { + it('stores every message, thread and body across pages', async () => { + const server = new FakeJmap(); + for (let i = 0; i < 1234; i += 1) server.addEmail(); + const db = openMemoryStore(); + await engineFor(server, db).requestSync(); + expect(count(db, 'email')).toBe(1234); + expect(count(db, 'email_body')).toBe(1234); + expect(count(db, 'thread')).toBe(1234); + expect(count(db, 'email_mailbox')).toBe(1234); + }); + + it('captures the state before paging, so a message landing mid-sync is not lost', async () => { + const server = new FakeJmap(); + for (let i = 0; i < 600; i += 1) server.addEmail(); + let landed = false; + server.beforeRequest = (n) => { + if (n === 4 && !landed) { + landed = true; + server.addEmail({ id: 'late' }); + } + }; + const db = openMemoryStore(); + await engineFor(server, db).requestSync(); + expect(landed).toBe(true); + expect(db.get('SELECT id FROM email WHERE id = ?', ['late'])).toBeTruthy(); + }); + + it('keeps only the chosen window', async () => { + const server = new FakeJmap(); + server.addEmail({ id: 'old', receivedAt: '2020-01-01T00:00:00Z' }); + server.addEmail({ id: 'new', receivedAt: '2026-10-30T00:00:00Z' }); + const db = openMemoryStore(); + const engine = new AccountEngine({ + account: 'a@fake.test', db, session: FAKE_SESSION, transport: server, areas: [mailArea], + scope: { ...DEFAULT_SCOPE, days: 30 }, now: () => Date.UTC(2026, 10, 3), sleep: async () => {}, + }); + await engine.requestSync(); + expect(db.all('SELECT id FROM email').map((r) => r.id)).toEqual(['new']); + }); +}); + +describe('persisted state', () => { + it('survives a reload and continues from the saved state with a delta', async () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'store-')); + const file = path.join(dir, 's.db'); + try { + const server = new FakeJmap(); + for (let i = 0; i < 50; i += 1) server.addEmail(); + const first = openFileStore(file); + await engineFor(server, first).requestSync(); + first.close(); + + server.addEmail({ id: 'after-reload' }); + server.calls.length = 0; + const second = openFileStore(file); + await engineFor(server, second).requestSync(); + expect(second.get('SELECT id FROM email WHERE id = ?', ['after-reload'])).toBeTruthy(); + expect(count(second, 'email')).toBe(51); + // A delta, not another full listing. + expect(callsNamed(server, 'Email/query')).toHaveLength(0); + expect(callsNamed(server, 'Email/changes').length).toBeGreaterThan(0); + second.close(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('resumes an interrupted full sync from its cursor', async () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'store-')); + const file = path.join(dir, 's.db'); + try { + const server = new FakeJmap(); + for (let i = 0; i < 4500; i += 1) server.addEmail(); + const first = openFileStore(file); + // Goes down after the first page of the list. + server.beforeRequest = (n) => { + if (n === 4) server.offline = true; + }; + await engineFor(server, first).requestSync(); + const listed = count(first, 'email'); + expect(listed).toBeGreaterThan(0); + expect(listed).toBeLessThan(4500); + first.close(); + + server.offline = false; + server.beforeRequest = null; + server.calls.length = 0; + const second = openFileStore(file); + await engineFor(server, second).requestSync(); + expect(count(second, 'email')).toBe(4500); + const firstQuery = callsNamed(server, 'Email/query')[0]; + expect(firstQuery[1].position).toBe(listed); + second.close(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe('deltas', () => { + it('applies created, updated and destroyed, and leaves bodies alone on an update', async () => { + const server = new FakeJmap(); + const a = server.addEmail(); + const b = server.addEmail(); + const db = openMemoryStore(); + const engine = engineFor(server, db); + await engine.requestSync(); + + server.updateEmail(a.id, { keywords: { $seen: true } }); + server.destroyEmail(b.id); + server.addEmail({ id: 'c' }); + await engine.requestSync(); + + expect(db.all('SELECT id FROM email ORDER BY id').map((r) => r.id)).toEqual(['c', a.id]); + expect(db.get('SELECT 1 AS x FROM email_keyword WHERE keyword = ?', ['$seen'])).toBeTruthy(); + const kept = db.get<{ body_state: number }>('SELECT body_state FROM email WHERE id = ?', [a.id]); + expect(kept?.body_state).toBe(1); + // The destroyed message left nothing behind in the search index. + expect(db.all("SELECT rowid FROM email_fts_word WHERE email_fts_word MATCH ?", [`"Subject" `]).length).toBe(2); + }); + + it('pages through more changes than one call gives', async () => { + const server = new FakeJmap(); + server.addEmail(); + const db = openMemoryStore(); + const engine = engineFor(server, db); + await engine.requestSync(); + server.calls.length = 0; + for (let i = 0; i < 1500; i += 1) server.addEmail(); + await engine.requestSync(); + expect(count(db, 'email')).toBe(1501); + expect(callsNamed(server, 'Email/changes').length).toBeGreaterThanOrEqual(3); + const page = callsNamed(server, 'Email/changes')[0][1]; + expect(page.maxChanges).toBe(500); + }); + + it('sends sinceState last, because a malformed one otherwise fails the whole request on Stalwart', async () => { + const server = new FakeJmap(); + server.addEmail(); + const engine = engineFor(server); + await engine.requestSync(); + server.addEmail(); + server.calls.length = 0; + await engine.requestSync(); + const keys = Object.keys(callsNamed(server, 'Email/changes')[0][1]); + expect(keys[keys.length - 1]).toBe('sinceState'); + }); + + it('keeps going while behind the pushed state, since hasMoreChanges false does not mean caught up', async () => { + const server = new FakeJmap(); + server.addEmail(); + const applied: string[] = []; + let wrote = false; + const outcome = await runChangesLoop({ + transport: server, + type: 'Email', + jmap: JMAP_ACCOUNT, + using: [], + since: 's0', + target: 's2', + apply: async (page) => { + applied.push(page.newState); + if (!wrote) { + wrote = true; + server.addEmail(); + } + }, + }); + expect(outcome).toMatchObject({ outcome: 'caughtUp', state: 's2' }); + expect(applied).toEqual(['s1', 's2']); + }); + + it('stops when the state stands still even though the push named a newer one', async () => { + const server = new FakeJmap(); + server.addEmail(); + const outcome = await runChangesLoop({ + transport: server, type: 'Email', jmap: JMAP_ACCOUNT, using: [], since: 's1', target: 's9', + apply: async () => {}, + }); + expect(outcome.outcome).toBe('caughtUp'); + expect(server.requests).toBe(1); + }); +}); + +describe('resync', () => { + it('starts Email over on cannotCalculateChanges, keeps bodies, and drops what is gone', async () => { + const server = new FakeJmap(); + const keep = server.addEmail({ id: 'keep' }); + server.addEmail({ id: 'gone' }); + const db = openMemoryStore(); + const engine = engineFor(server, db); + await engine.requestSync(); + + server.destroyEmail('gone'); + server.updateEmail(keep.id, { keywords: { $flagged: true } }); + server.truncateLog('Email'); + server.calls.length = 0; + await engine.requestSync(); + + expect(callsNamed(server, 'Email/query').length).toBeGreaterThan(0); + expect(db.all('SELECT id FROM email').map((r) => r.id)).toEqual(['keep']); + expect(db.get('SELECT 1 AS x FROM email_keyword WHERE keyword = ?', ['$flagged'])).toBeTruthy(); + // The body wasn't fetched a second time. + const bodyFetches = callsNamed(server, 'Email/get').filter(([, a]) => a.fetchTextBodyValues); + expect(bodyFetches).toHaveLength(0); + expect(count(db, 'email_body')).toBe(1); + }); + + it.each([ + ['invalidArguments', { jmapError: 'invalidArguments' }], + ['cannotCalculateChanges', { jmapError: 'cannotCalculateChanges' }], + ])('treats %s as a reason to start over', async (_name, thrown) => { + const server = new FakeJmap(); + const original = server.request.bind(server); + let refused = false; + server.request = async (calls) => { + if (!refused && calls[0][0] === 'Email/changes') { + refused = true; + return { methodResponses: [['error', { type: thrown.jmapError }, 'c']] }; + } + return original(calls); + }; + const outcome = await runChangesLoop({ transport: server, type: 'Email', jmap: JMAP_ACCOUNT, using: [], since: 's0', apply: async () => {} }); + expect(outcome.outcome).toBe('resync'); + }); + + it('treats an HTTP 400 notRequest as a reason to start over, not as an outage', async () => { + const server = new FakeJmap(); + server.request = async () => { + throw new TransportError('http', 'bad', 400, 'urn:ietf:params:jmap:error:notRequest'); + }; + const outcome = await runChangesLoop({ transport: server, type: 'Email', jmap: JMAP_ACCOUNT, using: [], since: 's0', apply: async () => {} }); + expect(outcome.outcome).toBe('resync'); + }); + + it('never sends a stored state a server cannot have sent', async () => { + const server = new FakeJmap(); + const outcome = await runChangesLoop({ transport: server, type: 'Email', jmap: JMAP_ACCOUNT, using: [], since: 'bad state\n', apply: async () => {} }); + expect(outcome.outcome).toBe('resync'); + expect(server.requests).toBe(0); + }); +}); + +describe('when the server is busy or gone', () => { + it('backs off and carries on after a 429', async () => { + const server = new FakeJmap(); + server.addEmail(); + server.busyNext = 2; + const db = openMemoryStore(); + await engineFor(server, db).requestSync(); + expect(count(db, 'email')).toBe(1); + }); + + it('ends the run quietly when the network is gone, and picks up when back', async () => { + const server = new FakeJmap(); + server.addEmail(); + server.offline = true; + const events: StoreEvent[] = []; + const db = openMemoryStore(); + const engine = engineFor(server, db, (e) => events.push(e)); + await engine.requestSync(); + expect(count(db, 'email')).toBe(0); + expect(events.some((e) => e.type === 'error')).toBe(false); + server.offline = false; + await engine.requestSync(); + expect(count(db, 'email')).toBe(1); + }); + + it('tells the page about new mail only for messages it had not seen', async () => { + const server = new FakeJmap(); + server.addEmail(); + const events: StoreEvent[] = []; + const engine = engineFor(server, openMemoryStore(), (e) => events.push(e)); + await engine.requestSync(); + expect(events.filter((e) => e.type === 'created')).toHaveLength(0); + server.addEmail({ id: 'fresh' }); + await engine.requestSync(); + expect(events.filter((e) => e.type === 'created')).toEqual([ + { type: 'created', account: 'a@fake.test', jmap: JMAP_ACCOUNT, collection: 'Email', ids: ['fresh'] }, + ]); + }); +}); + +describe('query plans', () => { + it('reads a newest-first folder page off the index, without sorting the folder', async () => { + const server = new FakeJmap(); + for (let i = 0; i < 30; i += 1) server.addEmail(); + const db = openMemoryStore(); + const engine = engineFor(server, db); + await engine.requestSync(); + const seen: string[] = []; + const spied = { + ...db, + all: (sql: string, params?: readonly SqlValue[]) => { + seen.push(sql); + return db.all(sql, params); + }, + }; + const answer = await answerRequest( + { account: 'a', db: spied as typeof db, session: FAKE_SESSION, scope: EVERYTHING, now: Date.now, emit: () => {}, defaultJmap: JMAP_ACCOUNT, invertedKeywordSort: false }, + buildMethodTable([mailArea]), + [['Email/query', { filter: INBOX, sort: [{ property: 'receivedAt', isAscending: false }], limit: 5 }, 'q']], + ); + expect(answer?.methodResponses[0][1].ids).toHaveLength(5); + const [sql] = seen; + const plan = db.all<{ detail: string }>(`EXPLAIN QUERY PLAN ${sql}`, [JMAP_ACCOUNT, 'inbox', 5, 0]).map((row) => row.detail).join('; '); + expect(plan).not.toContain('TEMP B-TREE'); + expect(plan).toContain('email_mailbox_list'); + // The folder condition isn't looked up again for every row. + expect(sql).not.toContain('m.mailbox_id'); + }); +}); + +describe('a failure inside a transaction', () => { + it('reports the real error even when SQLite has already rolled back', () => { + const db = openMemoryStore(); + db.exec('CREATE TABLE t (id INTEGER PRIMARY KEY)'); + expect(() => db.transaction(() => { + db.exec('ROLLBACK'); + throw new Error('disk is full'); + })).toThrow('disk is full'); + // And the connection still works. + db.transaction(() => db.run('INSERT INTO t (id) VALUES (1)')); + expect(db.all('SELECT id FROM t')).toHaveLength(1); + }); +}); + +describe('calls into an area', () => { + const noteArea = { + name: 'note', + migrations: [{ id: 9999, name: 'note', sql: 'CREATE TABLE note (id INTEGER PRIMARY KEY, text TEXT)' }], + methods: {}, + syncers: [], + calls: { + 'note.add': (ctx: StoreContext, params: { text: string }) => ctx.db.run('INSERT INTO note (text) VALUES (?)', [params.text]).lastInsertRowid, + }, + }; + + it('reaches the area and runs its migration', async () => { + const engine = new AccountEngine({ + account: 'a@fake.test', db: openMemoryStore(), session: FAKE_SESSION, transport: new FakeJmap(), areas: [mailArea, noteArea], scope: EVERYTHING, + }); + expect(await engine.areaCall('note.add', { text: 'hello' })).toBe(1); + await expect(engine.areaCall('note.nothing', {})).rejects.toThrow('Unknown store call'); + }); + + it('refuses two areas defining the same name', () => { + expect(() => new AccountEngine({ + account: 'a@fake.test', db: openMemoryStore(), session: FAKE_SESSION, transport: new FakeJmap(), + areas: [noteArea, { ...noteArea, name: 'other', migrations: [] }], scope: EVERYTHING, + })).toThrow('Two store areas define note.add'); + }); +}); + +describe('answers from the store', () => { + async function seeded() { + const server = new FakeJmap(); + server.addEmail({ id: 'one', subject: 'Zahlungserinnerung Rechnungsbetrag', keywords: { $seen: true } }); + server.addEmail({ id: 'two', subject: 'Hello', mailboxIds: { inbox: true }, bodyValues: { 1: { value: 'See you Tuesday' } } }); + const db = openMemoryStore(); + const engine = engineFor(server, db); + await engine.requestSync(); + return { engine, db, server }; + } + + it('gives every call its own answer, in order, with a per-call error for what it cannot answer', async () => { + const { engine } = await seeded(); + const answer = await engine.answer([ + ['Email/query', { filter: INBOX, sort: [{ property: 'receivedAt', isAscending: false }], calculateTotal: true }, 'a'], + ['SieveScript/get', {}, 'b'], + ['Email/get', { '#ids': { resultOf: 'a', name: 'Email/query', path: '/ids' }, properties: ['id', 'subject'] }, 'c'], + ]); + expect(answer?.methodResponses.map(([name, , id]) => [name, id])).toEqual([ + ['Email/query', 'a'], ['error', 'b'], ['Email/get', 'c'], + ]); + expect(answer?.methodResponses[0][1]).toMatchObject({ ids: ['two', 'one'], total: 2, position: 0 }); + expect(answer?.methodResponses[1][1].type).toBe('serverUnavailable'); + expect(answer?.methodResponses[2][1].list).toEqual([ + { id: 'two', subject: 'Hello' }, + { id: 'one', subject: 'Zahlungserinnerung Rechnungsbetrag' }, + ]); + }); + + it('answers null when nothing in the request could be answered', async () => { + const { engine } = await seeded(); + expect(await engine.answer([['SieveScript/get', {}, 'a']])).toBeNull(); + }); + + it('finds a German compound from inside the word, and a word start', async () => { + const { engine } = await seeded(); + const search = async (text: string) => (await engine.answer([['Email/query', { filter: { text } }, 'q']]))?.methodResponses[0][1].ids; + expect(await search('rechnung')).toEqual(['one']); + expect(await search('erinnerung')).toEqual(['one']); + expect(await search('zahl*')).toEqual(['one']); + expect(await search('tuesday')).toEqual(['two']); + expect(await search('nichtda')).toEqual([]); + }); + + it('reads keyword sort the way the client sends it for that server', async () => { + const { engine } = await seeded(); + const sorted = async () => (await engine.answer([['Email/query', { + filter: INBOX, + sort: [{ property: 'hasKeyword', keyword: '$seen', isAscending: false }], + }, 'q']]))?.methodResponses[0][1].ids; + expect(await sorted()).toEqual(['one', 'two']); + engine.setInvertedKeywordSort(true); + expect(await sorted()).toEqual(['two', 'one']); + }); + + it('does not make up a message with empty text when the body is not kept', async () => { + const { engine, db } = await seeded(); + db.run('DELETE FROM email_body'); + const answer = await engine.answer([['Email/get', { ids: ['one'], fetchTextBodyValues: true }, 'g']]); + expect(answer?.methodResponses[0][1]).toMatchObject({ list: [], notFound: ['one'] }); + }); + + it('answers with the real server state, never a made-up one', async () => { + const { engine, server } = await seeded(); + const answer = await engine.answer([['Email/get', { ids: ['one'], properties: ['id'] }, 'g']]); + expect(answer?.methodResponses[0][1].state).toBe(server.coll.Email.state()); + }); +}); diff --git a/lib/store/__tests__/search.test.ts b/lib/store/__tests__/search.test.ts new file mode 100644 index 000000000..711d48539 --- /dev/null +++ b/lib/store/__tests__/search.test.ts @@ -0,0 +1,557 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { buildJMAPFilter, DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; +import { parseSearchSnippet, snippetFilterFor } from '@/lib/search-snippet'; +import { DEFAULT_SCOPE } from '../area'; +import { AccountEngine } from '../engine/account-engine'; +import { mailArea } from '../mail'; +import { bodyText, deleteEmail } from '../mail/records'; +import { FAKE_SESSION, FakeJmap } from './helpers/fake-jmap'; +import { openMemoryStore } from './helpers/node-adapter'; + +type Email = Parameters[0]; + +function withBody(value: string): Email { + return { bodyValues: { 1: { value, isTruncated: false, isEncodingProblem: false } } }; +} + +async function storeWith(emails: Email[], db = openMemoryStore()) { + const server = new FakeJmap(); + for (const email of emails) server.addEmail(email); + const engine = new AccountEngine({ + account: 'a@fake.test', + db, + session: FAKE_SESSION, + transport: server, + areas: [mailArea], + scope: { ...DEFAULT_SCOPE, days: null }, + now: () => Date.UTC(2026, 10, 3), + sleep: async () => {}, + }); + await engine.requestSync(); + const query = async (filter: Record) => { + // A second call, because a request with no call answered comes back as null. + const answer = await engine.answer([ + ['Email/query', { filter, sort: [{ property: 'receivedAt', isAscending: true }] }, 'q'], + ['Mailbox/get', { ids: [] }, 'm'], + ]); + const [name, result] = answer?.methodResponses[0] ?? ['error', { type: 'no answer' }]; + if (name === 'error') throw new Error(`per-call error: ${result.type}`); + return result.ids as string[]; + }; + const snippets = async (filter: Record | null, emailIds: string[]) => { + const answer = await engine.answer([['SearchSnippet/get', { filter, emailIds }, 's']]); + return answer?.methodResponses[0][1] as { + list: Array<{ emailId: string; subject: string | null; preview: string | null }>; + notFound: string[]; + }; + }; + return { engine, query, snippets, db }; +} + +/** A store that records the statements it runs. */ +function recordingStore() { + const db = openMemoryStore(); + const statements: string[] = []; + const run = db.run.bind(db); + db.run = (sql, params) => { + statements.push(sql); + return run(sql, params); + }; + return { db, statements }; +} + +const alice = [{ name: 'Alice Adler', email: 'alice@example.org' }]; +const bob = [{ name: 'Bob Berg', email: 'bob@example.org' }]; +const carol = [{ name: 'Carol Conrad', email: 'carol@example.org' }]; +const dave = [{ name: 'Dave Dorn', email: 'dave@example.org' }]; + +describe('matching', () => { + it('finds a word inside a German compound, in the subject and in the body', async () => { + const { query } = await storeWith([ + { id: 'subject', subject: 'Zahlungserinnerungsrechnung Oktober' }, + { id: 'body', subject: 'Hallo', ...withBody('Anbei die Stromrechnung.') }, + { id: 'other', subject: 'Einladung', ...withBody('Bis morgen') }, + ]); + expect(await query({ text: 'rechnung' })).toEqual(['subject', 'body']); + expect(await query({ subject: 'rechnung' })).toEqual(['subject']); + expect(await query({ body: 'rechnung' })).toEqual(['body']); + }); + + it('reads word* as the start of a word, not anywhere in it', async () => { + const { query } = await storeWith([ + { id: 'start', subject: 'Rechnungsbetrag offen' }, + { id: 'inside', subject: 'Stromrechnung offen' }, + ]); + expect(await query({ text: 'rechn*' })).toEqual(['start']); + expect(await query({ text: 'rechn' })).toEqual(['start', 'inside']); + }); + + it('reads a quoted phrase as exact words in order', async () => { + const { query } = await storeWith([ + { id: 'phrase', subject: 'The annual report is here' }, + { id: 'apart', subject: 'The report for annual leave' }, + { id: 'compound', subject: 'Stromrechnung' }, + { id: 'word', subject: 'Eine Rechnung' }, + ]); + expect(await query({ text: '"annual report"' })).toEqual(['phrase']); + expect(await query({ text: '"rechnung"' })).toEqual(['word']); + }); + + it('matches one and two characters as whole words only, unless a star asks for more', async () => { + const { query } = await storeWith([ + { id: 'zu', subject: 'Bitte zu Hause' }, + { id: 'zum', subject: 'Bitte zum Arzt' }, + { id: 'a', subject: 'Plan a today' }, + { id: 'anna', subject: 'Anna kommt' }, + ]); + expect(await query({ subject: 'zu' })).toEqual(['zu']); + expect(await query({ subject: 'zu*' })).toEqual(['zu', 'zum']); + expect(await query({ subject: 'a' })).toEqual(['a']); + // One letter is too expensive as a prefix, so it stays the word. + expect(await query({ subject: 'a*' })).toEqual(['a']); + }); + + it('keeps To, Cc and Bcc apart, and searches all of them for text', async () => { + const { query } = await storeWith([ + { id: 'one', from: alice, to: bob, cc: carol, bcc: dave }, + ]); + expect(await query({ from: 'alice' })).toEqual(['one']); + expect(await query({ to: 'bob' })).toEqual(['one']); + expect(await query({ to: 'carol' })).toEqual([]); + expect(await query({ to: 'dave' })).toEqual([]); + expect(await query({ cc: 'carol' })).toEqual(['one']); + expect(await query({ cc: 'bob' })).toEqual([]); + expect(await query({ bcc: 'dave' })).toEqual(['one']); + expect(await query({ bcc: 'carol' })).toEqual([]); + for (const name of ['alice', 'bob', 'carol', 'dave']) expect(await query({ text: name })).toEqual(['one']); + }); + + it('answers the recipient search the client sends', async () => { + const { query } = await storeWith([ + { id: 'to', to: bob }, + { id: 'cc', to: alice, cc: bob }, + { id: 'none', to: alice }, + ]); + expect(await query({ operator: 'OR', conditions: [{ to: 'bob@example.org' }, { cc: 'bob@example.org' }] })).toEqual(['to', 'cc']); + }); + + it('answers every search shape buildJMAPFilter produces', async () => { + const { query } = await storeWith([ + { + id: 'hit', + subject: 'Budget 2024 annual report', + from: alice, + to: bob, + ...withBody('Die Stromrechnung liegt bei'), + }, + { id: 'miss', subject: 'Budget 2024', from: alice, to: bob, ...withBody('Nichts') }, + ]); + const filter = buildJMAPFilter('"annual report" budg* 2024', { + ...DEFAULT_SEARCH_FILTERS, + from: 'alice', + to: 'bob', + subject: 'annual', + body: 'rechnung', + }, 'inbox'); + expect(await query(filter)).toEqual(['hit']); + }); + + it('ignores diacritics both ways', async () => { + const { query } = await storeWith([ + { id: 'umlaut', subject: 'Frau Müller' }, + { id: 'plain', subject: 'Herr Muller' }, + { id: 'accent', subject: 'Café am Markt' }, + ]); + expect(await query({ text: 'Müller' })).toEqual(['umlaut', 'plain']); + expect(await query({ text: 'muller' })).toEqual(['umlaut', 'plain']); + expect(await query({ text: 'cafe' })).toEqual(['accent']); + expect(await query({ text: 'Café' })).toEqual(['accent']); + expect(await query({ text: 'mül*' })).toEqual(['umlaut', 'plain']); + }); + + it('finds the spelled-out German spelling both ways, without false friends', async () => { + const { query } = await storeWith([ + { id: 'umlaut', subject: 'Frau Müller' }, + { id: 'written', subject: 'Herr Mueller' }, + { id: 'sharp', subject: 'Hauptstraße 1' }, + { id: 'double', subject: 'Hauptstrasse 2' }, + { id: 'care', subject: 'Die Betreuung beginnt' }, + { id: 'loyal', subject: 'Treue' }, + ]); + expect(await query({ text: 'Mueller' })).toEqual(['umlaut', 'written']); + expect(await query({ text: 'Müller' })).toEqual(['umlaut', 'written']); + expect(await query({ text: 'Hauptstraße' })).toEqual(['sharp', 'double']); + expect(await query({ text: 'Hauptstrasse' })).toEqual(['sharp', 'double']); + // "treu" is inside "Betreuung": the plain spelling is a whole word only. + expect(await query({ text: 'Treue' })).toEqual(['loyal']); + }); + + it('finds an umlaut an HTML mail wrote as an entity', async () => { + const { query } = await storeWith([{ + id: 'html', + subject: 'Hallo', + textBody: [], + htmlBody: [{ partId: '1', type: 'text/html' }], + ...withBody('Vorlage

Grüße von Frau Müller &lt;Chefin&gt;

'), + }]); + expect(await query({ body: 'Müller' })).toEqual(['html']); + expect(await query({ body: 'grüße' })).toEqual(['html']); + expect(await query({ body: 'Vorlage' })).toEqual([]); + // Decoded once: the encoded text stays text. + expect(await query({ body: '"<Chefin>"' })).toEqual(['html']); + }); + + it('answers a very common word', async () => { + const emails: Email[] = []; + for (let i = 0; i < 300; i += 1) emails.push({ subject: 'die Rechnung für die Firma' }); + const { query } = await storeWith(emails); + expect((await query({ text: 'die' })).length).toBe(300); + }); +}); + +describe('the split indexes', () => { + it('writes each index once for a message, with no delete when its body arrives', async () => { + const { db, statements } = recordingStore(); + await storeWith([{ id: 'one', subject: 'Hallo', ...withBody('Inhalt') }], db); + const fts = statements.filter((sql) => sql.includes('email_fts')); + expect(fts.filter((sql) => sql.startsWith('DELETE'))).toEqual([]); + for (const table of ['email_fts_word', 'email_fts_tri', 'email_fts_body_word', 'email_fts_body_tri']) { + expect(fts.filter((sql) => sql.startsWith(`INSERT INTO ${table} `)).length, table).toBe(1); + } + }); + + it('touches the head row again only for a message with Bcc', async () => { + const { db, statements } = recordingStore(); + const { query } = await storeWith([{ id: 'one', subject: 'Hallo', bcc: dave }], db); + const fts = statements.filter((sql) => sql.includes('email_fts')); + expect(fts.filter((sql) => sql.startsWith('DELETE FROM email_fts_word')).length).toBe(1); + expect(fts.filter((sql) => sql.startsWith('DELETE FROM email_fts_body'))).toEqual([]); + // Nothing of the head was lost in writing it again. + expect(await query({ subject: 'hallo' })).toEqual(['one']); + expect(await query({ bcc: 'dave' })).toEqual(['one']); + }); + + it('finds head fields only in the head and body text only in the body', async () => { + const { query } = await storeWith([ + { id: 'in-subject', subject: 'Stromrechnung', ...withBody('nichts') }, + { id: 'in-body', subject: 'Hallo', ...withBody('Die Stromrechnung') }, + { id: 'from-word', subject: 'Hallo', from: [{ name: 'Stromrechnung', email: 'x@example.org' }], ...withBody('nichts') }, + ]); + expect(await query({ text: 'rechnung' })).toEqual(['in-subject', 'in-body', 'from-word']); + expect(await query({ text: 'stromrechnung' })).toEqual(['in-subject', 'in-body', 'from-word']); + expect(await query({ subject: 'rechnung' })).toEqual(['in-subject']); + expect(await query({ body: 'rechnung' })).toEqual(['in-body']); + expect(await query({ from: 'rechnung' })).toEqual(['from-word']); + }); + + it('keeps each word of a search free to match in the head or the body', async () => { + const { query } = await storeWith([{ id: 'one', subject: 'Rechnung', ...withBody('Oktober') }, { id: 'two', subject: 'Rechnung' }]); + expect(await query({ text: 'rechnung oktober' })).toEqual(['one']); + }); + + it('caps the trigrams of a body at 32 KB and leaves its words to 200 KB', async () => { + const filler = 'fuellwort '.repeat(4000); + const { query } = await storeWith([ + { id: 'early', subject: 'x', ...withBody('Stromrechnung am Anfang') }, + { id: 'late', subject: 'x', ...withBody(`${filler} Gasrechnung am Ende`) }, + { id: 'past', subject: 'x', ...withBody(`${filler.repeat(6)} Wasserrechnung`) }, + ]); + expect(await query({ body: 'rechnung' })).toEqual(['early']); + // Past the trigram cap the whole word and its start are still found. + expect(await query({ body: 'gasrechnung' })).toEqual(['late']); + expect(await query({ body: 'gasrech*' })).toEqual(['late']); + // Past the word cap too: not indexed at all. + expect(await query({ body: 'wasserrechnung' })).toEqual([]); + }); + + it('removes a message from all four indexes', async () => { + const { db, query } = await storeWith([{ id: 'one', subject: 'Hallo', ...withBody('Inhalt') }]); + const jmap = String(db.get<{ jmap: string }>('SELECT jmap FROM email')?.jmap); + deleteEmail(db, jmap, 'one'); + for (const table of ['email_fts_word', 'email_fts_tri']) expect(db.all(`SELECT rowid FROM ${table} WHERE ${table} MATCH ?`, ['"hallo"'])).toEqual([]); + for (const table of ['email_fts_body_word', 'email_fts_body_tri']) expect(db.all(`SELECT rowid FROM ${table} WHERE ${table} MATCH ?`, ['"inhalt"'])).toEqual([]); + expect(await query({ text: 'inhalt' })).toEqual([]); + }); + + it('sets the options for bulk writes', async () => { + const { db } = await storeWith([]); + const option = (table: string, key: string) => Number(db.get<{ v: number }>(`SELECT v FROM ${table}_config WHERE k = ?`, [key])?.v); + expect(option('email_fts_body_word', 'hashsize')).toBe(8 * 1024 * 1024); + expect(option('email_fts_body_tri', 'hashsize')).toBe(16 * 1024 * 1024); + for (const table of ['email_fts_word', 'email_fts_tri', 'email_fts_body_word', 'email_fts_body_tri']) { + expect(option(table, 'automerge'), table).toBe(8); + } + }); +}); + +describe('bodyText', () => { + const html = (value: string) => ({ + textBody: [], + htmlBody: [{ partId: '1', type: 'text/html' }], + bodyValues: { 1: { value } }, + }); + const MEGABYTE = 1_000_000; + const HOSTILE: Record = { + 'opening brackets': '<'.repeat(MEGABYTE), + 'comment openers': 'b
kept

text

', + )).trim()).toBe('kept text'); + }); + + it('leaves the text of a block that never closes', () => { + expect(bodyText(html('before

Ihre Rechnung von Müller

'), + }]); + expect((await snippets({ body: 'rechnung' }, ['one'])).list[0].preview).toBe('Ihre Rechnung von Müller'); + }); + + it('works chained onto the query, as the client asks for it', async () => { + const { engine } = await storeWith([{ id: 'one', subject: 'Rechnung' }, { id: 'two', subject: 'Hallo' }]); + const answer = await engine.answer([ + ['Email/query', { filter: { text: 'rechnung' } }, '0'], + ['SearchSnippet/get', { filter: { text: 'rechnung' }, '#emailIds': { resultOf: '0', name: 'Email/query', path: '/ids' } }, 'snippets'], + ]); + expect(answer?.methodResponses[1][0]).toBe('SearchSnippet/get'); + expect(answer?.methodResponses[1][1].list).toEqual([{ emailId: 'one', subject: 'Rechnung', preview: null }]); + }); + + it('answers per-call errors for bad arguments', async () => { + const { engine } = await storeWith([]); + const answer = await engine.answer([ + ['SearchSnippet/get', { filter: { text: 'x' } }, 'a'], + ['SearchSnippet/get', { filter: { text: 'x' }, emailIds: Array.from({ length: 501 }, (_, i) => `e${i}`) }, 'b'], + ['Mailbox/query', {}, 'c'], + ]); + expect(answer?.methodResponses[0]).toEqual(['error', expect.objectContaining({ type: 'invalidArguments' }), 'a']); + expect(answer?.methodResponses[1]).toEqual(['error', expect.objectContaining({ type: 'requestTooLarge' }), 'b']); + }); + + it('caps the patterns one highlight can use, keeping it working', async () => { + const words = Array.from({ length: 512 }, (_, i) => `wort${String(i).padStart(3, '0')}`); + const { snippets } = await storeWith([{ id: 'one', subject: words.join(' ').slice(0, 1000) }]); + const conditions = Array.from({ length: 32 }, (_, i) => ({ subject: words.slice(i * 16, i * 16 + 16).join(' ') })); + const { list } = await snippets({ operator: 'AND', conditions }, ['one']); + const marks = (list[0].subject as string).split('').length - 1; + expect(marks).toBeGreaterThan(0); + // The subject holds about 125 of the words, so more than this would be uncapped. + expect(marks).toBeLessThanOrEqual(64); + }); + + it('caps how much of a subject it reads', async () => { + const { snippets } = await storeWith([{ id: 'one', subject: `foo ${'x'.repeat(100_000)}` }]); + const subject = (await snippets({ subject: 'foo' }, ['one'])).list[0].subject as string; + expect(subject.startsWith('foo')).toBe(true); + expect(subject.length).toBeLessThan(2_000); + }); + + it('survives hostile search terms', async () => { + const { snippets } = await storeWith([{ id: 'one', subject: 'foo (bar) [baz]', ...withBody('foo (bar) [baz] \\ ^ $ . * + ? | /') }]); + for (const text of ['(', ')', '[', ']', '\\', '^', '$', '.', '*', '+', '?', '|', '/', '(bar)', '[baz', 'a|b', '.*', '(?', '\ud83d', 'foo\0bar']) { + await expect(snippets({ text }, ['one']), text).resolves.toHaveProperty('list'); + } + expect((await snippets({ text: '.*' }, ['one'])).list[0].preview).toBeNull(); + expect((await snippets({ text: '(bar)' }, ['one'])).list[0].subject).toBe('foo (bar) [baz]'); + }); +}); diff --git a/lib/store/adapters/wasm.ts b/lib/store/adapters/wasm.ts new file mode 100644 index 000000000..2af1373fe --- /dev/null +++ b/lib/store/adapters/wasm.ts @@ -0,0 +1,199 @@ +/** + * SQLite3MultipleCiphers on OPFS, for the owner tab's worker. + * + * Rules that come from bugs reproduced in 2.6.1, see + * local-data/desktop-offline-facts-parts/sqlite-wasm.md section 5: + * - PRAGMA key is the very first thing on a connection. Reading first deletes + * a hot journal (#292). + * - The page size must be 8192. On 4096, rollback doesn't work (#293). + * - No PRAGMA rekey (#291, #294). The key is random and only ever re-wrapped. + * - Always the multipleciphers VFS. On plain opfs-sahpool, PRAGMA key throws + * and the connection still writes in the clear if that's ignored. + */ + +import { runTransaction, type SqlRow, type SqlValue, type StorageAdapter } from '../storage'; + +const VFS = 'multipleciphers-opfs-sahpool'; +const INIT_TIMEOUT_MS = 30_000; +const REQUIRED_PAGE_SIZE = 8192; +const POOL_DIRECTORY = '.bulwark-store'; +const INITIAL_CAPACITY = 16; +/** A database and its journal, plus room for one more. */ +const SLOTS_PER_DATABASE = 3; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +interface Pool { + getFileNames(): string[]; + getCapacity(): number; + getFileCount(): number; + addCapacity(n: number): Promise; + unlink(name: string): boolean; + wipeFiles(): Promise; + pauseVfs(): void; + unpauseVfs(): Promise; + isPaused(): boolean; +} + +export class StoreKeyError extends Error { + constructor() { + super('The local store could not be opened with this key'); + this.name = 'StoreKeyError'; + } +} + +function withTimeout(promise: Promise, ms: number, what: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`${what} timed out`)), ms); + promise.then((value) => { + clearTimeout(timer); + resolve(value); + }, (error) => { + clearTimeout(timer); + reject(error); + }); + }); +} + +class WasmAdapter implements StorageAdapter { + private readonly tx = { depth: 0 }; + private readonly statements = new Map(); + + constructor(private readonly sqlite3: any, private readonly db: any) {} + + exec(sql: string): void { + this.db.exec(sql); + } + + private statement(sql: string): any { + let stmt = this.statements.get(sql); + if (!stmt) { + stmt = this.db.prepare(sql); + this.statements.set(sql, stmt); + } + return stmt; + } + + private bindAndRun(sql: string, params: readonly SqlValue[] | undefined, read: (stmt: any) => T): T { + const stmt = this.statement(sql); + try { + if (params && params.length > 0) stmt.bind(params as SqlValue[]); + return read(stmt); + } finally { + stmt.reset(true); + } + } + + run(sql: string, params?: readonly SqlValue[]): { changes: number; lastInsertRowid: number } { + this.bindAndRun(sql, params, (stmt) => { + while (stmt.step()) { /* no rows wanted */ } + }); + return { + changes: Number(this.db.changes()), + lastInsertRowid: Number(this.sqlite3.capi.sqlite3_last_insert_rowid(this.db.pointer)), + }; + } + + all(sql: string, params?: readonly SqlValue[]): T[] { + return this.bindAndRun(sql, params, (stmt) => { + const rows: T[] = []; + while (stmt.step()) rows.push(stmt.get({}) as T); + return rows; + }); + } + + get(sql: string, params?: readonly SqlValue[]): T | undefined { + return this.bindAndRun(sql, params, (stmt) => (stmt.step() ? stmt.get({}) as T : undefined)); + } + + transaction(fn: () => T): T { + return runTransaction(this.tx, (sql) => this.db.exec(sql), fn); + } + + close(): void { + for (const stmt of this.statements.values()) stmt.finalize(); + this.statements.clear(); + this.db.close(); + } +} + +/** The module and the pool, once. Only the owner gets here. */ +export class WasmStorage { + private constructor(private readonly sqlite3: any, private readonly pool: Pool) {} + + /** + * Call only while holding the owner lock. The holder before may still be + * letting go, so a refusal is retried for a while. + */ + static async install(moduleUrl: string): Promise { + const { default: init } = await import(/* webpackIgnore: true */ moduleUrl) as { default: (o: unknown) => Promise }; + // A failed wasm load never settles the promise from the module's init. + const sqlite3 = await withTimeout(init({ print() {}, printErr() {} }), INIT_TIMEOUT_MS, 'Loading the store engine'); + let lastError: unknown; + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + const pool = await sqlite3.installOpfsSAHPoolVfs({ + name: 'opfs-sahpool', + directory: POOL_DIRECTORY, + initialCapacity: INITIAL_CAPACITY, + preserveOnInitFailure: true, + forceReinitIfPreviouslyFailed: true, + }) as Pool; + return new WasmStorage(sqlite3, pool); + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 100 + attempt * 50)); + } + } + throw lastError; + } + + /** Opens one account's database. `keyHex` is 32 random bytes in hex. */ + async open(file: string, keyHex: string): Promise { + if (!/^\/a[0-9a-f]+\.db$/.test(file)) throw new Error('Store file names carry no account data'); + if (!/^[0-9a-f]{64}$/.test(keyHex)) throw new Error('A store key is 32 bytes of hex'); + const needed = this.pool.getFileCount() + SLOTS_PER_DATABASE; + if (this.pool.getCapacity() < needed) await this.pool.addCapacity(needed - this.pool.getCapacity()); + const db = new this.sqlite3.oo1.DB({ filename: file, vfs: VFS, flags: 'c' }); + try { + db.exec(`PRAGMA key = "raw:${keyHex}"`); + try { + db.selectValue('SELECT count(*) FROM sqlite_master'); + } catch { + throw new StoreKeyError(); + } + const pageSize = Number(db.selectValue('PRAGMA page_size')); + if (pageSize !== REQUIRED_PAGE_SIZE) throw new Error(`Refusing an unsafe page size: ${pageSize}`); + db.exec('PRAGMA temp_store = MEMORY'); + // Every page read is a decryption, so a miss costs more than usual. + db.exec('PRAGMA cache_size = -65536'); + db.exec('PRAGMA foreign_keys = OFF'); + db.exec('PRAGMA synchronous = NORMAL'); + } catch (error) { + db.close(); + throw error; + } + return new WasmAdapter(this.sqlite3, db); + } + + remove(file: string): boolean { + let removed = false; + for (const name of this.pool.getFileNames()) { + if (name === file || name === `${file}-journal`) removed = this.pool.unlink(name) || removed; + } + return removed; + } + + /** Lets go of the OPFS handles. Every database must be closed first. */ + pause(): void { + this.pool.pauseVfs(); + } + + async unpause(): Promise { + await this.pool.unpauseVfs(); + } + + files(): string[] { + return this.pool.getFileNames(); + } +} diff --git a/lib/store/area.ts b/lib/store/area.ts new file mode 100644 index 000000000..b73740b5b --- /dev/null +++ b/lib/store/area.ts @@ -0,0 +1,110 @@ +/** + * What one part of the local store (mail, calendar, contacts, files, ...) + * plugs into the engine: its tables, the JMAP methods it answers while the + * server can't be reached, and how it syncs. + * + * Migration id ranges: core 1-99, mail 100-199, outbox 200-299, search + * 300-399, drafts 400-499, calendar 500-599, contacts 600-699, files 700-799, + * settings and reminders 800-899, wipe and lock 900-999. + */ + +import type { JmapTransport, MethodCall, StoreSession } from './engine/transport'; +import type { Migration, StorageAdapter } from './storage'; + +export interface StoreScope { + /** How far back mail is kept, in days. Null keeps everything. */ + days: number | null; + /** Whether attachments are kept. */ + attachments: boolean; + /** The largest attachment kept, and the total for all of them. */ + maxAttachmentBytes: number; + maxTotalBytes: number; +} + +export const DEFAULT_SCOPE: StoreScope = { + days: 90, + attachments: true, + maxAttachmentBytes: 25 * 1024 * 1024, + maxTotalBytes: 2 * 1024 * 1024 * 1024, +}; + +export interface SyncStateRow { + state: string | null; + /** The newest state the server told us about, to know when a run is caught up. */ + target: string | null; + /** The area's own phase name; null when in normal delta mode. */ + phase: string | null; + /** The area's own resume point. */ + cursor: Record | null; +} + +export type StoreEvent = + | { type: 'progress'; account: string; area: string; phase: string; done: number; total: number | null } + | { type: 'synced'; account: string; at: number } + | { type: 'changed'; account: string; collections: string[] } + | { type: 'created'; account: string; jmap: string; collection: string; ids: string[] } + | { type: 'error'; account: string; area: string; message: string }; + +export interface StoreContext { + account: string; + db: StorageAdapter; + session: StoreSession; + scope: StoreScope; + now(): number; + emit(event: StoreEvent): void; +} + +export interface SyncContext extends StoreContext { + transport: JmapTransport; + /** True once the engine is closing or lost the network: stop at the next batch. */ + stopped(): boolean; + syncState(jmap: string, type: string): SyncStateRow; + /** Call inside the transaction that writes the data, so the two can't disagree. */ + setSyncState(jmap: string, type: string, row: Partial): void; +} + +export interface Syncer { + /** A short name for progress and errors. */ + name: string; + /** The JMAP capability an account must advertise for this to run for it. */ + capability: string; + /** Runs one pass for one JMAP account. Must be safe to stop between batches and start again. */ + run(ctx: SyncContext, jmap: string): Promise; +} + +export type MethodResult = Record; + +/** Thrown by a method handler: the client gets a per-call JMAP error. */ +export class MethodError extends Error { + constructor(readonly type: string, readonly description?: string) { + super(description ?? type); + this.name = 'MethodError'; + } +} + +export interface MethodContext extends StoreContext { + /** The account's own default JMAP account for calls that don't say. */ + defaultJmap: string; + /** Whether keyword sort comparators arrive inverted from this client. */ + invertedKeywordSort: boolean; + /** Earlier responses in this request, for creation-id references. */ + createdIds: Map; +} + +export type MethodHandler = (ctx: MethodContext, args: Record, call: MethodCall) => MethodResult | Promise; + +/** + * A call from the page that isn't JMAP: outbox rows, usage, clearing. Reached + * from any tab with `store.area(account, 'outbox.list', params)`. Names are + * unique across areas and start with the area's name. It gets the sync context, + * because some calls talk to the server (`calendar.flush`). + */ +export type AreaCall = (ctx: SyncContext, params: never) => unknown | Promise; + +export interface StoreArea { + name: string; + migrations: readonly Migration[]; + methods: Readonly>; + syncers: readonly Syncer[]; + calls?: Readonly>; +} diff --git a/lib/store/areas.ts b/lib/store/areas.ts new file mode 100644 index 000000000..30a6541ff --- /dev/null +++ b/lib/store/areas.ts @@ -0,0 +1,7 @@ +/** Every part of the local store. New areas go here. */ + +import type { StoreArea } from './area'; +import { calendarArea } from './calendar'; +import { mailArea } from './mail'; + +export const STORE_AREAS: readonly StoreArea[] = [mailArea, calendarArea]; diff --git a/lib/store/calendar/filter.ts b/lib/store/calendar/filter.ts new file mode 100644 index 000000000..41a29ffd3 --- /dev/null +++ b/lib/store/calendar/filter.ts @@ -0,0 +1,220 @@ +/** + * CalendarEvent/query filters, read the way Stalwart reads them: after and + * before are LocalDateTimes in the request's time zone, and a series matches + * when any occurrence is in the range. Text matches parts of words, where the + * server only matches whole ones: offline search finds more, not less. + */ + +import { addDays, format, parseISO } from 'date-fns'; +import { isTaskLikeObject } from '@/lib/calendar-component-detection'; +import { normalizeCalendarEventLike, parseDurationSeconds } from '@/lib/calendar-event-normalization'; +import type { CalendarEvent, CalendarEventFilter } from '@/lib/jmap/types'; +import { expandRecurringEvents } from '@/lib/recurrence-expansion'; +import { MethodError } from '../area'; +import { instantOf, instantsOf, isSeries, wallClock, type CalendarObject } from './records'; + +export interface RangeBounds { + after?: string; + before?: string; +} + +/** The range of a filter, in the two shapes the client sends it: plain keys, or an AND of one-key conditions. */ +export function boundsOf(filter: CalendarEventFilter): RangeBounds { + if (filter.operator === 'AND') { + const bounds: RangeBounds = {}; + for (const condition of filter.conditions ?? []) { + if (condition.operator || Object.keys(condition).length !== 1) continue; + if (condition.after) bounds.after = condition.after; + if (condition.before) bounds.before = condition.before; + } + return bounds; + } + if (filter.operator) return {}; + return { after: filter.after, before: filter.before }; +} + +function fold(text: string): string { + return text.normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase(); +} + +function containsWords(haystack: Array, needle: string): boolean { + const text = fold(haystack.filter((part): part is string => typeof part === 'string').join('\n')); + return fold(needle).split(/\s+/).filter(Boolean).every((word) => text.includes(word)); +} + +type Participant = { + name?: string; + email?: string; + calendarAddress?: string; + participationStatus?: string; + roles?: Record; +}; + +function participantsOf(object: CalendarObject): Participant[] { + return Object.values((object.participants ?? {}) as Record); +} + +function placeTexts(object: CalendarObject): unknown[] { + const places = Object.values((object.locations ?? {}) as Record); + const virtual = Object.values((object.virtualLocations ?? {}) as Record); + return [...places, ...virtual].flatMap((place) => [place.name, place.description]); +} + +function personTexts(participant: Participant): unknown[] { + return [participant.name, participant.email, participant.calendarAddress]; +} + +function isOwner(participant: Participant): boolean { + return participant.roles?.owner === true; +} + +function isAttendee(participant: Participant): boolean { + return !participant.roles || participant.roles.attendee === true; +} + +function ownerTexts(object: CalendarObject): unknown[] { + return [object.organizerCalendarAddress, ...participantsOf(object).filter(isOwner).flatMap(personTexts)]; +} + +// An occurrence's wall clock is in the series' zone and the bounds in the +// request's: up to 26 hours apart. The client narrows the rest when it expands. +const SERIES_SLACK_DAYS = 2; +const OPEN_RANGE_DAYS = 5 * 366; + +function seriesOccursIn(object: CalendarObject, after: string | undefined, before: string | undefined): boolean { + const seriesStart = typeof object.start === 'string' ? parseISO(object.start) : null; + if (!seriesStart || Number.isNaN(seriesStart.getTime())) return false; + const lengthMs = (parseDurationSeconds(typeof object.duration === 'string' ? object.duration : undefined) ?? 0) * 1000; + const from = after + ? new Date(addDays(parseISO(wallClock(after)), -SERIES_SLACK_DAYS).getTime() - lengthMs) + : seriesStart; + const to = before ? addDays(parseISO(wallClock(before)), SERIES_SLACK_DAYS) : addDays(from, OPEN_RANGE_DAYS); + if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || from >= to) return false; + const stamp = (date: Date) => format(date, "yyyy-MM-dd'T'HH:mm:ss"); + const event = normalizeCalendarEventLike(object as unknown as CalendarEvent); + return expandRecurringEvents([event], stamp(from), stamp(to)).length > 0; +} + +function inRange(object: CalendarObject, bounds: RangeBounds, zone: string): boolean { + if (isSeries(object)) return seriesOccursIn(object, bounds.after, bounds.before); + const { start, end } = instantsOf(object, zone); + if (start === null || end === null) return false; + const after = bounds.after ? instantOf(bounds.after, zone) : null; + const before = bounds.before ? instantOf(bounds.before, zone) : null; + if (before !== null && start >= before) return false; + if (after === null) return true; + return end > after || (end === start && start >= after); +} + +function matchesCondition(object: CalendarObject, condition: CalendarEventFilter, zone: string): boolean { + const bounds: RangeBounds = {}; + for (const [key, value] of Object.entries(condition)) { + if (value === undefined) continue; + switch (key) { + case 'after': + case 'before': + bounds[key as 'after' | 'before'] = value as string; + break; + case 'inCalendar': + if (!(object.calendarIds as Record | undefined)?.[value as string]) return false; + break; + case 'inCalendars': + if (!(value as string[]).some((id) => (object.calendarIds as Record | undefined)?.[id])) return false; + break; + case 'uid': + if (object.uid !== value) return false; + break; + case 'title': + if (!containsWords([object.title], value as string)) return false; + break; + case 'description': + if (!containsWords([object.description], value as string)) return false; + break; + case 'location': + if (!containsWords(placeTexts(object), value as string)) return false; + break; + case 'owner': + if (!containsWords(ownerTexts(object), value as string)) return false; + break; + case 'attendee': { + // With participationStatus in the same condition: the same person. + const status = condition.participationStatus; + const found = participantsOf(object).some((person) => isAttendee(person) + && containsWords(personTexts(person), value as string) + && (!status || person.participationStatus === status)); + if (!found) return false; + break; + } + case 'participationStatus': + if (!participantsOf(object).some((person) => person.participationStatus === value)) return false; + break; + case 'text': + if (!containsWords([ + object.title, + object.description, + ...placeTexts(object), + ...ownerTexts(object), + ...participantsOf(object).flatMap(personTexts), + ], value as string)) return false; + break; + case 'types': { + const type = isTaskLikeObject(object) ? 'task' : 'event'; + if (!(value as string[]).some((name) => name.toLowerCase() === type)) return false; + break; + } + default: + throw new MethodError('unsupportedFilter', `${key} isn't available offline`); + } + } + return bounds.after || bounds.before ? inRange(object, bounds, zone) : true; +} + +export function matchesFilter(object: CalendarObject, filter: CalendarEventFilter, zone: string): boolean { + const conditions = filter.conditions ?? []; + switch (filter.operator) { + case 'AND': { + // A range split over two conditions is one range: a series must have one + // occurrence inside it, not one after the start and another before the end. + const bounds = boundsOf(filter); + const isBound = (c: CalendarEventFilter) => !c.operator && Object.keys(c).length === 1 && Boolean(c.after || c.before); + if ((bounds.after || bounds.before) && !inRange(object, bounds, zone)) return false; + return conditions.every((c) => isBound(c) || matchesFilter(object, c, zone)); + } + case 'OR': + return conditions.some((c) => matchesFilter(object, c, zone)); + case 'NOT': + return !conditions.some((c) => matchesFilter(object, c, zone)); + case undefined: + return matchesCondition(object, filter, zone); + default: + throw new MethodError('unsupportedFilter', `Unknown operator ${String(filter.operator)}`); + } +} + +export interface EventComparator { + property?: string; + isAscending?: boolean; +} + +function sortValue(object: CalendarObject, property: string | undefined, zone: string): number | string { + if (property === 'start') return instantsOf(object, zone).start ?? 0; + const value = property ? object[property] : undefined; + return typeof value === 'number' || typeof value === 'string' ? value : ''; +} + +/** By start when the request names no order. Ties go by id, so paging is stable. */ +export function sortEvents(objects: CalendarObject[], sort: readonly EventComparator[], zone: string): CalendarObject[] { + const levels = sort.length > 0 ? sort : [{ property: 'start', isAscending: true }]; + const keyed = objects.map((object) => ({ object, keys: levels.map((level) => sortValue(object, level.property, zone)) })); + keyed.sort((a, b) => { + for (let i = 0; i < levels.length; i += 1) { + const x = a.keys[i]; + const y = b.keys[i]; + if (x === y) continue; + const order = x < y ? -1 : 1; + return levels[i].isAscending === false ? -order : order; + } + return a.object.id < b.object.id ? -1 : a.object.id > b.object.id ? 1 : 0; + }); + return keyed.map((entry) => entry.object); +} diff --git a/lib/store/calendar/flush.ts b/lib/store/calendar/flush.ts new file mode 100644 index 000000000..550930279 --- /dev/null +++ b/lib/store/calendar/flush.ts @@ -0,0 +1,201 @@ +/** + * Sends pending calendar writes, one write per request, so a lost response leaves + * one write's outcome open and not a batch's. An event's writes go in order. + * + * A row is marked before it goes out. A create so marked first looks for its uid + * on the server, because repeating it could duplicate the event. A marked edit + * whose values read back from the server is done, and one that's repeated writes + * the same values. A delete sent twice finds nothing the second time. + * + * A write that can't work goes to the conflict list and the others still go out. + * Anything that may pass stays pending: an answer with no verdict for it, a set + * error such as serverFail or rateLimit, and for the whole run a network failure, + * 401, 429 or 5xx. + */ + +import { jmapError } from '@/lib/jmap/jmap-error'; +import type { CalendarEvent } from '@/lib/jmap/types'; +import { isOverrideSeriesKeyRejection, withoutOverrideSeriesKeys } from '@/lib/recurrence-instances'; +import type { SyncContext } from '../area'; +import { JmapMethodError, responseFor, TransportError } from '../engine/transport'; +import { holdsPatch, patchForServer } from './patches'; +import { calendarUsing, EVENT_PROPERTIES, eventRow, type CalendarObject } from './records'; +import { adoptStandIn, finishWrite, markSent, pendingBySeq, recordConflict, type PendingRow } from './writes'; + +export interface FlushTally { + sent: number; + conflicts: number; +} + +interface SetError { + type?: string; + description?: string; + properties?: string[]; +} + +interface SetResult { + created?: Record | null; + updated?: Record | null; + destroyed?: string[] | null; + notCreated?: Record | null; + notUpdated?: Record | null; + notDestroyed?: Record | null; +} + +// The server had a bad moment, nothing is wrong with this write: try again later. +const PASSING_ERRORS = new Set(['serverFail', 'serverPartialFail', 'serverUnavailable', 'rateLimit', 'tooManyRequests']); + +export function isPassingError(error: unknown): boolean { + return error instanceof JmapMethodError && PASSING_ERRORS.has(error.type); +} + +function passes(refusal: SetError | undefined): boolean { + return !refusal || PASSING_ERRORS.has(refusal.type ?? ''); +} + +/** A 4xx other than 401 and 429, which the transport reports as their own kinds: this request won't work. */ +function isRefusedRequest(error: unknown): boolean { + return error instanceof TransportError && error.kind === 'http' && (error.status ?? 0) >= 400 && (error.status ?? 0) < 500; +} + +type Outcome = 'sent' | 'conflict' | 'again' | 'retry'; + +async function sendSet(ctx: SyncContext, row: PendingRow, args: Record): Promise { + const response = await ctx.transport.request([['CalendarEvent/set', { + accountId: row.jmap, + ...(row.send_scheduling === null ? {} : { sendSchedulingMessages: row.send_scheduling === 1 }), + ...args, + }, 's']], calendarUsing(ctx.session)); + return responseFor(response, 's')[1] as SetResult; +} + +/** Stalwart 1.0 refuses what 0.16 expects inside an override. The client normally learns that from the refusal. */ +function isSeriesKeyRefusal(error: SetError | undefined): boolean { + return Boolean(error) && isOverrideSeriesKeyRejection(jmapError(error?.description ?? '', error)); +} + +async function findByUid(ctx: SyncContext, jmap: string, uid: string | null): Promise { + if (!uid) return null; + const response = await ctx.transport.request([['CalendarEvent/query', { accountId: jmap, filter: { uid }, limit: 1 }, 'u']], calendarUsing(ctx.session)); + const ids = responseFor(response, 'u')[1].ids as string[]; + return ids[0] ?? null; +} + +async function fetchEvent(ctx: SyncContext, jmap: string, id: string): Promise { + const response = await ctx.transport.request([['CalendarEvent/get', { accountId: jmap, ids: [id], properties: EVENT_PROPERTIES }, 'g']], calendarUsing(ctx.session)); + return (responseFor(response, 'g')[1].list as CalendarObject[])[0] ?? null; +} + +function conflict(ctx: SyncContext, row: PendingRow, reason: 'refused' | 'destroyedOnServer', error: SetError): Outcome { + ctx.db.transaction(() => recordConflict(ctx, row, reason, error)); + return 'conflict'; +} + +async function sendCreate(ctx: SyncContext, row: PendingRow): Promise { + const payload = JSON.parse(row.patch) as Record; + let realId = row.attempted ? await findByUid(ctx, row.jmap, row.uid) : null; + if (!realId) { + markSent(ctx, row, payload); + let result = await sendSet(ctx, row, { create: { c: payload } }); + if (isSeriesKeyRefusal(result.notCreated?.c)) { + result = await sendSet(ctx, row, { create: { c: withoutOverrideSeriesKeys(payload as Partial) } }); + } + realId = result.created?.c?.id ?? null; + const refusal = result.notCreated?.c; + if (!realId) { + // Both Stalwarts refuse a second event with one uid. Nothing of ours went + // in then, so what the server holds is compared as a whole. + if (refusal?.type === 'invalidProperties' && refusal.properties?.includes('uid')) { + ctx.db.transaction(() => ctx.db.run('UPDATE calendar_pending SET sent = NULL WHERE seq = ?', [row.seq])); + realId = await findByUid(ctx, row.jmap, row.uid); + } + if (!realId) return passes(refusal) ? 'retry' : conflict(ctx, row, 'refused', refusal as SetError); + } + } + const event = await fetchEvent(ctx, row.jmap, realId) ?? ({ ...payload, id: realId } as CalendarObject); + const left = ctx.db.transaction(() => adoptStandIn(ctx, row, event)); + return left ? 'again' : 'sent'; +} + +async function sendUpdate(ctx: SyncContext, row: PendingRow): Promise { + const id = row.record_id; + const stored = eventRow(ctx.db, row.jmap, id); + const server = JSON.parse(stored?.server_json ?? stored?.json ?? '{}') as Record; + const queued = JSON.parse(row.patch) as Record; + let patch = patchForServer(queued, server); + // Nothing to change, and an answer sent twice would mail the organizer twice. + if (holdsPatch(server, queued) || Object.keys(patch).length === 0) { + return ctx.db.transaction(() => finishWrite(ctx, row, {})) ? 'again' : 'sent'; + } + markSent(ctx, row, patch); + let result = await sendSet(ctx, row, { update: { [id]: patch } }); + if (isSeriesKeyRefusal(result.notUpdated?.[id])) { + patch = withoutOverrideSeriesKeys(patch as Partial) as Record; + result = await sendSet(ctx, row, { update: { [id]: patch } }); + } + const refusal = result.notUpdated?.[id]; + if (refusal && !passes(refusal)) return conflict(ctx, row, refusal.type === 'notFound' ? 'destroyedOnServer' : 'refused', refusal); + if (refusal || !result.updated || !(id in result.updated)) return 'retry'; + return ctx.db.transaction(() => finishWrite(ctx, row, patch)) ? 'again' : 'sent'; +} + +async function sendDestroy(ctx: SyncContext, row: PendingRow): Promise { + // A create turned delete: only the server knows whether the create got there. + const id = row.uid ? await findByUid(ctx, row.jmap, row.uid) : row.record_id; + const finish = (): Outcome => ctx.db.transaction(() => { + ctx.db.run('DELETE FROM calendar_pending WHERE seq = ?', [row.seq]); + ctx.db.run('DELETE FROM calendar_event WHERE jmap = ? AND id = ?', [row.jmap, row.record_id]); + ctx.emit({ type: 'changed', account: ctx.account, collections: ['CalendarEvent'] }); + return 'sent' as const; + }); + if (!id) return finish(); + const result = await sendSet(ctx, row, { destroy: [id] }); + const refusal = result.notDestroyed?.[id]; + if (refusal?.type === 'notFound' || result.destroyed?.includes(id)) return finish(); + return passes(refusal) ? 'retry' : conflict(ctx, row, 'refused', refusal as SetError); +} + +function conflictCount(ctx: SyncContext): number { + return Number(ctx.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM calendar_conflict')?.n ?? 0); +} + +/** + * Sends what's pending for one JMAP account. Network failures and a server's bad + * moment are thrown, with the rest still pending. + */ +export async function sendPending(ctx: SyncContext, jmap: string): Promise { + const tally: FlushTally = { sent: 0, conflicts: 0 }; + const finished = new Set(); + // An event whose write has to wait: its later writes wait with it. + const waiting = new Set(); + for (;;) { + if (ctx.stopped()) return tally; + const row = ctx.db.all('SELECT * FROM calendar_pending WHERE jmap = ? ORDER BY seq', [jmap]) + .find((candidate) => !finished.has(Number(candidate.seq)) && !waiting.has(candidate.record_id)); + if (!row) return tally; + const conflictsBefore = conflictCount(ctx); + let outcome: Outcome; + try { + if (row.kind === 'create') outcome = await sendCreate(ctx, row); + else if (row.kind === 'update') outcome = await sendUpdate(ctx, row); + else outcome = await sendDestroy(ctx, row); + } catch (error) { + const current = pendingBySeq(ctx.db, row.seq) ?? row; + if (isRefusedRequest(error)) { + outcome = conflict(ctx, current, 'refused', { type: 'http', description: (error as Error).message }); + } else if (error instanceof JmapMethodError && !isPassingError(error)) { + outcome = conflict(ctx, current, 'refused', { type: error.type, description: error.message }); + } else { + throw error; + } + } + if (outcome === 'again') continue; + if (outcome === 'retry') { + waiting.add(row.record_id); + continue; + } + finished.add(Number(row.seq)); + if (outcome === 'sent') tally.sent += 1; + else tally.conflicts += conflictCount(ctx) - conflictsBefore; + } +} diff --git a/lib/store/calendar/index.ts b/lib/store/calendar/index.ts new file mode 100644 index 000000000..12d908660 --- /dev/null +++ b/lib/store/calendar/index.ts @@ -0,0 +1,20 @@ +import type { StoreArea } from '../area'; +import { CALENDAR_METHODS } from './methods'; +import { CALENDAR_MIGRATIONS } from './schema'; +import { calendarSyncer, flushCalendar } from './sync'; +import { listConflicts, resolveStandIns } from './writes'; + +export const calendarArea: StoreArea = { + name: 'calendar', + migrations: CALENDAR_MIGRATIONS, + methods: CALENDAR_METHODS, + syncers: [calendarSyncer], + calls: { + 'calendar.flush': (ctx) => flushCalendar(ctx), + 'calendar.conflicts': (ctx) => listConflicts(ctx.db), + 'calendar.resolveStandIns': (ctx, params: { ids: string[] }) => resolveStandIns(ctx.db, params.ids), + 'calendar.dismissConflict': (ctx, params: { id: number }) => { + ctx.db.run('DELETE FROM calendar_conflict WHERE id = ?', [params.id]); + }, + }, +}; diff --git a/lib/store/calendar/methods.ts b/lib/store/calendar/methods.ts new file mode 100644 index 000000000..2443584dc --- /dev/null +++ b/lib/store/calendar/methods.ts @@ -0,0 +1,197 @@ +/** + * Calendar calls answered from the store. + * + * Series, not occurrences, in both client modes. A client in synthetic-id mode + * sends `expandRecurrences: true` and gets the masters, with their overrides + * (see projectEvent). hydrateRecurrenceInstances leaves those alone, and + * expandRecurringEvents in the calendar store makes the occurrences, the same as + * for a client in the other mode. Occurrence writes then arrive as overrides on + * the master, which is what a stand-in id and the pending queue can work with. + */ + +import type { CalendarEventFilter } from '@/lib/jmap/types'; +import { SYNTHETIC_ID_PROBE } from '@/lib/recurrence-instances'; +import { MethodError, type MethodContext, type MethodHandler, type MethodResult } from '../area'; +import { listObjects } from '../engine/core-schema'; +import type { SqlValue, StorageAdapter } from '../storage'; +import { boundsOf, matchesFilter, sortEvents, type EventComparator } from './filter'; +import { CALENDARS_CAPABILITY, instantOf, projectEvent, type CalendarObject } from './records'; +import { syntheticIdVerdict } from './sync'; +import { queueCreate, queueDestroy, queueUpdate, resolveId } from './writes'; + +function jmapOf(ctx: MethodContext, args: Record): string { + return (args.accountId as string | undefined) ?? ctx.session.primaryAccounts[CALENDARS_CAPABILITY] ?? ctx.defaultJmap; +} + +function stateOf(db: StorageAdapter, jmap: string, type: string): string | undefined { + return db.get<{ state: string | null }>('SELECT state FROM sync_state WHERE jmap = ? AND type = ?', [jmap, type])?.state ?? undefined; +} + +/** An account never synced has no answer yet. An empty one would read as "no events". */ +function assertSynced(db: StorageAdapter, jmap: string, type: string): void { + const row = db.get<{ state: string | null; phase: string | null }>('SELECT state, phase FROM sync_state WHERE jmap = ? AND type = ?', [jmap, type]); + if (!row?.state && !row?.phase) throw new MethodError('serverUnavailable', `${type} hasn't been kept on this device yet`); +} + +// An event's start_ms reads a floating event in UTC, and a request's bounds are +// in its own zone: up to 14 hours apart. This only narrows, the filter decides. +const ZONE_SLACK_MS = 15 * 3_600_000; + +function candidates(db: StorageAdapter, jmap: string, filter: CalendarEventFilter, zone: string): CalendarObject[] { + const where = ['jmap = ?', 'hidden = 0']; + const params: SqlValue[] = [jmap]; + if (typeof filter.uid === 'string' && !filter.operator) { + where.push('uid = ?'); + params.push(filter.uid); + } + const { after, before } = boundsOf(filter); + const afterMs = after ? instantOf(after, zone) : null; + const beforeMs = before ? instantOf(before, zone) : null; + if (afterMs !== null || beforeMs !== null) { + where.push(`(is_series = 1 OR start_ms IS NULL OR (start_ms < ? AND end_ms >= ?))`); + params.push(beforeMs === null ? Number.MAX_SAFE_INTEGER : beforeMs + ZONE_SLACK_MS); + params.push(afterMs === null ? Number.MIN_SAFE_INTEGER : afterMs - ZONE_SLACK_MS); + } + return db.all<{ json: string }>(`SELECT json FROM calendar_event WHERE ${where.join(' AND ')}`, params) + .map((row) => JSON.parse(row.json) as CalendarObject); +} + +const calendarGet: MethodHandler = (ctx, args) => { + const jmap = jmapOf(ctx, args); + assertSynced(ctx.db, jmap, 'Calendar'); + const ids = args.ids as string[] | null | undefined; + const properties = args.properties as string[] | undefined; + const all = listObjects<{ id: string } & Record>(ctx.db, jmap, 'Calendar'); + const chosen = ids ? all.filter((calendar) => ids.includes(calendar.id)) : all; + const state = stateOf(ctx.db, jmap, 'Calendar'); + return { + accountId: jmap, + ...(state ? { state } : {}), + list: chosen.map((calendar) => (properties + ? Object.fromEntries(['id', ...properties].filter((name) => name in calendar).map((name) => [name, calendar[name]])) + : calendar)), + notFound: ids ? ids.filter((id) => !all.some((calendar) => calendar.id === id)) : [], + }; +}; + +const eventQuery: MethodHandler = (ctx, args) => { + const jmap = jmapOf(ctx, args); + assertSynced(ctx.db, jmap, 'CalendarEvent'); + const filter = (args.filter ?? {}) as CalendarEventFilter; + const zone = typeof args.timeZone === 'string' && args.timeZone ? args.timeZone : 'UTC'; + const { after, before } = boundsOf(filter); + if (args.expandRecurrences && !(after && before)) { + throw new MethodError('invalidArguments', 'expandRecurrences needs both after and before'); + } + const matching = candidates(ctx.db, jmap, filter, zone).filter((object) => matchesFilter(object, filter, zone)); + const sorted = sortEvents(matching, (args.sort ?? []) as EventComparator[], zone); + const position = Math.max(0, Number(args.position ?? 0) || 0); + const limit = Number(args.limit); + const ids = sorted.map((object) => object.id); + return { + accountId: jmap, + // No queryState: the client doesn't read it, and the store has no real one. + canCalculateChanges: false, + position, + ids: ids.slice(position, limit > 0 ? position + limit : undefined), + ...(args.calculateTotal ? { total: ids.length } : {}), + }; +}; + +const eventGet: MethodHandler = (ctx, args) => { + const jmap = jmapOf(ctx, args); + const requested = args.ids as string[] | null | undefined; + // A page's first calendar list carries `ids: []` to check a time zone, also before any sync. + if (!(Array.isArray(requested) && requested.length === 0)) assertSynced(ctx.db, jmap, 'CalendarEvent'); + const properties = (args.properties as string[] | undefined) ?? null; + const zone = typeof args.timeZone === 'string' && args.timeZone ? args.timeZone : 'UTC'; + const list: Record[] = []; + const notFound: string[] = []; + if (requested) { + for (const id of requested) { + const row = ctx.db.get<{ json: string }>('SELECT json FROM calendar_event WHERE jmap = ? AND id = ? AND hidden = 0', [jmap, resolveId(ctx.db, jmap, id)]); + if (row) list.push(projectEvent(JSON.parse(row.json) as CalendarObject, properties, zone)); + else notFound.push(id); + } + } else { + const rows = ctx.db.all<{ json: string }>('SELECT json FROM calendar_event WHERE jmap = ? AND hidden = 0', [jmap]); + for (const row of rows) list.push(projectEvent(JSON.parse(row.json) as CalendarObject, properties, zone)); + } + const state = stateOf(ctx.db, jmap, 'CalendarEvent'); + return { accountId: jmap, ...(state ? { state } : {}), list, notFound }; +}; + +// What a server without synthetic-id writes says. It's the answer here too, for +// an id the store doesn't hold, which is an occurrence still on screen from +// before the connection went: the client catches this (updateOccurrence, +// destroyOccurrence in stores/calendar-store.ts) and writes the change as an +// override on the base event, which the store does hold. +function syntheticRefusal(verb: 'Updating' | 'Deleting'): { type: string; description: string; properties: string[] } { + return { type: 'invalidProperties', description: `${verb} synthetic ids is not yet supported.`, properties: ['id'] }; +} + +const eventSet: MethodHandler = (ctx, args) => { + const jmap = jmapOf(ctx, args); + const send = typeof args.sendSchedulingMessages === 'boolean' ? args.sendSchedulingMessages : undefined; + const created: Record = {}; + const updated: Record = {}; + const destroyed: string[] = []; + const notUpdated: Record = {}; + const notDestroyed: Record = {}; + let wrote = false; + ctx.db.transaction(() => { + for (const [key, event] of Object.entries((args.create ?? {}) as Record>)) { + created[key] = queueCreate(ctx, jmap, event, send); + wrote = true; + } + for (const [id, patch] of Object.entries((args.update ?? {}) as Record>)) { + if (id === SYNTHETIC_ID_PROBE) { + // A client that started offline asks here. Tell it what the server said + // at the last sync, so it behaves the same when the server is back. With + // no answer yet: client-side expansion, which works on every server. + notUpdated[id] = syntheticIdVerdict(ctx, jmap) === true ? { type: 'notFound' } : syntheticRefusal('Updating'); + continue; + } + if (queueUpdate(ctx, jmap, resolveId(ctx.db, jmap, id), patch, send)) { + updated[id] = null; + wrote = true; + } else { + notUpdated[id] = syntheticRefusal('Updating'); + } + } + for (const id of (args.destroy ?? []) as string[]) { + if (queueDestroy(ctx, jmap, resolveId(ctx.db, jmap, id), send)) { + destroyed.push(id); + wrote = true; + } else { + notDestroyed[id] = syntheticRefusal('Deleting'); + } + } + }); + if (wrote) ctx.emit({ type: 'changed', account: ctx.account, collections: ['CalendarEvent'] }); + const result: MethodResult = { accountId: jmap }; + if (Object.keys(created).length > 0) result.created = created; + if (Object.keys(updated).length > 0) result.updated = updated; + if (destroyed.length > 0) result.destroyed = destroyed; + if (Object.keys(notUpdated).length > 0) result.notUpdated = notUpdated; + if (Object.keys(notDestroyed).length > 0) result.notDestroyed = notDestroyed; + return result; +}; + +const identityGet: MethodHandler = (ctx, args) => { + const jmap = jmapOf(ctx, args); + const list = listObjects(ctx.db, jmap, 'ParticipantIdentity'); + if (list.length === 0) throw new MethodError('serverUnavailable', 'No participant identities kept yet'); + return { accountId: jmap, list, notFound: [] }; +}; + +export const CALENDAR_METHODS: Readonly> = { + 'Calendar/get': calendarGet, + 'CalendarEvent/query': eventQuery, + 'CalendarEvent/get': eventGet, + 'CalendarEvent/set': eventSet, + 'ParticipantIdentity/get': identityGet, + // Notifications about others' changes wait for the server. + 'CalendarEventNotification/query': (ctx, args) => ({ accountId: jmapOf(ctx, args), canCalculateChanges: false, position: 0, ids: [] }), + 'CalendarEventNotification/get': (ctx, args) => ({ accountId: jmapOf(ctx, args), list: [], notFound: [] }), +}; diff --git a/lib/store/calendar/patches.ts b/lib/store/calendar/patches.ts new file mode 100644 index 000000000..8fb751de3 --- /dev/null +++ b/lib/store/calendar/patches.ts @@ -0,0 +1,141 @@ +/** + * Reading a PatchObject against the server's copy of an event. + * + * What goes in doesn't read back the same (measured on both Stalwarts, + * 2026-11-03). 1.0 keeps only what an override changes against its series, a + * participant as a pointer key such as `participants/me/participationStatus`. + * 0.16 keeps the whole entry, moves `updated` in every entry on any change of the + * series, and drops an attendee-only `roles`. Both drop `@type` from a recurrence + * rule and add `iCalendar` to a location. So a value counts as held when the + * server's has what was sent, and an override is read as its occurrence. + */ + +import { applyPatchObject } from '@/lib/jmap/patch-object'; +import { pointerToken, pointerTokenValue } from '@/lib/jmap/patch-pointer'; +import { applyOverridePatch, stableJson } from '@/lib/recurrence-instances'; +import { valueAtPointer } from './records'; + +type Patch = Record; + +const OVERRIDE_POINTER = /^recurrenceOverrides\/([^/]+)(?:\/(.+))?$/; +const IGNORED_MEMBERS = new Set(['@type']); + +function isEmptyDefault(value: unknown): boolean { + if (value === null || value === undefined || value === false || value === 0 || value === '') return true; + return typeof value === 'object' && Object.keys(value as object).length === 0; +} + +/** Whether `actual` has what `sent` says. Extra members in `actual` don't matter. */ +function contains(actual: unknown, sent: unknown, member?: string): boolean { + if (actual === null || actual === undefined) { + if (isEmptyDefault(sent)) return true; + return member === 'roles' && stableJson(sent) === stableJson({ attendee: true }); + } + if (Array.isArray(sent)) { + return Array.isArray(actual) && actual.length === sent.length && sent.every((item, i) => contains(actual[i], item)); + } + if (sent !== null && typeof sent === 'object') { + if (typeof actual !== 'object' || Array.isArray(actual)) return false; + return Object.entries(sent).every(([name, value]) => IGNORED_MEMBERS.has(name) + || contains((actual as Record)[name], value, name)); + } + return actual === sent; +} + +function overrideEntry(server: Patch, key: string): Patch | undefined { + return (server.recurrenceOverrides as Record | null | undefined)?.[key]; +} + +/** Whether the server's copy holds `value` at PatchObject key `key`. */ +export function holdsValue(server: Patch, key: string, value: unknown): boolean { + if (key === 'recurrenceOverrides') { + const entries = Object.entries((value ?? {}) as Record); + if (entries.length === 0) return Object.keys((server.recurrenceOverrides ?? {}) as object).length === 0; + return entries.every(([id, entry]) => holdsValue(server, `recurrenceOverrides/${pointerToken(id)}`, entry)); + } + const match = OVERRIDE_POINTER.exec(key); + if (!match) return contains(valueAtPointer(server, key), value); + const id = pointerTokenValue(match[1]); + const entry = overrideEntry(server, id); + const inEntry = match[2]; + if (value === null && !inEntry) return !entry; + if (!entry) return false; + if (!inEntry && (entry.excluded || (value as Patch).excluded)) return Boolean(entry.excluded) === Boolean((value as Patch).excluded); + // `start` as the rule puts it: a server leaves it out of an unmoved override. + const occurrence = applyOverridePatch({ ...server, start: id } as Patch, entry); + return contains(inEntry ? valueAtPointer(occurrence, inEntry) : occurrence, value); +} + +/** Whether a row's write is already on the server, whoever put it there. */ +export function holdsPatch(server: Patch, patch: Patch): boolean { + return Object.entries(patch).every(([key, value]) => holdsValue(server, key, value)); +} + +// `updated` inside an override moves on 0.16 when any part of the series changes. +function comparable(key: string, value: unknown): string { + if (OVERRIDE_POINTER.test(key) && value && typeof value === 'object') { + const { updated: _updated, ...rest } = value as Patch; + return stableJson(rest); + } + return stableJson(value); +} + +export interface PatchState { + patch: Patch; + /** The server's value at each key when this was queued. */ + base: Patch; + /** Values of ours that may have reached the server: a send whose answer was lost, an earlier row of the same event. */ + sent: Patch | null; +} + +/** Whether the server moved a value this patch writes, to something that isn't ours. */ +export function collides({ patch, base, sent }: PatchState, server: Patch): boolean { + return Object.keys(patch).some((key) => { + if (holdsValue(server, key, patch[key])) return false; + if (sent && key in sent && holdsValue(server, key, sent[key])) return false; + return comparable(key, valueAtPointer(server, key)) !== comparable(key, base[key]); + }); +} + +/** + * A whole overrides map becomes one key per entry that differs from what the + * writer could see. Written whole, it would erase any override someone else + * added while the write waited; this way that one collides only on the same entry. + */ +export function asOverridePointers(patch: Patch, shown: Patch): Patch { + if (!('recurrenceOverrides' in patch)) return patch; + const { recurrenceOverrides: map, ...rest } = patch; + const entries = (map ?? {}) as Record; + const known = (shown.recurrenceOverrides ?? {}) as Record; + const out: Patch = { ...rest }; + for (const [id, entry] of Object.entries(entries)) { + if (stableJson(entry) !== stableJson(known[id])) out[`recurrenceOverrides/${pointerToken(id)}`] = entry; + } + for (const id of Object.keys(known)) { + if (!(id in entries)) out[`recurrenceOverrides/${pointerToken(id)}`] = null; + } + return out; +} + +/** + * The patch as it can go out now. A pointer into overrides fails while a series + * has none, on both Stalwarts, so they go as a whole map then, which erases + * nothing. Removing an entry the server doesn't have is nothing to send. + */ +export function patchForServer(patch: Patch, server: Patch): Patch { + const pointers = Object.keys(patch).filter((key) => OVERRIDE_POINTER.test(key)); + if (pointers.length === 0) return patch; + const out: Patch = {}; + for (const [key, value] of Object.entries(patch)) if (!OVERRIDE_POINTER.test(key)) out[key] = value; + if (Object.keys((server.recurrenceOverrides ?? {}) as object).length === 0) { + const built = applyPatchObject({ recurrenceOverrides: {} }, Object.fromEntries(pointers.map((key) => [key, patch[key]]))); + if (Object.keys(built.recurrenceOverrides).length > 0) out.recurrenceOverrides = built.recurrenceOverrides; + return out; + } + for (const key of pointers) { + const entry = overrideEntry(server, pointerTokenValue((OVERRIDE_POINTER.exec(key) as RegExpExecArray)[1])); + if (patch[key] === null && !entry) continue; + out[key] = patch[key]; + } + return out; +} diff --git a/lib/store/calendar/records.ts b/lib/store/calendar/records.ts new file mode 100644 index 000000000..84cdcb0cc --- /dev/null +++ b/lib/store/calendar/records.ts @@ -0,0 +1,162 @@ +/** How calendar objects are stored and read back. */ + +import { parseISO } from 'date-fns'; +import { isTaskLikeObject } from '@/lib/calendar-component-detection'; +import { parseDurationSeconds } from '@/lib/calendar-event-normalization'; +import { pointerTokenValue } from '@/lib/jmap/patch-pointer'; +import { wallClockFieldsAsUtc, zonedWallTimeToUtc } from '@/lib/recurrence-expansion'; +import type { StoreSession } from '../engine/transport'; +import type { SqlValue, StorageAdapter } from '../storage'; + +export const CALENDARS_CAPABILITY = 'urn:ietf:params:jmap:calendars'; +const PRINCIPALS_OWNER = 'urn:ietf:params:jmap:principals:owner'; + +export type CalendarObject = Record & { id: string }; + +export function calendarUsing(session: StoreSession): string[] { + const using = ['urn:ietf:params:jmap:core', CALENDARS_CAPABILITY]; + if (PRINCIPALS_OWNER in session.capabilities) using.push(PRINCIPALS_OWNER); + return using; +} + +/** Stalwart's default list for Calendar/get leaves out sharing and default alerts. */ +export const CALENDAR_PROPERTIES = [ + 'id', 'name', 'description', 'color', 'sortOrder', 'isSubscribed', 'isVisible', 'isDefault', + 'includeInAvailability', 'defaultAlertsWithTime', 'defaultAlertsWithoutTime', 'timeZone', 'shareWith', 'myRights', +] as const; + +/** + * Everything the client's event and task property lists ask for, in one get. + * No utcStart or utcEnd: Stalwart 1.0 refuses those next to recurrenceOverrides, + * and they depend on the zone of the request, so answers work them out. + */ +export const EVENT_PROPERTIES = [ + 'id', '@type', 'uid', 'calendarIds', 'title', 'description', 'descriptionContentType', 'created', 'updated', + 'sequence', 'start', 'due', 'duration', 'timeZone', 'showWithoutTime', 'status', 'freeBusyStatus', 'privacy', + 'color', 'keywords', 'categories', 'locale', 'replyTo', 'organizerCalendarAddress', 'participants', + 'mayInviteSelf', 'mayInviteOthers', 'hideAttendees', 'recurrenceId', 'recurrenceIdTimeZone', 'recurrenceRule', + 'excludedRecurrenceRule', 'recurrenceOverrides', 'useDefaultAlerts', 'alerts', 'locations', 'virtualLocations', + 'links', 'relatedTo', 'isDraft', 'isOrigin', 'baseEventId', 'progress', 'priority', 'percentComplete', +] as const; + +export function isSeries(object: Record): boolean { + const rule = object.recurrenceRule ?? object.recurrenceRules; + return Array.isArray(rule) ? rule.length > 0 : Boolean(rule); +} + +/** The value a PatchObject key points at. */ +export function valueAtPointer(object: unknown, pointer: string): unknown { + let value = object; + for (const token of pointer.split('/')) { + if (value === null || typeof value !== 'object') return undefined; + value = (value as Record)[pointerTokenValue(token)]; + } + return value; +} + +/** A LocalDateTime without any `Z` or offset: Stalwart drops those instead of honouring them. */ +export function wallClock(value: string): string { + return value.replace(/(?:Z|[+-]\d{2}:?\d{2})$/, ''); +} + +/** The instant a wall clock names in `zone`. A zone the runtime doesn't know reads as UTC, as 0.16 does. */ +export function instantOf(value: string, zone: string | null | undefined): number | null { + const wall = parseISO(wallClock(value)); + if (Number.isNaN(wall.getTime())) return null; + const zoned = zone ? zonedWallTimeToUtc(wall, zone) : null; + return zoned ? zoned.getTime() : wallClockFieldsAsUtc(wall); +} + +/** An object's start and end as instants. A floating one is read in `zone`. A task without a start goes by its due. */ +export function instantsOf(object: Record, zone: string | null | undefined): { start: number | null; end: number | null } { + const at = typeof object.start === 'string' ? object.start : typeof object.due === 'string' ? object.due : null; + if (!at) return { start: null, end: null }; + const start = instantOf(at, typeof object.timeZone === 'string' ? object.timeZone : zone); + if (start === null) return { start: null, end: null }; + const seconds = typeof object.duration === 'string' ? parseDurationSeconds(object.duration) : null; + return { start, end: start + (seconds ?? 0) * 1000 }; +} + +function utcString(ms: number): string { + return new Date(ms).toISOString().replace('.000Z', 'Z'); +} + +/** + * An object as a CalendarEvent/get asked for it. utcStart and utcEnd are worked + * out here, so they hold for the request's zone and for events edited offline. + * A series keeps its overrides even when not asked for: the client's + * synthetic-id mode reads occurrences without them, and it gets series from the + * store, which it can only expand correctly with them. + */ +export function projectEvent(object: CalendarObject, properties: readonly string[] | null, zone: string | null | undefined): Record { + if (!properties) return { ...object }; + const out: Record = { id: object.id }; + for (const name of properties) { + if (name === 'utcStart' || name === 'utcEnd') continue; + if (name in object) out[name] = object[name]; + } + if (properties.includes('utcStart') || properties.includes('utcEnd')) { + const { start, end } = typeof object.start === 'string' ? instantsOf(object, zone) : { start: null, end: null }; + if (start !== null && properties.includes('utcStart')) out.utcStart = utcString(start); + if (end !== null && properties.includes('utcEnd')) out.utcEnd = utcString(end); + } + if (properties.includes('recurrenceRule') && object.recurrenceOverrides && isSeries(object)) { + out.recurrenceOverrides = object.recurrenceOverrides; + } + return out; +} + +export interface EventRow { + jmap: string; + id: string; + json: string; + server_json: string | null; + hidden: number; + gen: number; +} + +export function eventRow(db: StorageAdapter, jmap: string, id: string): EventRow | undefined { + return db.get('SELECT jmap, id, json, server_json, hidden, gen FROM calendar_event WHERE jmap = ? AND id = ?', [jmap, id]); +} + +export function currentGen(db: StorageAdapter, jmap: string): number { + return Number(db.get<{ gen: number | null }>('SELECT MAX(gen) AS gen FROM calendar_event WHERE jmap = ?', [jmap])?.gen ?? 0); +} + +export interface EventWrite { + /** What answers show. */ + shown: CalendarObject; + /** The server's copy while a write is pending, otherwise null. */ + server?: CalendarObject | null; + hidden?: boolean; + gen: number; +} + +export function writeEvent(db: StorageAdapter, jmap: string, write: EventWrite): void { + const { shown } = write; + const { start, end } = instantsOf(shown, 'UTC'); + const params: SqlValue[] = [ + jmap, + shown.id, + typeof shown.uid === 'string' ? shown.uid : null, + isTaskLikeObject(shown) ? 1 : 0, + isSeries(shown) ? 1 : 0, + start, + end, + JSON.stringify(shown), + write.server ? JSON.stringify(write.server) : null, + write.hidden ? 1 : 0, + write.gen, + ]; + db.run( + `INSERT INTO calendar_event (jmap, id, uid, is_task, is_series, start_ms, end_ms, json, server_json, hidden, gen) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (jmap, id) DO UPDATE SET uid = excluded.uid, is_task = excluded.is_task, is_series = excluded.is_series, start_ms = excluded.start_ms, + end_ms = excluded.end_ms, json = excluded.json, server_json = excluded.server_json, hidden = excluded.hidden, gen = excluded.gen`, + params, + ); +} + +export function deleteEventRow(db: StorageAdapter, jmap: string, id: string): void { + db.run('DELETE FROM calendar_event WHERE jmap = ? AND id = ?', [jmap, id]); +} diff --git a/lib/store/calendar/reminders.ts b/lib/store/calendar/reminders.ts new file mode 100644 index 000000000..b42307346 --- /dev/null +++ b/lib/store/calendar/reminders.ts @@ -0,0 +1,163 @@ +/** + * Which alerts fire in a time window, from what's stored. No DOM, no stores, no + * React: desktop reminders call this while the window is hidden. + * + * Keys use the series' uid and the fire time, never an id. Synthetic ids can + * change, browser occurrences don't exist on the server, and a stand-in id becomes + * a real one. A moved occurrence gets a new key, as it should: it's a new time to + * remind. + */ + +import { format, parseISO } from 'date-fns'; +import { buildAlertKey, computeFireTime, computeTaskFireTime, getEffectiveAlerts, parseAlertOffset } from '@/lib/calendar-alerts'; +import { isTaskLikeObject } from '@/lib/calendar-component-detection'; +import { normalizeCalendarEventLike, parseDurationSeconds } from '@/lib/calendar-event-normalization'; +import type { Calendar, CalendarEvent, CalendarEventAlert, CalendarTask } from '@/lib/jmap/types'; +import { expandRecurringEvents } from '@/lib/recurrence-expansion'; +import { listObjects } from '../engine/core-schema'; +import type { StorageAdapter } from '../storage'; +import { instantsOf, isSeries, type CalendarObject } from './records'; + +export interface UpcomingAlert { + /** Stable for this alert at this time. Use it to remember what was shown. */ + key: string; + jmap: string; + kind: 'event' | 'task'; + /** The stored event or task: the master of a series. */ + baseEventId: string; + /** Which occurrence of the series, when it's one. */ + recurrenceId: string | null; + alertId: string; + fireTimeMs: number; + title: string; + /** The start as a wall clock, or a task's due when it has no start. */ + start: string | null; + timeZone: string | null; + calendarId: string | null; + calendarName: string | null; +} + +export interface UpcomingAlertsOptions { + /** Alerts firing at or after this. */ + from: number; + /** Alerts firing before this. */ + to: number; + /** Reads floating events. The user's zone. */ + timeZone: string; +} + +type AlertMap = Record | null | undefined; + +// An alert fires up to its offset away from its occurrence, and the occurrence's +// wall clock is up to 26 hours from the instant. Expand wide, filter by instant. +function offsetReachMs(alerts: AlertMap): number { + let reach = 0; + for (const alert of Object.values(alerts ?? {})) { + if (alert.trigger?.['@type'] === 'AbsoluteTrigger') continue; + reach = Math.max(reach, Math.abs(parseAlertOffset(alert.trigger?.offset ?? '') ?? 0)); + } + return reach; +} + +function alertsInOverrides(event: CalendarEvent): AlertMap[] { + return Object.values(event.recurrenceOverrides ?? {}).map((override) => (override as { alerts?: AlertMap }).alerts); +} + +const ZONE_REACH_MS = 27 * 3_600_000; + +function occurrencesNear(event: CalendarEvent, calendars: Calendar[], options: UpcomingAlertsOptions): CalendarEvent[] { + const reach = ZONE_REACH_MS + + Math.max(offsetReachMs(getEffectiveAlerts(event, calendars)), ...alertsInOverrides(event).map(offsetReachMs), 0) + + (parseDurationSeconds(event.duration) ?? 0) * 1000; + const stamp = (ms: number) => format(new Date(ms), "yyyy-MM-dd'T'HH:mm:ss"); + return expandRecurringEvents([event], stamp(options.from - reach), stamp(options.to + reach)); +} + +function withInstants(object: CalendarObject, zone: string): CalendarObject { + const { start, end } = instantsOf(object, zone); + return { + ...object, + ...(start !== null ? { utcStart: new Date(start).toISOString() } : {}), + ...(end !== null ? { utcEnd: new Date(end).toISOString() } : {}), + }; +} + +function firstCalendar(calendarIds: unknown, calendars: Calendar[]): { id: string | null; name: string | null } { + const id = Object.keys((calendarIds ?? {}) as Record)[0] ?? null; + return { id, name: calendars.find((calendar) => calendar.id === id)?.name ?? null }; +} + +/** Every `display` alert of the stored events and tasks that fires in the window, soonest first. */ +export function listUpcomingAlerts(db: StorageAdapter, options: UpcomingAlertsOptions): UpcomingAlert[] { + const found = new Map(); + const add = (alert: UpcomingAlert) => { + if (alert.fireTimeMs >= options.from && alert.fireTimeMs < options.to) found.set(alert.key, alert); + }; + const rows = db.all<{ jmap: string; json: string }>('SELECT jmap, json FROM calendar_event WHERE hidden = 0'); + const calendarsOf = new Map(); + for (const row of rows) { + if (!calendarsOf.has(row.jmap)) calendarsOf.set(row.jmap, listObjects(db, row.jmap, 'Calendar')); + const calendars = calendarsOf.get(row.jmap)!; + const object = JSON.parse(row.json) as CalendarObject; + const calendar = firstCalendar(object.calendarIds, calendars); + // With the account: Stalwart's ids repeat across accounts, and one meeting can be in two. + const stableId = `${row.jmap}:${typeof object.uid === 'string' && object.uid ? object.uid : object.id}`; + + if (isTaskLikeObject(object)) { + const task = object as unknown as CalendarTask; + if (task.progress === 'completed' || task.progress === 'cancelled') continue; + for (const [alertId, alert] of Object.entries(task.alerts ?? {})) { + if (alert.action !== 'display' || alert.acknowledged) continue; + const fireTimeMs = computeTaskFireTime(task, alert.trigger); + if (fireTimeMs === null) continue; + add({ + key: buildAlertKey(stableId, alertId, fireTimeMs), + jmap: row.jmap, + kind: 'task', + baseEventId: object.id, + recurrenceId: null, + alertId, + fireTimeMs, + title: task.title ?? '', + start: task.start ?? task.due ?? null, + timeZone: task.timeZone ?? null, + calendarId: calendar.id, + calendarName: calendar.name, + }); + } + continue; + } + + const master = normalizeCalendarEventLike(withInstants(object, options.timeZone) as unknown as CalendarEvent); + if (typeof master.start !== 'string' || Number.isNaN(parseISO(master.start).getTime())) continue; + const series = isSeries(object); + const occurrences = series ? occurrencesNear(master, calendars, options) : [master]; + // A fixed time doesn't need an occurrence nearby. + const candidates = series ? [master, ...occurrences] : occurrences; + for (const occurrence of candidates) { + if (occurrence.status === 'cancelled') continue; + const isMasterOfSeries = series && occurrence === master; + for (const [alertId, alert] of Object.entries(getEffectiveAlerts(occurrence, calendars) ?? {})) { + if (alert.action !== 'display' || alert.acknowledged) continue; + if (isMasterOfSeries && alert.trigger['@type'] !== 'AbsoluteTrigger') continue; + const fireTimeMs = computeFireTime(occurrence, alert.trigger); + if (fireTimeMs === null) continue; + add({ + key: buildAlertKey(stableId, alertId, fireTimeMs), + jmap: row.jmap, + kind: 'event', + baseEventId: object.id, + recurrenceId: occurrence.recurrenceId ?? null, + alertId, + fireTimeMs, + title: occurrence.title ?? '', + start: occurrence.start, + timeZone: occurrence.timeZone ?? null, + calendarId: calendar.id, + calendarName: calendar.name, + }); + } + } + } + return [...found.values()].sort((a, b) => a.fireTimeMs - b.fireTimeMs || a.key.localeCompare(b.key)); +} diff --git a/lib/store/calendar/schema.ts b/lib/store/calendar/schema.ts new file mode 100644 index 000000000..3bf7e82ba --- /dev/null +++ b/lib/store/calendar/schema.ts @@ -0,0 +1,81 @@ +import type { Migration } from '../storage'; + +export const CALENDAR_MIGRATIONS: readonly Migration[] = [ + { + id: 500, + name: 'calendar', + sql: ` + CREATE TABLE calendar_event ( + jmap TEXT NOT NULL, + id TEXT NOT NULL, + uid TEXT, + is_task INTEGER NOT NULL DEFAULT 0, + -- 1 for a series master. Occurrences are never stored: /changes only + -- lists masters, and the client expands them. + is_series INTEGER NOT NULL DEFAULT 0, + -- Instants to narrow a query with. A floating event is read in UTC here. + start_ms INTEGER, + end_ms INTEGER, + -- What answers show: the server's copy with any pending write applied. + json TEXT NOT NULL, + -- The server's copy while a write is pending on it, otherwise NULL. + server_json TEXT, + -- 1 while a delete is pending. + hidden INTEGER NOT NULL DEFAULT 0, + gen INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (jmap, id) + ) WITHOUT ROWID; + CREATE INDEX calendar_event_uid ON calendar_event (jmap, uid); + CREATE INDEX calendar_event_start ON calendar_event (jmap, start_ms); + CREATE INDEX calendar_event_end ON calendar_event (jmap, end_ms); + + -- An event's writes in order. A later one merges when the flags match (see writes.ts). + CREATE TABLE calendar_pending ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + jmap TEXT NOT NULL, + record_id TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('create', 'update', 'destroy')), + -- create: the whole event. update: a PatchObject. destroy: empty. + patch TEXT NOT NULL, + -- The server's value at each patched path when it was queued. + base TEXT NOT NULL DEFAULT '{}', + -- For a create: how to find it again after a lost response. + uid TEXT, + -- sendSchedulingMessages: NULL when no write named it. + send_scheduling INTEGER, + -- 1 once it went out, or when the server may hold it for another reason. + attempted INTEGER NOT NULL DEFAULT 0, + -- What went out last, or for a later row what its earlier rows wrote: + -- values of ours the server may hold. + sent TEXT, + -- Moves on every merge, to see a write that came in during a send. + rev INTEGER NOT NULL DEFAULT 0, + queued_at INTEGER NOT NULL + ); + CREATE INDEX calendar_pending_record ON calendar_pending (jmap, record_id, seq); + CREATE INDEX calendar_pending_uid ON calendar_pending (jmap, uid); + + -- What a stand-in id became, for a page still holding the stand-in. + CREATE TABLE calendar_id_map ( + jmap TEXT NOT NULL, + local_id TEXT NOT NULL, + server_id TEXT NOT NULL, + PRIMARY KEY (jmap, local_id) + ) WITHOUT ROWID; + + CREATE TABLE calendar_conflict ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + jmap TEXT NOT NULL, + record_id TEXT NOT NULL, + kind TEXT NOT NULL, + reason TEXT NOT NULL, + error_type TEXT, + description TEXT, + title TEXT, + local TEXT NOT NULL, + server TEXT, + created_at INTEGER NOT NULL + ); + `, + }, +]; diff --git a/lib/store/calendar/sync.ts b/lib/store/calendar/sync.ts new file mode 100644 index 000000000..71ad94a0c --- /dev/null +++ b/lib/store/calendar/sync.ts @@ -0,0 +1,324 @@ +/** + * Calendar sync for one JMAP account: calendars, the last participant + * identities, then every event and task. + * + * Masters only. CalendarEvent/changes lists nothing else on either Stalwart, an + * instance edit shows up as `updated: [master]`, and 1.0's expanded queries + * can't give changes and refuse long ranges. Occurrences are made when read. + */ + +import { isTaskLikeObject } from '@/lib/calendar-component-detection'; +import { SYNTHETIC_ID_PROBE } from '@/lib/recurrence-instances'; +import type { StoreContext, SyncContext, Syncer } from '../area'; +import { runChangesLoop } from '../engine/changes'; +import { metaGet, metaSet, putObject } from '../engine/core-schema'; +import { JmapMethodError, responseFor, TransportError } from '../engine/transport'; +import type { StorageAdapter } from '../storage'; +import { isPassingError, sendPending, type FlushTally } from './flush'; +import { + CALENDAR_PROPERTIES, + CALENDARS_CAPABILITY, + calendarUsing, + currentGen, + EVENT_PROPERTIES, + eventRow, + instantsOf, + isSeries, + type CalendarObject, +} from './records'; +import { applyServerDestroy, applyServerEvent, isStandIn, pendingChain, recordConflict, type PendingRow } from './writes'; + +const PAGE = 500; +/** How far back single events are kept. A series stays whatever its start, a task whatever its date. */ +const PAST_WINDOW_MS = 2 * 366 * 86_400_000; + +function syntheticIdsKey(jmap: string): string { + return `calendar.syntheticIds.${jmap}`; +} + +/** + * What this account's server said to the synthetic-id probe at the last sync. + * Null before it ever answered. A client that probed online holds its verdict + * for life and never asks again; only one started offline asks the store. + */ +export function syntheticIdVerdict(ctx: StoreContext, jmap: string): boolean | null { + const value = metaGet(ctx.db, syntheticIdsKey(jmap)); + return value === null ? null : value === '1'; +} + +// A shared account probed on suspicion that grants no calendar access (the +// client treats these the same way). +function isAccessDenial(error: unknown): boolean { + return error instanceof JmapMethodError + && (error.type === 'forbidden' || error.type === 'accountNotFound' || error.type === 'accountNotSupportedByMethod'); +} + +/** False when the account grants no calendar access. */ +async function syncCalendars(ctx: SyncContext, jmap: string): Promise { + const response = await ctx.transport.request([ + ['Calendar/get', { accountId: jmap, ids: null, properties: CALENDAR_PROPERTIES }, 'c'], + ['CalendarEvent/set', { accountId: jmap, update: { [SYNTHETIC_ID_PROBE]: {} } }, 'p'], + ['ParticipantIdentity/get', { accountId: jmap }, 'i'], + ], calendarUsing(ctx.session)); + let result: { list: Array<{ id: string }>; state: unknown }; + try { + result = responseFor(response, 'c')[1]; + } catch (error) { + if (isAccessDenial(error)) return false; + throw error; + } + const probe = response.methodResponses.find(([, , id]) => id === 'p'); + const identities = response.methodResponses.find(([, , id]) => id === 'i'); + const before = ctx.syncState(jmap, 'Calendar').state; + ctx.db.transaction(() => { + ctx.db.run("DELETE FROM object WHERE jmap = ? AND type = 'Calendar'", [jmap]); + for (const calendar of result.list) putObject(ctx.db, jmap, 'Calendar', calendar.id, calendar); + ctx.setSyncState(jmap, 'Calendar', { state: String(result.state) }); + if (probe?.[0] === 'CalendarEvent/set') { + const failure = probe[1]?.notUpdated?.[SYNTHETIC_ID_PROBE] as { type?: string } | undefined; + metaSet(ctx.db, syntheticIdsKey(jmap), failure?.type === 'notFound' ? '1' : '0'); + } + // Only ever read as "the last value". 0.16 has no usable /changes for it. + if (identities?.[0] === 'ParticipantIdentity/get') { + ctx.db.run("DELETE FROM object WHERE jmap = ? AND type = 'ParticipantIdentity'", [jmap]); + for (const identity of identities[1].list as Array<{ id: string }>) putObject(ctx.db, jmap, 'ParticipantIdentity', identity.id, identity); + } + }); + if (before !== String(result.state)) ctx.emit({ type: 'changed', account: ctx.account, collections: ['Calendar'] }); + return true; +} + +function inWindow(ctx: SyncContext, event: CalendarObject): boolean { + if (isSeries(event) || isTaskLikeObject(event)) return true; + const { end } = instantsOf(event, 'UTC'); + return end === null || end >= ctx.now() - PAST_WINDOW_MS; +} + +interface ListCursor { position: number; gen: number; captured: string } + +async function startFullList(ctx: SyncContext, jmap: string): Promise { + const response = await ctx.transport.request([['CalendarEvent/get', { accountId: jmap, ids: [], properties: ['id'] }, 's']], calendarUsing(ctx.session)); + const captured = String(responseFor(response, 's')[1].state); + const cursor: ListCursor = { position: 0, gen: currentGen(ctx.db, jmap) + 1, captured }; + ctx.db.transaction(() => ctx.setSyncState(jmap, 'CalendarEvent', { state: null, phase: 'list', cursor: { ...cursor } })); + return cursor; +} + +function storeEvent(ctx: SyncContext, jmap: string, event: CalendarObject, gen: number): void { + // An old event changing doesn't pull it back into the window. + if (!inWindow(ctx, event) && !eventRow(ctx.db, jmap, event.id) && pendingChain(ctx.db, jmap, event.id).length === 0 + && !ctx.db.get('SELECT 1 AS x FROM calendar_pending WHERE jmap = ? AND uid = ?', [jmap, typeof event.uid === 'string' ? event.uid : ''])) return; + applyServerEvent(ctx, jmap, event, gen); +} + +/** Pages through every object: the query has no filter for tasks, and old series must stay. False when stopped. */ +async function runFullList(ctx: SyncContext, jmap: string, start: ListCursor): Promise { + const cursor = { ...start }; + for (;;) { + if (ctx.stopped()) return false; + const response = await ctx.transport.request([ + ['CalendarEvent/query', { accountId: jmap, position: cursor.position, limit: PAGE }, 'q'], + ['CalendarEvent/get', { + accountId: jmap, + '#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' }, + properties: EVENT_PROPERTIES, + }, 'g'], + ], calendarUsing(ctx.session)); + const ids = responseFor(response, 'q')[1].ids as string[]; + const list = responseFor(response, 'g')[1].list as CalendarObject[]; + const done = ids.length < PAGE; + cursor.position += ids.length; + ctx.db.transaction(() => { + for (const event of list) storeEvent(ctx, jmap, event, cursor.gen); + if (!done) ctx.setSyncState(jmap, 'CalendarEvent', { cursor: { ...cursor } }); + }); + if (done && !(await finishFullList(ctx, jmap, cursor))) return false; + ctx.emit({ type: 'progress', account: ctx.account, area: 'calendar', phase: 'list', done: cursor.position, total: null }); + if (done) { + ctx.emit({ type: 'changed', account: ctx.account, collections: ['CalendarEvent'] }); + return true; + } + } +} + +/** + * A delete during paging moves later objects across a page boundary, so what this + * pass didn't see may still be there. Asks for it by id, and only what the server + * says it doesn't have goes. False when stopped. + */ +async function finishFullList(ctx: SyncContext, jmap: string, cursor: ListCursor): Promise { + const unseen = ctx.db.all<{ id: string }>('SELECT id FROM calendar_event WHERE jmap = ? AND gen < ?', [jmap, cursor.gen]) + .map((row) => row.id) + .filter((id) => !isStandIn(id)); + const found: CalendarObject[] = []; + const gone: string[] = []; + for (let i = 0; i < unseen.length; i += PAGE) { + if (ctx.stopped()) return false; + const response = await ctx.transport.request([['CalendarEvent/get', { + accountId: jmap, + ids: unseen.slice(i, i + PAGE), + properties: EVENT_PROPERTIES, + }, 'g']], calendarUsing(ctx.session)); + const [, result] = responseFor(response, 'g'); + found.push(...result.list as CalendarObject[]); + gone.push(...result.notFound as string[]); + } + ctx.db.transaction(() => { + for (const event of found) storeEvent(ctx, jmap, event, cursor.gen); + for (const id of gone) applyServerDestroy(ctx, jmap, id); + ctx.setSyncState(jmap, 'CalendarEvent', { state: cursor.captured, phase: null, cursor: null }); + }); + return true; +} + +async function syncDelta(ctx: SyncContext, jmap: string, since: string, target: string | null): Promise<'done' | 'resync' | 'stopped'> { + const outcome = await runChangesLoop({ + transport: ctx.transport, + type: 'CalendarEvent', + jmap, + using: calendarUsing(ctx.session), + since, + target, + stopped: ctx.stopped, + apply: async (page) => { + const ids = [...page.created, ...page.updated]; + const fetched: CalendarObject[] = []; + for (let i = 0; i < ids.length; i += PAGE) { + const response = await ctx.transport.request([['CalendarEvent/get', { + accountId: jmap, + ids: ids.slice(i, i + PAGE), + properties: EVENT_PROPERTIES, + }, 'g']], calendarUsing(ctx.session)); + fetched.push(...responseFor(response, 'g')[1].list as CalendarObject[]); + } + ctx.db.transaction(() => { + const gen = currentGen(ctx.db, jmap); + for (const event of fetched) storeEvent(ctx, jmap, event, gen); + for (const id of page.destroyed) applyServerDestroy(ctx, jmap, id); + ctx.setSyncState(jmap, 'CalendarEvent', { state: page.newState }); + }); + if (ids.length + page.destroyed.length > 0) ctx.emit({ type: 'changed', account: ctx.account, collections: ['CalendarEvent'] }); + }, + }); + if (outcome.outcome === 'resync') return 'resync'; + return outcome.outcome === 'stopped' ? 'stopped' : 'done'; +} + +/** False when stopped before catching up. */ +async function syncEvents(ctx: SyncContext, jmap: string): Promise { + if (ctx.stopped()) return false; + const sync = ctx.syncState(jmap, 'CalendarEvent'); + let cursor = sync.phase === 'list' ? sync.cursor as unknown as ListCursor : null; + if (!cursor && !sync.state) cursor = await startFullList(ctx, jmap); + if (!cursor && sync.state) { + const outcome = await syncDelta(ctx, jmap, sync.state, sync.target); + if (outcome === 'stopped') return false; + if (outcome === 'resync') cursor = await startFullList(ctx, jmap); + } + if (cursor) { + if (!(await runFullList(ctx, jmap, cursor))) return false; + // What changed while paging. + const after = ctx.syncState(jmap, 'CalendarEvent'); + if (after.state && (await syncDelta(ctx, jmap, after.state, after.target)) === 'stopped') return false; + } + return true; +} + +/** Single events that aged out of the window. The window moves on without any change on the server. */ +function pruneOld(ctx: SyncContext, jmap: string): void { + ctx.db.run( + `DELETE FROM calendar_event WHERE jmap = ? AND is_series = 0 AND is_task = 0 AND end_ms IS NOT NULL AND end_ms < ? + AND NOT EXISTS (SELECT 1 FROM calendar_pending p WHERE p.jmap = calendar_event.jmap AND p.record_id = calendar_event.id)`, + [jmap, ctx.now() - PAST_WINDOW_MS], + ); +} + +/** + * A share taken away: the session stops listing the account, and nothing of it + * stays on the device, reminders included. Its waiting writes can't go anywhere. + */ +function forgetUnlistedAccounts(ctx: SyncContext): void { + const listed = new Set(Object.keys(ctx.session.accounts)); + if (listed.size === 0) return; + const kept = ctx.db.all<{ jmap: string }>( + `SELECT jmap FROM calendar_event UNION SELECT jmap FROM calendar_pending + UNION SELECT jmap FROM object WHERE type IN ('Calendar', 'ParticipantIdentity')`, + ).map((row) => row.jmap); + for (const jmap of kept) { + if (listed.has(jmap)) continue; + ctx.db.transaction(() => { + for (;;) { + const row = ctx.db.get('SELECT * FROM calendar_pending WHERE jmap = ? ORDER BY seq LIMIT 1', [jmap]); + if (!row) break; + recordConflict(ctx, row, 'destroyedOnServer', { type: 'accountNotFound' }); + } + ctx.db.run('DELETE FROM calendar_event WHERE jmap = ?', [jmap]); + ctx.db.run('DELETE FROM calendar_id_map WHERE jmap = ?', [jmap]); + ctx.db.run("DELETE FROM object WHERE jmap = ? AND type IN ('Calendar', 'ParticipantIdentity')", [jmap]); + ctx.db.run("DELETE FROM sync_state WHERE jmap = ? AND type IN ('Calendar', 'CalendarEvent')", [jmap]); + metaSet(ctx.db, syntheticIdsKey(jmap), null); + }); + ctx.emit({ type: 'changed', account: ctx.account, collections: ['Calendar', 'CalendarEvent'] }); + } +} + +// A sync and a flush must not interleave. Our own write coming back through +// /changes while it's still pending would read as someone else's change. +const queues = new WeakMap>(); + +function oneAtATime(db: StorageAdapter, work: () => Promise): Promise { + const next = (queues.get(db) ?? Promise.resolve()).catch(() => {}).then(work); + queues.set(db, next); + return next; +} + +async function syncAndSend(ctx: SyncContext, jmap: string): Promise { + const none: FlushTally = { sent: 0, conflicts: 0 }; + if (!(await syncEvents(ctx, jmap)) || ctx.stopped()) return none; + const tally = await sendPending(ctx, jmap); + if (tally.sent > 0 && !ctx.stopped()) await syncEvents(ctx, jmap); + return tally; +} + +export const calendarSyncer: Syncer = { + name: 'calendar', + capability: CALENDARS_CAPABILITY, + async run(ctx, jmap) { + forgetUnlistedAccounts(ctx); + if (!(await syncCalendars(ctx, jmap)) || ctx.stopped()) return; + await oneAtATime(ctx.db, () => syncAndSend(ctx, jmap)); + pruneOld(ctx, jmap); + }, +}; + +export interface FlushResult extends FlushTally { + /** Writes still waiting: the network went away, or the server had a bad moment. */ + pending: number; +} + +/** + * `calendar.flush`: catches up with the server, so a write that collides with + * a change there is found, then sends every pending write. Doesn't throw for a + * lost connection: what wasn't sent stays pending. + */ +export function flushCalendar(ctx: SyncContext): Promise { + return oneAtATime(ctx.db, async () => { + forgetUnlistedAccounts(ctx); + const accounts = ctx.db.all<{ jmap: string }>('SELECT DISTINCT jmap FROM calendar_pending').map((row) => row.jmap); + const total: FlushTally = { sent: 0, conflicts: 0 }; + for (const jmap of accounts) { + try { + const tally = await syncAndSend(ctx, jmap); + total.sent += tally.sent; + total.conflicts += tally.conflicts; + } catch (error) { + // No network, no credentials: no point in trying the other accounts. + if (error instanceof TransportError && (error.kind === 'network' || error.kind === 'auth')) break; + // Anything else is about this account, whose writes stay pending. + if (!(error instanceof TransportError) && !(error instanceof JmapMethodError) && !isPassingError(error)) throw error; + } + } + const pending = Number(ctx.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM calendar_pending')?.n ?? 0); + return { ...total, pending }; + }); +} diff --git a/lib/store/calendar/writes.ts b/lib/store/calendar/writes.ts new file mode 100644 index 000000000..d47ff1eca --- /dev/null +++ b/lib/store/calendar/writes.ts @@ -0,0 +1,398 @@ +/** + * Calendar writes made while the server can't be reached. + * + * An event has a chain of pending rows, sent in order. A later write merges into + * the last row when both have the same scheduling flag: edit + edit is one patch, + * create + edit is one create. Different flags stay apart, because they mean + * different things to attendees. The change to one occurrence that must reach + * those who answered is a quiet write and then one with messages, and a change + * made with "don't notify" must not go out with a later answer that does. + * + * Answers read calendar_event.json, which is the server's copy with the chain + * applied. server_json keeps the server's copy underneath, so sync can move it on + * and reapply the chain, or notice that the two collide. + */ + +import { applyPatchObject } from '@/lib/jmap/patch-object'; +import { isStandInId, newStandInId } from '@/lib/jmap/stand-in-ids'; +import { mergeOverrideEntry, stableJson } from '@/lib/recurrence-instances'; +import type { StoreContext } from '../area'; +import type { StorageAdapter } from '../storage'; +import { asOverridePointers, collides, holdsValue } from './patches'; +import { currentGen, deleteEventRow, eventRow, valueAtPointer, writeEvent, type CalendarObject, type EventRow } from './records'; + +export interface PendingRow { + seq: number; + jmap: string; + record_id: string; + kind: 'create' | 'update' | 'destroy'; + patch: string; + base: string; + uid: string | null; + send_scheduling: number | null; + attempted: number; + sent: string | null; + rev: number; + queued_at: number; +} + +export type ConflictReason = 'refused' | 'changedOnServer' | 'destroyedOnServer'; + +export interface CalendarConflict { + id: number; + jmap: string; + recordId: string; + kind: PendingRow['kind']; + reason: ConflictReason; + errorType: string | null; + description: string | null; + title: string | null; + /** The write that wasn't made: the whole event for a create, otherwise the patch. */ + local: Record; + /** The event as the server has it, when it still does. */ + server: Record | null; + createdAt: number; +} + +export const isStandIn = isStandInId; + +/** An event's pending writes, in the order they go out. */ +export function pendingChain(db: StorageAdapter, jmap: string, id: string): PendingRow[] { + return db.all('SELECT * FROM calendar_pending WHERE jmap = ? AND record_id = ? ORDER BY seq', [jmap, id]); +} + +export function pendingBySeq(db: StorageAdapter, seq: number): PendingRow | undefined { + return db.get('SELECT * FROM calendar_pending WHERE seq = ?', [seq]); +} + +/** What a stand-in id became, once its create reached the server. */ +export function resolveId(db: StorageAdapter, jmap: string, id: string): string { + if (!isStandIn(id)) return id; + return db.get<{ server_id: string }>('SELECT server_id FROM calendar_id_map WHERE jmap = ? AND local_id = ?', [jmap, id])?.server_id ?? id; +} + +/** For the page's client: the real id, or null while it's still only on this device. */ +export function resolveStandIns(db: StorageAdapter, ids: readonly string[]): Record { + const out: Record = {}; + for (const id of ids) { + out[id] = db.get<{ server_id: string }>('SELECT server_id FROM calendar_id_map WHERE local_id = ?', [id])?.server_id ?? null; + } + return out; +} + +function flagOf(sendScheduling: boolean | undefined): number | null { + return sendScheduling === undefined ? null : Number(sendScheduling); +} + +function parse(json: string): Record { + return JSON.parse(json) as Record; +} + +function serverCopy(row: EventRow): CalendarObject { + return JSON.parse(row.server_json ?? row.json) as CalendarObject; +} + +function changed(ctx: StoreContext, ...collections: string[]): void { + ctx.emit({ type: 'changed', account: ctx.account, collections }); +} + +/** Writes what answers show: `server` with the event's chain on top. */ +function rewriteShown(db: StorageAdapter, jmap: string, id: string, server: CalendarObject | null, gen: number): void { + const chain = pendingChain(db, jmap, id); + if (chain.length === 0) { + if (server) writeEvent(db, jmap, { shown: server, gen }); + else deleteEventRow(db, jmap, id); + return; + } + const row = eventRow(db, jmap, id); + let shown: CalendarObject | null = server ?? (row ? JSON.parse(row.json) as CalendarObject : null); + let hidden = false; + for (const pending of chain) { + if (pending.kind === 'create') shown = { ...parse(pending.patch), id, isOrigin: true }; + else if (pending.kind === 'update') shown = applyPatchObject(shown as CalendarObject, parse(pending.patch)); + else hidden = true; + } + if (shown) writeEvent(db, jmap, { shown, server, hidden, gen }); +} + +/** + * Creates a local event under a stand-in id. The uid is what finds it again. One + * the device chose can't be on the server yet. One from the caller might: the + * client's request may have got through before its answer was lost, or an import + * names an event that exists. That counts as attempted, so a flush looks first. + */ +export function queueCreate(ctx: StoreContext, jmap: string, event: Record, sendScheduling: boolean | undefined): { id: string; uid: string } { + const id = newStandInId(); + const given = typeof event.uid === 'string' && event.uid ? event.uid : null; + const uid = given ?? crypto.randomUUID(); + const payload = { '@type': 'Event', ...event, uid }; + ctx.db.run( + `INSERT INTO calendar_pending (jmap, record_id, kind, patch, uid, send_scheduling, attempted, queued_at) VALUES (?, ?, 'create', ?, ?, ?, ?, ?)`, + [jmap, id, JSON.stringify(payload), uid, flagOf(sendScheduling), given ? 1 : 0, ctx.now()], + ); + rewriteShown(ctx.db, jmap, id, null, currentGen(ctx.db, jmap)); + return { id, uid }; +} + +function baseFor(server: Record, patch: Record, earlier: Record): Record { + const base: Record = {}; + for (const key of Object.keys(patch)) base[key] = key in earlier ? earlier[key] : valueAtPointer(server, key) ?? null; + return base; +} + +/** False when the store has no such event. */ +export function queueUpdate(ctx: StoreContext, jmap: string, id: string, rawPatch: Record, sendScheduling: boolean | undefined): boolean { + const { db } = ctx; + const row = eventRow(db, jmap, id); + if (!row || row.hidden) return false; + const chain = pendingChain(db, jmap, id); + const last = chain.at(-1); + const flag = flagOf(sendScheduling); + const local = chain[0]?.kind === 'create'; + const server = local ? null : serverCopy(row); + if (last?.kind === 'create' && last.send_scheduling === flag) { + db.run( + 'UPDATE calendar_pending SET patch = ?, rev = rev + 1 WHERE seq = ?', + [JSON.stringify(applyPatchObject(parse(last.patch), rawPatch)), last.seq], + ); + rewriteShown(db, jmap, id, null, row.gen); + return true; + } + const patch = asOverridePointers(rawPatch, JSON.parse(row.json) as Record); + if (Object.keys(patch).length === 0) return true; + if (last?.kind === 'update' && last.send_scheduling === flag) { + const merged = mergeOverrideEntry(parse(last.patch), patch); + const base = baseFor(server ?? {}, merged, parse(last.base)); + db.run('UPDATE calendar_pending SET patch = ?, base = ?, rev = rev + 1 WHERE seq = ?', [JSON.stringify(merged), JSON.stringify(base), last.seq]); + } else { + db.run( + `INSERT INTO calendar_pending (jmap, record_id, kind, patch, base, send_scheduling, queued_at) VALUES (?, ?, 'update', ?, ?, ?, ?)`, + [jmap, id, JSON.stringify(patch), JSON.stringify(baseFor(server ?? {}, patch, {})), flag, ctx.now()], + ); + } + rewriteShown(db, jmap, id, server, row.gen); + return true; +} + +/** False when the store has no such event. */ +export function queueDestroy(ctx: StoreContext, jmap: string, id: string, sendScheduling: boolean | undefined): boolean { + const { db } = ctx; + const row = eventRow(db, jmap, id); + if (!row || row.hidden) return false; + const chain = pendingChain(db, jmap, id); + const flag = flagOf(sendScheduling); + const head = chain[0]; + if (head?.kind === 'create') { + db.run('DELETE FROM calendar_pending WHERE jmap = ? AND record_id = ? AND seq <> ?', [jmap, id, head.seq]); + if (!head.attempted) { + // It never left the device. + db.run('DELETE FROM calendar_pending WHERE seq = ?', [head.seq]); + deleteEventRow(db, jmap, id); + return true; + } + // It may be on the server. Its uid stays, so the delete can follow it. + db.run( + `UPDATE calendar_pending SET kind = 'destroy', patch = '{}', base = '{}', send_scheduling = ?, rev = rev + 1 WHERE seq = ?`, + [flag, head.seq], + ); + rewriteShown(db, jmap, id, null, row.gen); + return true; + } + const last = chain.at(-1); + if (last?.kind === 'update' && last.send_scheduling === flag) { + db.run(`UPDATE calendar_pending SET kind = 'destroy', patch = '{}', base = '{}', sent = NULL, rev = rev + 1 WHERE seq = ?`, [last.seq]); + } else { + db.run( + `INSERT INTO calendar_pending (jmap, record_id, kind, patch, send_scheduling, queued_at) VALUES (?, ?, 'destroy', '{}', ?, ?)`, + [jmap, id, flag, ctx.now()], + ); + } + rewriteShown(db, jmap, id, serverCopy(row), row.gen); + return true; +} + +/** + * Puts a write on the conflict list with every later write of that event, which + * were built on it. The event goes back to the server's copy plus what's left. A + * refused create leaves nothing behind. + */ +export function recordConflict( + ctx: StoreContext, + pending: PendingRow, + reason: ConflictReason, + error?: { type?: string; description?: string }, +): void { + const { db } = ctx; + const { jmap, record_id: id } = pending; + const row = eventRow(db, jmap, id); + const gone = reason === 'destroyedOnServer'; + const local = pending.kind === 'create' || (pending.kind === 'destroy' && pending.uid !== null); + const server = row && !local && !gone ? serverCopy(row) : null; + const title = row ? (parse(row.json).title as string | undefined) ?? null : null; + const chain = pendingChain(db, jmap, id).filter((other) => gone || other.seq >= pending.seq); + for (const other of chain) { + db.run( + `INSERT INTO calendar_conflict (jmap, record_id, kind, reason, error_type, description, title, local, server, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [jmap, id, other.kind, reason, error?.type ?? null, error?.description ?? null, title, other.patch, server ? JSON.stringify(server) : null, ctx.now()], + ); + db.run('DELETE FROM calendar_pending WHERE seq = ?', [other.seq]); + } + if (row) { + if (local || gone) deleteEventRow(db, jmap, id); + else rewriteShown(db, jmap, id, serverCopy(row), row.gen); + } + changed(ctx, 'CalendarEvent', 'CalendarConflict'); +} + +/** Before a row goes out: what it says may be on the server from now on. */ +export function markSent(ctx: StoreContext, row: PendingRow, sent: Record): void { + ctx.db.transaction(() => ctx.db.run('UPDATE calendar_pending SET attempted = 1, sent = ? WHERE seq = ?', [JSON.stringify(sent), row.seq])); +} + +/** + * The server's event for a create found again, or just made: the stand-in becomes + * it, and later rows go on under the real id. What the server doesn't hold of the + * create goes on as an update: what came in after the last send, or, when nothing + * of ours was sent, whatever differs. True when writes are left. + */ +export function adoptStandIn(ctx: StoreContext, row: PendingRow, event: CalendarObject): boolean { + const { db } = ctx; + const { jmap, record_id: standIn } = row; + const head = pendingBySeq(db, row.seq); + const gen = currentGen(db, jmap); + deleteEventRow(db, jmap, standIn); + db.run('INSERT OR REPLACE INTO calendar_id_map (jmap, local_id, server_id) VALUES (?, ?, ?)', [jmap, standIn, event.id]); + db.run('UPDATE calendar_pending SET record_id = ? WHERE jmap = ? AND record_id = ?', [event.id, jmap, standIn]); + if (head?.kind === 'destroy') { + db.run('UPDATE calendar_pending SET uid = NULL, attempted = 0, sent = NULL WHERE seq = ?', [head.seq]); + } else if (head) { + const payload = parse(head.patch); + const sent = head.sent ? parse(head.sent) : null; + const patch: Record = {}; + if (sent) { + for (const key of new Set([...Object.keys(sent), ...Object.keys(payload)])) { + if (stableJson(sent[key]) !== stableJson(payload[key])) patch[key] = payload[key] ?? null; + } + } else { + for (const [key, value] of Object.entries(payload)) { + if (key !== '@type' && key !== 'uid' && !holdsValue(event, key, value)) patch[key] = value; + } + } + const pointers = asOverridePointers(patch, event); + if (Object.keys(pointers).length === 0) { + db.run('DELETE FROM calendar_pending WHERE seq = ?', [head.seq]); + } else { + db.run( + `UPDATE calendar_pending SET kind = 'update', patch = ?, base = ?, uid = NULL, attempted = 0, sent = NULL WHERE seq = ?`, + [JSON.stringify(pointers), JSON.stringify(baseFor(event, pointers, {})), head.seq], + ); + } + } + const rest = pendingChain(db, jmap, event.id); + for (const later of rest) { + if (later.kind === 'update' && later.seq !== head?.seq) { + db.run('UPDATE calendar_pending SET base = ? WHERE seq = ?', [JSON.stringify(baseFor(event, parse(later.patch), {})), later.seq]); + } + } + rewriteShown(db, jmap, event.id, event, gen); + changed(ctx, 'CalendarEvent'); + return rest.length > 0; +} + +/** + * A write went through: `sent` is the server's now. True when this row has to go + * again, because a newer write merged into it during the send. Later rows read + * their overlap with `sent` as ours, not as someone's change. + */ +export function finishWrite(ctx: StoreContext, row: PendingRow, sent: Record): boolean { + const { db } = ctx; + const current = pendingBySeq(db, row.seq); + const event = eventRow(db, row.jmap, row.record_id); + if (!current || !event) return false; + const server = applyPatchObject(serverCopy(event), sent); + const stays = current.rev !== row.rev; + if (stays) { + db.run('UPDATE calendar_pending SET attempted = 0, sent = ? WHERE seq = ?', [JSON.stringify(sent), row.seq]); + } else { + db.run('DELETE FROM calendar_pending WHERE seq = ?', [row.seq]); + } + for (const later of pendingChain(db, row.jmap, row.record_id)) { + if (later.kind !== 'update') continue; + const earlierSent = later.sent ? parse(later.sent) : {}; + db.run( + 'UPDATE calendar_pending SET base = ?, sent = ? WHERE seq = ?', + [JSON.stringify(baseFor(server, parse(later.patch), {})), JSON.stringify({ ...earlierSent, ...sent }), later.seq], + ); + } + rewriteShown(db, row.jmap, row.record_id, server, event.gen); + changed(ctx, 'CalendarEvent'); + return stays; +} + +/** The server's copy of an event arriving during sync. Any pending writes go back on top, or to the conflict list. */ +export function applyServerEvent(ctx: StoreContext, jmap: string, event: CalendarObject, gen: number): void { + const { db } = ctx; + if (typeof event.uid === 'string') { + // A create of ours that no answer told us about. + const waiting = db.get('SELECT * FROM calendar_pending WHERE jmap = ? AND uid = ? ORDER BY seq LIMIT 1', [jmap, event.uid]); + if (waiting && waiting.record_id !== event.id) { + adoptStandIn(ctx, waiting, event); + return; + } + } + const before = eventRow(db, jmap, event.id); + const previous = before ? stableJson(serverCopy(before)) : null; + const chain = pendingChain(db, jmap, event.id); + if (chain.length === 0) { + writeEvent(db, jmap, { shown: event, gen }); + return; + } + writeEvent(db, jmap, { shown: event, server: event, gen }); + for (const pending of chain) { + if (pending.kind === 'destroy') { + // Deleting what someone just changed is the one write that can't be taken + // back, so the change wins and the user decides. + if (previous !== null && previous !== stableJson(event)) recordConflict(ctx, pending, 'changedOnServer'); + break; + } + if (collides({ patch: parse(pending.patch), base: parse(pending.base), sent: pending.sent ? parse(pending.sent) : null }, event)) { + recordConflict(ctx, pending, 'changedOnServer'); + break; + } + } + rewriteShown(db, jmap, event.id, event, gen); +} + +/** The server no longer has this event. A pending delete is done; a pending edit has nothing left to change. */ +export function applyServerDestroy(ctx: StoreContext, jmap: string, id: string): void { + const { db } = ctx; + const chain = pendingChain(db, jmap, id); + const edit = chain.find((row) => row.kind === 'update'); + if (edit) { + recordConflict(ctx, edit, 'destroyedOnServer'); + return; + } + db.run('DELETE FROM calendar_pending WHERE jmap = ? AND record_id = ?', [jmap, id]); + deleteEventRow(db, jmap, id); +} + +export function listConflicts(db: StorageAdapter): CalendarConflict[] { + const rows = db.all<{ + id: number; jmap: string; record_id: string; kind: PendingRow['kind']; reason: ConflictReason; error_type: string | null; + description: string | null; title: string | null; local: string; server: string | null; created_at: number; + }>('SELECT * FROM calendar_conflict ORDER BY id'); + return rows.map((row) => ({ + id: Number(row.id), + jmap: row.jmap, + recordId: row.record_id, + kind: row.kind, + reason: row.reason, + errorType: row.error_type, + description: row.description, + title: row.title, + local: JSON.parse(row.local) as Record, + server: row.server ? JSON.parse(row.server) as Record : null, + createdAt: Number(row.created_at), + })); +} diff --git a/lib/store/client/keys.ts b/lib/store/client/keys.ts new file mode 100644 index 000000000..ecd4fe4c2 --- /dev/null +++ b/lib/store/client/keys.ts @@ -0,0 +1,173 @@ +/** + * The per-account database keys, as the page sees them. + * + * Each account's database has a random 32-byte key. It is only ever stored + * wrapped: AES-GCM with a non-extractable key that lives in IndexedDB, then + * any extra layers on top (the desktop app's OS keychain, app lock). Forgetting + * the record makes the file unreadable even if deleting it failed. + */ + +const DB_NAME = 'bulwark-store'; +const DB_VERSION = 1; +const KEYS = 'keys'; +const ACCOUNTS = 'accounts'; +const WRAP_ID = 'wrap'; + +export interface WrapLayer { + /** A name for the layer: 'safeStorage', 'pin', 'prf'. */ + name: string; + wrap(data: Uint8Array): Promise; + unwrap(data: Uint8Array): Promise; +} + +export interface AccountRecord { + account: string; + /** `/a.db`. */ + file: string; + iv: Uint8Array; + wrapped: Uint8Array; + /** Layers applied after the WebCrypto wrap, innermost first. */ + layers: string[]; + createdAt: number; +} + +function promisify(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +function openDb(): Promise { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(KEYS)) db.createObjectStore(KEYS, { keyPath: 'id' }); + if (!db.objectStoreNames.contains(ACCOUNTS)) db.createObjectStore(ACCOUNTS, { keyPath: 'account' }); + }; + return promisify(request); +} + +async function withDb(fn: (db: IDBDatabase) => Promise): Promise { + const db = await openDb(); + try { + return await fn(db); + } finally { + db.close(); + } +} + +async function wrapKey(db: IDBDatabase): Promise { + const read = db.transaction(KEYS, 'readonly').objectStore(KEYS).get(WRAP_ID); + const existing = await promisify(read) as { key: CryptoKey } | undefined; + if (existing) return existing.key; + const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); + const tx = db.transaction(KEYS, 'readwrite'); + const store = tx.objectStore(KEYS); + // Another tab may have got there first: keep theirs. + const raced = await promisify(store.get(WRAP_ID)) as { key: CryptoKey } | undefined; + if (raced) return raced.key; + await promisify(store.put({ id: WRAP_ID, key })); + return key; +} + +function toHex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); +} + +function asBuffer(bytes: Uint8Array): ArrayBuffer { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; +} + +export function newStoreFileName(): string { + return `/a${toHex(crypto.getRandomValues(new Uint8Array(8)))}.db`; +} + +export async function getAccountRecord(account: string): Promise { + return withDb(async (db) => { + const found = await promisify(db.transaction(ACCOUNTS, 'readonly').objectStore(ACCOUNTS).get(account)) as AccountRecord | undefined; + return found ?? null; + }); +} + +export async function listAccountRecords(): Promise { + return withDb((db) => promisify(db.transaction(ACCOUNTS, 'readonly').objectStore(ACCOUNTS).getAll()) as Promise); +} + +/** The account's key in hex, created on first use. Returns the record too, for the file name. */ +export async function openAccountKey(account: string, layers: readonly WrapLayer[] = []): Promise<{ keyHex: string; record: AccountRecord }> { + return withDb(async (db) => { + const wrapping = await wrapKey(db); + const found = await promisify(db.transaction(ACCOUNTS, 'readonly').objectStore(ACCOUNTS).get(account)) as AccountRecord | undefined; + if (found) { + let data = found.wrapped; + for (const name of [...found.layers].reverse()) { + const layer = layers.find((candidate) => candidate.name === name); + if (!layer) throw new StoreKeyLayerMissingError(name); + data = await layer.unwrap(data); + } + const raw = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: asBuffer(found.iv) }, wrapping, asBuffer(data))); + const keyHex = toHex(raw); + raw.fill(0); + return { keyHex, record: found }; + } + const raw = crypto.getRandomValues(new Uint8Array(32)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + let wrapped = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, wrapping, raw)); + for (const layer of layers) wrapped = new Uint8Array(await layer.wrap(wrapped)); + const record: AccountRecord = { + account, + file: newStoreFileName(), + iv, + wrapped, + layers: layers.map((layer) => layer.name), + createdAt: Date.now(), + }; + await promisify(db.transaction(ACCOUNTS, 'readwrite').objectStore(ACCOUNTS).put(record)); + const keyHex = toHex(raw); + raw.fill(0); + return { keyHex, record }; + }); +} + +/** Adds or removes wrap layers without touching the database key. */ +export async function rewrapAccountKey( + account: string, + from: readonly WrapLayer[], + to: readonly WrapLayer[], +): Promise { + await withDb(async (db) => { + const found = await promisify(db.transaction(ACCOUNTS, 'readonly').objectStore(ACCOUNTS).get(account)) as AccountRecord | undefined; + if (!found) return; + let data = found.wrapped; + for (const name of [...found.layers].reverse()) { + const layer = from.find((candidate) => candidate.name === name); + if (!layer) throw new StoreKeyLayerMissingError(name); + data = await layer.unwrap(data); + } + for (const layer of to) data = new Uint8Array(await layer.wrap(data)); + const record: AccountRecord = { ...found, wrapped: data, layers: to.map((layer) => layer.name) }; + await promisify(db.transaction(ACCOUNTS, 'readwrite').objectStore(ACCOUNTS).put(record)); + }); +} + +/** Forgets the account's key. The database file can't be read after this. */ +export async function forgetAccountKey(account: string): Promise { + await withDb((db) => promisify(db.transaction(ACCOUNTS, 'readwrite').objectStore(ACCOUNTS).delete(account)).then(() => undefined)); +} + +export class StoreKeyLayerMissingError extends Error { + constructor(readonly layer: string) { + super(`The key needs the ${layer} layer`); + this.name = 'StoreKeyLayerMissingError'; + } +} + +/** Deletes every key at sign-out. Works with no worker running, on any page. */ +export function deleteKeyDatabase(): void { + try { + indexedDB.deleteDatabase(DB_NAME); + } catch { + // Private mode or similar: nothing was stored either. + } +} diff --git a/lib/store/client/store-client.ts b/lib/store/client/store-client.ts new file mode 100644 index 000000000..5ae621748 --- /dev/null +++ b/lib/store/client/store-client.ts @@ -0,0 +1,265 @@ +/** + * The page's side of the local store. Each tab starts its own worker, which + * waits for the owner lock. Calls go over a BroadcastChannel to whichever + * worker owns the store, including this tab's own, so a tab doesn't need to + * know whether it's the owner. A key never goes over the channel. + */ + +import type { StoreEvent, StoreScope } from '../area'; +import type { JmapResponse, MethodCall, StoreSession } from '../engine/transport'; +import { SQLITE3MC_PUBLIC_DIR } from '../sqlite-version'; +import { + RPC_CHANNEL, + type ChannelMessage, + type OpenAccountParams, + type PageToWorker, + type RpcReply, + type StoreRpc, + type StoreStatus, + type WorkerToPage, +} from '../worker/protocol'; + +const CALL_TIMEOUT_MS = 60_000; +const OWNER_WAIT_MS = 15_000; + +export class StoreOwnerGoneError extends Error { + constructor() { + super('The local store is not available in this browser window'); + this.name = 'StoreOwnerGoneError'; + } +} + +interface Pending { + resolve(value: unknown): void; + reject(error: Error): void; + timer: ReturnType; +} + +export interface StoreClientOptions { + /** NEXT_PUBLIC_BASE_PATH plus the build id, passed in so this works in tests. */ + basePath?: string; + buildId?: string; + onEvent?(event: StoreEvent): void; + /** The tab became the owner: open its accounts. */ + onOwner?(): void; + /** The worker asks for a fresh Authorization header. */ + onNeedAuth?(account: string): void; +} + +export class StoreClient { + readonly clientId = crypto.randomUUID(); + private worker: Worker | null = null; + private channel: BroadcastChannel | null = null; + private owner: string | null = null; + private isOwner = false; + private readonly pending = new Map(); + private readonly ownerWaiters: Array<() => void> = []; + + constructor(private readonly options: StoreClientOptions = {}) {} + + static supported(): boolean { + return typeof Worker === 'function' + && typeof BroadcastChannel === 'function' + && typeof navigator !== 'undefined' + && Boolean(navigator.locks) + && typeof navigator.storage?.getDirectory === 'function' + && typeof FileSystemFileHandle !== 'undefined' + && 'createSyncAccessHandle' in FileSystemFileHandle.prototype; + } + + start(): void { + if (this.worker) return; + const base = this.options.basePath ?? ''; + const query = this.options.buildId ? `?v=${encodeURIComponent(this.options.buildId)}` : ''; + this.channel = new BroadcastChannel(RPC_CHANNEL); + this.channel.onmessage = (event: MessageEvent) => this.onChannel(event.data); + this.worker = new Worker(`${base}/store-worker.js${query}`, { type: 'module' }); + this.worker.onmessage = (event: MessageEvent) => this.onWorker(event.data); + this.post({ + kind: 'init', + clientId: this.clientId, + moduleUrl: new URL(`${base}/${SQLITE3MC_PUBLIC_DIR}/sqlite3.mjs`, location.origin).href, + }); + this.bindLifecycle(); + } + + private bindLifecycle(): void { + // A clean handover when the tab goes. If this doesn't get to run, the lock + // and the handles die with the worker and the next owner retries. + addEventListener('pagehide', (event) => { + if (!event.persisted) void this.release().catch(() => {}); + }); + } + + private post(message: PageToWorker): void { + this.worker?.postMessage(message); + } + + private onWorker(message: WorkerToPage): void { + switch (message.kind) { + case 'owner': + this.isOwner = true; + this.owner = this.clientId; + this.flushOwnerWaiters(); + this.options.onOwner?.(); + break; + case 'result': { + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + clearTimeout(pending.timer); + if (message.error) { + const error = new Error(message.error); + if (message.errorName) error.name = message.errorName; + pending.reject(error); + } else { + pending.resolve(message.result); + } + break; + } + case 'need-auth': + this.options.onNeedAuth?.(message.account); + break; + case 'event': + // The owner's events also reach every other tab over the channel. + this.options.onEvent?.(message.event); + break; + } + } + + private onChannel(message: ChannelMessage): void { + switch (message.kind) { + case 'reply': + if ((message as RpcReply).to === this.clientId) this.settle(message); + break; + case 'event': + if (!this.isOwner) this.options.onEvent?.(message.event); + break; + case 'owner-changed': + this.owner = message.owner; + if (message.owner) this.flushOwnerWaiters(); + break; + default: + break; + } + } + + private settle(message: RpcReply): void { + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + clearTimeout(pending.timer); + if (message.error) pending.reject(new Error(message.error)); + else pending.resolve(message.result); + } + + private flushOwnerWaiters(): void { + for (const waiter of this.ownerWaiters.splice(0)) waiter(); + } + + private track(id: string, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new StoreOwnerGoneError()); + }, timeoutMs); + this.pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timer }); + }); + } + + private async waitForOwner(): Promise { + if (this.owner) return; + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new StoreOwnerGoneError()), OWNER_WAIT_MS); + this.ownerWaiters.push(() => { + clearTimeout(timer); + resolve(); + }); + }); + } + + private async rpc(method: K, params: Parameters[0]): Promise> { + await this.waitForOwner(); + const id = crypto.randomUUID(); + const result = this.track>(id, CALL_TIMEOUT_MS); + this.channel?.postMessage({ kind: 'rpc', id, from: this.clientId, method, params } as ChannelMessage); + return result; + } + + private direct(message: PageToWorker & { id: string }): Promise { + const result = this.track(message.id, CALL_TIMEOUT_MS); + this.post(message); + return result; + } + + // ---- Owner only: these carry keys or manage files ---- + + get ownsStore(): boolean { + return this.isOwner; + } + + openAccount(params: OpenAccountParams): Promise { + return this.direct({ kind: 'open', id: crypto.randomUUID(), params }); + } + + closeAccount(account: string): Promise { + return this.direct({ kind: 'close-account', id: crypto.randomUUID(), account }); + } + + removeAccount(account: string, file: string): Promise { + return this.direct({ kind: 'remove-account', id: crypto.randomUUID(), account, file }); + } + + setAuth(account: string, auth: string | null): void { + this.post({ kind: 'set-auth', account, auth }); + } + + setSession(account: string, session: StoreSession): void { + this.post({ kind: 'set-session', account, session }); + } + + setScope(account: string, scope: StoreScope): void { + this.post({ kind: 'set-scope', account, scope }); + } + + /** Hands the store over: the worker closes everything, then lets go of the lock. */ + async release(): Promise { + if (!this.worker) return; + const worker = this.worker; + if (this.isOwner) { + await this.direct({ kind: 'release', id: crypto.randomUUID() }); + this.isOwner = false; + this.owner = null; + } + worker.terminate(); + this.worker = null; + this.channel?.close(); + this.channel = null; + } + + // ---- Any tab ---- + + answer(account: string, calls: MethodCall[]): Promise { + return this.rpc('answer', { account, calls }); + } + + sync(account: string): Promise { + return this.rpc('sync', { account }); + } + + noteStateChange(account: string, changed: Record>): Promise { + return this.rpc('noteStateChange', { account, changed }); + } + + setOnline(online: boolean): Promise { + return this.rpc('setOnline', { online }); + } + + status(account: string): Promise { + return this.rpc('status', { account }); + } + + area(account: string, name: string, params?: unknown): Promise { + return this.rpc('area', { account, name, params }) as Promise; + } +} diff --git a/lib/store/engine/account-engine.ts b/lib/store/engine/account-engine.ts new file mode 100644 index 000000000..ab3a2b9b5 --- /dev/null +++ b/lib/store/engine/account-engine.ts @@ -0,0 +1,243 @@ +/** + * One local account in the store: its database, its sync runs, its answers. + * It acts as a JMAP client toward the real server and as a JMAP server toward + * the UI. Nothing here touches the DOM, so tests run it on node:sqlite. + */ + +import { MethodError, type AreaCall, type MethodHandler, type StoreArea, type StoreContext, type StoreEvent, type StoreScope, type SyncContext, type SyncStateRow, type Syncer } from '../area'; +import { migrate, type StorageAdapter } from '../storage'; +import { answerRequest, buildMethodTable } from './responder'; +import { TransportError, type JmapResponse, type JmapTransport, type MethodCall, type StoreSession } from './transport'; +import { CORE_MIGRATIONS, metaGet, metaSet } from './core-schema'; + +export interface AccountEngineOptions { + account: string; + db: StorageAdapter; + session: StoreSession; + transport: JmapTransport; + areas: readonly StoreArea[]; + scope: StoreScope; + invertedKeywordSort?: boolean; + emit?: (event: StoreEvent) => void; + now?: () => number; + /** Waits before a retry after the server said it's busy. Tests pass a stub. */ + sleep?: (ms: number) => Promise; +} + +const RATE_RETRIES = 5; +const RATE_BACKOFF_MS = 2_000; + +export class AccountEngine { + readonly account: string; + private readonly db: StorageAdapter; + private session: StoreSession; + private scope: StoreScope; + private readonly transport: JmapTransport; + private readonly syncers: readonly Syncer[]; + private readonly methods: ReadonlyMap; + private readonly calls = new Map(); + private readonly emitter: (event: StoreEvent) => void; + private readonly now: () => number; + private readonly sleep: (ms: number) => Promise; + private invertedKeywordSort: boolean; + private closed = false; + private online = true; + private running: Promise | null = null; + private again = false; + + constructor(options: AccountEngineOptions) { + this.account = options.account; + this.db = options.db; + this.session = options.session; + this.scope = options.scope; + this.transport = options.transport; + this.syncers = options.areas.flatMap((area) => area.syncers); + this.methods = buildMethodTable(options.areas); + for (const area of options.areas) { + for (const [name, call] of Object.entries(area.calls ?? {})) { + if (this.calls.has(name)) throw new Error(`Two store areas define ${name}`); + this.calls.set(name, call); + } + } + this.emitter = options.emit ?? (() => {}); + this.now = options.now ?? Date.now; + this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); + this.invertedKeywordSort = options.invertedKeywordSort ?? false; + migrate(this.db, [...CORE_MIGRATIONS, ...options.areas.flatMap((area) => area.migrations)]); + metaSet(this.db, 'session', JSON.stringify(options.session)); + } + + /** The session kept from the last time, for a start without a network. */ + static storedSession(db: StorageAdapter): StoreSession | null { + migrate(db, CORE_MIGRATIONS); + const raw = metaGet(db, 'session'); + return raw ? JSON.parse(raw) as StoreSession : null; + } + + setSession(session: StoreSession): void { + this.session = session; + metaSet(this.db, 'session', JSON.stringify(session)); + } + + setScope(scope: StoreScope): void { + this.scope = scope; + metaSet(this.db, 'scope', JSON.stringify(scope)); + } + + setInvertedKeywordSort(inverted: boolean): void { + this.invertedKeywordSort = inverted; + } + + setOnline(online: boolean): void { + this.online = online; + if (online) this.requestSync(); + } + + private syncState(jmap: string, type: string): SyncStateRow { + const row = this.db.get<{ state: string | null; target: string | null; phase: string | null; cursor: string | null }>( + 'SELECT state, target, phase, cursor FROM sync_state WHERE jmap = ? AND type = ?', + [jmap, type], + ); + return { + state: row?.state ?? null, + target: row?.target ?? null, + phase: row?.phase ?? null, + cursor: row?.cursor ? JSON.parse(row.cursor) as Record : null, + }; + } + + private setSyncState(jmap: string, type: string, patch: Partial): void { + const row = { ...this.syncState(jmap, type), ...patch }; + this.db.run( + `INSERT INTO sync_state (jmap, type, state, target, phase, cursor, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (jmap, type) DO UPDATE SET state = excluded.state, target = excluded.target, phase = excluded.phase, + cursor = excluded.cursor, updated_at = excluded.updated_at`, + [jmap, type, row.state, row.target, row.phase, row.cursor ? JSON.stringify(row.cursor) : null, this.now()], + ); + } + + /** What the server pushed: jmap account id to { type: state }. */ + noteStateChange(changed: Record>): void { + this.db.transaction(() => { + for (const [jmap, types] of Object.entries(changed)) { + for (const [type, state] of Object.entries(types)) this.setSyncState(jmap, type, { target: state }); + } + }); + this.requestSync(); + } + + /** Starts a run, or asks the running one to go again when it's done. */ + requestSync(): Promise { + if (this.closed) return Promise.resolve(); + if (this.running) { + this.again = true; + return this.running; + } + this.running = this.runLoop().finally(() => { + this.running = null; + }); + return this.running; + } + + private async runLoop(): Promise { + do { + this.again = false; + await this.runOnce(); + } while (this.again && !this.closed && this.online); + } + + private async runOnce(): Promise { + if (!this.online) return; + const ctx = this.syncContext(); + const jmapAccounts = Object.keys(this.session.accounts); + for (const syncer of this.syncers) { + for (const jmap of jmapAccounts) { + const capabilities = this.session.accounts[jmap].accountCapabilities ?? this.session.capabilities; + if (!(syncer.capability in capabilities)) continue; + if (this.closed || !this.online) return; + if (!(await this.runSyncer(ctx, syncer, jmap))) return; + } + } + if (!this.closed && this.online) this.emitter({ type: 'synced', account: this.account, at: this.now() }); + } + + /** False when the run should end: no network, no credentials. */ + private async runSyncer(ctx: SyncContext, syncer: Syncer, jmap: string): Promise { + for (let attempt = 0; ; attempt += 1) { + try { + await syncer.run(ctx, jmap); + return true; + } catch (error) { + if (error instanceof TransportError) { + if (error.kind === 'network' || error.kind === 'auth') return false; + if (error.kind === 'rate' && attempt < RATE_RETRIES) { + await this.sleep(error.retryAfterMs ?? RATE_BACKOFF_MS * 2 ** attempt); + continue; + } + } + // One part failing doesn't hold up the others. It runs again next time. + this.emitter({ + type: 'error', + account: this.account, + area: syncer.name, + message: error instanceof Error ? error.message : String(error), + }); + return true; + } + } + } + + private syncContext(): SyncContext { + return { + account: this.account, + db: this.db, + session: this.session, + scope: this.scope, + transport: this.transport, + now: this.now, + emit: this.emitter, + stopped: () => this.closed || !this.online, + syncState: (jmap, type) => this.syncState(jmap, type), + setSyncState: (jmap, type, row) => this.setSyncState(jmap, type, row), + }; + } + + private storeContext(): StoreContext { + return { + account: this.account, + db: this.db, + session: this.session, + scope: this.scope, + now: this.now, + emit: this.emitter, + }; + } + + /** A non-JMAP call into one of the areas. */ + async areaCall(name: string, params: unknown): Promise { + const call = this.calls.get(name); + if (!call) throw new Error(`Unknown store call ${name}`); + return call(this.syncContext(), params as never); + } + + /** An answer from the device. Null when nothing in the request could be answered. */ + async answer(calls: MethodCall[]): Promise { + if (this.closed) throw new MethodError('serverUnavailable', 'The local store is closed'); + const primary = this.session.primaryAccounts['urn:ietf:params:jmap:mail'] ?? Object.keys(this.session.accounts)[0]; + return answerRequest( + { + ...this.storeContext(), + defaultJmap: primary, + invertedKeywordSort: this.invertedKeywordSort, + }, + this.methods, + calls, + ); + } + + /** Stops syncing and waits for the current batch. The database stays open for the caller to close. */ + async close(): Promise { + this.closed = true; + await this.running?.catch(() => {}); + } +} diff --git a/lib/store/engine/changes.ts b/lib/store/engine/changes.ts new file mode 100644 index 000000000..a14b7ca17 --- /dev/null +++ b/lib/store/engine/changes.ts @@ -0,0 +1,101 @@ +/** + * The /changes loop, written for how servers really answer (see + * local-data/desktop-offline-facts-parts/stalwart-changes-prf.md): + * + * - sinceState goes last: on Stalwart a malformed state followed by more + * arguments is an HTTP 400 that fails the whole request. + * - hasMoreChanges false doesn't mean caught up. A page walks a fixed range of + * the log, so the loop also runs while newState differs from the state the + * server last pushed. + * - An unusable state comes as cannotCalculateChanges, invalidArguments or that + * HTTP 400. All three mean: start this type over. + */ + +import { JmapMethodError, responseFor, TransportError, type JmapTransport } from './transport'; + +export interface ChangesPage { + created: string[]; + updated: string[]; + destroyed: string[]; + newState: string; + /** Set when only these properties changed (Mailbox counts). */ + updatedProperties: string[] | null; +} + +export interface ChangesLoopOptions { + transport: JmapTransport; + type: string; + jmap: string; + using: string[]; + since: string; + /** The newest state the server pushed, if any. */ + target?: string | null; + maxChanges?: number; + stopped?: () => boolean; + /** Stores one page and its newState. Runs to completion before the next call. */ + apply(page: ChangesPage): Promise; +} + +export type ChangesOutcome = + | { outcome: 'caughtUp'; state: string; pages: number } + | { outcome: 'stopped'; state: string; pages: number } + | { outcome: 'resync'; state: string; pages: number }; + +const MAX_PAGES_PAST_TARGET = 3; + +/** A stored state a server can't have sent: don't send it, it can fail the whole request. */ +export function isUsableState(state: unknown): state is string { + return typeof state === 'string' && state.length > 0 && state.length < 1024 && /^[\w.~+=:/-]+$/.test(state); +} + +function isStateRefusal(error: unknown): boolean { + if (error instanceof JmapMethodError) { + return error.type === 'cannotCalculateChanges' || error.type === 'invalidArguments'; + } + if (error instanceof TransportError) { + return error.kind === 'http' && error.status === 400 && /notRequest/.test(error.problem ?? ''); + } + return false; +} + +export async function runChangesLoop(options: ChangesLoopOptions): Promise { + const { transport, type, jmap, using, target } = options; + const maxChanges = options.maxChanges ?? 500; + let state = options.since; + let pages = 0; + let pastTarget = 0; + if (!isUsableState(state)) return { outcome: 'resync', state, pages }; + for (;;) { + if (options.stopped?.()) return { outcome: 'stopped', state, pages }; + let page: ChangesPage & { hasMoreChanges: boolean }; + try { + const response = await transport.request( + [[`${type}/changes`, { accountId: jmap, maxChanges, sinceState: state }, 'c']], + using, + ); + const [, result] = responseFor(response, 'c'); + page = { + created: result.created ?? [], + updated: result.updated ?? [], + destroyed: result.destroyed ?? [], + newState: String(result.newState), + updatedProperties: Array.isArray(result.updatedProperties) ? result.updatedProperties : null, + hasMoreChanges: Boolean(result.hasMoreChanges), + }; + } catch (error) { + if (isStateRefusal(error)) return { outcome: 'resync', state, pages }; + throw error; + } + const before = state; + await options.apply(page); + state = page.newState; + pages += 1; + if (page.hasMoreChanges) continue; + if (!target || state === target) return { outcome: 'caughtUp', state, pages }; + // Behind the pushed state: the log moved on while we paged. A state that + // stands still means the server has nothing more for this type. + if (state === before) return { outcome: 'caughtUp', state, pages }; + pastTarget += 1; + if (pastTarget >= MAX_PAGES_PAST_TARGET) return { outcome: 'caughtUp', state, pages }; + } +} diff --git a/lib/store/engine/core-schema.ts b/lib/store/engine/core-schema.ts new file mode 100644 index 000000000..938f4baa2 --- /dev/null +++ b/lib/store/engine/core-schema.ts @@ -0,0 +1,57 @@ +import type { Migration, StorageAdapter } from '../storage'; + +export const CORE_MIGRATIONS: readonly Migration[] = [ + { + id: 1, + name: 'core', + sql: ` + CREATE TABLE meta (k TEXT PRIMARY KEY, v TEXT) WITHOUT ROWID; + CREATE TABLE sync_state ( + jmap TEXT NOT NULL, + type TEXT NOT NULL, + state TEXT, + target TEXT, + phase TEXT, + cursor TEXT, + updated_at INTEGER NOT NULL, + PRIMARY KEY (jmap, type) + ) WITHOUT ROWID; + CREATE TABLE object ( + jmap TEXT NOT NULL, + type TEXT NOT NULL, + id TEXT NOT NULL, + json TEXT NOT NULL, + PRIMARY KEY (jmap, type, id) + ) WITHOUT ROWID; + `, + }, +]; + +export function metaGet(db: StorageAdapter, key: string): string | null { + return db.get<{ v: string | null }>('SELECT v FROM meta WHERE k = ?', [key])?.v ?? null; +} + +export function metaSet(db: StorageAdapter, key: string, value: string | null): void { + db.run('INSERT INTO meta (k, v) VALUES (?, ?) ON CONFLICT (k) DO UPDATE SET v = excluded.v', [key, value]); +} + +export function putObject(db: StorageAdapter, jmap: string, type: string, id: string, value: unknown): void { + db.run( + 'INSERT INTO object (jmap, type, id, json) VALUES (?, ?, ?, ?) ON CONFLICT (jmap, type, id) DO UPDATE SET json = excluded.json', + [jmap, type, id, JSON.stringify(value)], + ); +} + +export function deleteObject(db: StorageAdapter, jmap: string, type: string, id: string): void { + db.run('DELETE FROM object WHERE jmap = ? AND type = ? AND id = ?', [jmap, type, id]); +} + +export function getObject>(db: StorageAdapter, jmap: string, type: string, id: string): T | null { + const row = db.get<{ json: string }>('SELECT json FROM object WHERE jmap = ? AND type = ? AND id = ?', [jmap, type, id]); + return row ? JSON.parse(row.json) as T : null; +} + +export function listObjects>(db: StorageAdapter, jmap: string, type: string): T[] { + return db.all<{ json: string }>('SELECT json FROM object WHERE jmap = ? AND type = ?', [jmap, type]) + .map((row) => JSON.parse(row.json) as T); +} diff --git a/lib/store/engine/responder.ts b/lib/store/engine/responder.ts new file mode 100644 index 000000000..be03dc5db --- /dev/null +++ b/lib/store/engine/responder.ts @@ -0,0 +1,89 @@ +/** + * Answers a JMAP request from the local store: the store acting as a server + * toward the UI. Every call gets its own answer, in order, as a server would: + * one the store can't answer gets a per-call error and the rest still work. + */ + +import { MethodError, type MethodContext, type MethodHandler, type MethodResult, type StoreArea } from '../area'; +import type { JmapResponse, MethodCall, MethodResponse } from './transport'; + +class BadReference extends Error {} + +/** RFC 8620 §3.7 result references, with the `*` array step. */ +export function resolvePointer(value: unknown, path: string): unknown { + const steps = path.split('/').slice(1).map((s) => s.replace(/~1/g, '/').replace(/~0/g, '~')); + const walk = (node: unknown, i: number): unknown => { + if (i === steps.length) return node; + const step = steps[i]; + if (step === '*') { + if (!Array.isArray(node)) throw new BadReference(); + return node.flatMap((item) => { + const out = walk(item, i + 1); + return Array.isArray(out) ? out : [out]; + }); + } + if (node === null || typeof node !== 'object') throw new BadReference(); + return walk((node as Record)[step], i + 1); + }; + return walk(value, 0); +} + +function resolveArgs(args: Record, done: readonly MethodResponse[]): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(args)) { + if (!key.startsWith('#')) { + out[key] = value; + continue; + } + const ref = value as { resultOf?: string; name?: string; path?: string }; + const source = done.find(([name, , id]) => id === ref.resultOf && name === ref.name); + if (!source || typeof ref.path !== 'string') throw new BadReference(); + out[key.slice(1)] = resolvePointer(source[1], ref.path); + } + return out; +} + +export function buildMethodTable(areas: readonly StoreArea[]): Map { + const table = new Map(); + for (const area of areas) { + for (const [name, handler] of Object.entries(area.methods)) { + if (table.has(name)) throw new Error(`Two store areas answer ${name}`); + table.set(name, handler); + } + } + return table; +} + +/** Answers are copied by structured clone on the way to the page, so callers can't mutate stored objects. */ +export async function answerRequest( + ctx: Omit, + methods: ReadonlyMap, + calls: readonly MethodCall[], +): Promise { + const done: MethodResponse[] = []; + const full: MethodContext = { ...ctx, createdIds: new Map() }; + let answered = 0; + for (const call of calls) { + const [name, rawArgs, callId] = call; + const handler = methods.get(name); + if (!handler) { + done.push(['error', { type: 'serverUnavailable', description: `${name} needs the server` }, callId]); + continue; + } + try { + const args = resolveArgs(rawArgs, done); + const result: MethodResult = await handler(full, args, call); + done.push([name, result, callId]); + answered += 1; + } catch (error) { + if (error instanceof BadReference) { + done.push(['error', { type: 'invalidResultReference' }, callId]); + } else if (error instanceof MethodError) { + done.push(['error', { type: error.type, description: error.description }, callId]); + } else { + throw error; + } + } + } + return answered === 0 ? null : { methodResponses: done }; +} diff --git a/lib/store/engine/transport.ts b/lib/store/engine/transport.ts new file mode 100644 index 000000000..cc55ff300 --- /dev/null +++ b/lib/store/engine/transport.ts @@ -0,0 +1,142 @@ +/** + * The engine's way to the real JMAP server. Bulk sync runs in the worker with + * its own fetch, so large JSON isn't parsed on the main thread. Auth stays + * with the page: it hands over the header and refreshes it on request. + */ + +export type MethodCall = [string, Record, string]; +// Shaped like the server's answer (any per method, as the callers read it). +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type MethodResponse = [string, any, string]; + +export interface JmapResponse { + methodResponses: MethodResponse[]; + sessionState?: string; +} + +/** The parts of a JMAP session the engine uses. */ +export interface StoreSession { + apiUrl: string; + downloadUrl: string; + uploadUrl: string; + primaryAccounts: Record; + accounts: Record }>; + capabilities: Record; +} + +export type TransportFailure = 'network' | 'auth' | 'rate' | 'http'; + +export class TransportError extends Error { + constructor( + readonly kind: TransportFailure, + message: string, + readonly status?: number, + /** The `type` of an RFC 7807 problem body, when there was one. */ + readonly problem?: string, + readonly retryAfterMs?: number, + ) { + super(message); + this.name = 'TransportError'; + } +} + +export interface JmapTransport { + request(calls: MethodCall[], using: string[]): Promise; + download(jmap: string, blobId: string, type: string, name: string): Promise; + upload(jmap: string, data: Uint8Array, type: string): Promise<{ blobId: string; type: string; size: number }>; +} + +export interface FetchTransportOptions { + session(): StoreSession; + /** The current Authorization header value, or null when the page has none yet. */ + auth(): string | null; + /** Asks the page for a fresh header; resolves null when it can't get one. */ + refreshAuth(): Promise; + fetch?: typeof fetch; +} + +const REQUEST_TIMEOUT_MS = 120_000; + +function fillTemplate(template: string, values: Record): string { + return template.replace(/\{(\w+)\}/g, (whole, key: string) => (key in values ? encodeURIComponent(values[key]) : whole)); +} + +export class FetchTransport implements JmapTransport { + constructor(private readonly options: FetchTransportOptions) {} + + private async fetchWithAuth(url: string, init: RequestInit): Promise { + const doFetch = this.options.fetch ?? fetch; + const send = async (header: string | null): Promise => { + if (!header) throw new TransportError('auth', 'No credentials yet'); + const signal = AbortSignal.timeout(REQUEST_TIMEOUT_MS); + try { + return await doFetch(url, { ...init, signal, headers: { ...init.headers, Authorization: header } }); + } catch (error) { + throw new TransportError('network', error instanceof Error ? error.message : 'Network failure'); + } + }; + let response = await send(this.options.auth()); + if (response.status === 401) { + response = await send(await this.options.refreshAuth()); + if (response.status === 401) throw new TransportError('auth', 'The server refused the credentials', 401); + } + if (response.status === 429 || response.status === 503) { + const seconds = Number(response.headers.get('Retry-After')); + throw new TransportError('rate', `Server busy (${response.status})`, response.status, undefined, Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : undefined); + } + return response; + } + + async request(calls: MethodCall[], using: string[]): Promise { + const response = await this.fetchWithAuth(this.options.session().apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ using, methodCalls: calls }), + }); + if (!response.ok) { + let problem: string | undefined; + try { + problem = (await response.json() as { type?: string }).type; + } catch { + // Not a problem document. + } + throw new TransportError('http', `JMAP request failed: ${response.status}`, response.status, problem); + } + return await response.json() as JmapResponse; + } + + async download(jmap: string, blobId: string, type: string, name: string): Promise { + const url = fillTemplate(this.options.session().downloadUrl, { accountId: jmap, blobId, type, name }); + const response = await this.fetchWithAuth(url, { method: 'GET' }); + if (!response.ok) throw new TransportError('http', `Download failed: ${response.status}`, response.status); + return new Uint8Array(await response.arrayBuffer()); + } + + async upload(jmap: string, data: Uint8Array, type: string): Promise<{ blobId: string; type: string; size: number }> { + const url = fillTemplate(this.options.session().uploadUrl, { accountId: jmap }); + const response = await this.fetchWithAuth(url, { + method: 'POST', + headers: { 'Content-Type': type || 'application/octet-stream' }, + body: data as BodyInit, + }); + if (!response.ok) throw new TransportError('http', `Upload failed: ${response.status}`, response.status); + return await response.json() as { blobId: string; type: string; size: number }; + } +} + +/** Finds the response to one call. Throws if the server answered it with an error. */ +export function responseFor(response: JmapResponse, callId: string): MethodResponse { + const found = response.methodResponses.find(([name, , id]) => id === callId && name !== 'error'); + if (found) return found; + const failed = response.methodResponses.find(([, , id]) => id === callId); + if (failed) throw new JmapMethodError(String(failed[1]?.type ?? 'serverFail'), failed[1]?.description); + throw new JmapMethodError('serverFail', `No response for call ${callId}`); +} + +/** The server answered a call with a method-level error. */ +export class JmapMethodError extends Error { + constructor(readonly type: string, description?: string) { + super(description ? `${type}: ${description}` : type); + this.name = 'JmapMethodError'; + } +} diff --git a/lib/store/mail/index.ts b/lib/store/mail/index.ts new file mode 100644 index 000000000..0c7749864 --- /dev/null +++ b/lib/store/mail/index.ts @@ -0,0 +1,12 @@ +import type { StoreArea } from '../area'; +import { MAIL_METHODS } from './methods'; +import { MAIL_MIGRATIONS } from './schema'; +import { SEARCH_MIGRATIONS } from './search-schema'; +import { mailSyncer } from './sync'; + +export const mailArea: StoreArea = { + name: 'mail', + migrations: [...MAIL_MIGRATIONS, ...SEARCH_MIGRATIONS], + methods: MAIL_METHODS, + syncers: [mailSyncer], +}; diff --git a/lib/store/mail/methods.ts b/lib/store/mail/methods.ts new file mode 100644 index 000000000..1b5c954d9 --- /dev/null +++ b/lib/store/mail/methods.ts @@ -0,0 +1,291 @@ +/** Mail reads answered from the store. */ + +import { MethodError, type MethodContext, type MethodHandler, type MethodResult } from '../area'; +import { listObjects } from '../engine/core-schema'; +import type { SqlValue } from '../storage'; +import { limitTextConditions, searchSnippetGet, textCondition, TEXT_FIELDS, type TextField } from './search'; + +type Filter = Record; + +interface Sql { sql: string; params: SqlValue[] } + +function jmapOf(ctx: MethodContext, args: Record): string { + return (args.accountId as string | undefined) ?? ctx.defaultJmap; +} + +/** + * `drivenBy` is the folder the query already starts from: repeating that + * condition would look the membership up again for every row. + */ +function filterSql(filter: Filter, jmap: string, drivenBy: string | null = null): Sql { + if (typeof filter.operator === 'string') { + const conditions = Array.isArray(filter.conditions) ? filter.conditions as Filter[] : []; + const parts = conditions.map((c) => filterSql(c, jmap, filter.operator === 'AND' ? drivenBy : null)); + const params = parts.flatMap((p) => p.params); + const joined = (joiner: string) => parts.map((p) => `(${p.sql})`).join(joiner); + if (filter.operator === 'AND') return { sql: parts.length ? joined(' AND ') : '1', params }; + if (filter.operator === 'OR') return { sql: parts.length ? joined(' OR ') : '0', params }; + if (filter.operator === 'NOT') return { sql: parts.length ? `NOT (${joined(' OR ')})` : '1', params }; + throw new MethodError('unsupportedFilter', `Unknown operator ${String(filter.operator)}`); + } + const conditions: string[] = []; + const params: SqlValue[] = []; + for (const [key, value] of Object.entries(filter)) { + switch (key) { + case 'inMailbox': + if (value === drivenBy) break; + conditions.push('EXISTS (SELECT 1 FROM email_mailbox m WHERE m.email_rowid = e.rowid AND m.mailbox_id = ?)'); + params.push(String(value)); + break; + case 'inMailboxOtherThan': { + const ids = (value as string[]).map(String); + conditions.push(`EXISTS (SELECT 1 FROM email_mailbox m WHERE m.email_rowid = e.rowid AND m.mailbox_id NOT IN (${ids.map(() => '?').join(',') || "''"}))`); + params.push(...ids); + break; + } + case 'hasKeyword': + conditions.push('EXISTS (SELECT 1 FROM email_keyword k WHERE k.email_rowid = e.rowid AND k.keyword = ?)'); + params.push(String(value).toLowerCase()); + break; + case 'notKeyword': + conditions.push('NOT EXISTS (SELECT 1 FROM email_keyword k WHERE k.email_rowid = e.rowid AND k.keyword = ?)'); + params.push(String(value).toLowerCase()); + break; + case 'after': + conditions.push('e.received_at >= ?'); + params.push(Date.parse(String(value)) || 0); + break; + case 'before': + conditions.push('e.received_at < ?'); + params.push(Date.parse(String(value)) || 0); + break; + case 'minSize': + conditions.push('e.size >= ?'); + params.push(Number(value)); + break; + case 'maxSize': + conditions.push('e.size < ?'); + params.push(Number(value)); + break; + case 'hasAttachment': + conditions.push(value ? 'e.has_attachment = 1' : 'e.has_attachment = 0'); + break; + case 'id': + conditions.push(`e.id IN (${(value as string[]).map(() => '?').join(',') || "''"})`); + params.push(...(value as string[]).map(String)); + break; + default: + if ((TEXT_FIELDS as readonly string[]).includes(key)) { + const text = textCondition(key as TextField, String(value)); + conditions.push(text.sql); + params.push(...text.params); + break; + } + // header and the thread keyword filters: the server's job. + throw new MethodError('unsupportedFilter', `${key} isn't available offline`); + } + } + return { sql: conditions.length ? conditions.join(' AND ') : '1', params }; +} + +interface Comparator { property?: string; isAscending?: boolean; keyword?: string } + +/** + * Newest first reads straight off an index. Any other order has to look at + * every row of the folder, which in a large mailbox takes seconds. + */ +function isNewestFirst(sort: Comparator[]): boolean { + if (sort.length === 0) return true; + return sort.length === 1 && sort[0].property === 'receivedAt' && sort[0].isAscending === false; +} + +function orderSql(ctx: MethodContext, sort: Comparator[]): Sql { + const levels = sort.length > 0 ? sort : [{ property: 'receivedAt', isAscending: false }]; + const parts: string[] = []; + const params: SqlValue[] = []; + for (const level of levels) { + // RFC 8621 says isAscending defaults to true. + let ascending = level.isAscending !== false; + switch (level.property) { + case 'receivedAt': parts.push(`e.received_at ${ascending ? 'ASC' : 'DESC'}`); break; + case 'sentAt': parts.push(`COALESCE(e.sent_at, e.received_at) ${ascending ? 'ASC' : 'DESC'}`); break; + case 'size': parts.push(`e.size ${ascending ? 'ASC' : 'DESC'}`); break; + case 'subject': parts.push(`e.subject_sort COLLATE NOCASE ${ascending ? 'ASC' : 'DESC'}`); break; + case 'from': parts.push(`e.from_sort COLLATE NOCASE ${ascending ? 'ASC' : 'DESC'}`); break; + case 'to': parts.push(`e.to_sort COLLATE NOCASE ${ascending ? 'ASC' : 'DESC'}`); break; + case 'hasKeyword': { + // The client flips this on servers that read it the other way, so a + // stored answer has to read it the way that server would. + if (ctx.invertedKeywordSort) ascending = !ascending; + parts.push(`EXISTS (SELECT 1 FROM email_keyword s WHERE s.email_rowid = e.rowid AND s.keyword = ?) ${ascending ? 'ASC' : 'DESC'}`); + params.push(String(level.keyword ?? '').toLowerCase()); + break; + } + default: + throw new MethodError('unsupportedSort', `${String(level.property)} isn't available offline`); + } + } + parts.push('e.rowid DESC'); + return { sql: parts.join(', '), params }; +} + +/** The one folder a filter limits itself to, so the query can start from that folder's index. */ +function drivingMailbox(filter: Filter | undefined): string | null { + if (!filter) return null; + if (typeof filter.inMailbox === 'string') return filter.inMailbox; + if (filter.operator === 'AND' && Array.isArray(filter.conditions)) { + for (const condition of filter.conditions as Filter[]) { + const found = drivingMailbox(condition); + if (found) return found; + } + } + return null; +} + +const emailQuery: MethodHandler = (ctx, args) => { + const jmap = jmapOf(ctx, args); + const filter = limitTextConditions((args.filter ?? undefined) as Filter | undefined); + if (args.anchor !== undefined) throw new MethodError('unsupportedFilter', 'anchor needs the server'); + const box = drivingMailbox(filter); + const where = filter ? filterSql(filter, jmap, box) : { sql: '1', params: [] }; + const sort = (args.sort as Comparator[]) ?? []; + const order: Sql = !isNewestFirst(sort) + ? orderSql(ctx, sort) + : { sql: box ? 'd.received_at DESC, d.email_rowid DESC' : 'e.received_at DESC, e.rowid DESC', params: [] }; + // Starting from the folder's own index keeps small folders fast in a large account. + const from = box + ? 'email_mailbox d INDEXED BY email_mailbox_list CROSS JOIN email e ON e.rowid = d.email_rowid' + : 'email e'; + const base = box + ? { sql: 'd.jmap = ? AND d.mailbox_id = ?', params: [jmap, box] as SqlValue[] } + : { sql: 'e.jmap = ?', params: [jmap] as SqlValue[] }; + const position = Math.max(0, Number(args.position ?? 0)); + const limit = typeof args.limit === 'number' && args.limit > 0 ? args.limit : -1; + const collapse = Boolean(args.collapseThreads); + const group = collapse ? 'GROUP BY COALESCE(e.thread_id, e.id)' : ''; + const ids = ctx.db.all<{ id: string }>( + `SELECT e.id FROM ${from} WHERE ${base.sql} AND (${where.sql}) ${group} ORDER BY ${order.sql} LIMIT ? OFFSET ?`, + [...base.params, ...where.params, ...order.params, limit, position], + ).map((row) => String(row.id)); + const result: MethodResult = { + accountId: jmap, + canCalculateChanges: false, + // Local answers aren't a position in the server's log, so this is a token only. + queryState: 'local', + position, + ids, + }; + if (args.calculateTotal) { + const count = collapse ? 'COUNT(DISTINCT COALESCE(e.thread_id, e.id))' : 'COUNT(*)'; + // A plain folder counts from its index alone, without reading a message. + const countFrom = box && !collapse && where.sql === '1' ? 'email_mailbox d INDEXED BY email_mailbox_list' : from; + result.total = Number(ctx.db.get<{ n: number }>( + `SELECT ${count} AS n FROM ${countFrom} WHERE ${base.sql} AND (${where.sql})`, + [...base.params, ...where.params], + )?.n ?? 0); + } + return result; +}; + +const BODY_PROPERTIES = new Set(['bodyValues', 'textBody', 'htmlBody', 'bodyStructure', 'headers', 'attachments', 'bcc', 'replyTo', 'messageId', 'inReplyTo', 'references']); + +function pick(email: Record, properties: string[] | undefined): Record { + if (!properties) return email; + const out: Record = { id: email.id }; + for (const property of properties) if (property in email) out[property] = email[property]; + return out; +} + +const emailGet: MethodHandler = (ctx, args) => { + const jmap = jmapOf(ctx, args); + if (!Array.isArray(args.ids)) throw new MethodError('requestTooLarge', 'ids: null needs the server'); + const properties = args.properties as string[] | undefined; + const wantsValues = Boolean(args.fetchTextBodyValues || args.fetchHTMLBodyValues || args.fetchAllBodyValues) || Boolean(properties?.includes('bodyValues')); + const wantsBody = wantsValues || !properties || properties.some((p) => BODY_PROPERTIES.has(p)); + const list: Record[] = []; + const notFound: string[] = []; + for (const id of args.ids as string[]) { + const row = ctx.db.get<{ rowid: number; json: string; body_state: number }>( + 'SELECT rowid, json, body_state FROM email WHERE jmap = ? AND id = ?', + [jmap, id], + ); + if (!row) { + notFound.push(id); + continue; + } + const email = JSON.parse(row.json) as Record; + if (!wantsBody) { + list.push(pick(email, properties)); + continue; + } + const body = ctx.db.get<{ json: string }>('SELECT json FROM email_body WHERE email_rowid = ?', [row.rowid]); + // Asked for the text and it isn't here: as if the message weren't there, not an empty message. + if (!body && wantsValues) { + notFound.push(id); + continue; + } + list.push(pick({ ...(body ? JSON.parse(body.json) as object : {}), ...email }, properties)); + } + const state = ctx.db.get<{ state: string | null }>("SELECT state FROM sync_state WHERE jmap = ? AND type = 'Email'", [jmap])?.state; + return { accountId: jmap, ...(state ? { state } : {}), list, notFound }; +}; + +const threadGet: MethodHandler = (ctx, args) => { + const jmap = jmapOf(ctx, args); + const ids = Array.isArray(args.ids) ? [...new Set(args.ids as string[])] : []; + const list: Array<{ id: string; emailIds: string[] }> = []; + const notFound: string[] = []; + for (const id of ids) { + const stored = ctx.db.get<{ email_ids: string }>('SELECT email_ids FROM thread WHERE jmap = ? AND id = ?', [jmap, id]); + if (stored) { + list.push({ id, emailIds: JSON.parse(stored.email_ids) as string[] }); + continue; + } + const rows = ctx.db.all<{ id: string }>('SELECT id FROM email WHERE jmap = ? AND thread_id = ? ORDER BY received_at ASC', [jmap, id]); + if (rows.length > 0) list.push({ id, emailIds: rows.map((row) => String(row.id)) }); + else notFound.push(id); + } + return { accountId: jmap, list, notFound }; +}; + +const mailboxGet: MethodHandler = (ctx, args) => { + const jmap = jmapOf(ctx, args); + const boxes = listObjects>(ctx.db, jmap, 'Mailbox'); + if (boxes.length === 0) throw new MethodError('serverUnavailable', 'Folders not kept yet'); + const ids = Array.isArray(args.ids) ? new Set(args.ids as string[]) : null; + const chosen = ids ? boxes.filter((box) => ids.has(box.id as string)) : boxes; + const state = ctx.db.get<{ state: string | null }>("SELECT state FROM sync_state WHERE jmap = ? AND type = 'Mailbox'", [jmap])?.state; + return { + accountId: jmap, + ...(state ? { state } : {}), + list: chosen.map((box) => pick(box, args.properties as string[] | undefined)), + notFound: ids ? [...ids].filter((id) => !boxes.some((box) => box.id === id)) : [], + }; +}; + +const mailboxQuery: MethodHandler = (ctx, args) => { + const jmap = jmapOf(ctx, args); + const boxes = listObjects>(ctx.db, jmap, 'Mailbox'); + if (boxes.length === 0) throw new MethodError('serverUnavailable', 'Folders not kept yet'); + const filter = (args.filter ?? {}) as { role?: string }; + const chosen = filter.role ? boxes.filter((box) => box.role === filter.role) : boxes; + const position = typeof args.position === 'number' ? args.position : 0; + const limit = typeof args.limit === 'number' ? args.limit : chosen.length; + return { + accountId: jmap, + queryState: 'local', + canCalculateChanges: false, + position, + ids: chosen.slice(position, position + limit).map((box) => box.id), + ...(args.calculateTotal ? { total: chosen.length } : {}), + }; +}; + +export const MAIL_METHODS: Readonly> = { + 'Email/query': emailQuery, + 'Email/get': emailGet, + 'Thread/get': threadGet, + 'Mailbox/get': mailboxGet, + 'Mailbox/query': mailboxQuery, + 'SearchSnippet/get': searchSnippetGet, +}; diff --git a/lib/store/mail/records.ts b/lib/store/mail/records.ts new file mode 100644 index 000000000..72b2397f8 --- /dev/null +++ b/lib/store/mail/records.ts @@ -0,0 +1,229 @@ +/** How an Email object becomes rows, and back. */ + +import type { StorageAdapter } from '../storage'; + +export const MAIL_USING = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail']; + +export const EMAIL_LIST_PROPERTIES = [ + 'id', 'threadId', 'mailboxIds', 'keywords', 'size', 'receivedAt', 'sentAt', 'from', 'to', 'cc', + 'subject', 'preview', 'hasAttachment', 'blobId', 'header:Authentication-Results:all', +]; + +/** What opening a message asks for, past the list properties. */ +export const EMAIL_BODY_PROPERTIES = [ + 'bcc', 'replyTo', 'textBody', 'htmlBody', 'bodyValues', 'attachments', 'messageId', 'inReplyTo', + 'references', 'headers', 'bodyStructure', +]; + +export type EmailObject = Record & { id: string }; + +interface Address { name?: string | null; email?: string | null } + +function firstAddress(value: unknown): string | null { + const first = Array.isArray(value) ? value[0] as Address | undefined : undefined; + const text = first?.name || first?.email; + return text ? text.toLowerCase() : null; +} + +function addressText(value: unknown): string { + if (!Array.isArray(value)) return ''; + return (value as Address[]).map((a) => [a.name, a.email].filter(Boolean).join(' ')).join(' '); +} + +function time(value: unknown): number | null { + if (typeof value !== 'string') return null; + const ms = Date.parse(value); + return Number.isFinite(ms) ? ms : null; +} + +/** The subject without Re:, Fwd: and the like, as the client sorts it. */ +export function baseSubject(subject: string): string { + return subject.replace(/^(\s*(re|aw|fw|fwd|wg)\s*(\[\d+\])?\s*:\s*)+/i, '').trim().toLowerCase(); +} + +/** Inserts or updates one message from its list properties. Keeps a body already stored. */ +export function upsertEmail(db: StorageAdapter, jmap: string, email: EmailObject, gen: number): number { + const receivedAt = time(email.receivedAt) ?? 0; + const subject = typeof email.subject === 'string' ? email.subject : ''; + const existing = db.get<{ rowid: number }>('SELECT rowid FROM email WHERE jmap = ? AND id = ?', [jmap, email.id]); + const values = [ + typeof email.threadId === 'string' ? email.threadId : null, + receivedAt, + time(email.sentAt), + typeof email.size === 'number' ? email.size : 0, + email.hasAttachment ? 1 : 0, + baseSubject(subject), + firstAddress(email.from), + firstAddress(email.to), + JSON.stringify(email), + gen, + ]; + let rowid: number; + if (existing) { + rowid = Number(existing.rowid); + db.run( + `UPDATE email SET thread_id = ?, received_at = ?, sent_at = ?, size = ?, has_attachment = ?, subject_sort = ?, + from_sort = ?, to_sort = ?, json = ?, gen = ? WHERE rowid = ?`, + [...values, rowid], + ); + db.run('DELETE FROM email_mailbox WHERE email_rowid = ?', [rowid]); + db.run('DELETE FROM email_keyword WHERE email_rowid = ?', [rowid]); + } else { + rowid = db.run( + `INSERT INTO email (thread_id, received_at, sent_at, size, has_attachment, subject_sort, from_sort, to_sort, json, gen, jmap, id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [...values, jmap, email.id], + ).lastInsertRowid; + indexEmail(db, rowid, email, ''); + } + for (const [box, on] of Object.entries((email.mailboxIds ?? {}) as Record)) { + if (on) db.run('INSERT INTO email_mailbox (email_rowid, jmap, mailbox_id, received_at) VALUES (?, ?, ?, ?)', [rowid, jmap, box, receivedAt]); + } + for (const [keyword, on] of Object.entries((email.keywords ?? {}) as Record)) { + if (on) db.run('INSERT INTO email_keyword (email_rowid, keyword) VALUES (?, ?)', [rowid, keyword.toLowerCase()]); + } + return rowid; +} + +export function deleteEmail(db: StorageAdapter, jmap: string, id: string): void { + const row = db.get<{ rowid: number }>('SELECT rowid FROM email WHERE jmap = ? AND id = ?', [jmap, id]); + if (!row) return; + deleteEmailRow(db, Number(row.rowid)); +} + +export function deleteEmailRow(db: StorageAdapter, rowid: number): void { + deleteHeadIndex(db, rowid); + deleteBodyIndex(db, rowid); + // Explicit rather than ON DELETE CASCADE: foreign keys stay off for bulk speed. + db.run('DELETE FROM email_mailbox WHERE email_rowid = ?', [rowid]); + db.run('DELETE FROM email_keyword WHERE email_rowid = ?', [rowid]); + db.run('DELETE FROM email_body WHERE email_rowid = ?', [rowid]); + db.run('DELETE FROM email WHERE rowid = ?', [rowid]); +} + +/** German HTML mail often writes its umlauts as entities: unread, "Müller" would never be found. */ +const NAMED_ENTITIES = new Map(Object.entries({ + amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', shy: '', + auml: 'ä', ouml: 'ö', uuml: 'ü', Auml: 'Ä', Ouml: 'Ö', Uuml: 'Ü', szlig: 'ß', + eacute: 'é', egrave: 'è', agrave: 'à', ccedil: 'ç', ndash: '-', mdash: '-', hellip: '...', euro: '€', + bdquo: '"', ldquo: '"', rdquo: '"', lsquo: "'", rsquo: "'", +})); + +function decodeEntity(whole: string, name: string): string { + if (name[0] !== '#') return NAMED_ENTITIES.get(name) ?? whole; + const code = /^#x/i.test(name) ? parseInt(name.slice(2), 16) : parseInt(name.slice(1), 10); + return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole; +} + +/** More than a server sends for one part. Everything below is only linear up to a point of patience. */ +const RAW_BODY_CHARS = 1_000_000; + +/** + * Takes out comments and style, script and head blocks. A single scan: each + * search for a closer starts past the last one, and a kind of block with no + * closer left isn't looked for again, so hostile HTML that only ever opens them + * can't make this quadratic. An unclosed block stays as text. + */ +function stripBlocks(html: string): string { + const opener = /', opener.lastIndex); + end = closer < 0 ? -1 : closer + '-->'.length; + } else { + const closer = new RegExp(``, 'gi'); + closer.lastIndex = opener.lastIndex; + end = closer.exec(html) ? closer.lastIndex : -1; + } + if (end < 0) { + unclosed.add(name); + continue; + } + kept.push(html.slice(at, match.index)); + at = end; + opener.lastIndex = end; + } + kept.push(html.slice(at)); + return kept.join(' '); +} + +/** Plain text of a message for the search index: the text part, or the HTML part without its tags. */ +export function bodyText(email: Record): string { + const values = (email.bodyValues ?? {}) as Record; + const parts = (key: string) => ((email[key] ?? []) as Array<{ partId?: string; type?: string }>); + const text = parts('textBody') + .filter((part) => part.type !== 'text/html') + .map((part) => values[part.partId ?? '']?.value ?? '') + .join('\n') + .slice(0, RAW_BODY_CHARS); + if (text.trim()) return text; + const html = parts('htmlBody') + .map((part) => values[part.partId ?? '']?.value ?? '') + .join('\n') + .slice(0, RAW_BODY_CHARS); + return stripBlocks(html) + .replace(/<[^<>]*>/g, ' ') + .replace(/&(#\d{1,7}|#x[\da-f]{1,6}|[a-z]{1,31}\d{0,2});/gi, decodeEntity) + .replace(/\s+/g, ' '); +} + +/** A long message past this is rare and only bloats the index. */ +export const INDEX_BODY_CHARS = 200_000; +/** + * Trigrams cost several times what words do, and a term deep in a long mail is + * rarely part of a compound someone remembers. Past this, a mail is found by its + * whole words and word starts only. + */ +export const TRIGRAM_BODY_CHARS = 32 * 1024; + +function deleteHeadIndex(db: StorageAdapter, rowid: number): void { + db.run('DELETE FROM email_fts_word WHERE rowid = ?', [rowid]); + db.run('DELETE FROM email_fts_tri WHERE rowid = ?', [rowid]); +} + +function deleteBodyIndex(db: StorageAdapter, rowid: number): void { + db.run('DELETE FROM email_fts_body_word WHERE rowid = ?', [rowid]); + db.run('DELETE FROM email_fts_body_tri WHERE rowid = ?', [rowid]); +} + +function indexHead(db: StorageAdapter, rowid: number, email: Record): void { + const subject = typeof email.subject === 'string' ? email.subject : ''; + const values = [rowid, subject, addressText(email.from), addressText(email.to), addressText(email.cc), addressText(email.bcc)]; + for (const table of ['email_fts_word', 'email_fts_tri']) { + db.run(`INSERT INTO ${table} (rowid, subject, from_addr, to_addr, cc_addr, bcc_addr) VALUES (?, ?, ?, ?, ?, ?)`, values); + } +} + +function indexBody(db: StorageAdapter, rowid: number, body: string): void { + if (!body) return; + db.run('INSERT INTO email_fts_body_word (rowid, body) VALUES (?, ?)', [rowid, body.slice(0, INDEX_BODY_CHARS)]); + db.run('INSERT INTO email_fts_body_tri (rowid, body) VALUES (?, ?)', [rowid, body.slice(0, TRIGRAM_BODY_CHARS)]); +} + +export function indexEmail(db: StorageAdapter, rowid: number, email: Record, body: string): void { + indexHead(db, rowid, email); + indexBody(db, rowid, body); +} + +export function storeBody(db: StorageAdapter, rowid: number, listJson: string, full: Record): void { + const list = JSON.parse(listJson) as Record; + const body: Record = {}; + for (const [key, value] of Object.entries(full)) if (!(key in list)) body[key] = value; + const hadBody = Number(db.get<{ body_state: number }>('SELECT body_state FROM email WHERE rowid = ?', [rowid])?.body_state) === 1; + db.run('INSERT INTO email_body (email_rowid, json) VALUES (?, ?) ON CONFLICT (email_rowid) DO UPDATE SET json = excluded.json', [rowid, JSON.stringify(body)]); + db.run('UPDATE email SET body_state = 1 WHERE rowid = ?', [rowid]); + // A first body is one insert. Only a body stored again has an old row to replace. + if (hadBody) deleteBodyIndex(db, rowid); + indexBody(db, rowid, bodyText(full)); + // Bcc isn't a list property, so a message that has one gets its head row again. + if (addressText(full.bcc)) { + deleteHeadIndex(db, rowid); + indexHead(db, rowid, { ...list, ...body }); + } +} diff --git a/lib/store/mail/schema.ts b/lib/store/mail/schema.ts new file mode 100644 index 000000000..98f29576d --- /dev/null +++ b/lib/store/mail/schema.ts @@ -0,0 +1,65 @@ +import type { Migration } from '../storage'; + +export const MAIL_MIGRATIONS: readonly Migration[] = [ + { + id: 100, + name: 'mail', + sql: ` + CREATE TABLE email ( + rowid INTEGER PRIMARY KEY, + jmap TEXT NOT NULL, + id TEXT NOT NULL, + thread_id TEXT, + received_at INTEGER NOT NULL, + sent_at INTEGER, + size INTEGER NOT NULL DEFAULT 0, + has_attachment INTEGER NOT NULL DEFAULT 0, + subject_sort TEXT, + from_sort TEXT, + to_sort TEXT, + json TEXT NOT NULL, + -- 0 no body yet, 1 kept, 2 the server had none to give + body_state INTEGER NOT NULL DEFAULT 0, + gen INTEGER NOT NULL DEFAULT 0, + UNIQUE (jmap, id) + ); + CREATE INDEX email_received ON email (jmap, received_at DESC, rowid DESC); + CREATE INDEX email_thread ON email (jmap, thread_id); + CREATE INDEX email_backfill ON email (jmap, body_state, received_at DESC); + CREATE TABLE email_mailbox ( + email_rowid INTEGER NOT NULL REFERENCES email (rowid) ON DELETE CASCADE, + jmap TEXT NOT NULL, + mailbox_id TEXT NOT NULL, + received_at INTEGER NOT NULL, + PRIMARY KEY (email_rowid, mailbox_id) + ) WITHOUT ROWID; + CREATE INDEX email_mailbox_list ON email_mailbox (jmap, mailbox_id, received_at DESC, email_rowid DESC); + CREATE TABLE email_keyword ( + email_rowid INTEGER NOT NULL REFERENCES email (rowid) ON DELETE CASCADE, + keyword TEXT NOT NULL, + PRIMARY KEY (email_rowid, keyword) + ) WITHOUT ROWID; + CREATE INDEX email_keyword_lookup ON email_keyword (keyword, email_rowid); + CREATE TABLE email_body ( + email_rowid INTEGER PRIMARY KEY REFERENCES email (rowid) ON DELETE CASCADE, + json TEXT NOT NULL + ); + CREATE TABLE thread ( + jmap TEXT NOT NULL, + id TEXT NOT NULL, + email_ids TEXT NOT NULL, + PRIMARY KEY (jmap, id) + ) WITHOUT ROWID; + CREATE VIRTUAL TABLE email_fts_word USING fts5( + subject, from_addr, to_addr, body, + content = '', contentless_delete = 1, + tokenize = 'unicode61 remove_diacritics 2' + ); + CREATE VIRTUAL TABLE email_fts_tri USING fts5( + subject, from_addr, to_addr, body, + content = '', contentless_delete = 1, + tokenize = 'trigram remove_diacritics 1' + ); + `, + }, +]; diff --git a/lib/store/mail/search-schema.ts b/lib/store/mail/search-schema.ts new file mode 100644 index 000000000..3c4d943da --- /dev/null +++ b/lib/store/mail/search-schema.ts @@ -0,0 +1,65 @@ +import type { Migration } from '../storage'; + +const INDEX_OPTIONS = "content = '', contentless_delete = 1"; +const WORD = "tokenize = 'unicode61 remove_diacritics 2'"; +const TRIGRAM = "tokenize = 'trigram remove_diacritics 1'"; +const HEAD_COLUMNS = 'subject, from_addr, to_addr, cc_addr, bcc_addr'; + +/** Bytes of pending terms a body index holds before it writes a segment. */ +const BODY_WORD_HASH = 8 * 1024 * 1024; +const BODY_TRIGRAM_HASH = 16 * 1024 * 1024; +const AUTOMERGE = 8; + +function setOption(table: string, option: string, value: number): string { + return `INSERT INTO ${table} (${table}, rank) VALUES ('${option}', ${value});`; +} + +/** + * The final search tables. Migration 100's two indexes held everything, so a + * message went in twice: its head at list time, then a delete and a second + * insert when its body arrived. Now the head tables are written once at list + * time and the body tables once at body time. Only a message with Bcc touches + * its head row again, since Bcc isn't known before the body. To, Cc and Bcc + * have their own columns: `to` used to find Cc and Bcc too, and `cc` (the + * client's recipient search asks for it) had no answer. + * + * Options for the bulk writes of a first sync, which trade memory and some + * reading speed for less writing: + * - `hashsize` on the body tables: FTS5 writes a new segment when this much is + * pending (1 MiB by default), and at every commit. A larger value turns a + * batch of bodies into one segment, not many that have to be merged later. + * The memory is only held during a batch. Trigrams produce the most terms. + * - `automerge` 8 (4 by default): merge when eight segments of a size exist, so + * each term is rewritten less often on its way up. Queries read a few more + * segments until then, and `crisismerge` (16) still bounds how many. + * + * FTS5 can't add a column and contentless tables have nothing to copy, so the + * mail syncs again to fill them. No released version has data here: this only + * touches a development database. + */ +export const SEARCH_MIGRATIONS: readonly Migration[] = [ + { + id: 300, + name: 'search-indexes', + sql: ` + DROP TABLE email_fts_word; + DROP TABLE email_fts_tri; + CREATE VIRTUAL TABLE email_fts_word USING fts5(${HEAD_COLUMNS}, ${INDEX_OPTIONS}, ${WORD}); + CREATE VIRTUAL TABLE email_fts_tri USING fts5(${HEAD_COLUMNS}, ${INDEX_OPTIONS}, ${TRIGRAM}); + CREATE VIRTUAL TABLE email_fts_body_word USING fts5(body, ${INDEX_OPTIONS}, ${WORD}); + CREATE VIRTUAL TABLE email_fts_body_tri USING fts5(body, ${INDEX_OPTIONS}, ${TRIGRAM}); + ${setOption('email_fts_body_word', 'hashsize', BODY_WORD_HASH)} + ${setOption('email_fts_body_tri', 'hashsize', BODY_TRIGRAM_HASH)} + ${setOption('email_fts_word', 'automerge', AUTOMERGE)} + ${setOption('email_fts_tri', 'automerge', AUTOMERGE)} + ${setOption('email_fts_body_word', 'automerge', AUTOMERGE)} + ${setOption('email_fts_body_tri', 'automerge', AUTOMERGE)} + DELETE FROM email_keyword; + DELETE FROM email_mailbox; + DELETE FROM email_body; + DELETE FROM thread; + DELETE FROM email; + DELETE FROM sync_state WHERE type IN ('Email', 'Thread'); + `, + }, +]; diff --git a/lib/store/mail/search.ts b/lib/store/mail/search.ts new file mode 100644 index 000000000..f90c9b062 --- /dev/null +++ b/lib/store/mail/search.ts @@ -0,0 +1,407 @@ +/** + * Full-text matching and SearchSnippet/get over the two FTS indexes. + * + * What a search value means here (the shapes buildJMAPFilter sends): + * - `rechnung`: a word of three or more characters is found anywhere, also + * inside a longer word, through the trigram index ("Rechnung" in + * "Zahlungserinnerungsrechnung"). + * - `zu`: one or two characters can't use trigrams, so only the whole word + * matches, through the word index. "zu" doesn't find "zum". + * - `rech*`: the start of a word, through the word index. It needs two + * characters: `a*` is the word "a", since reading every word that starts + * with one letter is the most expensive thing FTS5 can be asked. + * - `"annual report"`: these exact words in this order, through the word index. + * - Several words have to all match, each in any field `text` covers. That's a + * little wider than Stalwart, which wants them in one field. + * - Case and diacritics don't count: Müller and muller find each other. + * + * Input never reaches FTS5 as syntax: every term goes in as a quoted string, so + * `"`, `*`, `-`, `:` and parentheses are text. A term without a letter or digit + * has nothing to search for and is left out; a value left with no terms matches + * nothing, because leaving it out would widen the search to every message. + * + * Limits: MAX_TERMS per value, MAX_TERM_CHARS per term, MAX_TEXT_CONDITIONS per + * filter. Past them the extra terms are ignored, which only widens the result, + * so a pasted page of text still gets an answer and can't keep the worker busy. + * A very common word costs one read of its list in each index, at most the size + * of the account. Body text past 32 KB has no trigrams (see records.ts), so deep + * in a long mail only whole words and word starts are found. + */ + +import { MethodError, type MethodHandler } from '../area'; +import type { SqlValue } from '../storage'; +import { bodyText, INDEX_BODY_CHARS } from './records'; + +export const TEXT_FIELDS = ['text', 'subject', 'from', 'to', 'cc', 'bcc', 'body'] as const; +export type TextField = (typeof TEXT_FIELDS)[number]; + +const HEAD_COLUMNS: Partial> = { + subject: 'subject', + from: 'from_addr', + to: 'to_addr', + cc: 'cc_addr', + bcc: 'bcc_addr', +}; + +const HEAD_TABLES = { word: 'email_fts_word', tri: 'email_fts_tri' }; +const BODY_TABLES = { word: 'email_fts_body_word', tri: 'email_fts_body_tri' }; + +const MAX_INPUT_CHARS = 8192; +const MAX_TERMS = 16; +const MAX_TERM_CHARS = 200; +const MAX_TEXT_CONDITIONS = 32; +const MIN_TRIGRAM_CHARS = 3; +const MIN_PREFIX_CHARS = 2; +const MIN_PLAIN_SPELLING_CHARS = 5; +const MAX_SNIPPET_IDS = 500; +const MAX_HIGHLIGHT_PATTERNS = 64; +const MAX_SNIPPET_SUBJECT_CHARS = 1024; +/** RFC 8621 §5: a preview is at most 255 octets. */ +const PREVIEW_BYTES = 255; +const PREVIEW_CONTEXT_CHARS = 40; + +const WORD_CHAR = '[\\p{L}\\p{N}\\p{M}]'; +const NOT_WORD_CHAR = '[^\\p{L}\\p{N}\\p{M}]'; +const EDGE_PUNCTUATION = new RegExp(`^${NOT_WORD_CHAR}+|${NOT_WORD_CHAR}+$`, 'gu'); + +interface Term { + needle: string; + kind: 'word' | 'prefix' | 'phrase'; +} + +/** One lookup in one index. A term matches when any of its lookups does. */ +interface Probe { + index: 'word' | 'tri'; + text: string; + prefix: boolean; +} + +function chars(text: string): number { + return [...text].length; +} + +function parseTerms(input: string): Term[] { + const clean = input.slice(0, MAX_INPUT_CHARS).normalize('NFC').replace(/\p{Cc}/gu, ' '); + const terms: Term[] = []; + for (const match of clean.matchAll(/"([^"]*)"|(\S+)/g)) { + const quoted = match[1] !== undefined; + const raw = match[1] ?? match[2]; + const needle = [...raw.replace(EDGE_PUNCTUATION, '')].slice(0, MAX_TERM_CHARS).join(''); + if (!needle) continue; + terms.push({ needle, kind: quoted ? 'phrase' : raw.endsWith('*') ? 'prefix' : 'word' }); + if (terms.length === MAX_TERMS) break; + } + return terms; +} + +/** + * The other German spellings of a term, worked out when searching so the + * indexes stay as they are. The indexes store ü as u, so: + * - Müller also looks for "mueller", the way the term itself is looked for; + * - Mueller also looks for "muller", as a whole word only: Treue must not find + * "Betreuung" through "treu". Short words stay out for the same reason (Poet, + * blue, neue). + * ß and ss work the same way. + */ +function spellings(needle: string): { written: string | null; plain: string | null } { + const lower = needle.toLowerCase(); + const written = lower.replace(/[äöüß]/g, (c) => ({ ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' })[c] as string); + const plain = chars(lower) >= MIN_PLAIN_SPELLING_CHARS + ? lower.replace(/ae|oe|ue/g, (pair) => pair[0]).replace(/ss/g, 'ß') + : lower; + return { written: written === lower ? null : written, plain: plain === lower ? null : plain }; +} + +function probesFor(term: Term): Probe[] { + const probes: Probe[] = []; + const addWord = (text: string) => { + probes.push({ index: 'word', text, prefix: term.kind === 'prefix' && chars(text) >= MIN_PREFIX_CHARS }); + }; + const addAll = (text: string) => { + addWord(text); + if (term.kind === 'word' && chars(text) >= MIN_TRIGRAM_CHARS) probes.push({ index: 'tri', text, prefix: false }); + }; + addAll(term.needle); + const { written, plain } = spellings(term.needle); + if (written) addAll(written); + if (plain) addWord(plain); + return probes; +} + +function quote(term: string): string { + return `"${term.replace(/"/g, '""')}"`; +} + +/** SQL for a text condition, as a condition on `e.rowid`. Every term has to match. */ +export function textCondition(field: TextField, input: string): { sql: string; params: SqlValue[] } { + const terms = parseTerms(input); + if (terms.length === 0) return { sql: '0', params: [] }; + const column = HEAD_COLUMNS[field]; + const scope = column ? `${column} : ` : ''; + const tables = field === 'text' ? [HEAD_TABLES, BODY_TABLES] : field === 'body' ? [BODY_TABLES] : [HEAD_TABLES]; + const conditions: string[] = []; + const params: SqlValue[] = []; + for (const term of terms) { + const selects: string[] = []; + for (const probe of probesFor(term)) { + for (const pair of tables) { + const table = pair[probe.index]; + selects.push(`SELECT rowid FROM ${table} WHERE ${table} MATCH ?`); + params.push(`${scope}${quote(probe.text)}${probe.prefix ? '*' : ''}`); + } + } + conditions.push(`e.rowid IN (${selects.join(' UNION ')})`); + } + return { sql: conditions.join(' AND '), params }; +} + +// ---- Limiting a whole filter ---- + +type Filter = Record; + +function isOperator(filter: Filter): boolean { + return typeof filter.operator === 'string'; +} + +function conditionsOf(filter: Filter): Filter[] { + return Array.isArray(filter.conditions) ? filter.conditions as Filter[] : []; +} + +function textKeysOf(filter: Filter): TextField[] { + return TEXT_FIELDS.filter((field) => field in filter); +} + +function countTextConditions(filter: Filter): number { + if (isOperator(filter)) return conditionsOf(filter).reduce((sum, condition) => sum + countTextConditions(condition), 0); + return textKeysOf(filter).length; +} + +/** + * Keeps a filter within MAX_TEXT_CONDITIONS. Under AND a text condition past + * the limit is dropped, which only widens the result. Under OR or NOT that + * would narrow it or turn it around, so such a branch has to fit or the call + * gets an error. + */ +export function limitTextConditions(filter: Filter | undefined): Filter | undefined { + if (!filter) return filter; + let left = MAX_TEXT_CONDITIONS; + const walk = (node: Filter): Filter | null => { + if (isOperator(node)) { + if (node.operator === 'AND') { + const kept = conditionsOf(node).map(walk).filter((condition): condition is Filter => condition !== null); + return kept.length > 0 || conditionsOf(node).length === 0 ? { ...node, conditions: kept } : null; + } + const count = countTextConditions(node); + if (count > left) throw new MethodError('unsupportedFilter', 'This search has too many terms'); + left -= count; + return node; + } + const keys = textKeysOf(node); + if (keys.length <= left) { + left -= keys.length; + return node; + } + const kept = { ...node }; + for (const key of keys.slice(left)) delete kept[key]; + left = 0; + return Object.keys(kept).length > 0 ? kept : null; + }; + return walk(filter) ?? {}; +} + +// ---- SearchSnippet/get ---- + +const FOLD_CACHE = new Map(); + +function foldChar(ch: string): string { + const code = ch.charCodeAt(0); + if (code < 128) return code >= 65 && code <= 90 ? String.fromCharCode(code + 32) : ch; + let folded = FOLD_CACHE.get(ch); + if (folded === undefined) { + folded = ch.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase(); + if (FOLD_CACHE.size < 4096) FOLD_CACHE.set(ch, folded); + } + return folded; +} + +function fold(text: string): string { + let out = ''; + for (const ch of text) out += foldChar(ch); + return out; +} + +/** Text without case and diacritics, and where each of its positions came from. */ +function foldWithMap(text: string): { folded: string; map: number[] } { + const map: number[] = []; + let folded = ''; + let at = 0; + for (const ch of text) { + const piece = foldChar(ch); + for (let i = 0; i < piece.length; i += 1) map.push(at); + folded += piece; + at += ch.length; + } + map.push(text.length); + return { folded, map }; +} + +function escapeRegExp(text: string): string { + return text.replace(/[\\^$.*+?()[\]{}|/]/g, '\\$&'); +} + +/** What a probe finds, as a pattern over folded text, so a mark lands where the index matched. */ +function probePattern(probe: Probe): string | null { + const text = fold(probe.text); + if (probe.index === 'tri') return escapeRegExp(text); + const words = text.split(new RegExp(`${NOT_WORD_CHAR}+`, 'u')).filter(Boolean).map(escapeRegExp); + if (words.length === 0) return null; + return `(?(); + for (const term of terms) { + for (const probe of probesFor(term)) { + const pattern = probePattern(probe); + if (pattern) patterns.add(pattern); + } + } + if (patterns.size === 0) return null; + // Longest first, so a match isn't cut short by a shorter spelling of it. + return new RegExp([...patterns].sort((a, b) => b.length - a.length).slice(0, MAX_HIGHLIGHT_PATTERNS).join('|'), 'gu'); +} + +/** The terms a snippet marks. Anything under NOT was searched to be absent, so it isn't marked. */ +function snippetTerms(filter: Filter | null | undefined): { subject: Term[]; body: Term[] } { + const subject: Term[] = []; + const body: Term[] = []; + let left = MAX_TEXT_CONDITIONS; + const walk = (node: Filter) => { + if (isOperator(node)) { + if (node.operator !== 'NOT') conditionsOf(node).forEach(walk); + return; + } + for (const key of ['text', 'subject', 'body'] as const) { + const value = node[key]; + if (typeof value !== 'string' || left === 0) continue; + left -= 1; + const terms = parseTerms(value); + if (key !== 'body') subject.push(...terms); + if (key !== 'subject') body.push(...terms); + } + }; + if (filter) walk(filter); + return { subject, body }; +} + +const ENTITIES: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; + +function escapeHtml(text: string): string { + return text.replace(/[&<>"']/g, (c) => ENTITIES[c]); +} + +function utf8Bytes(text: string): number { + let bytes = 0; + for (const ch of text) { + const code = ch.codePointAt(0) as number; + bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4; + } + return bytes; +} + +/** The whole text, escaped, with its matches marked; null when nothing matches. */ +function markAll(text: string, pattern: RegExp): string | null { + const { folded, map } = foldWithMap(text); + let out = ''; + let at = 0; + for (const match of folded.matchAll(pattern)) { + const start = map[match.index]; + const end = map[match.index + match[0].length]; + out += `${escapeHtml(text.slice(at, start))}${escapeHtml(text.slice(start, end))}`; + at = end; + } + return out === '' ? null : out + escapeHtml(text.slice(at)); +} + +/** A section around the first match, escaped and marked, never past PREVIEW_BYTES; null when nothing matches. */ +function excerpt(raw: string, pattern: RegExp): string | null { + const text = raw.replace(/\s+/g, ' ').trim(); + const { folded, map } = foldWithMap(text); + let out = ''; + let used = 0; + // Stops before the character that wouldn't fit, keeping room for what must still follow. + const put = (segment: string, reserve: number): boolean => { + for (const ch of segment) { + const piece = escapeHtml(ch); + const size = utf8Bytes(piece); + if (used + size + reserve > PREVIEW_BYTES) return false; + out += piece; + used += size; + } + return true; + }; + let at = -1; + for (const match of folded.matchAll(pattern)) { + const start = map[match.index]; + const end = map[match.index + match[0].length]; + if (at < 0) { + const earliest = Math.max(0, start - PREVIEW_CONTEXT_CHARS); + const space = earliest === 0 ? -1 : text.indexOf(' ', earliest); + at = earliest === 0 ? 0 : space >= 0 && space < start ? space + 1 : start; + } + if (!put(text.slice(at, start), 0)) return out; + const before = out; + const usedBefore = used; + out += ''; + used += ''.length; + const opened = out.length; + const whole = put(text.slice(start, end), ''.length); + if (out.length === opened) { + // Not even one character fit: no empty mark. + out = before; + used = usedBefore; + return out; + } + out += ''; + used += ''.length; + if (!whole) return out; + at = end; + } + if (at < 0) return null; + put(text.slice(at), 0); + return out; +} + +/** The indexes are contentless, so snippets come from the stored message itself. */ +export const searchSnippetGet: MethodHandler = (ctx, args) => { + const jmap = (args.accountId as string | undefined) ?? ctx.defaultJmap; + const ids = args.emailIds; + if (!Array.isArray(ids)) throw new MethodError('invalidArguments', 'emailIds is required'); + if (ids.length > MAX_SNIPPET_IDS) throw new MethodError('requestTooLarge', `At most ${MAX_SNIPPET_IDS} messages at a time`); + const terms = snippetTerms(args.filter as Filter | null | undefined); + const subjectPattern = highlighter(terms.subject); + const bodyPattern = highlighter(terms.body); + const list: Array<{ emailId: string; subject: string | null; preview: string | null }> = []; + const notFound: string[] = []; + for (const id of ids as string[]) { + const row = ctx.db.get<{ rowid: number; json: string }>('SELECT rowid, json FROM email WHERE jmap = ? AND id = ?', [jmap, id]); + if (!row) { + notFound.push(id); + continue; + } + const email = JSON.parse(row.json) as { subject?: unknown; preview?: unknown }; + const subject = subjectPattern && typeof email.subject === 'string' + ? markAll(email.subject.slice(0, MAX_SNIPPET_SUBJECT_CHARS), subjectPattern) + : null; + let preview: string | null = null; + if (bodyPattern) { + const body = ctx.db.get<{ json: string }>('SELECT json FROM email_body WHERE email_rowid = ?', [row.rowid]); + // Before its body has arrived, a message only has its preview to show. + const text = body ? bodyText(JSON.parse(body.json) as Record) : ''; + const source = text.trim() ? text.slice(0, INDEX_BODY_CHARS) : typeof email.preview === 'string' ? email.preview : ''; + preview = excerpt(source, bodyPattern); + } + list.push({ emailId: id, subject, preview }); + } + return { accountId: jmap, list, notFound }; +}; diff --git a/lib/store/mail/sync.ts b/lib/store/mail/sync.ts new file mode 100644 index 000000000..30b8c7b7b --- /dev/null +++ b/lib/store/mail/sync.ts @@ -0,0 +1,338 @@ +/** + * Mail sync for one JMAP account. + * + * A full sync captures the state first, then pages through the window, so a + * change that lands during it is picked up by the delta afterwards. Its cursor + * lives in sync_state, written in the same transaction as the page, so a + * reload continues where it stopped. Bodies come after the list, newest first. + */ + +import type { SyncContext, Syncer } from '../area'; +import { deleteObject, putObject } from '../engine/core-schema'; +import { runChangesLoop } from '../engine/changes'; +import { responseFor, type MethodCall } from '../engine/transport'; +import { + deleteEmail, + deleteEmailRow, + EMAIL_BODY_PROPERTIES, + EMAIL_LIST_PROPERTIES, + MAIL_USING, + storeBody, + upsertEmail, + type EmailObject, +} from './records'; + +const PAGE = 500; +const PAIRS_PER_REQUEST = 4; +const BODY_BATCH_IDS = 100; +const BODY_BATCH_BYTES = 8 * 1024 * 1024; +/** Bodies fetched before one commit. Every commit writes search index segments, so fewer is much cheaper. */ +const BODY_REQUESTS_PER_COMMIT = 5; +const MAX_BODY_VALUE_BYTES = 1_000_000; +const DELTA_BATCH = 500; + +function windowFilter(ctx: SyncContext): Record | undefined { + if (ctx.scope.days === null) return undefined; + return { after: new Date(ctx.now() - ctx.scope.days * 86_400_000).toISOString() }; +} + +function inWindow(ctx: SyncContext, email: EmailObject): boolean { + if (ctx.scope.days === null) return true; + return (Date.parse(String(email.receivedAt)) || 0) >= ctx.now() - ctx.scope.days * 86_400_000; +} + +// ---- Mailboxes ---- + +async function fullMailboxes(ctx: SyncContext, jmap: string): Promise { + const response = await ctx.transport.request([['Mailbox/get', { accountId: jmap, ids: null }, 'm']], MAIL_USING); + const [, result] = responseFor(response, 'm'); + ctx.db.transaction(() => { + ctx.db.run("DELETE FROM object WHERE jmap = ? AND type = 'Mailbox'", [jmap]); + for (const box of result.list as Array<{ id: string }>) putObject(ctx.db, jmap, 'Mailbox', box.id, box); + ctx.setSyncState(jmap, 'Mailbox', { state: String(result.state), phase: null, cursor: null }); + }); +} + +async function syncMailboxes(ctx: SyncContext, jmap: string): Promise { + const { state, target } = ctx.syncState(jmap, 'Mailbox'); + if (!state) return fullMailboxes(ctx, jmap); + const outcome = await runChangesLoop({ + transport: ctx.transport, + type: 'Mailbox', + jmap, + using: MAIL_USING, + since: state, + target, + stopped: ctx.stopped, + apply: async (page) => { + const ids = [...page.created, ...page.updated]; + const list: Array<{ id: string }> = []; + for (let i = 0; i < ids.length; i += PAGE) { + const response = await ctx.transport.request([['Mailbox/get', { accountId: jmap, ids: ids.slice(i, i + PAGE) }, 'm']], MAIL_USING); + list.push(...responseFor(response, 'm')[1].list); + } + ctx.db.transaction(() => { + for (const box of list) putObject(ctx.db, jmap, 'Mailbox', box.id, box); + for (const id of page.destroyed) deleteObject(ctx.db, jmap, 'Mailbox', id); + ctx.setSyncState(jmap, 'Mailbox', { state: page.newState }); + }); + }, + }); + if (outcome.outcome === 'resync') await fullMailboxes(ctx, jmap); +} + +// ---- Email list ---- + +interface ListCursor { position: number; gen: number; captured: string } + +/** The generation of the last full listing. One row is enough: every row of a pass has it or an older one. */ +function currentGen(ctx: SyncContext, jmap: string): number { + return Number(ctx.db.get<{ gen: number }>('SELECT gen FROM email WHERE jmap = ? ORDER BY gen DESC LIMIT 1', [jmap])?.gen ?? 0); +} + +async function startFullList(ctx: SyncContext, jmap: string): Promise { + const response = await ctx.transport.request([['Email/get', { accountId: jmap, ids: [], properties: ['id'] }, 's']], MAIL_USING); + const captured = String(responseFor(response, 's')[1].state); + const cursor: ListCursor = { position: 0, gen: currentGen(ctx, jmap) + 1, captured }; + ctx.db.transaction(() => ctx.setSyncState(jmap, 'Email', { state: null, phase: 'list', cursor: { ...cursor } })); + return cursor; +} + +async function runFullList(ctx: SyncContext, jmap: string, start: ListCursor): Promise { + const cursor = { ...start }; + const filter = windowFilter(ctx); + let total: number | null = null; + for (;;) { + if (ctx.stopped()) return false; + const calls: MethodCall[] = []; + for (let i = 0; i < PAIRS_PER_REQUEST; i += 1) { + calls.push(['Email/query', { + accountId: jmap, + ...(filter ? { filter } : {}), + sort: [{ property: 'receivedAt', isAscending: false }], + position: cursor.position + i * PAGE, + limit: PAGE, + calculateTotal: i === 0, + }, `q${i}`]); + calls.push(['Email/get', { + accountId: jmap, + '#ids': { resultOf: `q${i}`, name: 'Email/query', path: '/ids' }, + properties: EMAIL_LIST_PROPERTIES, + }, `g${i}`]); + } + const response = await ctx.transport.request(calls, MAIL_USING); + let done = false; + let got = 0; + const lists: EmailObject[][] = []; + for (let i = 0; i < PAIRS_PER_REQUEST; i += 1) { + const [, query] = responseFor(response, `q${i}`); + if (i === 0 && typeof query.total === 'number') total = query.total; + lists.push(responseFor(response, `g${i}`)[1].list as EmailObject[]); + got += (query.ids as string[]).length; + if ((query.ids as string[]).length < PAGE) { + done = true; + break; + } + } + cursor.position += got; + ctx.db.transaction(() => { + for (const list of lists) for (const email of list) upsertEmail(ctx.db, jmap, email, cursor.gen); + if (done) { + // Whatever this pass didn't see is gone from the server or the window. + const stale = ctx.db.all<{ rowid: number }>('SELECT rowid FROM email WHERE jmap = ? AND gen < ?', [jmap, cursor.gen]); + for (const row of stale) deleteEmailRow(ctx.db, Number(row.rowid)); + ctx.setSyncState(jmap, 'Email', { state: cursor.captured, phase: null, cursor: null }); + } else { + ctx.setSyncState(jmap, 'Email', { cursor: { ...cursor } }); + } + }); + ctx.emit({ type: 'progress', account: ctx.account, area: 'mail', phase: 'list', done: cursor.position, total }); + if (done) { + ctx.emit({ type: 'changed', account: ctx.account, collections: ['Email'] }); + return true; + } + } +} + +async function syncEmailDelta(ctx: SyncContext, jmap: string, since: string, target: string | null): Promise<'done' | 'resync' | 'stopped'> { + const outcome = await runChangesLoop({ + transport: ctx.transport, + type: 'Email', + jmap, + using: MAIL_USING, + since, + target, + stopped: ctx.stopped, + apply: async (page) => { + const ids = [...page.created, ...page.updated]; + const fetched: EmailObject[] = []; + for (let i = 0; i < ids.length; i += DELTA_BATCH) { + const response = await ctx.transport.request([['Email/get', { + accountId: jmap, + ids: ids.slice(i, i + DELTA_BATCH), + properties: EMAIL_LIST_PROPERTIES, + }, 'g']], MAIL_USING); + fetched.push(...responseFor(response, 'g')[1].list as EmailObject[]); + } + const created = new Set(page.created); + const newIds: string[] = []; + ctx.db.transaction(() => { + const gen = fetched.length > 0 ? currentGen(ctx, jmap) : 0; + for (const email of fetched) { + const known = ctx.db.get('SELECT 1 AS x FROM email WHERE jmap = ? AND id = ?', [jmap, email.id]); + // An old message changing doesn't pull it into the window. + if (!known && !inWindow(ctx, email)) continue; + upsertEmail(ctx.db, jmap, email, gen); + if (!known && created.has(email.id)) newIds.push(email.id); + } + for (const id of page.destroyed) deleteEmail(ctx.db, jmap, id); + ctx.setSyncState(jmap, 'Email', { state: page.newState }); + }); + if (newIds.length > 0) ctx.emit({ type: 'created', account: ctx.account, jmap, collection: 'Email', ids: newIds }); + if (ids.length + page.destroyed.length > 0) ctx.emit({ type: 'changed', account: ctx.account, collections: ['Email'] }); + }, + }); + if (outcome.outcome === 'resync') return 'resync'; + return outcome.outcome === 'stopped' ? 'stopped' : 'done'; +} + +// ---- Threads ---- + +async function fetchThreads(ctx: SyncContext, jmap: string, ids: string[]): Promise> { + const out: Array<{ id: string; emailIds: string[] }> = []; + for (let i = 0; i < ids.length; i += PAGE) { + const response = await ctx.transport.request([['Thread/get', { accountId: jmap, ids: ids.slice(i, i + PAGE) }, 't']], MAIL_USING); + out.push(...responseFor(response, 't')[1].list); + } + return out; +} + +async function syncThreads(ctx: SyncContext, jmap: string): Promise { + const { state, target } = ctx.syncState(jmap, 'Thread'); + const fullThreads = async () => { + const response = await ctx.transport.request([['Thread/get', { accountId: jmap, ids: [] }, 's']], MAIL_USING); + const captured = String(responseFor(response, 's')[1].state); + const known = ctx.db.all<{ id: string }>( + `SELECT DISTINCT e.thread_id AS id FROM email e WHERE e.jmap = ? AND e.thread_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM thread t WHERE t.jmap = e.jmap AND t.id = e.thread_id)`, + [jmap], + ).map((row) => String(row.id)); + for (let i = 0; i < known.length; i += PAGE) { + if (ctx.stopped()) return; + const list = await fetchThreads(ctx, jmap, known.slice(i, i + PAGE)); + ctx.db.transaction(() => { + for (const thread of list) ctx.db.run('INSERT OR REPLACE INTO thread (jmap, id, email_ids) VALUES (?, ?, ?)', [jmap, thread.id, JSON.stringify(thread.emailIds)]); + }); + ctx.emit({ type: 'progress', account: ctx.account, area: 'mail', phase: 'threads', done: i + list.length, total: known.length }); + } + ctx.db.transaction(() => ctx.setSyncState(jmap, 'Thread', { state: captured })); + }; + if (!state) return fullThreads(); + const outcome = await runChangesLoop({ + transport: ctx.transport, + type: 'Thread', + jmap, + using: MAIL_USING, + since: state, + target, + stopped: ctx.stopped, + apply: async (page) => { + const list = await fetchThreads(ctx, jmap, [...page.created, ...page.updated]); + ctx.db.transaction(() => { + for (const thread of list) ctx.db.run('INSERT OR REPLACE INTO thread (jmap, id, email_ids) VALUES (?, ?, ?)', [jmap, thread.id, JSON.stringify(thread.emailIds)]); + for (const id of page.destroyed) ctx.db.run('DELETE FROM thread WHERE jmap = ? AND id = ?', [jmap, id]); + ctx.setSyncState(jmap, 'Thread', { state: page.newState }); + }); + }, + }); + if (outcome.outcome === 'resync') { + ctx.db.transaction(() => { + ctx.db.run('DELETE FROM thread WHERE jmap = ?', [jmap]); + ctx.setSyncState(jmap, 'Thread', { state: null }); + }); + await fullThreads(); + } +} + +// ---- Bodies ---- + +async function backfillBodies(ctx: SyncContext, jmap: string): Promise { + let done = 0; + const totalRow = ctx.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM email WHERE jmap = ? AND body_state = 0', [jmap]); + const total = Number(totalRow?.n ?? 0); + if (total === 0) return; + for (;;) { + if (ctx.stopped()) return; + const candidates = ctx.db.all<{ rowid: number; id: string; size: number; json: string }>( + 'SELECT rowid, id, size, json FROM email WHERE jmap = ? AND body_state = 0 ORDER BY received_at DESC LIMIT ?', + [jmap, BODY_BATCH_IDS * BODY_REQUESTS_PER_COMMIT], + ); + if (candidates.length === 0) return; + const fetched = new Map(); + const asked: typeof candidates = []; + let next = 0; + for (let request = 0; request < BODY_REQUESTS_PER_COMMIT && next < candidates.length; request += 1) { + let bytes = 0; + const batch: typeof candidates = []; + while (next < candidates.length && batch.length < BODY_BATCH_IDS) { + const row = candidates[next]; + if (batch.length > 0 && bytes + Number(row.size) > BODY_BATCH_BYTES) break; + bytes += Number(row.size); + batch.push(row); + next += 1; + } + const response = await ctx.transport.request([['Email/get', { + accountId: jmap, + ids: batch.map((row) => String(row.id)), + properties: ['id', ...EMAIL_BODY_PROPERTIES], + fetchTextBodyValues: true, + fetchHTMLBodyValues: true, + fetchAllBodyValues: true, + maxBodyValueBytes: MAX_BODY_VALUE_BYTES, + }, 'b']], MAIL_USING); + for (const email of responseFor(response, 'b')[1].list as EmailObject[]) fetched.set(email.id, email); + asked.push(...batch); + if (ctx.stopped()) break; + } + ctx.db.transaction(() => { + for (const row of asked) { + const full = fetched.get(String(row.id)); + if (full) storeBody(ctx.db, Number(row.rowid), String(row.json), full); + else ctx.db.run('UPDATE email SET body_state = 2 WHERE rowid = ?', [row.rowid]); + } + }); + done += asked.length; + ctx.emit({ type: 'progress', account: ctx.account, area: 'mail', phase: 'bodies', done, total }); + } +} + +// ---- The whole pass ---- + +export const mailSyncer: Syncer = { + name: 'mail', + capability: 'urn:ietf:params:jmap:mail', + async run(ctx, jmap) { + await syncMailboxes(ctx, jmap); + if (ctx.stopped()) return; + + const email = ctx.syncState(jmap, 'Email'); + let cursor = email.phase === 'list' ? email.cursor as unknown as ListCursor : null; + if (!cursor && !email.state) cursor = await startFullList(ctx, jmap); + if (!cursor && email.state) { + const outcome = await syncEmailDelta(ctx, jmap, email.state, email.target); + if (outcome === 'stopped') return; + if (outcome === 'resync') cursor = await startFullList(ctx, jmap); + } + if (cursor) { + const finished = await runFullList(ctx, jmap, cursor); + if (!finished) return; + // Catch what changed while paging. + const after = ctx.syncState(jmap, 'Email'); + if (after.state && (await syncEmailDelta(ctx, jmap, after.state, after.target)) === 'stopped') return; + } + if (ctx.stopped()) return; + await syncThreads(ctx, jmap); + if (ctx.stopped()) return; + await backfillBodies(ctx, jmap); + }, +}; diff --git a/lib/store/sqlite-version.ts b/lib/store/sqlite-version.ts new file mode 100644 index 000000000..1b6ee653c --- /dev/null +++ b/lib/store/sqlite-version.ts @@ -0,0 +1,3 @@ +/** The vendored SQLite3MultipleCiphers build. Change it with scripts/fetch-sqlite3mc.mjs. */ +export const SQLITE3MC_VERSION = '2.6.1-3.54.0'; +export const SQLITE3MC_PUBLIC_DIR = `sqlite3mc/${SQLITE3MC_VERSION}`; diff --git a/lib/store/storage.ts b/lib/store/storage.ts new file mode 100644 index 000000000..5ca4c29af --- /dev/null +++ b/lib/store/storage.ts @@ -0,0 +1,79 @@ +/** + * The small storage interface the local store is written against. + * + * Synchronous on purpose: SQLite3MC in a worker and node:sqlite in the tests + * both are, and a transaction then can't hold an await by accident. A native + * or mobile adapter runs the engine next to its database, not across a + * message boundary. + */ + +export type SqlValue = string | number | bigint | null | Uint8Array; +export type SqlRow = Record; + +export interface StorageAdapter { + /** Runs one or more statements without parameters. */ + exec(sql: string): void; + /** Runs one statement. */ + run(sql: string, params?: readonly SqlValue[]): { changes: number; lastInsertRowid: number }; + all(sql: string, params?: readonly SqlValue[]): T[]; + get(sql: string, params?: readonly SqlValue[]): T | undefined; + /** Commits when fn returns, rolls back when it throws. Nested calls join the outer one. */ + transaction(fn: () => T): T; + close(): void; +} + +/** Shared by the adapters: BEGIN/COMMIT around fn, and a guard against async bodies. */ +export function runTransaction( + state: { depth: number }, + exec: (sql: string) => void, + fn: () => T, +): T { + if (state.depth > 0) return fn(); + exec('BEGIN IMMEDIATE'); + state.depth += 1; + try { + const result = fn(); + if (result && typeof (result as { then?: unknown }).then === 'function') { + throw new Error('A store transaction must be synchronous'); + } + exec('COMMIT'); + return result; + } catch (error) { + // SQLite has already rolled back after some errors (a full disk, a failed + // write). A second failure here must not hide the real one. + try { + exec('ROLLBACK'); + } catch { + // Nothing left to roll back. + } + throw error; + } finally { + state.depth -= 1; + } +} + +export interface Migration { + /** Unique, ascending. Ranges per area are listed in lib/store/area.ts. */ + id: number; + name: string; + sql: string; +} + +/** Applies what's missing, in id order, each in its own transaction. */ +export function migrate(db: StorageAdapter, migrations: readonly Migration[]): number[] { + db.exec('CREATE TABLE IF NOT EXISTS schema_migration (id INTEGER PRIMARY KEY, name TEXT NOT NULL, at INTEGER NOT NULL)'); + const done = new Set(db.all<{ id: number }>('SELECT id FROM schema_migration').map((row) => Number(row.id))); + const ids = new Set(); + const applied: number[] = []; + for (const migration of [...migrations].sort((a, b) => a.id - b.id)) { + if (ids.has(migration.id)) throw new Error(`Duplicate store migration id ${migration.id}`); + ids.add(migration.id); + if (done.has(migration.id)) continue; + db.transaction(() => { + db.exec(migration.sql); + db.run('INSERT INTO schema_migration (id, name, at) VALUES (?, ?, ?)', [migration.id, migration.name, Date.now()]); + }); + applied.push(migration.id); + } + return applied; +} diff --git a/lib/store/worker/host.ts b/lib/store/worker/host.ts new file mode 100644 index 000000000..636c231c9 --- /dev/null +++ b/lib/store/worker/host.ts @@ -0,0 +1,174 @@ +/** + * What runs in the owner tab's worker: one database and engine per account. + * The list of areas is the only thing that decides what the store answers. + */ + +import type { StoreArea, StoreEvent } from '../area'; +import { AccountEngine } from '../engine/account-engine'; +import { FetchTransport, type StoreSession } from '../engine/transport'; +import type { StorageAdapter } from '../storage'; +import type { OpenAccountParams, StoreRpc, StoreStatus } from './protocol'; + +export interface HostStorage { + open(file: string, keyHex: string): Promise; + remove(file: string): boolean; +} + +interface OpenAccount { + engine: AccountEngine; + db: StorageAdapter; + file: string; + auth: string | null; + pendingAuth: Array<(header: string | null) => void>; + session: StoreSession; +} + +export interface StoreHostOptions { + areas: readonly StoreArea[]; + storage: HostStorage; + emit(event: StoreEvent): void; + needAuth(account: string): void; +} + +export class StoreHost { + private readonly accounts = new Map(); + private online = true; + private readonly synced = new Map(); + + constructor(private readonly options: StoreHostOptions) {} + + async openAccount(params: OpenAccountParams): Promise { + const existing = this.accounts.get(params.account); + if (existing) { + if (params.session) this.setSession(params.account, params.session); + this.setAuth(params.account, params.auth); + existing.engine.setScope(params.scope); + existing.engine.setInvertedKeywordSort(params.invertedKeywordSort); + void existing.engine.requestSync(); + return; + } + const db = await this.options.storage.open(params.file, params.keyHex); + const session = params.session ?? AccountEngine.storedSession(db); + if (!session) { + db.close(); + throw new Error('No session for this account yet'); + } + const account: OpenAccount = { db, file: params.file, auth: params.auth, pendingAuth: [], session, engine: undefined as unknown as AccountEngine }; + const transport = new FetchTransport({ + session: () => account.session, + auth: () => account.auth, + refreshAuth: () => new Promise((resolve) => { + account.pendingAuth.push(resolve); + this.options.needAuth(params.account); + }), + }); + account.engine = new AccountEngine({ + account: params.account, + db, + session, + transport, + areas: this.options.areas, + scope: params.scope, + invertedKeywordSort: params.invertedKeywordSort, + emit: (event) => { + if (event.type === 'synced') this.synced.set(event.account, event.at); + this.options.emit(event); + }, + }); + account.engine.setOnline(this.online); + this.accounts.set(params.account, account); + void account.engine.requestSync(); + } + + setAuth(account: string, auth: string | null): void { + const open = this.accounts.get(account); + if (!open) return; + open.auth = auth; + for (const resolve of open.pendingAuth.splice(0)) resolve(auth); + } + + setSession(account: string, session: StoreSession): void { + const open = this.accounts.get(account); + if (!open) return; + open.session = session; + open.engine.setSession(session); + } + + setScope(account: string, scope: Parameters[0]): void { + const open = this.accounts.get(account); + if (!open) return; + open.engine.setScope(scope); + void open.engine.requestSync(); + } + + async closeAccount(account: string): Promise { + const open = this.accounts.get(account); + if (!open) return; + this.accounts.delete(account); + await open.engine.close(); + for (const resolve of open.pendingAuth.splice(0)) resolve(null); + open.db.close(); + } + + async removeAccount(account: string, file: string): Promise { + await this.closeAccount(account); + this.synced.delete(account); + return this.options.storage.remove(file); + } + + async closeAll(): Promise { + await Promise.all([...this.accounts.keys()].map((account) => this.closeAccount(account))); + } + + /** The calls other tabs can make. */ + async call(method: keyof StoreRpc, params: unknown): Promise { + switch (method) { + case 'answer': { + const { account, calls } = params as Parameters[0]; + const open = this.accounts.get(account); + if (!open) throw new Error('The local store is not open for this account'); + return open.engine.answer(calls); + } + case 'sync': { + const { account } = params as Parameters[0]; + await this.accounts.get(account)?.engine.requestSync(); + return undefined; + } + case 'noteStateChange': { + const { account, changed } = params as Parameters[0]; + this.accounts.get(account)?.engine.noteStateChange(changed); + return undefined; + } + case 'setOnline': { + const { online } = params as Parameters[0]; + this.online = online; + for (const open of this.accounts.values()) open.engine.setOnline(online); + return undefined; + } + case 'status': { + const { account } = params as Parameters[0]; + return this.status(account); + } + case 'area': { + const { account, name, params: callParams } = params as Parameters[0]; + const open = this.accounts.get(account); + if (!open) throw new Error('The local store is not open for this account'); + return open.engine.areaCall(name, callParams); + } + default: + throw new Error(`Unknown store call ${String(method)}`); + } + } + + private status(account: string): StoreStatus | null { + const open = this.accounts.get(account); + if (!open) return null; + return { + account, + open: true, + emails: Number(open.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM email')?.n ?? 0), + bodies: Number(open.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM email_body')?.n ?? 0), + lastSyncedAt: this.synced.get(account) ?? null, + }; + } +} diff --git a/lib/store/worker/protocol.ts b/lib/store/worker/protocol.ts new file mode 100644 index 000000000..ed5a08f96 --- /dev/null +++ b/lib/store/worker/protocol.ts @@ -0,0 +1,78 @@ +/** Messages between the page and the store worker. */ + +import type { StoreEvent, StoreScope } from '../area'; +import type { JmapResponse, MethodCall, StoreSession } from '../engine/transport'; + +export const RPC_CHANNEL = 'bulwark-store-rpc'; +export const OWNER_LOCK = 'bulwark-store-owner'; + +export interface OpenAccountParams { + account: string; + /** `/a.db`, no account data: the name is plaintext in the OPFS slot. */ + file: string; + /** 32 bytes in hex. Only ever sent to this tab's own worker, never over the channel. */ + keyHex: string; + session: StoreSession | null; + scope: StoreScope; + invertedKeywordSort: boolean; + /** The page's current Authorization header. */ + auth: string | null; +} + +/** What other tabs can ask for, over the channel. */ +export interface StoreRpc { + answer(params: { account: string; calls: MethodCall[] }): JmapResponse | null; + sync(params: { account: string }): void; + noteStateChange(params: { account: string; changed: Record> }): void; + setOnline(params: { online: boolean }): void; + status(params: { account: string }): StoreStatus | null; + /** A non-JMAP call into an area of the store. */ + area(params: { account: string; name: string; params: unknown }): unknown; +} + +export interface StoreStatus { + account: string; + open: boolean; + emails: number; + bodies: number; + lastSyncedAt: number | null; +} + +export type RpcRequest = { + kind: 'rpc'; + id: string; + from: string; + method: K; + params: Parameters[0]; +}; + +export type RpcReply = { + kind: 'reply'; + id: string; + to: string; + result?: unknown; + error?: string; +}; + +export type ChannelMessage = + | RpcRequest + | RpcReply + | { kind: 'event'; event: StoreEvent } + | { kind: 'owner-changed'; owner: string | null }; + +/** From this tab's page to its own worker, never on the channel. */ +export type PageToWorker = + | { kind: 'init'; clientId: string; moduleUrl: string } + | { kind: 'open'; id: string; params: OpenAccountParams } + | { kind: 'close-account'; id: string; account: string } + | { kind: 'remove-account'; id: string; account: string; file: string } + | { kind: 'set-auth'; account: string; auth: string | null } + | { kind: 'set-session'; account: string; session: StoreSession } + | { kind: 'set-scope'; account: string; scope: StoreScope } + | { kind: 'release'; id: string }; + +export type WorkerToPage = + | { kind: 'owner' } + | { kind: 'result'; id: string; result?: unknown; error?: string; errorName?: string } + | { kind: 'need-auth'; account: string } + | { kind: 'event'; event: StoreEvent }; diff --git a/lib/store/worker/worker.ts b/lib/store/worker/worker.ts new file mode 100644 index 000000000..003340c82 --- /dev/null +++ b/lib/store/worker/worker.ts @@ -0,0 +1,147 @@ +/** + * The store worker. Every tab has one, parked on the owner lock, and only the + * holder loads the wasm and touches OPFS. The lock is held for the worker's + * whole life, so handing over means: close, pause the VFS, then let go. + * + * Built to public/store-worker.js by scripts/build-store-worker.mjs. + */ + +import { WasmStorage } from '../adapters/wasm'; +import { STORE_AREAS } from '../areas'; +import { RPC_CHANNEL, OWNER_LOCK, type ChannelMessage, type PageToWorker, type WorkerToPage } from './protocol'; +import { StoreHost } from './host'; + +const scope = self as unknown as { + postMessage(message: WorkerToPage): void; + addEventListener(type: 'message', listener: (event: MessageEvent) => void): void; + close(): void; +}; + +let clientId = ''; +let moduleUrl = ''; +let storage: WasmStorage | null = null; +let host: StoreHost | null = null; +let channel: BroadcastChannel | null = null; +let releaseLock: (() => void) | null = null; + +function reply(id: string, result?: unknown): void { + scope.postMessage({ kind: 'result', id, result }); +} + +function replyError(id: string, error: unknown): void { + scope.postMessage({ + kind: 'result', + id, + error: error instanceof Error ? error.message : String(error), + errorName: error instanceof Error ? error.name : undefined, + }); +} + +function requireHost(): StoreHost { + if (!host) throw new Error('This tab does not own the local store'); + return host; +} + +async function becomeOwner(): Promise { + storage = await WasmStorage.install(moduleUrl); + host = new StoreHost({ + areas: STORE_AREAS, + storage, + emit: (event) => { + scope.postMessage({ kind: 'event', event }); + channel?.postMessage({ kind: 'event', event } satisfies ChannelMessage); + }, + needAuth: (account) => scope.postMessage({ kind: 'need-auth', account }), + }); + channel = new BroadcastChannel(RPC_CHANNEL); + channel.onmessage = async (event: MessageEvent) => { + const message = event.data; + if (message.kind !== 'rpc') return; + try { + const result = await requireHost().call(message.method, message.params); + channel?.postMessage({ kind: 'reply', id: message.id, to: message.from, result } satisfies ChannelMessage); + } catch (error) { + channel?.postMessage({ + kind: 'reply', + id: message.id, + to: message.from, + error: error instanceof Error ? error.message : String(error), + } satisfies ChannelMessage); + } + }; + channel.postMessage({ kind: 'owner-changed', owner: clientId } satisfies ChannelMessage); + scope.postMessage({ kind: 'owner' }); +} + +async function release(): Promise { + // The order matters: the OPFS handles have to be free before the lock is. + channel?.postMessage({ kind: 'owner-changed', owner: null } satisfies ChannelMessage); + await host?.closeAll(); + host = null; + storage?.pause(); + storage = null; + channel?.close(); + channel = null; + releaseLock?.(); + releaseLock = null; +} + +scope.addEventListener('message', async (event) => { + const message = event.data; + switch (message.kind) { + case 'init': + clientId = message.clientId; + moduleUrl = message.moduleUrl; + void navigator.locks.request(OWNER_LOCK, () => new Promise((resolve) => { + releaseLock = resolve; + becomeOwner().catch((error) => { + scope.postMessage({ + kind: 'event', + event: { type: 'error', account: '', area: 'store', message: error instanceof Error ? error.message : String(error) }, + }); + resolve(); + }); + })); + break; + case 'open': + try { + await requireHost().openAccount(message.params); + reply(message.id); + } catch (error) { + replyError(message.id, error); + } + break; + case 'close-account': + try { + await requireHost().closeAccount(message.account); + reply(message.id); + } catch (error) { + replyError(message.id, error); + } + break; + case 'remove-account': + try { + reply(message.id, await requireHost().removeAccount(message.account, message.file)); + } catch (error) { + replyError(message.id, error); + } + break; + case 'set-auth': + host?.setAuth(message.account, message.auth); + break; + case 'set-session': + host?.setSession(message.account, message.session); + break; + case 'set-scope': + host?.setScope(message.account, message.scope); + break; + case 'release': + try { + await release(); + reply(message.id); + } catch (error) { + replyError(message.id, error); + } + break; + } +}); diff --git a/locales/ar/common.json b/locales/ar/common.json index fe781a228..172d28651 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -249,6 +249,28 @@ "active_account": "نشط", "switching_account": "جارٍ تبديل الحساب..." }, + "pwaLaunch": { + "shortcut_new_message": "رسالة جديدة", + "shortcut_calendar": "التقويم", + "shortcut_contacts": "جهات الاتصال", + "unsupported_file": "لا يمكن فتح {name} هنا.", + "open_failed": "تعذر فتح الملف. حاول فتحه مرة أخرى.", + "share_gone": "ما شاركته لم يعد موجودًا. يرجى مشاركته مرة أخرى.", + "share_files_lost": "تعذر الاحتفاظ بالملفات المشتركة. أضفها إلى الرسالة مرة أخرى.", + "ics_needs_server": "يحتاج استيراد ملف تقويم إلى اتصال بالخادم. يبدأ الاستيراد عند عودتك إلى الاتصال بالإنترنت.", + "contacts_need_server": "يحتاج استيراد جهات الاتصال إلى اتصال بالخادم. حاول مرة أخرى عند عودتك إلى الاتصال بالإنترنت.", + "share_confirm_title": "هل تريد بدء رسالة جديدة؟", + "share_confirm_message": "يريد موقع أو تطبيق آخر بدء رسالة. لن يُرفع أو يُحفظ أي شيء إلا إذا وافقت.", + "share_confirm_subject": "الموضوع:", + "share_confirm_text": "النص:", + "share_confirm_files": "{count, plural, one {ملف:} other {ملفات:}}", + "share_confirm_accept": "بدء الرسالة", + "share_confirm_decline": "عدم البدء", + "share_cut": "كان النص المشترك طويلًا جدًا وتم اختصاره.", + "handoff_expired": "انتظر شيء فتحته أو شاركته مدة طويلة ولم يعد موجودًا. يرجى فتحه أو مشاركته مرة أخرى.", + "compose_waiting": "أغلق هذه الرسالة لبدء الرسالة التالية.", + "save_first_failed": "تعذر حفظ رسالتك، لذلك لم يُفتح الملف. احفظ الرسالة ثم افتح التقويم أو جهات الاتصال." + }, "sidebar_apps": { "modal_title": "تطبيقات الشريط الجانبي", "add_new": "إضافة تطبيق", @@ -4909,6 +4931,9 @@ "mini_has_events": "فيه أحداث", "mini_week_number": "الأسبوع {week}" }, + "calendar_offline": { + "split_needs_server": "يحتاج هذا التغيير إلى اتصال بالخادم. غيّر هذا الحدث فقط الآن، أو حاول مرة أخرى عند عودة الاتصال." + }, "calendar_event_notifications": { "someone": "شخص ما", "invited": "دعاك {name} إلى \"{title}\"", @@ -6350,6 +6375,12 @@ "sign_language": "لغة الإشارة", "new_tab": "يُفتح في علامة تبويب جديدة" }, + "pwaNotify": { + "action_archive": "أرشفة", + "action_mark_read": "تمييز كمقروءة", + "action_reply": "رد", + "reply_placeholder": "اكتب ردًا" + }, "structured_mail_calendar": { "add_to_calendar": "إضافة إلى التقويم", "update_in_calendar": "تحديث في التقويم", diff --git a/locales/ca/common.json b/locales/ca/common.json index e1f77dd7f..57ab12d13 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -249,6 +249,28 @@ "active_account": "Actiu", "switching_account": "Canviant de compte..." }, + "pwaLaunch": { + "shortcut_new_message": "Missatge nou", + "shortcut_calendar": "Calendari", + "shortcut_contacts": "Contactes", + "unsupported_file": "{name} no es pot obrir aquí.", + "open_failed": "No s'ha pogut obrir el fitxer. Torneu a obrir-lo.", + "share_gone": "El que heu compartit ja no hi és. Torneu a compartir-ho.", + "share_files_lost": "No s'han pogut conservar els fitxers compartits. Torneu-los a afegir al missatge.", + "ics_needs_server": "Per importar un fitxer de calendari cal connexió amb el servidor. La importació començarà quan torneu a estar en línia.", + "contacts_need_server": "Per importar contactes cal connexió amb el servidor. Torneu-ho a provar quan torneu a estar en línia.", + "share_confirm_title": "Voleu començar un missatge nou?", + "share_confirm_message": "Un altre lloc o aplicació vol començar un missatge. No es puja ni es desa res si no hi esteu d'acord.", + "share_confirm_subject": "Assumpte:", + "share_confirm_text": "Text:", + "share_confirm_files": "{count, plural, one {Fitxer:} other {Fitxers:}}", + "share_confirm_accept": "Comença el missatge", + "share_confirm_decline": "No el comencis", + "share_cut": "El text compartit era massa llarg i s'ha escurçat.", + "handoff_expired": "Una cosa que heu obert o compartit ha esperat massa i ja no hi és. Torneu-la a obrir o compartir.", + "compose_waiting": "Tanqueu aquest missatge per començar el següent.", + "save_first_failed": "No s'ha pogut desar el missatge, per això no s'ha obert el fitxer. Deseu el missatge i obriu el calendari o els contactes." + }, "sidebar_apps": { "modal_title": "Aplicacions de la barra lateral", "add_new": "Afegeix una aplicació", @@ -4909,6 +4931,9 @@ "mini_has_events": "té esdeveniments", "mini_week_number": "Setmana {week}" }, + "calendar_offline": { + "split_needs_server": "Aquest canvi necessita connexió amb el servidor. Canvia només aquest esdeveniment de moment, o torna-ho a provar quan tornis a estar connectat." + }, "calendar_event_notifications": { "someone": "Algú", "invited": "{name} t'ha convidat a \"{title}\"", @@ -6350,6 +6375,12 @@ "sign_language": "Llengua de signes", "new_tab": "s'obre en una pestanya nova" }, + "pwaNotify": { + "action_archive": "Arxiva", + "action_mark_read": "Marca com a llegit", + "action_reply": "Respon", + "reply_placeholder": "Escriu una resposta" + }, "structured_mail_calendar": { "add_to_calendar": "Afegeix al calendari", "update_in_calendar": "Actualitza al calendari", diff --git a/locales/cs/common.json b/locales/cs/common.json index b657149fc..811b3987f 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -249,6 +249,28 @@ "active_account": "Aktivní", "switching_account": "Přepínání účtu..." }, + "pwaLaunch": { + "shortcut_new_message": "Nová zpráva", + "shortcut_calendar": "Kalendář", + "shortcut_contacts": "Kontakty", + "unsupported_file": "{name} zde nelze otevřít.", + "open_failed": "Soubor se nepodařilo otevřít. Zkuste ho otevřít znovu.", + "share_gone": "To, co jste sdíleli, už tu není. Sdílejte to prosím znovu.", + "share_files_lost": "Sdílené soubory se nepodařilo uchovat. Přidejte je do zprávy znovu.", + "ics_needs_server": "Import souboru kalendáře vyžaduje připojení k serveru. Import začne, až budete znovu online.", + "contacts_need_server": "Import kontaktů vyžaduje připojení k serveru. Zkuste to znovu, až budete online.", + "share_confirm_title": "Začít novou zprávu?", + "share_confirm_message": "Jiný web nebo aplikace chce začít zprávu. Bez vašeho souhlasu se nic nenahraje ani neuloží.", + "share_confirm_subject": "Předmět:", + "share_confirm_text": "Text:", + "share_confirm_files": "{count, plural, one {Soubor:} few {Soubory:} many {Souborů:} other {Soubory:}}", + "share_confirm_accept": "Začít zprávu", + "share_confirm_decline": "Nezačínat", + "share_cut": "Sdílený text byl příliš dlouhý a byl zkrácen.", + "handoff_expired": "To, co jste otevřeli nebo sdíleli, čekalo příliš dlouho a už tu není. Otevřete to nebo sdílejte znovu.", + "compose_waiting": "Zavřete tuto zprávu, abyste mohli začít další.", + "save_first_failed": "Zprávu se nepodařilo uložit, proto se soubor neotevřel. Uložte zprávu a pak otevřete kalendář nebo kontakty." + }, "sidebar_apps": { "modal_title": "Aplikace postranního panelu", "add_new": "Přidat aplikaci", @@ -4909,6 +4931,9 @@ "mini_has_events": "má události", "mini_week_number": "Týden {week}" }, + "calendar_offline": { + "split_needs_server": "Tato změna vyžaduje připojení k serveru. Prozatím změňte jen tuto událost, nebo to zkuste znovu po obnovení připojení." + }, "advanced_search": { "title": "Pokročilé hledání", "from": "Od", @@ -6350,6 +6375,12 @@ "sign_language": "Znakový jazyk", "new_tab": "otevře se na nové kartě" }, + "pwaNotify": { + "action_archive": "Archivovat", + "action_mark_read": "Označit jako přečtené", + "action_reply": "Odpovědět", + "reply_placeholder": "Napište odpověď" + }, "structured_mail_calendar": { "add_to_calendar": "Přidat do kalendáře", "update_in_calendar": "Aktualizovat v kalendáři", diff --git a/locales/da/common.json b/locales/da/common.json index 2cf84faeb..53e827736 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -249,6 +249,28 @@ "active_account": "Aktiv", "switching_account": "Skifter konto..." }, + "pwaLaunch": { + "shortcut_new_message": "Ny besked", + "shortcut_calendar": "Kalender", + "shortcut_contacts": "Kontakter", + "unsupported_file": "{name} kan ikke åbnes her.", + "open_failed": "Filen kunne ikke åbnes. Prøv at åbne den igen.", + "share_gone": "Det, du delte, er her ikke længere. Del det venligst igen.", + "share_files_lost": "De delte filer kunne ikke gemmes. Tilføj dem til beskeden igen.", + "ics_needs_server": "Import af en kalenderfil kræver forbindelse til serveren. Importen starter, når du er online igen.", + "contacts_need_server": "Import af kontakter kræver forbindelse til serveren. Prøv igen, når du er online igen.", + "share_confirm_title": "Vil du starte en ny besked?", + "share_confirm_message": "Et andet websted eller en anden app vil starte en besked. Intet uploades eller gemmes, medmindre du accepterer.", + "share_confirm_subject": "Emne:", + "share_confirm_text": "Tekst:", + "share_confirm_files": "{count, plural, one {Fil:} other {Filer:}}", + "share_confirm_accept": "Start besked", + "share_confirm_decline": "Start ikke", + "share_cut": "Den delte tekst var for lang og er blevet forkortet.", + "handoff_expired": "Noget, du åbnede eller delte, ventede for længe og er væk. Åbn eller del det igen.", + "compose_waiting": "Luk denne besked for at starte den næste.", + "save_first_failed": "Din besked kunne ikke gemmes, så filen er ikke åbnet. Gem beskeden, og åbn derefter kalenderen eller kontakterne." + }, "sidebar_apps": { "modal_title": "Sidepanel-apps", "add_new": "Tilføj app", @@ -4909,6 +4931,9 @@ "mini_has_events": "har begivenheder", "mini_week_number": "Uge {week}" }, + "calendar_offline": { + "split_needs_server": "Denne ændring kræver forbindelse til serveren. Ret kun denne begivenhed for nu, eller prøv igen, når du er online." + }, "calendar_event_notifications": { "someone": "Nogen", "invited": "{name} har inviteret dig til \"{title}\"", @@ -6350,6 +6375,12 @@ "sign_language": "Tegnsprog", "new_tab": "åbner i en ny fane" }, + "pwaNotify": { + "action_archive": "Arkivér", + "action_mark_read": "Markér som læst", + "action_reply": "Svar", + "reply_placeholder": "Skriv et svar" + }, "structured_mail_calendar": { "add_to_calendar": "Tilføj til kalender", "update_in_calendar": "Opdater i kalender", diff --git a/locales/de/common.json b/locales/de/common.json index 3528ade90..5ba043d71 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -249,6 +249,28 @@ "active_account": "Aktiv", "switching_account": "Account wird gewechselt..." }, + "pwaLaunch": { + "shortcut_new_message": "Neue Nachricht", + "shortcut_calendar": "Kalender", + "shortcut_contacts": "Kontakte", + "unsupported_file": "{name} kann hier nicht geöffnet werden.", + "open_failed": "Die Datei konnte nicht geöffnet werden. Öffnen Sie sie bitte noch einmal.", + "share_gone": "Das Geteilte ist nicht mehr vorhanden. Bitte teilen Sie es noch einmal.", + "share_files_lost": "Die geteilten Dateien konnten nicht aufbewahrt werden. Fügen Sie sie der Nachricht bitte noch einmal hinzu.", + "ics_needs_server": "Zum Importieren einer Kalenderdatei wird eine Verbindung zum Server gebraucht. Der Import beginnt, sobald Sie wieder online sind.", + "contacts_need_server": "Zum Importieren von Kontakten wird eine Verbindung zum Server gebraucht. Versuchen Sie es noch einmal, wenn Sie wieder online sind.", + "share_confirm_title": "Neue Nachricht beginnen?", + "share_confirm_message": "Eine andere Website oder App möchte eine Nachricht beginnen. Es wird nichts hochgeladen oder gespeichert, wenn Sie nicht zustimmen.", + "share_confirm_subject": "Betreff:", + "share_confirm_text": "Text:", + "share_confirm_files": "{count, plural, one {Datei:} other {Dateien:}}", + "share_confirm_accept": "Nachricht beginnen", + "share_confirm_decline": "Nicht beginnen", + "share_cut": "Der geteilte Text war zu lang und wurde gekürzt.", + "handoff_expired": "Etwas, das Sie geöffnet oder geteilt haben, hat zu lange gewartet und ist nicht mehr da. Bitte öffnen oder teilen Sie es noch einmal.", + "compose_waiting": "Schließen Sie diese Nachricht, um die nächste zu beginnen.", + "save_first_failed": "Ihre Nachricht konnte nicht gespeichert werden, deshalb wurde die Datei nicht geöffnet. Speichern Sie die Nachricht und öffnen Sie dann den Kalender oder die Kontakte." + }, "sidebar_apps": { "modal_title": "Sidebar-Apps", "add_new": "App hinzufügen", @@ -4909,6 +4931,9 @@ "mini_has_events": "hat Termine", "mini_week_number": "Woche {week}" }, + "calendar_offline": { + "split_needs_server": "Für diese Änderung ist eine Verbindung zum Server nötig. Ändern Sie vorerst nur diesen Termin, oder versuchen Sie es erneut, wenn Sie wieder online sind." + }, "advanced_search": { "title": "Erweiterte Suche", "from": "Von", @@ -6350,6 +6375,12 @@ "sign_language": "Gebärdensprache", "new_tab": "öffnet in einem neuen Tab" }, + "pwaNotify": { + "action_archive": "Archivieren", + "action_mark_read": "Als gelesen markieren", + "action_reply": "Antworten", + "reply_placeholder": "Antwort schreiben" + }, "structured_mail_calendar": { "add_to_calendar": "Zum Kalender hinzufügen", "update_in_calendar": "Im Kalender aktualisieren", diff --git a/locales/en/common.json b/locales/en/common.json index d27ee973f..d0d3893e1 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -249,6 +249,28 @@ "active_account": "Active", "switching_account": "Switching account..." }, + "pwaLaunch": { + "shortcut_new_message": "New message", + "shortcut_calendar": "Calendar", + "shortcut_contacts": "Contacts", + "unsupported_file": "{name} can't be opened here.", + "open_failed": "The file couldn't be opened. Try opening it again.", + "share_gone": "What you shared is no longer here. Please share it again.", + "share_files_lost": "The shared files couldn't be kept. Add them to the message again.", + "ics_needs_server": "Importing a calendar file needs a connection to the server. The import starts when you're back online.", + "contacts_need_server": "Importing contacts needs a connection to the server. Try again when you're back online.", + "share_confirm_title": "Start a new message?", + "share_confirm_message": "Another site or app wants to start a message. Nothing is uploaded or saved unless you agree.", + "share_confirm_subject": "Subject:", + "share_confirm_text": "Text:", + "share_confirm_files": "{count, plural, one {File:} other {Files:}}", + "share_confirm_accept": "Start message", + "share_confirm_decline": "Don't start", + "share_cut": "The shared text was too long and has been shortened.", + "handoff_expired": "Something you opened or shared waited too long and is gone. Please open or share it again.", + "compose_waiting": "Close this message to start the next one.", + "save_first_failed": "Your message couldn't be saved, so the file hasn't been opened. Save the message, then open the calendar or contacts." + }, "sidebar_apps": { "modal_title": "Sidebar apps", "add_new": "Add app", @@ -4909,6 +4931,9 @@ "mini_has_events": "has events", "mini_week_number": "Week {week}" }, + "calendar_offline": { + "split_needs_server": "This change needs a connection to the server. Change only this event for now, or try again when you're back online." + }, "calendar_event_notifications": { "someone": "Someone", "invited": "{name} invited you to \"{title}\"", @@ -6350,6 +6375,12 @@ "sign_language": "Sign language", "new_tab": "opens in a new tab" }, + "pwaNotify": { + "action_archive": "Archive", + "action_mark_read": "Mark as read", + "action_reply": "Reply", + "reply_placeholder": "Write a reply" + }, "structured_mail_calendar": { "add_to_calendar": "Add to calendar", "update_in_calendar": "Update in calendar", diff --git a/locales/es/common.json b/locales/es/common.json index 2c1959490..06791d723 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -249,6 +249,28 @@ "active_account": "Activa", "switching_account": "Cambiando de cuenta..." }, + "pwaLaunch": { + "shortcut_new_message": "Mensaje nuevo", + "shortcut_calendar": "Calendario", + "shortcut_contacts": "Contactos", + "unsupported_file": "{name} no se puede abrir aquí.", + "open_failed": "No se pudo abrir el archivo. Intenta abrirlo de nuevo.", + "share_gone": "Lo que compartiste ya no está aquí. Compártelo de nuevo.", + "share_files_lost": "No se pudieron conservar los archivos compartidos. Añádelos de nuevo al mensaje.", + "ics_needs_server": "Importar un archivo de calendario requiere conexión con el servidor. La importación empezará cuando vuelvas a estar en línea.", + "contacts_need_server": "Importar contactos requiere conexión con el servidor. Inténtalo de nuevo cuando vuelvas a estar en línea.", + "share_confirm_title": "¿Empezar un mensaje nuevo?", + "share_confirm_message": "Otro sitio o aplicación quiere empezar un mensaje. No se sube ni se guarda nada si no aceptas.", + "share_confirm_subject": "Asunto:", + "share_confirm_text": "Texto:", + "share_confirm_files": "{count, plural, one {Archivo:} other {Archivos:}}", + "share_confirm_accept": "Empezar mensaje", + "share_confirm_decline": "No empezar", + "share_cut": "El texto compartido era demasiado largo y se ha acortado.", + "handoff_expired": "Algo que abriste o compartiste esperó demasiado y ya no está. Ábrelo o compártelo de nuevo.", + "compose_waiting": "Cierra este mensaje para empezar el siguiente.", + "save_first_failed": "No se pudo guardar tu mensaje, así que el archivo no se ha abierto. Guarda el mensaje y luego abre el calendario o los contactos." + }, "sidebar_apps": { "modal_title": "Aplicaciones de la barra lateral", "add_new": "Añadir aplicación", @@ -4909,6 +4931,9 @@ "mini_has_events": "tiene eventos", "mini_week_number": "Semana {week}" }, + "calendar_offline": { + "split_needs_server": "Este cambio necesita conexión con el servidor. Cambia solo este evento por ahora o inténtalo de nuevo cuando vuelvas a tener conexión." + }, "advanced_search": { "title": "Búsqueda avanzada", "from": "De", @@ -6350,6 +6375,12 @@ "sign_language": "Lengua de signos", "new_tab": "se abre en una pestaña nueva" }, + "pwaNotify": { + "action_archive": "Archivar", + "action_mark_read": "Marcar como leído", + "action_reply": "Responder", + "reply_placeholder": "Escribe una respuesta" + }, "structured_mail_calendar": { "add_to_calendar": "Añadir al calendario", "update_in_calendar": "Actualizar en el calendario", diff --git a/locales/et/common.json b/locales/et/common.json index b3bd5282f..1b71e356b 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -249,6 +249,28 @@ "active_account": "Aktiivne", "switching_account": "Konto vahetamine..." }, + "pwaLaunch": { + "shortcut_new_message": "Uus kiri", + "shortcut_calendar": "Kalender", + "shortcut_contacts": "Kontaktid", + "unsupported_file": "Faili {name} ei saa siin avada.", + "open_failed": "Faili ei saanud avada. Proovi see uuesti avada.", + "share_gone": "See, mida jagasid, pole enam siin. Jaga seda palun uuesti.", + "share_files_lost": "Jagatud faile ei saanud alles hoida. Lisa need kirjale uuesti.", + "ics_needs_server": "Kalendrifaili importimiseks on vaja ühendust serveriga. Import algab, kui oled jälle võrgus.", + "contacts_need_server": "Kontaktide importimiseks on vaja ühendust serveriga. Proovi uuesti, kui oled jälle võrgus.", + "share_confirm_title": "Kas alustada uut kirja?", + "share_confirm_message": "Teine sait või rakendus soovib kirja alustada. Ilma sinu nõusolekuta ei laadita ega salvestata midagi.", + "share_confirm_subject": "Teema:", + "share_confirm_text": "Tekst:", + "share_confirm_files": "{count, plural, one {Fail:} other {Failid:}}", + "share_confirm_accept": "Alusta kirja", + "share_confirm_decline": "Ära alusta", + "share_cut": "Jagatud tekst oli liiga pikk ja seda lühendati.", + "handoff_expired": "Midagi, mille avasid või jagasid, ootas liiga kaua ja seda pole enam. Ava või jaga see uuesti.", + "compose_waiting": "Järgmise alustamiseks sulge see kiri.", + "save_first_failed": "Kirja ei saanud salvestada, seega faili ei avatud. Salvesta kiri ja ava siis kalender või kontaktid." + }, "sidebar_apps": { "modal_title": "Külgriba rakendused", "add_new": "Lisa rakendus", @@ -4909,6 +4931,9 @@ "mini_has_events": "on sündmusi", "mini_week_number": "Nädal {week}" }, + "calendar_offline": { + "split_needs_server": "See muudatus vajab ühendust serveriga. Muuda praegu ainult seda sündmust või proovi uuesti, kui oled taas võrgus." + }, "calendar_event_notifications": { "someone": "Keegi", "invited": "{name} kutsus sind sündmusele \"{title}\"", @@ -6350,6 +6375,12 @@ "sign_language": "Viipekeel", "new_tab": "avaneb uuel vahekaardil" }, + "pwaNotify": { + "action_archive": "Arhiveeri", + "action_mark_read": "Märgi loetuks", + "action_reply": "Vasta", + "reply_placeholder": "Kirjuta vastus" + }, "structured_mail_calendar": { "add_to_calendar": "Lisa kalendrisse", "update_in_calendar": "Uuenda kalendris", diff --git a/locales/fa/common.json b/locales/fa/common.json index e7534be79..0e34826d5 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -249,6 +249,28 @@ "active_account": "فعال", "switching_account": "در حال تعویض حساب..." }, + "pwaLaunch": { + "shortcut_new_message": "پیام جدید", + "shortcut_calendar": "تقویم", + "shortcut_contacts": "مخاطبان", + "unsupported_file": "{name} اینجا باز نمی‌شود.", + "open_failed": "فایل باز نشد. دوباره آن را باز کنید.", + "share_gone": "چیزی که هم‌رسانی کردید دیگر اینجا نیست. لطفاً دوباره آن را هم‌رسانی کنید.", + "share_files_lost": "فایل‌های هم‌رسانی‌شده نگه داشته نشدند. دوباره آن‌ها را به پیام اضافه کنید.", + "ics_needs_server": "وارد کردن فایل تقویم به اتصال به سرور نیاز دارد. وقتی دوباره آنلاین شوید، وارد کردن شروع می‌شود.", + "contacts_need_server": "وارد کردن مخاطبان به اتصال به سرور نیاز دارد. وقتی دوباره آنلاین شدید، دوباره امتحان کنید.", + "share_confirm_title": "پیام جدیدی شروع شود؟", + "share_confirm_message": "سایت یا برنامه دیگری می‌خواهد پیامی را شروع کند. تا موافقت نکنید چیزی بارگذاری یا ذخیره نمی‌شود.", + "share_confirm_subject": "موضوع:", + "share_confirm_text": "متن:", + "share_confirm_files": "{count, plural, one {فایل:} other {فایل‌ها:}}", + "share_confirm_accept": "شروع پیام", + "share_confirm_decline": "شروع نشود", + "share_cut": "متن هم‌رسانی‌شده خیلی طولانی بود و کوتاه شد.", + "handoff_expired": "چیزی که باز یا هم‌رسانی کردید بیش از حد منتظر ماند و دیگر نیست. لطفاً دوباره آن را باز یا هم‌رسانی کنید.", + "compose_waiting": "برای شروع پیام بعدی این پیام را ببندید.", + "save_first_failed": "پیام شما ذخیره نشد، بنابراین فایل باز نشد. پیام را ذخیره کنید و سپس تقویم یا مخاطبان را باز کنید." + }, "sidebar_apps": { "modal_title": "برنامه‌های نوار کناری", "add_new": "افزودن برنامه", @@ -4909,6 +4931,9 @@ "mini_has_events": "رویداد دارد", "mini_week_number": "هفته {week}" }, + "calendar_offline": { + "split_needs_server": "این تغییر به اتصال به سرور نیاز دارد. فعلاً فقط همین رویداد را تغییر دهید، یا پس از برقراری اتصال دوباره تلاش کنید." + }, "calendar_event_notifications": { "someone": "کسی", "invited": "{name} شما را به «{title}» دعوت کرد", @@ -6350,6 +6375,12 @@ "sign_language": "زبان اشاره", "new_tab": "در زبانه‌ای تازه باز می‌شود" }, + "pwaNotify": { + "action_archive": "بایگانی", + "action_mark_read": "علامت‌گذاری خوانده شده", + "action_reply": "پاسخ", + "reply_placeholder": "پاسخ بنویسید" + }, "structured_mail_calendar": { "add_to_calendar": "افزودن به تقویم", "update_in_calendar": "به‌روزرسانی در تقویم", diff --git a/locales/fr/common.json b/locales/fr/common.json index 04d52aad6..a8d63c5c4 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -249,6 +249,28 @@ "active_account": "Actif", "switching_account": "Changement de compte..." }, + "pwaLaunch": { + "shortcut_new_message": "Nouveau message", + "shortcut_calendar": "Calendrier", + "shortcut_contacts": "Contacts", + "unsupported_file": "{name} ne peut pas être ouvert ici.", + "open_failed": "Le fichier n'a pas pu être ouvert. Essayez de l'ouvrir à nouveau.", + "share_gone": "Ce que vous avez partagé n'est plus disponible. Partagez-le à nouveau.", + "share_files_lost": "Les fichiers partagés n'ont pas pu être conservés. Ajoutez-les de nouveau au message.", + "ics_needs_server": "L'importation d'un fichier de calendrier nécessite une connexion au serveur. Elle démarrera dès que vous serez de nouveau en ligne.", + "contacts_need_server": "L'importation de contacts nécessite une connexion au serveur. Réessayez quand vous serez de nouveau en ligne.", + "share_confirm_title": "Commencer un nouveau message ?", + "share_confirm_message": "Un autre site ou une autre application veut commencer un message. Rien n'est envoyé ni enregistré sans votre accord.", + "share_confirm_subject": "Objet :", + "share_confirm_text": "Texte :", + "share_confirm_files": "{count, plural, one {Fichier :} other {Fichiers :}}", + "share_confirm_accept": "Commencer le message", + "share_confirm_decline": "Ne pas commencer", + "share_cut": "Le texte partagé était trop long et a été raccourci.", + "handoff_expired": "Un élément que vous avez ouvert ou partagé a attendu trop longtemps et n'est plus disponible. Ouvrez-le ou partagez-le à nouveau.", + "compose_waiting": "Fermez ce message pour commencer le suivant.", + "save_first_failed": "Votre message n'a pas pu être enregistré, le fichier n'a donc pas été ouvert. Enregistrez le message, puis ouvrez le calendrier ou les contacts." + }, "sidebar_apps": { "modal_title": "Applications de la barre latérale", "add_new": "Ajouter une application", @@ -4909,6 +4931,9 @@ "mini_has_events": "a des événements", "mini_week_number": "Semaine {week}" }, + "calendar_offline": { + "split_needs_server": "Cette modification nécessite une connexion au serveur. Modifiez seulement cet événement pour l'instant, ou réessayez une fois de nouveau en ligne." + }, "advanced_search": { "title": "Recherche avancée", "from": "De", @@ -6350,6 +6375,12 @@ "sign_language": "Langue des signes", "new_tab": "s'ouvre dans un nouvel onglet" }, + "pwaNotify": { + "action_archive": "Archiver", + "action_mark_read": "Marquer comme lu", + "action_reply": "Répondre", + "reply_placeholder": "Écrire une réponse" + }, "structured_mail_calendar": { "add_to_calendar": "Ajouter au calendrier", "update_in_calendar": "Mettre à jour dans le calendrier", diff --git a/locales/he/common.json b/locales/he/common.json index e44b1ebe3..fc2684187 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -4828,6 +4828,9 @@ "mini_has_events": "יש אירועים", "mini_week_number": "שבוע {week}" }, + "calendar_offline": { + "split_needs_server": "שינוי זה דורש חיבור לשרת. שנו כרגע רק את האירוע הזה, או נסו שוב כשהחיבור יחזור." + }, "advanced_search": { "title": "חיפוש מתקדם", "from": "מ", @@ -5272,6 +5275,28 @@ "active_account": "פעיל", "switching_account": "החלפת חשבון…" }, + "pwaLaunch": { + "shortcut_new_message": "הודעה חדשה", + "shortcut_calendar": "לוח שנה", + "shortcut_contacts": "אנשי קשר", + "unsupported_file": "לא ניתן לפתוח כאן את {name}.", + "open_failed": "לא ניתן היה לפתוח את הקובץ. נסו לפתוח אותו שוב.", + "share_gone": "מה ששיתפתם כבר לא נמצא כאן. שתפו אותו שוב.", + "share_files_lost": "לא ניתן היה לשמור את הקבצים ששותפו. הוסיפו אותם שוב להודעה.", + "ics_needs_server": "ייבוא קובץ לוח שנה דורש חיבור לשרת. הייבוא יתחיל כשתחזרו להיות מחוברים.", + "contacts_need_server": "ייבוא אנשי קשר דורש חיבור לשרת. נסו שוב כשתחזרו להיות מחוברים.", + "share_confirm_title": "להתחיל הודעה חדשה?", + "share_confirm_message": "אתר או אפליקציה אחרים רוצים להתחיל הודעה. שום דבר לא יועלה או יישמר בלי הסכמתכם.", + "share_confirm_subject": "נושא:", + "share_confirm_text": "טקסט:", + "share_confirm_files": "{count, plural, one {קובץ:} other {קבצים:}}", + "share_confirm_accept": "התחלת הודעה", + "share_confirm_decline": "לא להתחיל", + "share_cut": "הטקסט ששותף היה ארוך מדי וקוצר.", + "handoff_expired": "משהו שפתחתם או שיתפתם המתין יותר מדי זמן וכבר אינו כאן. פתחו או שתפו אותו שוב.", + "compose_waiting": "סגרו הודעה זו כדי להתחיל את הבאה.", + "save_first_failed": "לא ניתן היה לשמור את ההודעה, לכן הקובץ לא נפתח. שמרו את ההודעה ואז פתחו את לוח השנה או את אנשי הקשר." + }, "mailbox_context_menu": { "mark_folder_read": "סמן תיקייה כקרויה", "mark_folder_tree_read": "סמן תיקייה ותת־תיקיות כקרויות", @@ -6351,6 +6376,12 @@ "sign_language": "שפת סימנים", "new_tab": "נפתח בכרטיסייה חדשה" }, + "pwaNotify": { + "action_archive": "ארכיון", + "action_mark_read": "סמן כנקרא", + "action_reply": "השב", + "reply_placeholder": "כתיבת תשובה" + }, "structured_mail_calendar": { "add_to_calendar": "הוסף ליומן", "update_in_calendar": "עדכן ביומן", diff --git a/locales/hu/common.json b/locales/hu/common.json index d957c82c1..43cd7b8cc 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -249,6 +249,28 @@ "active_account": "Aktív", "switching_account": "Fiók váltása..." }, + "pwaLaunch": { + "shortcut_new_message": "Új üzenet", + "shortcut_calendar": "Naptár", + "shortcut_contacts": "Névjegyek", + "unsupported_file": "A(z) {name} itt nem nyitható meg.", + "open_failed": "A fájlt nem sikerült megnyitni. Próbálja meg újra megnyitni.", + "share_gone": "Amit megosztott, már nincs itt. Kérjük, ossza meg újra.", + "share_files_lost": "A megosztott fájlokat nem sikerült megőrizni. Adja hozzá őket újra az üzenethez.", + "ics_needs_server": "A naptárfájl importálásához kapcsolat kell a szerverrel. Az importálás akkor indul el, amikor újra online lesz.", + "contacts_need_server": "A névjegyek importálásához kapcsolat kell a szerverrel. Próbálja újra, amikor ismét online lesz.", + "share_confirm_title": "Új üzenetet kezd?", + "share_confirm_message": "Egy másik webhely vagy alkalmazás üzenetet szeretne kezdeni. Az Ön beleegyezése nélkül semmi sem kerül feltöltésre vagy mentésre.", + "share_confirm_subject": "Tárgy:", + "share_confirm_text": "Szöveg:", + "share_confirm_files": "{count, plural, one {Fájl:} other {Fájlok:}}", + "share_confirm_accept": "Üzenet kezdése", + "share_confirm_decline": "Ne kezdje el", + "share_cut": "A megosztott szöveg túl hosszú volt, ezért rövidítettük.", + "handoff_expired": "Valami, amit megnyitott vagy megosztott, túl sokáig várakozott, és már nincs meg. Kérjük, nyissa meg vagy ossza meg újra.", + "compose_waiting": "Zárja be ezt az üzenetet a következő kezdéséhez.", + "save_first_failed": "Az üzenetet nem sikerült menteni, ezért a fájl nem nyílt meg. Mentse az üzenetet, majd nyissa meg a naptárat vagy a névjegyeket." + }, "sidebar_apps": { "modal_title": "Oldalsáv alkalmazások", "add_new": "Alkalmazás hozzáadása", @@ -4909,6 +4931,9 @@ "mini_has_events": "vannak események", "mini_week_number": "{week}. hét" }, + "calendar_offline": { + "split_needs_server": "Ehhez a módosításhoz kapcsolat kell a kiszolgálóval. Egyelőre csak ezt az eseményt módosítsa, vagy próbálja újra, ha ismét online lesz." + }, "calendar_event_notifications": { "someone": "Valaki", "invited": "{name} meghívott a(z) „{title}” eseményre", @@ -6350,6 +6375,12 @@ "sign_language": "Jelnyelv", "new_tab": "új lapon nyílik meg" }, + "pwaNotify": { + "action_archive": "Archiválás", + "action_mark_read": "Megjelölés olvasottként", + "action_reply": "Válasz", + "reply_placeholder": "Válasz írása" + }, "structured_mail_calendar": { "add_to_calendar": "Hozzáadás a naptárhoz", "update_in_calendar": "Frissítés a naptárban", diff --git a/locales/it/common.json b/locales/it/common.json index ea5b317df..8e9df1dd1 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -249,6 +249,28 @@ "active_account": "Attivo", "switching_account": "Cambio account..." }, + "pwaLaunch": { + "shortcut_new_message": "Nuovo messaggio", + "shortcut_calendar": "Calendario", + "shortcut_contacts": "Contatti", + "unsupported_file": "{name} non può essere aperto qui.", + "open_failed": "Non è stato possibile aprire il file. Prova ad aprirlo di nuovo.", + "share_gone": "Ciò che hai condiviso non è più disponibile. Condividilo di nuovo.", + "share_files_lost": "Non è stato possibile conservare i file condivisi. Aggiungili di nuovo al messaggio.", + "ics_needs_server": "Per importare un file di calendario serve una connessione al server. L'importazione inizierà quando tornerai online.", + "contacts_need_server": "Per importare i contatti serve una connessione al server. Riprova quando tornerai online.", + "share_confirm_title": "Iniziare un nuovo messaggio?", + "share_confirm_message": "Un altro sito o app vuole iniziare un messaggio. Non viene caricato o salvato nulla senza il tuo consenso.", + "share_confirm_subject": "Oggetto:", + "share_confirm_text": "Testo:", + "share_confirm_files": "{count, plural, one {File:} other {File:}}", + "share_confirm_accept": "Inizia messaggio", + "share_confirm_decline": "Non iniziare", + "share_cut": "Il testo condiviso era troppo lungo ed è stato accorciato.", + "handoff_expired": "Qualcosa che hai aperto o condiviso ha atteso troppo a lungo e non c'è più. Aprilo o condividilo di nuovo.", + "compose_waiting": "Chiudi questo messaggio per iniziare il successivo.", + "save_first_failed": "Non è stato possibile salvare il messaggio, quindi il file non è stato aperto. Salva il messaggio, poi apri il calendario o i contatti." + }, "sidebar_apps": { "modal_title": "App della barra laterale", "add_new": "Aggiungi app", @@ -4909,6 +4931,9 @@ "mini_has_events": "ha eventi", "mini_week_number": "Settimana {week}" }, + "calendar_offline": { + "split_needs_server": "Questa modifica richiede una connessione al server. Per ora modifica solo questo evento, oppure riprova quando sei di nuovo online." + }, "advanced_search": { "title": "Ricerca avanzata", "from": "Da", @@ -6350,6 +6375,12 @@ "sign_language": "Lingua dei segni", "new_tab": "si apre in una nuova scheda" }, + "pwaNotify": { + "action_archive": "Archivia", + "action_mark_read": "Segna come letto", + "action_reply": "Rispondi", + "reply_placeholder": "Scrivi una risposta" + }, "structured_mail_calendar": { "add_to_calendar": "Aggiungi al calendario", "update_in_calendar": "Aggiorna nel calendario", diff --git a/locales/ja/common.json b/locales/ja/common.json index 7be2a0971..427d67e6b 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -249,6 +249,28 @@ "active_account": "アクティブ", "switching_account": "アカウントを切り替えています..." }, + "pwaLaunch": { + "shortcut_new_message": "新規メッセージ", + "shortcut_calendar": "カレンダー", + "shortcut_contacts": "連絡先", + "unsupported_file": "{name} はここでは開けません。", + "open_failed": "ファイルを開けませんでした。もう一度開いてください。", + "share_gone": "共有した内容はもうありません。もう一度共有してください。", + "share_files_lost": "共有したファイルを保持できませんでした。メッセージにもう一度追加してください。", + "ics_needs_server": "カレンダーファイルのインポートにはサーバーへの接続が必要です。オンラインに戻るとインポートが始まります。", + "contacts_need_server": "連絡先のインポートにはサーバーへの接続が必要です。オンラインに戻ってからもう一度お試しください。", + "share_confirm_title": "新しいメッセージを開始しますか?", + "share_confirm_message": "別のサイトまたはアプリがメッセージを開始しようとしています。同意しない限り、何もアップロードも保存もされません。", + "share_confirm_subject": "件名:", + "share_confirm_text": "本文:", + "share_confirm_files": "{count, plural, other {ファイル:}}", + "share_confirm_accept": "メッセージを開始", + "share_confirm_decline": "開始しない", + "share_cut": "共有されたテキストが長すぎるため短縮されました。", + "handoff_expired": "開いた、または共有した内容は待機時間が長すぎたためなくなりました。もう一度開くか共有してください。", + "compose_waiting": "次のメッセージを開始するには、このメッセージを閉じてください。", + "save_first_failed": "メッセージを保存できなかったため、ファイルを開いていません。メッセージを保存してから、カレンダーまたは連絡先を開いてください。" + }, "sidebar_apps": { "modal_title": "サイドバーアプリ", "add_new": "アプリを追加", @@ -4909,6 +4931,9 @@ "mini_has_events": "イベントあり", "mini_week_number": "第{week}週" }, + "calendar_offline": { + "split_needs_server": "この変更にはサーバーへの接続が必要です。今はこの予定だけを変更するか、オンラインに戻ってからもう一度お試しください。" + }, "advanced_search": { "title": "詳細検索", "from": "差出人", @@ -6350,6 +6375,12 @@ "sign_language": "手話", "new_tab": "新しいタブで開きます" }, + "pwaNotify": { + "action_archive": "アーカイブ", + "action_mark_read": "既読にする", + "action_reply": "返信", + "reply_placeholder": "返信を入力" + }, "structured_mail_calendar": { "add_to_calendar": "カレンダーに追加", "update_in_calendar": "カレンダーを更新", diff --git a/locales/ko/common.json b/locales/ko/common.json index e288ab8cd..c4f715426 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -249,6 +249,28 @@ "active_account": "활성", "switching_account": "계정 전환 중..." }, + "pwaLaunch": { + "shortcut_new_message": "새 메시지", + "shortcut_calendar": "캘린더", + "shortcut_contacts": "연락처", + "unsupported_file": "{name}은(는) 여기에서 열 수 없습니다.", + "open_failed": "파일을 열 수 없습니다. 다시 열어 보세요.", + "share_gone": "공유한 내용이 더 이상 없습니다. 다시 공유해 주세요.", + "share_files_lost": "공유한 파일을 보관할 수 없습니다. 메시지에 다시 추가해 주세요.", + "ics_needs_server": "캘린더 파일을 가져오려면 서버에 연결되어 있어야 합니다. 다시 온라인 상태가 되면 가져오기가 시작됩니다.", + "contacts_need_server": "연락처를 가져오려면 서버에 연결되어 있어야 합니다. 다시 온라인 상태가 되면 다시 시도해 주세요.", + "share_confirm_title": "새 메시지를 시작할까요?", + "share_confirm_message": "다른 사이트나 앱에서 메시지를 시작하려고 합니다. 동의하지 않으면 아무것도 업로드하거나 저장하지 않습니다.", + "share_confirm_subject": "제목:", + "share_confirm_text": "내용:", + "share_confirm_files": "{count, plural, other {파일:}}", + "share_confirm_accept": "메시지 시작", + "share_confirm_decline": "시작 안 함", + "share_cut": "공유한 텍스트가 너무 길어서 줄였습니다.", + "handoff_expired": "열었거나 공유한 항목이 너무 오래 대기하여 사라졌습니다. 다시 열거나 공유해 주세요.", + "compose_waiting": "다음 메시지를 시작하려면 이 메시지를 닫으세요.", + "save_first_failed": "메시지를 저장할 수 없어 파일을 열지 않았습니다. 메시지를 저장한 다음 캘린더나 연락처를 여세요." + }, "sidebar_apps": { "modal_title": "사이드바 앱", "add_new": "앱 추가", @@ -4909,6 +4931,9 @@ "mini_has_events": "일정 있음", "mini_week_number": "{week}주차" }, + "calendar_offline": { + "split_needs_server": "이 변경에는 서버 연결이 필요합니다. 지금은 이 일정만 변경하거나 다시 온라인이 되면 다시 시도하세요." + }, "advanced_search": { "title": "상세 검색", "from": "보낸 사람", @@ -6350,6 +6375,12 @@ "sign_language": "수어", "new_tab": "새 탭에서 열림" }, + "pwaNotify": { + "action_archive": "보관", + "action_mark_read": "읽은 상태로 표시", + "action_reply": "답장", + "reply_placeholder": "답장 쓰기" + }, "structured_mail_calendar": { "add_to_calendar": "캘린더에 추가", "update_in_calendar": "캘린더에서 업데이트", diff --git a/locales/lv/common.json b/locales/lv/common.json index 3caa2488c..456d20c2f 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -249,6 +249,28 @@ "active_account": "Aktīvs", "switching_account": "Notiek konta pārslēgšana..." }, + "pwaLaunch": { + "shortcut_new_message": "Jauna ziņa", + "shortcut_calendar": "Kalendārs", + "shortcut_contacts": "Kontakti", + "unsupported_file": "Failu {name} šeit nevar atvērt.", + "open_failed": "Failu neizdevās atvērt. Mēģiniet to atvērt vēlreiz.", + "share_gone": "Tas, ko kopīgojāt, šeit vairs nav. Lūdzu, kopīgojiet to vēlreiz.", + "share_files_lost": "Kopīgotos failus neizdevās saglabāt. Pievienojiet tos ziņai vēlreiz.", + "ics_needs_server": "Kalendāra faila importēšanai vajadzīgs savienojums ar serveri. Importēšana sāksies, kad atkal būsiet tiešsaistē.", + "contacts_need_server": "Kontaktu importēšanai vajadzīgs savienojums ar serveri. Mēģiniet vēlreiz, kad atkal būsiet tiešsaistē.", + "share_confirm_title": "Vai sākt jaunu ziņu?", + "share_confirm_message": "Cita vietne vai lietotne vēlas sākt ziņu. Bez jūsu piekrišanas nekas netiek augšupielādēts vai saglabāts.", + "share_confirm_subject": "Tēma:", + "share_confirm_text": "Teksts:", + "share_confirm_files": "{count, plural, zero {Faili:} one {Fails:} other {Faili:}}", + "share_confirm_accept": "Sākt ziņu", + "share_confirm_decline": "Nesākt", + "share_cut": "Kopīgotais teksts bija pārāk garš un tika saīsināts.", + "handoff_expired": "Tas, ko atvērāt vai kopīgojāt, gaidīja pārāk ilgi un vairs nav pieejams. Atveriet vai kopīgojiet to vēlreiz.", + "compose_waiting": "Aizveriet šo ziņu, lai sāktu nākamo.", + "save_first_failed": "Ziņu neizdevās saglabāt, tāpēc fails netika atvērts. Saglabājiet ziņu un pēc tam atveriet kalendāru vai kontaktus." + }, "sidebar_apps": { "modal_title": "Sānu joslas lietotnes", "add_new": "Pievienot lietotni", @@ -4909,6 +4931,9 @@ "mini_has_events": "ir notikumi", "mini_week_number": "{week}. nedēļa" }, + "calendar_offline": { + "split_needs_server": "Šīm izmaiņām nepieciešams savienojums ar serveri. Pagaidām mainiet tikai šo notikumu vai mēģiniet vēlreiz, kad būsiet tiešsaistē." + }, "advanced_search": { "title": "Izvērstā meklēšana", "from": "No", @@ -6350,6 +6375,12 @@ "sign_language": "Zīmju valoda", "new_tab": "atveras jaunā cilnē" }, + "pwaNotify": { + "action_archive": "Arhivēt", + "action_mark_read": "Atzīmēt kā izlasītu", + "action_reply": "Atbildēt", + "reply_placeholder": "Rakstiet atbildi" + }, "structured_mail_calendar": { "add_to_calendar": "Pievienot kalendāram", "update_in_calendar": "Atjaunināt kalendārā", diff --git a/locales/mn/common.json b/locales/mn/common.json index 623ac15ad..eb0e6dcd5 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -249,6 +249,28 @@ "active_account": "Идэвхтэй", "switching_account": "Бүртгэл сэлгэж байна..." }, + "pwaLaunch": { + "shortcut_new_message": "Шинэ зурвас", + "shortcut_calendar": "Хуанли", + "shortcut_contacts": "Харилцагчид", + "unsupported_file": "{name}-г энд нээх боломжгүй.", + "open_failed": "Файлыг нээж чадсангүй. Дахин нээж үзнэ үү.", + "share_gone": "Таны хуваалцсан зүйл энд байхгүй болсон. Дахин хуваалцана уу.", + "share_files_lost": "Хуваалцсан файлуудыг хадгалж чадсангүй. Зурваст дахин нэмнэ үү.", + "ics_needs_server": "Хуанлийн файл импортлоход сервертэй холболт хэрэгтэй. Та дахин онлайн болоход импорт эхэлнэ.", + "contacts_need_server": "Харилцагчдыг импортлоход сервертэй холболт хэрэгтэй. Дахин онлайн болсон үедээ оролдоно уу.", + "share_confirm_title": "Шинэ зурвас эхлүүлэх үү?", + "share_confirm_message": "Өөр сайт эсвэл апп зурвас эхлүүлэхийг хүсэж байна. Та зөвшөөрөхгүй бол юу ч байршуулахгүй, хадгалахгүй.", + "share_confirm_subject": "Гарчиг:", + "share_confirm_text": "Текст:", + "share_confirm_files": "{count, plural, one {Файл:} other {Файлууд:}}", + "share_confirm_accept": "Зурвас эхлүүлэх", + "share_confirm_decline": "Эхлүүлэхгүй", + "share_cut": "Хуваалцсан текст хэт урт байсан тул богиносгосон.", + "handoff_expired": "Таны нээсэн эсвэл хуваалцсан зүйл хэт удаан хүлээсэн тул байхгүй болсон. Дахин нээх эсвэл хуваалцана уу.", + "compose_waiting": "Дараагийнхыг эхлүүлэхийн тулд энэ зурвасыг хаана уу.", + "save_first_failed": "Зурвасыг хадгалж чадаагүй тул файлыг нээгээгүй. Зурвасаа хадгалаад хуанли эсвэл харилцагчдыг нээнэ үү." + }, "sidebar_apps": { "modal_title": "Sidebar програмууд", "add_new": "Апп нэмэх", @@ -4909,6 +4931,9 @@ "mini_has_events": "үйл явдалтай", "mini_week_number": "{week}-р долоо хоног" }, + "calendar_offline": { + "split_needs_server": "Энэ өөрчлөлтөд сервертэй холболт шаардлагатай. Одоохондоо зөвхөн энэ үйл явдлыг өөрчлөх эсвэл онлайн болсны дараа дахин оролдоно уу." + }, "calendar_event_notifications": { "someone": "Хэн нэгэн", "invited": "{name} таныг \"{title}\"-д урилаа", @@ -6350,6 +6375,12 @@ "sign_language": "Дохионы хэл", "new_tab": "шинэ табад нээгдэнэ" }, + "pwaNotify": { + "action_archive": "Архив", + "action_mark_read": "Уншсан гэж тэмдэглэх", + "action_reply": "Хариулах", + "reply_placeholder": "Хариу бичих" + }, "structured_mail_calendar": { "add_to_calendar": "Хуанлид нэмэх", "update_in_calendar": "Хуанлид шинэчлэх", diff --git a/locales/nb/common.json b/locales/nb/common.json index 9cb532938..42e370dec 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -249,6 +249,28 @@ "active_account": "Aktiv", "switching_account": "Bytter konto..." }, + "pwaLaunch": { + "shortcut_new_message": "Ny melding", + "shortcut_calendar": "Kalender", + "shortcut_contacts": "Kontakter", + "unsupported_file": "{name} kan ikke åpnes her.", + "open_failed": "Filen kunne ikke åpnes. Prøv å åpne den på nytt.", + "share_gone": "Det du delte, er ikke her lenger. Del det på nytt.", + "share_files_lost": "De delte filene kunne ikke tas vare på. Legg dem til i meldingen på nytt.", + "ics_needs_server": "Import av en kalenderfil krever tilkobling til serveren. Importen starter når du er på nett igjen.", + "contacts_need_server": "Import av kontakter krever tilkobling til serveren. Prøv igjen når du er på nett igjen.", + "share_confirm_title": "Vil du starte en ny melding?", + "share_confirm_message": "Et annet nettsted eller en annen app vil starte en melding. Ingenting lastes opp eller lagres uten at du godtar det.", + "share_confirm_subject": "Emne:", + "share_confirm_text": "Tekst:", + "share_confirm_files": "{count, plural, one {Fil:} other {Filer:}}", + "share_confirm_accept": "Start melding", + "share_confirm_decline": "Ikke start", + "share_cut": "Den delte teksten var for lang og er forkortet.", + "handoff_expired": "Noe du åpnet eller delte, ventet for lenge og er borte. Åpne eller del det på nytt.", + "compose_waiting": "Lukk denne meldingen for å starte den neste.", + "save_first_failed": "Meldingen kunne ikke lagres, så filen er ikke åpnet. Lagre meldingen, og åpne deretter kalenderen eller kontaktene." + }, "sidebar_apps": { "modal_title": "Apper i sidefeltet", "add_new": "Legg til app", @@ -4909,6 +4931,9 @@ "mini_has_events": "har hendelser", "mini_week_number": "Uke {week}" }, + "calendar_offline": { + "split_needs_server": "Denne endringen krever tilkobling til serveren. Endre bare denne hendelsen for nå, eller prøv igjen når du er tilkoblet." + }, "calendar_event_notifications": { "someone": "Noen", "invited": "{name} har invitert deg til «{title}»", @@ -6350,6 +6375,12 @@ "sign_language": "Tegnspråk", "new_tab": "åpnes i en ny fane" }, + "pwaNotify": { + "action_archive": "Arkiver", + "action_mark_read": "Merk som lest", + "action_reply": "Svar", + "reply_placeholder": "Skriv et svar" + }, "structured_mail_calendar": { "add_to_calendar": "Legg til i kalenderen", "update_in_calendar": "Oppdater i kalenderen", diff --git a/locales/nl/common.json b/locales/nl/common.json index 80af5c4e4..6dd627664 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -249,6 +249,28 @@ "active_account": "Actief", "switching_account": "Account wisselen..." }, + "pwaLaunch": { + "shortcut_new_message": "Nieuw bericht", + "shortcut_calendar": "Agenda", + "shortcut_contacts": "Contacten", + "unsupported_file": "{name} kan hier niet worden geopend.", + "open_failed": "Het bestand kon niet worden geopend. Probeer het opnieuw te openen.", + "share_gone": "Wat je hebt gedeeld, is er niet meer. Deel het opnieuw.", + "share_files_lost": "De gedeelde bestanden konden niet worden bewaard. Voeg ze opnieuw aan het bericht toe.", + "ics_needs_server": "Voor het importeren van een agendabestand is een verbinding met de server nodig. Het importeren begint zodra je weer online bent.", + "contacts_need_server": "Voor het importeren van contacten is een verbinding met de server nodig. Probeer het opnieuw als je weer online bent.", + "share_confirm_title": "Een nieuw bericht beginnen?", + "share_confirm_message": "Een andere site of app wil een bericht beginnen. Er wordt niets geüpload of opgeslagen zonder je toestemming.", + "share_confirm_subject": "Onderwerp:", + "share_confirm_text": "Tekst:", + "share_confirm_files": "{count, plural, one {Bestand:} other {Bestanden:}}", + "share_confirm_accept": "Bericht beginnen", + "share_confirm_decline": "Niet beginnen", + "share_cut": "De gedeelde tekst was te lang en is ingekort.", + "handoff_expired": "Iets wat je hebt geopend of gedeeld, heeft te lang gewacht en is weg. Open of deel het opnieuw.", + "compose_waiting": "Sluit dit bericht om het volgende te beginnen.", + "save_first_failed": "Je bericht kon niet worden opgeslagen, dus het bestand is niet geopend. Sla het bericht op en open daarna de agenda of de contacten." + }, "sidebar_apps": { "modal_title": "Zijbalk-apps", "add_new": "App toevoegen", @@ -4909,6 +4931,9 @@ "mini_has_events": "heeft afspraken", "mini_week_number": "Week {week}" }, + "calendar_offline": { + "split_needs_server": "Voor deze wijziging is een verbinding met de server nodig. Wijzig voorlopig alleen deze afspraak, of probeer het opnieuw zodra je weer online bent." + }, "advanced_search": { "title": "Geavanceerd zoeken", "from": "Van", @@ -6350,6 +6375,12 @@ "sign_language": "Gebarentaal", "new_tab": "opent in een nieuw tabblad" }, + "pwaNotify": { + "action_archive": "Archiveren", + "action_mark_read": "Markeren als gelezen", + "action_reply": "Beantwoorden", + "reply_placeholder": "Schrijf een antwoord" + }, "structured_mail_calendar": { "add_to_calendar": "Toevoegen aan agenda", "update_in_calendar": "Bijwerken in agenda", diff --git a/locales/pl/common.json b/locales/pl/common.json index 60a53be78..b3aae3664 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -249,6 +249,28 @@ "active_account": "Aktywne", "switching_account": "Przełączanie konta..." }, + "pwaLaunch": { + "shortcut_new_message": "Nowa wiadomość", + "shortcut_calendar": "Kalendarz", + "shortcut_contacts": "Kontakty", + "unsupported_file": "Nie można tu otworzyć pliku {name}.", + "open_failed": "Nie udało się otworzyć pliku. Spróbuj otworzyć go ponownie.", + "share_gone": "To, co udostępniono, już tu nie istnieje. Udostępnij to ponownie.", + "share_files_lost": "Nie udało się zachować udostępnionych plików. Dodaj je ponownie do wiadomości.", + "ics_needs_server": "Import pliku kalendarza wymaga połączenia z serwerem. Import zacznie się, gdy znów będziesz online.", + "contacts_need_server": "Import kontaktów wymaga połączenia z serwerem. Spróbuj ponownie, gdy znów będziesz online.", + "share_confirm_title": "Rozpocząć nową wiadomość?", + "share_confirm_message": "Inna witryna lub aplikacja chce rozpocząć wiadomość. Bez Twojej zgody nic nie zostanie wysłane ani zapisane.", + "share_confirm_subject": "Temat:", + "share_confirm_text": "Tekst:", + "share_confirm_files": "{count, plural, one {Plik:} few {Pliki:} many {Pliki:} other {Pliki:}}", + "share_confirm_accept": "Rozpocznij wiadomość", + "share_confirm_decline": "Nie rozpoczynaj", + "share_cut": "Udostępniony tekst był za długi i został skrócony.", + "handoff_expired": "To, co otwarto lub udostępniono, czekało zbyt długo i już go nie ma. Otwórz lub udostępnij to ponownie.", + "compose_waiting": "Zamknij tę wiadomość, aby rozpocząć następną.", + "save_first_failed": "Nie udało się zapisać wiadomości, więc plik nie został otwarty. Zapisz wiadomość, a potem otwórz kalendarz lub kontakty." + }, "sidebar_apps": { "modal_title": "Aplikacje paska bocznego", "add_new": "Dodaj aplikację", @@ -4909,6 +4931,9 @@ "mini_has_events": "ma wydarzenia", "mini_week_number": "Tydzień {week}" }, + "calendar_offline": { + "split_needs_server": "Ta zmiana wymaga połączenia z serwerem. Na razie zmień tylko to wydarzenie albo spróbuj ponownie, gdy wrócisz do trybu online." + }, "advanced_search": { "title": "Wyszukiwanie zaawansowane", "from": "Od", @@ -6350,6 +6375,12 @@ "sign_language": "Język migowy", "new_tab": "otwiera się w nowej karcie" }, + "pwaNotify": { + "action_archive": "Archiwizuj", + "action_mark_read": "Oznacz jako przeczytane", + "action_reply": "Odpowiedz", + "reply_placeholder": "Napisz odpowiedź" + }, "structured_mail_calendar": { "add_to_calendar": "Dodaj do kalendarza", "update_in_calendar": "Zaktualizuj w kalendarzu", diff --git a/locales/pt/common.json b/locales/pt/common.json index 079316bd5..c7a29368c 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -249,6 +249,28 @@ "active_account": "Ativa", "switching_account": "Alternando conta..." }, + "pwaLaunch": { + "shortcut_new_message": "Nova mensagem", + "shortcut_calendar": "Calendário", + "shortcut_contacts": "Contactos", + "unsupported_file": "Não é possível abrir {name} aqui.", + "open_failed": "Não foi possível abrir o ficheiro. Tente abri-lo novamente.", + "share_gone": "O que partilhou já não está aqui. Partilhe novamente.", + "share_files_lost": "Não foi possível guardar os ficheiros partilhados. Adicione-os novamente à mensagem.", + "ics_needs_server": "Importar um ficheiro de calendário precisa de uma ligação ao servidor. A importação começa quando voltar a estar online.", + "contacts_need_server": "Importar contactos precisa de uma ligação ao servidor. Tente novamente quando voltar a estar online.", + "share_confirm_title": "Começar uma nova mensagem?", + "share_confirm_message": "Outro site ou aplicação quer começar uma mensagem. Nada é carregado ou guardado sem o seu acordo.", + "share_confirm_subject": "Assunto:", + "share_confirm_text": "Texto:", + "share_confirm_files": "{count, plural, one {Ficheiro:} other {Ficheiros:}}", + "share_confirm_accept": "Começar mensagem", + "share_confirm_decline": "Não começar", + "share_cut": "O texto partilhado era demasiado longo e foi encurtado.", + "handoff_expired": "Algo que abriu ou partilhou esperou demasiado tempo e já não está disponível. Abra ou partilhe novamente.", + "compose_waiting": "Feche esta mensagem para começar a seguinte.", + "save_first_failed": "Não foi possível guardar a mensagem, por isso o ficheiro não foi aberto. Guarde a mensagem e depois abra o calendário ou os contactos." + }, "sidebar_apps": { "modal_title": "Apps da barra lateral", "add_new": "Adicionar app", @@ -4909,6 +4931,9 @@ "mini_has_events": "tem eventos", "mini_week_number": "Semana {week}" }, + "calendar_offline": { + "split_needs_server": "Esta alteração precisa de ligação ao servidor. Altere apenas este evento por agora, ou tente de novo quando voltar a estar online." + }, "advanced_search": { "title": "Pesquisa avançada", "from": "De", @@ -6350,6 +6375,12 @@ "sign_language": "Língua de sinais", "new_tab": "abre em uma nova aba" }, + "pwaNotify": { + "action_archive": "Arquivar", + "action_mark_read": "Marcar como lido", + "action_reply": "Responder", + "reply_placeholder": "Escreva uma resposta" + }, "structured_mail_calendar": { "add_to_calendar": "Adicionar ao calendário", "update_in_calendar": "Atualizar no calendário", diff --git a/locales/ro/common.json b/locales/ro/common.json index 36be951f0..d40ff3966 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -249,6 +249,28 @@ "active_account": "Activ", "switching_account": "Se schimbă contul..." }, + "pwaLaunch": { + "shortcut_new_message": "Mesaj nou", + "shortcut_calendar": "Calendar", + "shortcut_contacts": "Contacte", + "unsupported_file": "{name} nu poate fi deschis aici.", + "open_failed": "Fișierul nu a putut fi deschis. Încercați să îl deschideți din nou.", + "share_gone": "Ce ați partajat nu mai este aici. Partajați din nou.", + "share_files_lost": "Fișierele partajate nu au putut fi păstrate. Adăugați-le din nou la mesaj.", + "ics_needs_server": "Importul unui fișier de calendar are nevoie de conexiune la server. Importul începe când reveniți online.", + "contacts_need_server": "Importul contactelor are nevoie de conexiune la server. Încercați din nou când reveniți online.", + "share_confirm_title": "Începeți un mesaj nou?", + "share_confirm_message": "Alt site sau altă aplicație vrea să înceapă un mesaj. Nu se încarcă și nu se salvează nimic fără acordul dvs.", + "share_confirm_subject": "Subiect:", + "share_confirm_text": "Text:", + "share_confirm_files": "{count, plural, one {Fișier:} few {Fișiere:} other {Fișiere:}}", + "share_confirm_accept": "Începeți mesajul", + "share_confirm_decline": "Nu începeți", + "share_cut": "Textul partajat era prea lung și a fost scurtat.", + "handoff_expired": "Ceva ce ați deschis sau partajat a așteptat prea mult și nu mai este disponibil. Deschideți sau partajați din nou.", + "compose_waiting": "Închideți acest mesaj pentru a-l începe pe următorul.", + "save_first_failed": "Mesajul nu a putut fi salvat, așa că fișierul nu a fost deschis. Salvați mesajul, apoi deschideți calendarul sau contactele." + }, "sidebar_apps": { "modal_title": "Aplicații din bara laterală", "add_new": "Adăugați aplicația", @@ -4909,6 +4931,9 @@ "mini_has_events": "are evenimente", "mini_week_number": "Săptămâna {week}" }, + "calendar_offline": { + "split_needs_server": "Această modificare are nevoie de conexiune la server. Modifică deocamdată doar acest eveniment sau încearcă din nou când ești din nou online." + }, "calendar_event_notifications": { "someone": "Cineva", "invited": "{name} te-a invitat la „{title}”", @@ -6350,6 +6375,12 @@ "sign_language": "Limbaj mimico-gestual", "new_tab": "se deschide într-o filă nouă" }, + "pwaNotify": { + "action_archive": "Arhivează", + "action_mark_read": "Marcați ca citit", + "action_reply": "Răspunde", + "reply_placeholder": "Scrieți un răspuns" + }, "structured_mail_calendar": { "add_to_calendar": "Adaugă în calendar", "update_in_calendar": "Actualizează în calendar", diff --git a/locales/ru/common.json b/locales/ru/common.json index b4e299886..62bdd8add 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -249,6 +249,28 @@ "active_account": "Активен", "switching_account": "Переключение аккаунта..." }, + "pwaLaunch": { + "shortcut_new_message": "Новое письмо", + "shortcut_calendar": "Календарь", + "shortcut_contacts": "Контакты", + "unsupported_file": "{name} нельзя открыть здесь.", + "open_failed": "Не удалось открыть файл. Попробуйте открыть его ещё раз.", + "share_gone": "То, чем вы поделились, больше недоступно. Поделитесь ещё раз.", + "share_files_lost": "Не удалось сохранить полученные файлы. Добавьте их в письмо ещё раз.", + "ics_needs_server": "Для импорта файла календаря нужно подключение к серверу. Импорт начнётся, когда вы снова будете в сети.", + "contacts_need_server": "Для импорта контактов нужно подключение к серверу. Попробуйте ещё раз, когда снова будете в сети.", + "share_confirm_title": "Начать новое письмо?", + "share_confirm_message": "Другой сайт или приложение хочет начать письмо. Без вашего согласия ничего не будет загружено или сохранено.", + "share_confirm_subject": "Тема:", + "share_confirm_text": "Текст:", + "share_confirm_files": "{count, plural, one {Файл:} few {Файлы:} many {Файлы:} other {Файлы:}}", + "share_confirm_accept": "Начать письмо", + "share_confirm_decline": "Не начинать", + "share_cut": "Полученный текст был слишком длинным и был сокращён.", + "handoff_expired": "То, что вы открыли или чем поделились, ждало слишком долго и больше недоступно. Откройте или поделитесь ещё раз.", + "compose_waiting": "Закройте это письмо, чтобы начать следующее.", + "save_first_failed": "Не удалось сохранить письмо, поэтому файл не открыт. Сохраните письмо, затем откройте календарь или контакты." + }, "sidebar_apps": { "modal_title": "Приложения боковой панели", "add_new": "Добавить приложение", @@ -4909,6 +4931,9 @@ "mini_has_events": "есть события", "mini_week_number": "Неделя {week}" }, + "calendar_offline": { + "split_needs_server": "Для этого изменения нужно подключение к серверу. Пока измените только это событие или повторите попытку, когда снова будете в сети." + }, "advanced_search": { "title": "Расширенный поиск", "from": "От", @@ -6350,6 +6375,12 @@ "sign_language": "Жестовый язык", "new_tab": "открывается в новой вкладке" }, + "pwaNotify": { + "action_archive": "Архивировать", + "action_mark_read": "Отметить прочитанным", + "action_reply": "Ответить", + "reply_placeholder": "Напишите ответ" + }, "structured_mail_calendar": { "add_to_calendar": "Добавить в календарь", "update_in_calendar": "Обновить в календаре", diff --git a/locales/sk/common.json b/locales/sk/common.json index e2d4f3d6e..92deb06b3 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -249,6 +249,28 @@ "active_account": "Aktívny", "switching_account": "Prepínanie účtu..." }, + "pwaLaunch": { + "shortcut_new_message": "Nová správa", + "shortcut_calendar": "Kalendár", + "shortcut_contacts": "Kontakty", + "unsupported_file": "{name} sa tu nedá otvoriť.", + "open_failed": "Súbor sa nepodarilo otvoriť. Skúste ho otvoriť znova.", + "share_gone": "To, čo ste zdieľali, tu už nie je. Zdieľajte to prosím znova.", + "share_files_lost": "Zdieľané súbory sa nepodarilo uchovať. Pridajte ich do správy znova.", + "ics_needs_server": "Import súboru kalendára vyžaduje pripojenie k serveru. Import sa začne, keď budete znova online.", + "contacts_need_server": "Import kontaktov vyžaduje pripojenie k serveru. Skúste to znova, keď budete online.", + "share_confirm_title": "Začať novú správu?", + "share_confirm_message": "Iný web alebo aplikácia chce začať správu. Bez vášho súhlasu sa nič nenahrá ani neuloží.", + "share_confirm_subject": "Predmet:", + "share_confirm_text": "Text:", + "share_confirm_files": "{count, plural, one {Súbor:} few {Súbory:} many {Súborov:} other {Súbory:}}", + "share_confirm_accept": "Začať správu", + "share_confirm_decline": "Nezačínať", + "share_cut": "Zdieľaný text bol príliš dlhý a bol skrátený.", + "handoff_expired": "To, čo ste otvorili alebo zdieľali, čakalo príliš dlho a už tu nie je. Otvorte to alebo zdieľajte znova.", + "compose_waiting": "Zatvorte túto správu, aby ste mohli začať ďalšiu.", + "save_first_failed": "Správu sa nepodarilo uložiť, preto sa súbor neotvoril. Uložte správu a potom otvorte kalendár alebo kontakty." + }, "sidebar_apps": { "modal_title": "Aplikácie postranného panela", "add_new": "Pridať aplikáciu", @@ -4909,6 +4931,9 @@ "mini_has_events": "má udalosti", "mini_week_number": "Týždeň {week}" }, + "calendar_offline": { + "split_needs_server": "Táto zmena vyžaduje pripojenie k serveru. Zatiaľ zmeňte iba túto udalosť alebo to skúste znova, keď budete online." + }, "calendar_event_notifications": { "someone": "Niekto", "invited": "{name} vás pozval(a) na „{title}“", @@ -6350,6 +6375,12 @@ "sign_language": "Posunkový jazyk", "new_tab": "otvorí sa na novej karte" }, + "pwaNotify": { + "action_archive": "Archivovať", + "action_mark_read": "Označiť ako prečítané", + "action_reply": "Odpovedať", + "reply_placeholder": "Napíšte odpoveď" + }, "structured_mail_calendar": { "add_to_calendar": "Pridať do kalendára", "update_in_calendar": "Aktualizovať v kalendári", diff --git a/locales/th/common.json b/locales/th/common.json index f19480b77..f01cb6abf 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2382,6 +2382,28 @@ "active_account": "ใช้งานอยู่", "switching_account": "กำลังสลับบัญชี..." }, + "pwaLaunch": { + "shortcut_new_message": "ข้อความใหม่", + "shortcut_calendar": "ปฏิทิน", + "shortcut_contacts": "รายชื่อติดต่อ", + "unsupported_file": "ไม่สามารถเปิด {name} ที่นี่ได้", + "open_failed": "เปิดไฟล์ไม่ได้ ลองเปิดอีกครั้ง", + "share_gone": "สิ่งที่คุณแชร์ไม่อยู่ที่นี่แล้ว โปรดแชร์อีกครั้ง", + "share_files_lost": "เก็บไฟล์ที่แชร์ไว้ไม่ได้ โปรดเพิ่มไฟล์ลงในข้อความอีกครั้ง", + "ics_needs_server": "การนำเข้าไฟล์ปฏิทินต้องเชื่อมต่อกับเซิร์ฟเวอร์ การนำเข้าจะเริ่มเมื่อคุณกลับมาออนไลน์", + "contacts_need_server": "การนำเข้ารายชื่อติดต่อต้องเชื่อมต่อกับเซิร์ฟเวอร์ โปรดลองอีกครั้งเมื่อคุณกลับมาออนไลน์", + "share_confirm_title": "เริ่มข้อความใหม่หรือไม่", + "share_confirm_message": "เว็บไซต์หรือแอปอื่นต้องการเริ่มข้อความ จะไม่มีการอัปโหลดหรือบันทึกสิ่งใดหากคุณไม่ยินยอม", + "share_confirm_subject": "หัวเรื่อง:", + "share_confirm_text": "ข้อความ:", + "share_confirm_files": "{count, plural, other {ไฟล์:}}", + "share_confirm_accept": "เริ่มข้อความ", + "share_confirm_decline": "ไม่เริ่ม", + "share_cut": "ข้อความที่แชร์ยาวเกินไปจึงถูกตัดให้สั้นลง", + "handoff_expired": "สิ่งที่คุณเปิดหรือแชร์รอนานเกินไปและไม่อยู่แล้ว โปรดเปิดหรือแชร์อีกครั้ง", + "compose_waiting": "ปิดข้อความนี้เพื่อเริ่มข้อความถัดไป", + "save_first_failed": "บันทึกข้อความไม่ได้ จึงยังไม่เปิดไฟล์ โปรดบันทึกข้อความแล้วเปิดปฏิทินหรือรายชื่อติดต่อ" + }, "sidebar_apps": { "modal_title": "แอปพลิเคชันแถบด้านข้าง", "add_new": "เพิ่มแอป", @@ -4092,6 +4114,9 @@ "mini_has_events": "มีกิจกรรม", "mini_week_number": "สัปดาห์ที่ {week}" }, + "calendar_offline": { + "split_needs_server": "การเปลี่ยนแปลงนี้ต้องเชื่อมต่อกับเซิร์ฟเวอร์ ตอนนี้เปลี่ยนเฉพาะกิจกรรมนี้ หรือลองอีกครั้งเมื่อกลับมาออนไลน์" + }, "settings": { "title": "การตั้งค่า", "org_locked": "กำหนดโดยองค์กรของคุณ", @@ -6358,6 +6383,12 @@ "sign_language": "ภาษามือ", "new_tab": "เปิดในแท็บใหม่" }, + "pwaNotify": { + "action_archive": "เก็บถาวร", + "action_mark_read": "ทำเครื่องหมายว่าอ่านแล้ว", + "action_reply": "ตอบกลับ", + "reply_placeholder": "เขียนคำตอบ" + }, "structured_mail_calendar": { "add_to_calendar": "เพิ่มลงในปฏิทิน", "update_in_calendar": "อัปเดตในปฏิทิน", diff --git a/locales/tr/common.json b/locales/tr/common.json index cab9c409e..ba9bf2aff 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -249,6 +249,28 @@ "active_account": "Etkin", "switching_account": "Hesap değiştiriliyor..." }, + "pwaLaunch": { + "shortcut_new_message": "Yeni ileti", + "shortcut_calendar": "Takvim", + "shortcut_contacts": "Kişiler", + "unsupported_file": "{name} burada açılamıyor.", + "open_failed": "Dosya açılamadı. Yeniden açmayı deneyin.", + "share_gone": "Paylaştığınız içerik artık burada değil. Lütfen yeniden paylaşın.", + "share_files_lost": "Paylaşılan dosyalar saklanamadı. Onları iletiye yeniden ekleyin.", + "ics_needs_server": "Takvim dosyasını içe aktarmak için sunucu bağlantısı gerekir. İçe aktarma, yeniden çevrimiçi olduğunuzda başlar.", + "contacts_need_server": "Kişileri içe aktarmak için sunucu bağlantısı gerekir. Yeniden çevrimiçi olduğunuzda tekrar deneyin.", + "share_confirm_title": "Yeni bir ileti başlatılsın mı?", + "share_confirm_message": "Başka bir site veya uygulama ileti başlatmak istiyor. Siz kabul etmedikçe hiçbir şey yüklenmez veya kaydedilmez.", + "share_confirm_subject": "Konu:", + "share_confirm_text": "Metin:", + "share_confirm_files": "{count, plural, one {Dosya:} other {Dosyalar:}}", + "share_confirm_accept": "İletiyi başlat", + "share_confirm_decline": "Başlatma", + "share_cut": "Paylaşılan metin çok uzundu ve kısaltıldı.", + "handoff_expired": "Açtığınız veya paylaştığınız bir şey çok uzun süre bekledi ve artık yok. Lütfen yeniden açın veya paylaşın.", + "compose_waiting": "Sonrakini başlatmak için bu iletiyi kapatın.", + "save_first_failed": "İletiniz kaydedilemedi, bu yüzden dosya açılmadı. İletiyi kaydedin, ardından takvimi veya kişileri açın." + }, "sidebar_apps": { "modal_title": "Kenar çubuğu uygulamaları", "add_new": "Uygulama ekle", @@ -4909,6 +4931,9 @@ "mini_has_events": "etkinlik var", "mini_week_number": "{week}. hafta" }, + "calendar_offline": { + "split_needs_server": "Bu değişiklik için sunucu bağlantısı gerekir. Şimdilik yalnızca bu etkinliği değiştirin veya yeniden çevrimiçi olduğunuzda tekrar deneyin." + }, "calendar_event_notifications": { "someone": "Birisi", "invited": "{name} sizi \"{title}\" etkinliğine davet etti", @@ -6350,6 +6375,12 @@ "sign_language": "İşaret dili", "new_tab": "yeni sekmede açılır" }, + "pwaNotify": { + "action_archive": "Arşivle", + "action_mark_read": "Okundu olarak işaretle", + "action_reply": "Yanıtla", + "reply_placeholder": "Yanıt yazın" + }, "structured_mail_calendar": { "add_to_calendar": "Takvime ekle", "update_in_calendar": "Takvimde güncelle", diff --git a/locales/uk/common.json b/locales/uk/common.json index 11b850991..267821950 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -249,6 +249,28 @@ "active_account": "Активний", "switching_account": "Перемикання акаунта..." }, + "pwaLaunch": { + "shortcut_new_message": "Новий лист", + "shortcut_calendar": "Календар", + "shortcut_contacts": "Контакти", + "unsupported_file": "{name} не можна відкрити тут.", + "open_failed": "Не вдалося відкрити файл. Спробуйте відкрити його ще раз.", + "share_gone": "Те, чим ви поділилися, більше недоступне. Поділіться ще раз.", + "share_files_lost": "Не вдалося зберегти отримані файли. Додайте їх до листа ще раз.", + "ics_needs_server": "Для імпорту файлу календаря потрібне підключення до сервера. Імпорт почнеться, коли ви знову будете в мережі.", + "contacts_need_server": "Для імпорту контактів потрібне підключення до сервера. Спробуйте ще раз, коли знову будете в мережі.", + "share_confirm_title": "Почати новий лист?", + "share_confirm_message": "Інший сайт або застосунок хоче почати лист. Без вашої згоди нічого не буде завантажено чи збережено.", + "share_confirm_subject": "Тема:", + "share_confirm_text": "Текст:", + "share_confirm_files": "{count, plural, one {Файл:} few {Файли:} many {Файли:} other {Файли:}}", + "share_confirm_accept": "Почати лист", + "share_confirm_decline": "Не починати", + "share_cut": "Отриманий текст був задовгий, тому його скорочено.", + "handoff_expired": "Те, що ви відкрили або чим поділилися, чекало надто довго і більше недоступне. Відкрийте або поділіться ще раз.", + "compose_waiting": "Закрийте цей лист, щоб почати наступний.", + "save_first_failed": "Не вдалося зберегти лист, тому файл не відкрито. Збережіть лист, а потім відкрийте календар або контакти." + }, "sidebar_apps": { "modal_title": "Програми бічної панелі", "add_new": "Додати додаток", @@ -4909,6 +4931,9 @@ "mini_has_events": "є події", "mini_week_number": "Тиждень {week}" }, + "calendar_offline": { + "split_needs_server": "Для цієї зміни потрібне з'єднання із сервером. Поки що змініть лише цю подію або спробуйте ще раз, коли знову будете в мережі." + }, "advanced_search": { "title": "Розширений пошук", "from": "Від", @@ -6350,6 +6375,12 @@ "sign_language": "Жестова мова", "new_tab": "відкривається в новій вкладці" }, + "pwaNotify": { + "action_archive": "Архів", + "action_mark_read": "Позначити як прочитане", + "action_reply": "Відповісти", + "reply_placeholder": "Напишіть відповідь" + }, "structured_mail_calendar": { "add_to_calendar": "Додати в календар", "update_in_calendar": "Оновити в календарі", diff --git a/locales/vi/common.json b/locales/vi/common.json index fe4c3bd54..2dc0d5f06 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -249,6 +249,28 @@ "active_account": "Đang hoạt động", "switching_account": "Đang chuyển đổi tài khoản..." }, + "pwaLaunch": { + "shortcut_new_message": "Thư mới", + "shortcut_calendar": "Lịch", + "shortcut_contacts": "Danh bạ", + "unsupported_file": "Không thể mở {name} tại đây.", + "open_failed": "Không thể mở tệp. Hãy thử mở lại.", + "share_gone": "Nội dung bạn đã chia sẻ không còn ở đây. Vui lòng chia sẻ lại.", + "share_files_lost": "Không thể giữ các tệp đã chia sẻ. Hãy thêm lại chúng vào thư.", + "ics_needs_server": "Nhập tệp lịch cần kết nối với máy chủ. Quá trình nhập sẽ bắt đầu khi bạn trực tuyến trở lại.", + "contacts_need_server": "Nhập danh bạ cần kết nối với máy chủ. Hãy thử lại khi bạn trực tuyến trở lại.", + "share_confirm_title": "Bắt đầu thư mới?", + "share_confirm_message": "Một trang web hoặc ứng dụng khác muốn bắt đầu một thư. Sẽ không có gì được tải lên hoặc lưu nếu bạn không đồng ý.", + "share_confirm_subject": "Chủ đề:", + "share_confirm_text": "Nội dung:", + "share_confirm_files": "{count, plural, other {Tệp:}}", + "share_confirm_accept": "Bắt đầu thư", + "share_confirm_decline": "Không bắt đầu", + "share_cut": "Văn bản được chia sẻ quá dài và đã được rút ngắn.", + "handoff_expired": "Nội dung bạn đã mở hoặc chia sẻ đã chờ quá lâu và không còn nữa. Vui lòng mở hoặc chia sẻ lại.", + "compose_waiting": "Đóng thư này để bắt đầu thư tiếp theo.", + "save_first_failed": "Không thể lưu thư của bạn nên tệp chưa được mở. Hãy lưu thư, rồi mở lịch hoặc danh bạ." + }, "sidebar_apps": { "modal_title": "Ứng dụng thanh bên", "add_new": "Thêm ứng dụng", @@ -4921,6 +4943,9 @@ "mini_has_events": "Có sự kiện", "mini_week_number": "Tuần {week}" }, + "calendar_offline": { + "split_needs_server": "Thay đổi này cần kết nối với máy chủ. Hiện chỉ thay đổi sự kiện này, hoặc thử lại khi bạn trực tuyến trở lại." + }, "calendar_event_notifications": { "someone": "Ai đó", "invited": "{name} đã mời bạn tham gia \"{title}\"", @@ -6362,6 +6387,12 @@ "sign_language": "Ngôn ngữ ký hiệu", "new_tab": "Mở trong một tab mới" }, + "pwaNotify": { + "action_archive": "Lưu trữ", + "action_mark_read": "Đánh dấu là đã đọc", + "action_reply": "Trả lời", + "reply_placeholder": "Viết trả lời" + }, "structured_mail_calendar": { "add_to_calendar": "Thêm vào lịch", "update_in_calendar": "Cập nhật trong lịch", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index c1b1dbaa6..8fc2a36ae 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -249,6 +249,28 @@ "active_account": "使用中", "switching_account": "正在切換帳號…" }, + "pwaLaunch": { + "shortcut_new_message": "新郵件", + "shortcut_calendar": "行事曆", + "shortcut_contacts": "聯絡人", + "unsupported_file": "無法在此處開啟 {name}。", + "open_failed": "無法開啟檔案。請再次嘗試開啟。", + "share_gone": "您分享的內容已不在此處。請重新分享。", + "share_files_lost": "無法保留分享的檔案。請將它們重新加入郵件。", + "ics_needs_server": "匯入行事曆檔案需要連線到伺服器。您恢復連線後將開始匯入。", + "contacts_need_server": "匯入聯絡人需要連線到伺服器。請在恢復連線後再試一次。", + "share_confirm_title": "要開始撰寫新郵件嗎?", + "share_confirm_message": "另一個網站或應用程式想要開始撰寫郵件。除非您同意,否則不會上傳或儲存任何內容。", + "share_confirm_subject": "主旨:", + "share_confirm_text": "內文:", + "share_confirm_files": "{count, plural, other {檔案:}}", + "share_confirm_accept": "開始撰寫郵件", + "share_confirm_decline": "不要開始", + "share_cut": "分享的文字太長,已被縮短。", + "handoff_expired": "您開啟或分享的內容等待太久,已不存在。請重新開啟或分享。", + "compose_waiting": "請關閉此郵件以開始下一封。", + "save_first_failed": "無法儲存您的郵件,因此未開啟檔案。請儲存郵件,然後開啟行事曆或聯絡人。" + }, "sidebar_apps": { "modal_title": "側邊欄應用程式", "add_new": "新增應用程式", @@ -4909,6 +4931,9 @@ "mini_has_events": "有活動", "mini_week_number": "第 {week} 週" }, + "calendar_offline": { + "split_needs_server": "此變更需要連線到伺服器。請暫時只變更此活動,或在恢復連線後再試一次。" + }, "calendar_event_notifications": { "someone": "有人", "invited": "{name} 邀請您參加「{title}」", @@ -6350,6 +6375,12 @@ "sign_language": "手語", "new_tab": "在新分頁中開啟" }, + "pwaNotify": { + "action_archive": "封存", + "action_mark_read": "標示為已讀", + "action_reply": "回覆", + "reply_placeholder": "撰寫回覆" + }, "structured_mail_calendar": { "add_to_calendar": "新增到行事曆", "update_in_calendar": "在行事曆中更新", diff --git a/locales/zh/common.json b/locales/zh/common.json index 0b7890579..35649d2d5 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -249,6 +249,28 @@ "active_account": "活动", "switching_account": "正在切换账户..." }, + "pwaLaunch": { + "shortcut_new_message": "新邮件", + "shortcut_calendar": "日历", + "shortcut_contacts": "联系人", + "unsupported_file": "无法在此处打开 {name}。", + "open_failed": "无法打开文件。请再次尝试打开。", + "share_gone": "您分享的内容已不在此处。请重新分享。", + "share_files_lost": "无法保留分享的文件。请将它们重新添加到邮件中。", + "ics_needs_server": "导入日历文件需要连接到服务器。您恢复联网后将开始导入。", + "contacts_need_server": "导入联系人需要连接到服务器。请在恢复联网后重试。", + "share_confirm_title": "要开始写新邮件吗?", + "share_confirm_message": "另一个网站或应用想要开始写一封邮件。除非您同意,否则不会上传或保存任何内容。", + "share_confirm_subject": "主题:", + "share_confirm_text": "正文:", + "share_confirm_files": "{count, plural, other {文件:}}", + "share_confirm_accept": "开始写邮件", + "share_confirm_decline": "不开始", + "share_cut": "分享的文本太长,已被缩短。", + "handoff_expired": "您打开或分享的内容等待时间过长,已不存在。请重新打开或分享。", + "compose_waiting": "请关闭此邮件以开始下一封。", + "save_first_failed": "无法保存您的邮件,因此未打开文件。请保存邮件,然后打开日历或联系人。" + }, "sidebar_apps": { "modal_title": "侧边栏应用", "add_new": "添加应用", @@ -4909,6 +4931,9 @@ "mini_has_events": "有事件", "mini_week_number": "第 {week} 周" }, + "calendar_offline": { + "split_needs_server": "此更改需要连接到服务器。请暂时只更改此日程,或在恢复联网后重试。" + }, "advanced_search": { "title": "高级搜索", "from": "发件人", @@ -6350,6 +6375,12 @@ "sign_language": "手语", "new_tab": "在新标签页中打开" }, + "pwaNotify": { + "action_archive": "归档", + "action_mark_read": "标记为已读", + "action_reply": "回复", + "reply_placeholder": "撰写回复" + }, "structured_mail_calendar": { "add_to_calendar": "添加到日历", "update_in_calendar": "在日历中更新", diff --git a/next.config.ts b/next.config.ts index e57a5f3af..1ce8a7714 100644 --- a/next.config.ts +++ b/next.config.ts @@ -140,6 +140,11 @@ const nextConfig: NextConfig = { { key: "Cache-Control", value: "public, max-age=31536000, immutable" }, ], }, + { + // The local store's database engine, in a folder named after its version. + source: "/sqlite3mc/:path*", + headers: [{ key: "Cache-Control", value: "public, max-age=31536000, immutable" }], + }, // The upload routes proxy.ts's matcher leaves out, with the // headers it sets on every other API response. ...["/api/webdav", "/api/plugin-proxy", "/api/wopi/files/:fileId/contents"].map((source) => ({ diff --git a/package.json b/package.json index 9c1abb8ac..52adefb78 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,8 @@ "typescript" ], "scripts": { - "dev": "node scripts/build-pst-worker.mjs && node scripts/build-plugin-sandbox.mjs --watch -- next dev --turbopack", - "build": "node scripts/build-pst-worker.mjs && node scripts/build-plugin-sandbox.mjs && next build --turbopack && node scripts/standalone-guard.mjs", + "dev": "node scripts/build-pst-worker.mjs && node scripts/build-store-worker.mjs && node scripts/build-plugin-sandbox.mjs --watch -- next dev --turbopack", + "build": "node scripts/build-pst-worker.mjs && node scripts/build-store-worker.mjs && node scripts/build-plugin-sandbox.mjs && next build --turbopack && node scripts/standalone-guard.mjs", "build:lite": "node scripts/lite/build.mjs", "lite:serve": "node scripts/lite/serve.mjs out 4173", "test:lite-smoke": "playwright test -c scripts/lite/smoke/playwright.config.ts", diff --git a/proxy.ts b/proxy.ts index eb767eb03..4e47be5e8 100644 --- a/proxy.ts +++ b/proxy.ts @@ -86,7 +86,7 @@ export const config = { // /en/mail/folder/inbox/statement.pdf a real signed-in page, and it rendered // without a CSP or any other security header (GHSA-xvjh-v9c6-qcvc). Only what // is genuinely static may skip locale routing and the headers. -const STATIC_ASSET_PATTERN = /^\/[^/]+\.[^/]+$|^\/(?:branding|notification|demo|plugin-sandbox-assets)\//; +const STATIC_ASSET_PATTERN = /^\/[^/]+\.[^/]+$|^\/(?:branding|notification|demo|plugin-sandbox-assets|sqlite3mc)\//; export function isStaticAssetPath(pathname: string): boolean { return STATIC_ASSET_PATTERN.test(pathname); diff --git a/public/sw.js b/public/sw.js index a5552b96d..f46f5fee7 100644 --- a/public/sw.js +++ b/public/sw.js @@ -62,7 +62,7 @@ self.addEventListener("install", (event) => { }); self.addEventListener("activate", (event) => { - event.waitUntil(Promise.all([self.clients.claim(), deleteStaleShellCaches()])); + event.waitUntil(Promise.all([self.clients.claim(), deleteStaleShellCaches(), sweepHandoffsOnActivate()])); }); // ---- Offline mode (#523): the app shell ---- @@ -108,7 +108,7 @@ if (typeof caches !== "undefined" && caches && typeof caches.open === "function" readOfflineShell(); } -const APP_SECTIONS = new Set(["", "mail", "settings", "calendar", "contacts", "files", "pro", "apps", "popout"]); +const APP_SECTIONS = new Set(["", "mail", "settings", "calendar", "contacts", "files", "pro", "apps", "protocol", "popout"]); /** The page path inside the app ("/mail/inbox"), or null for anything that isn't a page of the app. */ function appPagePath(url) { @@ -316,7 +316,150 @@ async function deleteStaleShellCaches() { } } +// ---- Share target: what the OS shares goes into a new draft ---- +// +// The manifest's share_target POSTs here. Files can't travel through a +// redirect, so they wait in the Cache API until the page takes them (same +// layout and limits as lib/launch-handoff.ts, which also removes them). Any +// site can POST here, signed in or not, and the worker can't tell who did: the +// page asks the user. So what is kept is limited, and sealed with a key that +// only the landing address's fragment knows. A browser without share_target +// gets the same question. Without a worker the POST reaches +// app/(main)/protocol/share/route.ts. +const HANDOFF_CACHE = "bulwark-launch-handoff-v1"; +const HANDOFF_TTL_MS = 15 * 60 * 1000; +const HANDOFF_MAX_ENTRIES = 5; +const HANDOFF_MAX_BYTES = 100 * 1024 * 1024; + +function isShareTarget(request) { + // A document only: a hidden frame doesn't get to store anything. + if (request.method !== "POST" || request.mode !== "navigate" || request.destination !== "document") return false; + const url = new URL(request.url); + return url.origin === self.location.origin && url.pathname.replace(/\/+$/, "") === BASE_PATH + "/protocol/share"; +} + +/** Drops what is too old and, when `incomingBytes` wants in, the oldest entries in the way. */ +async function sweepHandoffs(cache, incomingBytes) { + const keys = await cache.keys(); + const pathOf = (key) => new URL(key.url, self.location.origin).pathname; + const entries = []; + for (const key of keys) { + const path = pathOf(key); + if (!path.endsWith("/meta")) continue; + const response = await cache.match(key); + const meta = response ? await response.json().catch(() => null) : null; + entries.push({ prefix: path.slice(0, -"meta".length), createdAt: (meta && meta.createdAt) || 0, bytes: (meta && meta.bytes) || 0 }); + } + entries.sort((a, b) => a.createdAt - b.createdAt); + let total = entries.reduce((sum, entry) => sum + entry.bytes, 0); + while (entries.length > 0) { + const tooOld = Date.now() - entries[0].createdAt > HANDOFF_TTL_MS; + const inTheWay = incomingBytes !== null && (entries.length >= HANDOFF_MAX_ENTRIES || total + incomingBytes > HANDOFF_MAX_BYTES); + if (!tooOld && !inTheWay) break; + const oldest = entries.shift(); + total -= oldest.bytes; + const own = keys.filter((key) => pathOf(key).startsWith(oldest.prefix)); + // The list last: this sweep goes by it. + await Promise.all(own.filter((key) => !pathOf(key).endsWith("/meta")).map((key) => cache.delete(key))); + await cache.delete(oldest.prefix + "meta"); + } +} + +async function sweepHandoffsOnActivate() { + try { + if (typeof caches === "undefined" || !(await caches.has(HANDOFF_CACHE))) return; + await sweepHandoffs(await caches.open(HANDOFF_CACHE), null); + } catch (_) { + // Tried again at the next share. + } +} + +/** A 12-byte IV, then the AES-GCM ciphertext. */ +async function sealHandoff(key, data) { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const body = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, data)); + const sealed = new Uint8Array(iv.length + body.length); + sealed.set(iv); + sealed.set(body, iv.length); + return sealed; +} + +/** As much of `value` as takes up to `budget` bytes once encoded, as in the server's fallback. */ +function fitEncoded(value, budget, flags) { + let used = 0; + let out = ""; + for (const character of value) { + try { + used += encodeURIComponent(character).length; + } catch (_) { + continue; + } + if (used > budget) { + flags.cut = true; + return out; + } + out += character; + } + return out; +} + +async function receiveShare(request) { + // In the fragment: it stays out of access logs, and the key out of the server's sight. + const land = (params) => Response.redirect(self.location.origin + BASE_PATH + "/#" + new URLSearchParams(params), 303); + let form; + try { + form = await request.formData(); + } catch (_) { + return land({ share: "unavailable", share_files: "1" }); + } + const field = (name) => { + const value = form.get(name); + return typeof value === "string" ? value : ""; + }; + const shared = { title: field("title"), text: field("text"), url: field("url") }; + const files = form.getAll("files").filter((file) => typeof file !== "string" && file.size > 0); + const id = crypto.randomUUID(); + const prefix = "/__bulwark/handoff/" + id + "/"; + const bytes = files.reduce((sum, file) => sum + file.size, 0); + let cache = null; + try { + if (bytes > HANDOFF_MAX_BYTES) throw new Error("too large"); + cache = await caches.open(HANDOFF_CACHE); + await sweepHandoffs(cache, bytes); + const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt"]); + const rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", key)); + const contents = JSON.stringify({ ...shared, files: files.map((file) => ({ name: file.name, type: file.type })) }); + // The list first: the sweep goes by it. + await cache.put(prefix + "meta", new Response(JSON.stringify({ kind: "compose", createdAt: Date.now(), bytes }))); + await cache.put(prefix + "sealed", new Response(await sealHandoff(key, new TextEncoder().encode(contents)))); + for (let index = 0; index < files.length; index += 1) { + await cache.put(prefix + index, new Response(await sealHandoff(key, await files[index].arrayBuffer()))); + } + const encodedKey = btoa(String.fromCharCode(...rawKey)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + return land({ share: id, key: encodedKey }); + } catch (_) { + // No room for the files. The text can still come along in the address. + if (cache) await Promise.all([...files.map((_file, index) => cache.delete(prefix + index)), cache.delete(prefix + "sealed"), cache.delete(prefix + "meta")]).catch(() => {}); + const flags = { cut: false }; + const title = fitEncoded(shared.title, 400, flags); + const text = fitEncoded(shared.text, 1800, flags); + const link = fitEncoded(shared.url, 1000, flags); + return land({ + share: "unavailable", + share_title: title, + share_text: text, + share_url: link, + ...(files.length > 0 ? { share_files: "1" } : {}), + ...(flags.cut ? { share_cut: "1" } : {}), + }); + } +} + self.addEventListener("fetch", (event) => { + if (isShareTarget(event.request)) { + event.respondWith(receiveShare(event.request)); + return; + } // Offline mode off (known): the network, untouched, as without a worker. if (offlineShellOn === false) return; const route = offlineRoute(event.request); @@ -342,12 +485,24 @@ self.addEventListener("pushsubscriptionchange", (event) => { event.waitUntil(handlePushSubscriptionChange(event)); }); +// Background Sync, thin: this worker has no login and can't reach the outbox, +// so it tells the open tabs to flush theirs. With no tab open, the next one +// flushes when it starts. +self.addEventListener("sync", (event) => { + if (event.tag !== OUTBOX_SYNC_TAG) return; + event.waitUntil(tellClientsToFlushOutbox()); +}); + self.addEventListener("message", (event) => { const data = event.data || {}; if (data.type === "client-build") { event.waitUntil(recordClientBuild(event.source && event.source.id, data.buildId).then(deleteStaleShellCaches)); return; } + if (data.type === "client-logins") { + event.waitUntil(recordClientLogins(event.source && event.source.id, data.logins)); + return; + } if (data.type === "offline-shell") { // The switch is kept before the event ends. The shell's files are // fetched without holding the event open: a worker busy with an event @@ -491,6 +646,9 @@ async function handlePush(event) { // The login (cookie slot) that owns this account, so the click opens the // message there and not in whichever account happens to be active. const slot = preview && Number.isInteger(preview.slot) ? preview.slot : undefined; + // Which login that slot held when the message came: an action on this + // notification must not land on whoever signed into the slot since. + const login = preview && typeof preview.login === "string" ? preview.login : undefined; // Push subscription is scoped to EmailDelivery, but stragglers from the // older broader-types subscription, marking-as-read races and verification @@ -526,7 +684,7 @@ async function handlePush(event) { // (Organisation > Devices): the route sent neither. title = accountLabel ? `New mail (${accountLabel})` : "New mail"; body = unreadTotal > 1 ? `${unreadTotal} unread messages` : "You have new mail"; - if (unreadTotal <= 1) data = { kind: "email", emailId: email.id, threadId: email.threadId, accountId, slot }; + if (unreadTotal <= 1) data = { kind: "email", emailId: email.id, threadId: email.threadId, accountId, slot, login }; } else if (email) { const sender = email.from && email.from[0]; const senderName = (sender && sender.name) || (sender && sender.email) || "New mail"; @@ -539,7 +697,7 @@ async function handlePush(event) { body += "\n" + (more === 1 ? "+1 more message" : `+${more} more messages`); } else { // Exactly one unread: deep-link straight to that message on click. - data = { kind: "email", emailId: email.id, threadId: email.threadId, accountId, slot }; + data = { kind: "email", emailId: email.id, threadId: email.threadId, accountId, slot, login }; } } else { title = accountLabel ? `New mail (${accountLabel})` : "New mail"; @@ -548,7 +706,7 @@ async function handlePush(event) { // With the sound setting off, nothing rings and no quiet window starts. const audible = (await soundPreference()) && !(await withinQuietWindow(accountId)); - await self.registration.showNotification(title, { + await showWithActions(title, { body, // Shared per-account tag: each new push replaces the account's single // notification rather than adding another. @@ -592,7 +750,7 @@ async function showWithoutAlert(groupTag) { try { const existing = (await self.registration.getNotifications({ tag: groupTag }))[0]; if (existing) { - await self.registration.showNotification(existing.title, { + await showWithActions(existing.title, { ...options, body: existing.body, data: existing.data, @@ -737,7 +895,7 @@ async function handleNotificationClick(event) { return handleMailtoFocusNotificationClick(); } - const targetUrl = buildClickUrl(data); + let targetUrl = buildClickUrl(data); const allClients = await self.clients.matchAll({ type: "window", @@ -753,6 +911,14 @@ async function handleNotificationClick(event) { } } + // A button on the notification. When it can't be done from here, the click + // carries on below and opens the message, like a click on the body. + const action = notificationActionOf(event, data); + if (action) { + if (await runNotificationAction(action, data, event.reply, allClients)) return; + if (action === "reply") targetUrl = buildClickUrl(data, true); + } + for (const client of allClients) { // Reuse an existing tab whenever possible - users on desktop browsers // get annoyed when each notification opens a fresh window. @@ -925,11 +1091,12 @@ function getReusableClientScore(state) { return 3; } -function buildClickUrl(data) { +function buildClickUrl(data, reply) { if (!data) return `${BASE_PATH}/`; const params = new URLSearchParams(); if (Number.isInteger(data.slot)) params.set("slot", String(data.slot)); if (typeof data.accountId === "string" && data.accountId) params.set("owner", data.accountId); + if (reply) params.set("reply", "1"); // Not params.size: Safari before 17 (iOS 16.4+ web push) lacks it. const qs = params.toString(); const targetQuery = qs ? `?${qs}` : ""; @@ -946,3 +1113,292 @@ function buildClickUrl(data) { // app shell, so the click still feels purposeful. return `${BASE_PATH}/?openLatestUnread=1`; } + +// ---- Notification actions: Archive, Mark read, Reply ---- +// +// A worker has no login. An open tab has one, so it goes first. With none, a +// same-origin route acts with the login's context cookie (app/api/push/action). +// That cookie goes with the browser session and holds a token only a page +// refreshes, so the route can fail; the message then opens in the app. + +const NOTIFICATION_ACTIONS = ["archive", "mark-read", "reply"]; +// How long a tab has to take an action. A frozen tab doesn't answer. A tab that +// holds the notification's login gets longer, since a busy page may still run +// the action with its plugins; if it stays silent, the message opens and the +// route isn't tried, which would go round them. +const CLIENT_TAKE_MS = 1_000; +const LOGIN_HOLDER_TAKE_MS = 3_000; +const CLIENTS_TAKE_TOTAL_MS = 4_000; +// How long a tab that took it has before this worker stops waiting for the outcome. +const CLIENT_RESULT_MS = 5_000; +const ACTION_ROUTE_DEADLINE_MS = 4_500; +// All of the above stay inside the 10 s a browser allows openWindow and focus +// after a notification click, so the fallback can still open the message. + +const OUTBOX_SYNC_TAG = "bulwark-outbox"; + +function notificationActionPrefsKey() { + return `${self.location.origin}${BASE_PATH}/__push-state/actions`; +} + +function pendingReplyKey() { + return `${self.location.origin}${BASE_PATH}/__push-state/pending-reply`; +} + +function clientLoginsKey() { + return `${self.location.origin}${BASE_PATH}/__push-state/client-logins`; +} + +async function readClientLogins() { + try { + const cache = await caches.open(PUSH_STATE_CACHE); + const res = await cache.match(clientLoginsKey()); + const map = res ? await res.json() : null; + return map && typeof map === "object" ? map : {}; + } catch (_) { + return {}; + } +} + +/** Notes which logins an open tab holds, and forgets tabs that are gone. Kept across worker restarts. */ +async function recordClientLogins(clientId, logins) { + if (!clientId || !Array.isArray(logins)) return; + try { + const open = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); + const openIds = new Set(open.map((c) => c.id)); + const known = await readClientLogins(); + const next = {}; + for (const [id, held] of Object.entries(known)) if (openIds.has(id)) next[id] = held; + next[clientId] = logins.filter((login) => typeof login === "string"); + const cache = await caches.open(PUSH_STATE_CACHE); + await cache.put(clientLoginsKey(), new Response(JSON.stringify(next), { headers: { "Content-Type": "application/json" } })); + } catch (_) { + // Such a tab counts as not holding the login. + } +} + +/** + * What the page mirrors into this store (lib/pwa-notify/worker-state.ts): the + * labels in the user's language, Archive on or off, how Archive files mail, + * and whether an enabled plugin may veto these actions. Nothing stored: no + * Archive button, since the admin may have turned it off, and no route, since + * a plugin may be there. + */ +async function readNotificationActionPrefs() { + let stored = null; + try { + const cache = await caches.open(PUSH_STATE_CACHE); + const res = await cache.match(notificationActionPrefsKey()); + stored = res ? await res.json() : null; + } catch (_) { + stored = null; + } + const labels = (stored && stored.labels) || {}; + return { + archiveEnabled: !!stored && stored.archiveEnabled === true, + archiveMode: stored && typeof stored.archiveMode === "string" ? stored.archiveMode : "single", + pluginsMayVeto: !stored || stored.pluginsMayVeto !== false, + labels: { + archive: labels.archive || "Archive", + markRead: labels.markRead || "Mark as read", + reply: labels.reply || "Reply", + replyPlaceholder: labels.replyPlaceholder || "Write a reply", + }, + }; +} + +function maxNotificationActions() { + if (typeof Notification === "undefined") return 0; + return Number.isInteger(Notification.maxActions) ? Notification.maxActions : 0; +} + +function inlineReplySupported() { + return typeof NotificationEvent !== "undefined" && "reply" in NotificationEvent.prototype; +} + +/** The buttons for a notification. Only one message, with its login known, gets any. */ +async function notificationActions(data) { + if (!data || data.kind !== "email" || !data.emailId || !data.accountId || !data.login) return []; + const max = maxNotificationActions(); + if (max < 1) return []; + const prefs = await readNotificationActionPrefs(); + const offered = { + "mark-read": { action: "mark-read", title: prefs.labels.markRead }, + reply: inlineReplySupported() + ? { action: "reply", title: prefs.labels.reply, type: "text", placeholder: prefs.labels.replyPlaceholder } + : { action: "reply", title: prefs.labels.reply }, + }; + if (prefs.archiveEnabled) offered.archive = { action: "archive", title: prefs.labels.archive }; + // Chromium allows two. Mark read goes first then: opening the message does + // that too, and nothing else on the toast archives or starts a reply. + const kept = ["archive", "reply", "mark-read"].filter((name) => offered[name]).slice(0, max); + return NOTIFICATION_ACTIONS.filter((name) => kept.includes(name)).map((name) => offered[name]); +} + +/** A notification must show, whatever a browser thinks of its buttons. */ +async function showWithActions(title, options) { + const actions = await notificationActions(options.data); + if (actions.length > 0) { + try { + await self.registration.showNotification(title, { ...options, actions }); + return; + } catch (_) { + // Shown without buttons below. + } + } + await self.registration.showNotification(title, options); +} + +function notificationActionOf(event, data) { + if (!NOTIFICATION_ACTIONS.includes(event.action)) return null; + return data.kind === "email" && data.emailId && data.accountId ? event.action : null; +} + +/** Asks one tab. Resolves "done", "failed" (it tried), "declined" (it didn't take it) or "silent" (no answer). */ +function askClient(client, message, takeMs) { + return new Promise((resolve) => { + if (typeof MessageChannel !== "function") { + resolve("declined"); + return; + } + const channel = new MessageChannel(); + let timer = null; + const finish = (result) => { + clearTimeout(timer); + channel.port1.onmessage = null; + resolve(result); + }; + timer = setTimeout(() => finish("silent"), takeMs); + channel.port1.onmessage = (answer) => { + const status = answer.data && answer.data.status; + if (status === "accepted") { + // It's on it. A slow server shouldn't send the action round twice. + clearTimeout(timer); + timer = setTimeout(() => finish("done"), CLIENT_RESULT_MS); + return; + } + finish(status === "done" || status === "failed" ? status : "declined"); + }; + try { + client.postMessage(message, [channel.port2]); + } catch (_) { + finish("declined"); + } + }); +} + +/** The first open tab that takes the action, the one in front first. */ +async function askOpenClients(message, allClients) { + const score = (client) => (client.focused ? 0 : client.visibilityState === "visible" ? 1 : 2); + const ordered = allClients + .filter((client) => !client.frameType || client.frameType === "top-level") + .sort((a, b) => score(a) - score(b)); + const held = await readClientLogins(); + const holdsLogin = (client) => Array.isArray(held[client.id]) && held[client.id].includes(message.login); + const giveUpAt = Date.now() + CLIENTS_TAKE_TOTAL_MS; + let silentHolder = false; + for (const client of ordered) { + if (Date.now() >= giveUpAt) { + silentHolder = silentHolder || holdsLogin(client); + continue; + } + const holder = holdsLogin(client); + const result = await askClient(client, message, holder ? LOGIN_HOLDER_TAKE_MS : CLIENT_TAKE_MS); + if (result === "silent") silentHolder = silentHolder || holder; + else if (result !== "declined") return { result, client }; + } + // A silent tab with this login may have plugins that would stop the action. + return { result: silentHolder ? "failed" : "declined", client: null }; +} + +/** Keeps a typed reply for the page the click is about to open; it's not for a URL. */ +async function keepPendingReply(emailId, text) { + try { + const cache = await caches.open(PUSH_STATE_CACHE); + await cache.put( + pendingReplyKey(), + new Response(JSON.stringify({ emailId, text, at: Date.now() }), { + headers: { "Content-Type": "application/json" }, + }), + ); + } catch (_) { + // The composer opens empty. + } +} + +async function postNotificationAction(action, data) { + const prefs = await readNotificationActionPrefs(); + if (action === "archive" && !prefs.archiveEnabled) return false; + // Plugins run in pages. One that may stop or watch this action can't from + // the route, so the app opens instead. + if (prefs.pluginsMayVeto) return false; + const body = { action, accountId: data.accountId, emailId: data.emailId, login: data.login }; + if (action === "archive") { + body.archiveMode = prefs.archiveMode; + // Year and month folders go by the date the user sees. + if (typeof Intl !== "undefined") body.timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + } + const controller = typeof AbortController === "function" ? new AbortController() : null; + const timer = controller ? setTimeout(() => controller.abort(), ACTION_ROUTE_DEADLINE_MS) : null; + try { + const res = await fetch(`${BASE_PATH}/api/push/action`, { + method: "POST", + credentials: "include", + cache: "no-store", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + ...(controller ? { signal: controller.signal } : {}), + }); + return res.ok; + } catch (_) { + return false; + } finally { + if (timer) clearTimeout(timer); + } +} + +/** True when the action is done and nothing has to open. */ +async function runNotificationAction(action, data, reply, allClients) { + const text = action === "reply" && typeof reply === "string" ? reply.trim() : ""; + const { result, client } = await askOpenClients({ + kind: "notification-action", + action, + emailId: data.emailId, + threadId: data.threadId, + accountId: data.accountId, + slot: data.slot, + login: data.login, + // A frozen tab that thaws later must not act on a click long since handled. + at: Date.now(), + ...(text ? { text } : {}), + }, allClients); + if (result === "done") { + if (action === "reply" && "focus" in client) { + try { + await client.focus(); + } catch (_) { + // The composer is open in that tab; focusing can be blocked by the browser. + } + } + return true; + } + if (action === "reply") { + if (text) await keepPendingReply(data.emailId, text); + return false; + } + // A tab with this login tried and failed, or stayed silent: the route won't + // do better and would go round its plugins. + if (result === "failed") return false; + return postNotificationAction(action, data); +} + +async function tellClientsToFlushOutbox() { + const allClients = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); + for (const client of allClients) { + try { + client.postMessage({ kind: "outbox-flush" }); + } catch (_) { + // Closed or detached client - ignore. + } + } +} diff --git a/scripts/build-store-worker.mjs b/scripts/build-store-worker.mjs new file mode 100644 index 000000000..835bc10e6 --- /dev/null +++ b/scripts/build-store-worker.mjs @@ -0,0 +1,46 @@ +// Builds the local store's worker (lib/store/worker/worker.ts) into +// public/store-worker.js and copies the vendored SQLite3MultipleCiphers build +// next to it, in a folder named after its version: emscripten looks for the +// literal name sqlite3.wasm, so the files can't have hashed names. +// Both go through proxy.ts's static asset rule, which keeps the page CSP off +// them: wasm compiles under the worker's own response headers. +// +// node scripts/build-store-worker.mjs (npm run build, npm run dev) + +import { build } from 'esbuild'; +import { copyFileSync, mkdirSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { SQLITE3MC, SQLITE3MC_VENDOR_DIR, verifyVendored } from './fetch-sqlite3mc.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +export const STORE_WORKER_OUTFILE = path.join(root, 'public', 'store-worker.js'); +export const SQLITE3MC_PUBLIC_DIR = path.join(root, 'public', 'sqlite3mc', SQLITE3MC.version); + +export async function buildStoreWorker({ outfile = STORE_WORKER_OUTFILE, publicDir = SQLITE3MC_PUBLIC_DIR } = {}) { + verifyVendored(); + mkdirSync(publicDir, { recursive: true }); + for (const name of [...Object.keys(SQLITE3MC.files), 'LICENSE']) { + copyFileSync(path.join(SQLITE3MC_VENDOR_DIR, name), path.join(publicDir, name)); + } + const result = await build({ + entryPoints: [path.join(root, 'lib/store/worker/worker.ts')], + bundle: true, + format: 'esm', + platform: 'browser', + target: 'es2022', + outfile, + minify: true, + legalComments: 'none', + alias: { '@': root }, + define: { 'process.env.NODE_ENV': '"production"' }, + metafile: true, + logLevel: 'warning', + }); + return { bytes: Object.values(result.metafile.outputs).reduce((n, o) => n + o.bytes, 0) }; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const { bytes } = await buildStoreWorker(); + console.log(`[build-store-worker] ${path.relative(root, STORE_WORKER_OUTFILE)} ${bytes}`); +} diff --git a/scripts/fetch-sqlite3mc.mjs b/scripts/fetch-sqlite3mc.mjs new file mode 100644 index 000000000..f119f3df7 --- /dev/null +++ b/scripts/fetch-sqlite3mc.mjs @@ -0,0 +1,79 @@ +// Fetches the pinned SQLite3MultipleCiphers WASM build into vendor/sqlite3mc/. +// Run it by hand when moving to a new version, and commit the result, so a +// Docker build needs no access to GitHub. Every download is checked against the +// pinned sha256: a changed artifact fails here. +// +// node scripts/fetch-sqlite3mc.mjs downloads and verifies +// node scripts/fetch-sqlite3mc.mjs --check verifies what is committed +// +// Fixes for bugs #291-#294 are on trunk, not in a release yet. Check the rules +// in lib/store/adapters/wasm.ts when moving to a newer build, and never go below 2.6.0. + +import { createHash } from 'node:crypto'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { unzipSync } from 'fflate'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +export const SQLITE3MC = { + version: '2.6.1-3.54.0', + tag: 'v2.6.1', + zip: 'sqlite3mc-2.6.1-sqlite-3.54.0-wasm.zip', + zipSha256: 'aba121490fd6123bcbef8404ee26df9b26c0301063781e2aa31f05aed6f48384', + innerDir: 'sqlite3mc-wasm-3540000/jswasm', + files: { + 'sqlite3.mjs': '5308c7cc857408697c657a8d314e647a6f31c4aee67974ab8efd80c1a07161e0', + 'sqlite3.wasm': '0b5a4bfff35f1ec87bf6f68ea293f425b86cbd661b77628a955cb827c9fbf524', + }, +}; + +export const SQLITE3MC_VENDOR_DIR = path.join(root, 'vendor', 'sqlite3mc', SQLITE3MC.version); + +function sha256(data) { + return createHash('sha256').update(data).digest('hex'); +} + +/** Throws unless the committed files match their pins. */ +export function verifyVendored(dir = SQLITE3MC_VENDOR_DIR) { + for (const [name, expected] of Object.entries(SQLITE3MC.files)) { + const actual = sha256(readFileSync(path.join(dir, name))); + if (actual !== expected) throw new Error(`[sqlite3mc] ${name}: expected ${expected}, got ${actual}`); + } + readFileSync(path.join(dir, 'LICENSE')); +} + +async function download(url) { + const response = await fetch(url); + if (!response.ok) throw new Error(`[sqlite3mc] ${url}: ${response.status}`); + return Buffer.from(await response.arrayBuffer()); +} + +async function fetchAndVendor() { + const base = `https://github.com/utelle/SQLite3MultipleCiphers/releases/download/${SQLITE3MC.tag}`; + const zip = await download(`${base}/${SQLITE3MC.zip}`); + const actual = sha256(zip); + if (actual !== SQLITE3MC.zipSha256) throw new Error(`[sqlite3mc] zip: expected ${SQLITE3MC.zipSha256}, got ${actual}`); + const entries = unzipSync(zip); + mkdirSync(SQLITE3MC_VENDOR_DIR, { recursive: true }); + for (const name of Object.keys(SQLITE3MC.files)) { + const entry = entries[`${SQLITE3MC.innerDir}/${name}`]; + if (!entry) throw new Error(`[sqlite3mc] ${name} is not in the zip`); + writeFileSync(path.join(SQLITE3MC_VENDOR_DIR, name), entry); + } + // The zip has no licence file. + const license = await download(`https://raw.githubusercontent.com/utelle/SQLite3MultipleCiphers/${SQLITE3MC.tag}/LICENSE`); + writeFileSync(path.join(SQLITE3MC_VENDOR_DIR, 'LICENSE'), license); + verifyVendored(); + console.log(`[fetch-sqlite3mc] vendored ${SQLITE3MC.version}`); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + if (process.argv.includes('--check')) { + verifyVendored(); + console.log('[fetch-sqlite3mc] ok'); + } else { + await fetchAndVendor(); + } +} diff --git a/scripts/lite/lib.mjs b/scripts/lite/lib.mjs index f2dee815e..3760b208a 100644 --- a/scripts/lite/lib.mjs +++ b/scripts/lite/lib.mjs @@ -44,6 +44,9 @@ export const LITE_REMOVED_PATHS = [ "app/(main)/[locale]/popout", "e2e", "integration", + // The local store: Lite has no offline mode. A dev checkout may hold these. + "public/store-worker.js", + "public/sqlite3mc", ]; /** Test trees are pruned too: they import the routes removed above. */ diff --git a/stores/__tests__/calendar-store-offline-occurrence.test.ts b/stores/__tests__/calendar-store-offline-occurrence.test.ts new file mode 100644 index 000000000..84b926f51 --- /dev/null +++ b/stores/__tests__/calendar-store-offline-occurrence.test.ts @@ -0,0 +1,248 @@ +// @vitest-environment node +import { afterEach, describe, expect, it } from 'vitest'; +import { calendarArea } from '@/lib/store/calendar'; +import type { CalendarConflict } from '@/lib/store/calendar/writes'; +import { FakeCalendarJmap } from '@/lib/store/__tests__/helpers/fake-calendar'; +import { wire, type Wire } from '@/lib/store/__tests__/helpers/client-wire'; +import { isBrowserExpandedOccurrence, isServerRecurrenceInstance } from '@/lib/recurrence-instances'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { CalendarEvent } from '@/lib/jmap/types'; +import { saveOccurrenceEdit } from '@/lib/recurrence-scope-edit'; +import { useCalendarStore } from '../calendar-store'; + +const NOW = Date.UTC(2026, 10, 3, 12); +const START = '2026-11-02T00:00:00'; +const END = '2026-11-23T00:00:00'; +const ME = { '@type': 'Participant', calendarAddress: 'mailto:a@example.org', participationStatus: 'needs-action', roles: { attendee: true } }; +const ORGANIZER = { '@type': 'Participant', calendarAddress: 'mailto:boss@example.org', participationStatus: 'accepted', roles: { owner: true } }; + +let current: Wire | null = null; + +afterEach(() => { + current?.restore(); + current = null; + useCalendarStore.setState({ events: [], calendars: [] }); +}); + +async function online() { + const server = new FakeCalendarJmap('1.0'); + server.addEvent({ + id: 'series', + title: 'Weekly', + start: '2026-11-02T10:00:00', + recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly' }, + organizerCalendarAddress: 'mailto:boss@example.org', + participants: { me: ME, boss: ORGANIZER }, + }); + const w = wire(server, [calendarArea], NOW); + current = w; + await w.engine.requestSync(); + const client = await w.newClient() as unknown as IJMAPClient; + await client.getCalendars(); + await useCalendarStore.getState().fetchEvents(client, START, END); + return { w, client }; +} + +const overrides = (w: Wire) => w.server.events.get('series')?.recurrenceOverrides as Record> | undefined; +const flush = (w: Wire) => w.engine.areaCall('calendar.flush', undefined) as Promise<{ sent: number; conflicts: number; pending: number }>; +const conflicts = (w: Wire) => w.engine.areaCall('calendar.conflicts', undefined) as Promise; +const occurrenceOn = (day: string) => useCalendarStore.getState().events.find((event) => event.start.startsWith(day)); + +describe('an occurrence on screen from before the connection went', () => { + it('RSVP: goes in as an override on the master, shows after the refetch, reaches the server', async () => { + const { w, client } = await online(); + const instance = occurrenceOn('2026-11-09'); + expect(instance && isServerRecurrenceInstance(instance)).toBe(true); + w.online = false; + + await useCalendarStore.getState().rsvpEvent(client, instance!.id, 'me', 'accepted', undefined, 'occurrence'); + expect(overrides(w)).toBeUndefined(); + + // The refetch after the answer came from the store, with occurrences of its own. + const again = occurrenceOn('2026-11-09'); + expect(again && isBrowserExpandedOccurrence(again)).toBe(true); + expect(again?.participants?.me.participationStatus).toBe('accepted'); + expect(occurrenceOn('2026-11-16')?.participants?.me.participationStatus).toBe('needs-action'); + + w.online = true; + expect(await flush(w)).toMatchObject({ sent: 1, pending: 0 }); + expect(await conflicts(w)).toEqual([]); + // As 1.0 reads it back: only what differs from the series. + expect(overrides(w)?.['2026-11-09T10:00:00']).toMatchObject({ 'participants/me/participationStatus': 'accepted' }); + }); + + it('RSVP for the whole series: a pointer patch on the master, every occurrence shows it', async () => { + const { w, client } = await online(); + const instance = occurrenceOn('2026-11-09'); + w.online = false; + await useCalendarStore.getState().rsvpEvent(client, instance!.id, 'me', 'accepted'); + expect(occurrenceOn('2026-11-16')?.participants?.me.participationStatus).toBe('accepted'); + w.online = true; + expect(await flush(w)).toMatchObject({ sent: 1, pending: 0 }); + const master = w.server.events.get('series') as unknown as { participants: Record }; + expect(master.participants.me.participationStatus).toBe('accepted'); + expect(master.participants.boss.participationStatus).toBe('accepted'); + }); + + it('delete of one occurrence: excluded on the master, gone from the grid', async () => { + const { w, client } = await online(); + const instance = occurrenceOn('2026-11-16'); + w.online = false; + await useCalendarStore.getState().deleteEvent(client, instance!.id); + expect(occurrenceOn('2026-11-16')).toBeUndefined(); + expect(occurrenceOn('2026-11-09')).toBeTruthy(); + w.online = true; + await flush(w); + expect(overrides(w)?.['2026-11-16T10:00:00']).toMatchObject({ excluded: true }); + }); + + it('edit of one occurrence, then another, of the same series: both reach the server in one write', async () => { + const { w, client } = await online(); + w.online = false; + await useCalendarStore.getState().updateEvent(client, occurrenceOn('2026-11-09')!.id, { title: 'First change' }); + expect(occurrenceOn('2026-11-09')?.title).toBe('First change'); + await useCalendarStore.getState().updateEvent(client, occurrenceOn('2026-11-16')!.id, { title: 'Second change' }); + expect(occurrenceOn('2026-11-09')?.title).toBe('First change'); + expect(occurrenceOn('2026-11-16')?.title).toBe('Second change'); + expect(occurrenceOn('2026-11-02')?.title).toBe('Weekly'); + + w.online = true; + const before = w.server.calls.length; + await flush(w); + const writes = w.server.calls.slice(before).filter(([name]) => name === 'CalendarEvent/set'); + expect(writes).toHaveLength(2); // series keys refused once by 1.0, then without them + expect(await conflicts(w)).toEqual([]); + expect(overrides(w)?.['2026-11-09T10:00:00']).toMatchObject({ title: 'First change' }); + expect(overrides(w)?.['2026-11-16T10:00:00']).toMatchObject({ title: 'Second change' }); + expect(overrides(w)?.['2026-11-09T10:00:00']).not.toHaveProperty('organizerCalendarAddress'); + }); +}); + +describe('a change that should reach everyone who answered', () => { + async function organizer() { + const server = new FakeCalendarJmap('1.0'); + server.addEvent({ + id: 'series', + title: 'Weekly', + start: '2026-11-02T10:00:00', + recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly' }, + organizerCalendarAddress: 'mailto:a@example.org', + participants: { + me: { '@type': 'Participant', calendarAddress: 'mailto:a@example.org', participationStatus: 'accepted', roles: { owner: true } }, + bob: { '@type': 'Participant', calendarAddress: 'mailto:bob@example.org', participationStatus: 'accepted', roles: { attendee: true } }, + }, + }); + const w = wire(server, [calendarArea], NOW); + current = w; + await w.engine.requestSync(); + const client = await w.newClient() as unknown as IJMAPClient; + await client.getCalendars(); + await useCalendarStore.getState().fetchEvents(client, START, END); + return { w, client }; + } + + const writes = (w: Wire) => w.server.calls.filter(([name, args]) => name === 'CalendarEvent/set' && !(args.update && 'h333333' in (args.update as object))); + + it('keeps its two steps: the override quietly first, then the change with messages', async () => { + const { w, client } = await organizer(); + w.online = false; + await useCalendarStore.getState().updateEvent(client, occurrenceOn('2026-11-09')!.id, { title: 'Moved room' }, true, { asOrganizer: true }); + expect(w.db.all('SELECT send_scheduling FROM calendar_pending ORDER BY seq').map((row) => row.send_scheduling)).toEqual([0, 1]); + expect(occurrenceOn('2026-11-09')?.title).toBe('Moved room'); + + w.online = true; + w.server.calls.length = 0; + await flush(w); + expect(await conflicts(w)).toEqual([]); + const flags = writes(w).map(([, args]) => args.sendSchedulingMessages); + expect(flags[0]).toBe(false); + expect(flags[flags.length - 1]).toBe(true); + const lastPatch = (writes(w).at(-1)?.[1].update as Record>).series; + expect(JSON.stringify(lastPatch)).toContain('Moved room'); + const firstPatch = (writes(w)[0][1].update as Record>).series; + expect(JSON.stringify(firstPatch)).not.toContain('Moved room'); + expect(overrides(w)?.['2026-11-09T10:00:00']).toMatchObject({ title: 'Moved room' }); + }); +}); + +describe('a stand-in id still held by the page once the server is back', () => { + async function created() { + const { w, client } = await online(); + w.online = false; + const event = await useCalendarStore.getState().createEvent(client, { + title: 'Offline', + start: '2026-11-11T08:00:00', + duration: 'PT1H', + timeZone: 'Europe/Berlin', + calendarIds: { cal1: true }, + } as Partial); + expect(event?.id).toMatch(/^local-/); + return { w, client, standIn: event!.id }; + } + const realId = (w: Wire) => [...w.server.events.values()].find((event) => event.title !== 'Weekly')!.id; + + it('an open dialog saves after the flush: the change reaches the real event', async () => { + const { w, client, standIn } = await created(); + w.online = true; + await flush(w); + const real = realId(w); + await useCalendarStore.getState().updateEvent(client, standIn, { title: 'Saved from the dialog' }); + expect(w.server.events.get(real)?.title).toBe('Saved from the dialog'); + expect(w.sent.flatMap(([, args]) => Object.keys((args.update ?? {}) as object))).not.toContain(`${standIn}x`); + const local = w.server.calls.filter(([, args]) => JSON.stringify(args).includes(standIn)); + expect(local).toEqual([]); + }); + + it('a delete and a read work too, and a refusal still reads as a refusal', async () => { + const { w, client, standIn } = await created(); + w.online = true; + await flush(w); + const real = realId(w); + expect((await client.getCalendarEvent(standIn))?.id).toBe(real); + w.server.refuse[real] = { type: 'forbidden', description: 'No rights' }; + await expect(client.updateCalendarEvent(standIn, { title: 'x' })).rejects.toThrow('No rights'); + await client.deleteCalendarEvent(standIn); + expect(w.server.events.has(real)).toBe(false); + }); + + it('before the flush the change joins the queue instead of asking the server for an unknown id', async () => { + const { w, client, standIn } = await created(); + w.online = true; + w.server.calls.length = 0; + await client.updateCalendarEvent(standIn, { title: 'Still queued' }); + expect(w.server.calls).toEqual([]); + await flush(w); + expect(w.server.events.get(realId(w))?.title).toBe('Still queued'); + }); +}); + +describe('this and future while offline', () => { + it('is refused before anything is written', async () => { + const { w, client } = await online(); + w.online = false; + await useCalendarStore.getState().fetchEvents(client, START, END); + const event = occurrenceOn('2026-11-09')!; + const updateEvent = useCalendarStore.getState().updateEvent; + const createEvent = useCalendarStore.getState().createEvent; + await expect(saveOccurrenceEdit('this_and_future', { + event, + updates: { title: 'From here on' }, + sendScheduling: false, + userEmails: ['a@example.org'], + }, { client, updateEvent, createEvent, findMasterEvent: async () => client.getCalendarEvent('series') })).rejects.toMatchObject({ name: 'SeriesSplitNeedsServerError' }); + expect(w.db.all('SELECT 1 FROM calendar_pending')).toEqual([]); + }); + + it('still works online', async () => { + const { w, client } = await online(); + const event = occurrenceOn('2026-11-09')!; + const { updateEvent, createEvent } = useCalendarStore.getState(); + await saveOccurrenceEdit('this_and_future', { + event, + updates: { title: 'From here on' }, + sendScheduling: false, + userEmails: ['a@example.org'], + }, { client, updateEvent, createEvent, findMasterEvent: async () => client.getCalendarEvent('series') }); + expect([...w.server.events.values()].some((e) => e.title === 'From here on')).toBe(true); + }); +}); diff --git a/stores/__tests__/email-store-batch-target.test.ts b/stores/__tests__/email-store-batch-target.test.ts index b2ad83e0f..589c90b2f 100644 --- a/stores/__tests__/email-store-batch-target.test.ts +++ b/stores/__tests__/email-store-batch-target.test.ts @@ -73,6 +73,14 @@ describe('batch functions with an explicit target', () => { expect(client.batchArchiveEmails).not.toHaveBeenCalled(); }); + it("files into an Archive someone else reads when the user pressed Archive, as the app's own button does", async () => { + const getMailboxShareWith = vi.fn(async (id: string) => (id === 'arch' ? { u2: { mayReadItems: true } } : null)); + Object.assign(client, { supportsMailboxSharing: () => true, getMailboxShareWith }); + await useEmailStore.getState().batchArchive(other, { emailIds: ['m1'], client, emails: [email('m1')], userArchive: true }); + expect(client.batchArchiveEmails).toHaveBeenCalled(); + expect(getMailboxShareWith).not.toHaveBeenCalled(); + }); + it('tags the named messages through the target\'s client and skips those already tagged', async () => { const ok = await useEmailStore.getState().batchSetTag(other, 'work', true, { emailIds: ['m1', 'm2'], client, emails: [email('m1'), email('m2', { '$label:work': true })], diff --git a/stores/__tests__/email-store-search-refresh-toast.test.ts b/stores/__tests__/email-store-search-refresh-toast.test.ts new file mode 100644 index 000000000..7eba711f7 --- /dev/null +++ b/stores/__tests__/email-store-search-refresh-toast.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useEmailStore } from '../email-store'; +import { useSettingsStore } from '../settings-store'; +import { useAuthStore } from '../auth-store'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +// A refresh during a search in the Inbox can bring a row only the local store +// found, for example once an old message's body has arrived. It isn't new mail, +// even when it's the newest row on the page and not in the list yet. + +const inbox = { id: 'inbox', role: 'inbox', isShared: false } as Mailbox; + +function email(id: string, receivedAt: string, extra: Partial = {}): Email { + return { + id, threadId: `thread-${id}`, mailboxIds: { inbox: true }, keywords: {}, + subject: id, receivedAt, from: [{ email: 'sender@example.com' }], to: [], + preview: '', size: 1, hasAttachment: false, ...extra, + } as Email; +} + +function client(rows: Email[]) { + return { + advancedSearchEmails: vi.fn(async () => ({ emails: rows, total: rows.length, hasMore: false })), + getThreads: vi.fn(async () => []), + getAccountId: () => 'me', + } as unknown as IJMAPClient; +} + +async function refreshWith(row: Email) { + const listed = email('listed', '2026-10-01T00:00:00Z'); + useEmailStore.setState({ emails: [listed], totalEmails: 1 }); + await useEmailStore.getState().refreshCurrentMailbox(client([row, listed])); + return useEmailStore.getState(); +} + +describe('the new-mail notification during a search', () => { + beforeEach(() => { + useAuthStore.setState(useAuthStore.getInitialState()); + useSettingsStore.setState({ + emailsPerPage: 25, messageListOrder: [], messageListOrderScope: 'inbox', + notificationMutes: { accounts: [], mailboxes: {} }, + }); + useEmailStore.setState({ + ...useEmailStore.getInitialState(), + selectedMailbox: 'inbox', mailboxes: [inbox], searchQuery: 'rechnung', searchMailboxId: 'inbox', + }); + }); + + it('stays quiet for a row only the local store found, and lists it', async () => { + const state = await refreshWith(email('old-find', '2026-10-15T00:00:00Z', { foundLocally: true })); + expect(state.newEmailNotification).toBeNull(); + expect(state.emails.map((row) => row.id)).toContain('old-find'); + }); + + it('still notifies for a new row from the server', async () => { + const state = await refreshWith(email('new', '2026-10-15T00:00:00Z')); + expect(state.newEmailNotification?.id).toBe('new'); + }); +}); diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index d7f9c53f7..fceb9c4e7 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -5,7 +5,7 @@ import type { Calendar, CalendarEvent, CalendarParticipant, CalendarParticipantI import { debug } from '@/lib/debug'; import { getEventStartDate, normalizeAllDayDuration } from '@/lib/calendar-utils'; import { displayNow } from '@/lib/timezone'; -import { parseDuration } from '@/components/calendar/event-card'; +import { parseDuration } from '@/lib/calendar-event-normalization'; import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization'; import { feedChanged, feedFields } from '@/lib/calendar-feed-diff'; import { expandRecurringEvents } from '@/lib/recurrence-expansion'; diff --git a/stores/email-store.ts b/stores/email-store.ts index 97d75caca..0f1b60161 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -208,6 +208,12 @@ export interface BatchActionTarget { * wanted state. batchArchive and batchSetTag act on these only. */ emails?: Email[]; + /** + * The user pressed Archive for these, as on the app's own button, which + * files into an Archive shared with others too. Rules and plugins acting + * on their own are refused one. + */ + userArchive?: boolean; } interface EmailStore { @@ -1403,7 +1409,7 @@ async function archiveTarget(client: IJMAPClient, target: BatchActionTarget): Pr if (!archive) return false; // Never into an Archive someone else reads (or a year or month folder of // it): the share map isn't in the folder list, so the server is asked. - if (await isSharedOut(target.client, [...mailboxes, archive], archive.originalId || archive.id, target.accountId, { descendants: true })) { + if (!target.userArchive && await isSharedOut(target.client, [...mailboxes, archive], archive.originalId || archive.id, target.accountId, { descendants: true })) { throw new Error('The Archive folder is shared with other people'); } const hookMailboxes = [...mailboxes.filter(m => m.id !== archive.id), archive]; @@ -7054,6 +7060,7 @@ export const useEmailStore = create((set, get) => ({ !selectedKeyword && mailbox?.role === 'inbox' && !currentEmails.some(e => e.id === newFirst.id) && + !newFirst.foundLocally && (!keepOpenThread || insertedCount > 0) ) { notifyNewInboxMail(client, mailbox, newFirst, get); diff --git a/stores/pro-tab-store.ts b/stores/pro-tab-store.ts index ecc393c05..36051d2a8 100644 --- a/stores/pro-tab-store.ts +++ b/stores/pro-tab-store.ts @@ -65,6 +65,8 @@ export interface ProComposeTabData { initialData?: ComposerDraftData | null; /** Fields a plugin filled in through onBeforeCompose; dropped on the first state save. */ prefill?: ComposePrefill | null; + /** Files from a share or a launch, attached when the composer opens. */ + initialFiles?: File[] | null; sourceEmailId?: string | null; /** The login and account of that message, for its $answered / $forwarded flag. */ sourceEmailSource?: EmailWriteSource | null; diff --git a/vendor/sqlite3mc/2.6.1-3.54.0/LICENSE b/vendor/sqlite3mc/2.6.1-3.54.0/LICENSE new file mode 100644 index 000000000..2fff29de4 --- /dev/null +++ b/vendor/sqlite3mc/2.6.1-3.54.0/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019-2026 Ulrich Telle + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/sqlite3mc/2.6.1-3.54.0/sqlite3.mjs b/vendor/sqlite3mc/2.6.1-3.54.0/sqlite3.mjs new file mode 100644 index 000000000..4ea31fb03 --- /dev/null +++ b/vendor/sqlite3mc/2.6.1-3.54.0/sqlite3.mjs @@ -0,0 +1,14727 @@ +/* @preserve +** +** LICENSE for the sqlite3 WebAssembly/JavaScript APIs. +** +** This bundle (typically released as sqlite3.js or sqlite3.mjs) +** is an amalgamation of JavaScript source code from two projects: +** +** 1) https://emscripten.org: the Emscripten "glue code" is covered by +** the terms of the MIT license and University of Illinois/NCSA +** Open Source License, as described at: +** +** https://emscripten.org/docs/introducing_emscripten/emscripten_license.html +** +** 2) https://sqlite.org: all code and documentation labeled as being +** from this source are released under the same terms as the sqlite3 +** C library: +** +** 2022-10-16 +** +** The author disclaims copyright to this source code. In place of a +** legal notice, here is a blessing: +** +** * May you do good and not evil. +** * May you find forgiveness for yourself and forgive others. +** * May you share freely, never taking more than you give. +*/ +/* @preserve +** This code was built from sqlite3 version... +** +** SQLITE_VERSION "3.54.0" +** SQLITE_VERSION_NUMBER 3054000 +** SQLITE_SOURCE_ID "2026-10-09 15:46:58 be8d059e9a49089ab2dce5ed26dd87aaf598fdc5fbe0b107c0dd758464bcd5e3" +** +** Emscripten SDK: 6.0.3 +*/ + + + + + +async function sqlite3InitModule(moduleArg = {}) { + var Module = moduleArg; + + + + + + + + + + + + + + + + + + + + + +var ENVIRONMENT_IS_WEB = !!globalThis.window; +var ENVIRONMENT_IS_WORKER = !!globalThis.WorkerGlobalScope; + + +var ENVIRONMENT_IS_NODE = globalThis.process?.versions?.node && globalThis.process?.type != 'renderer'; +var ENVIRONMENT_IS_SHELL = !ENVIRONMENT_IS_WEB && !ENVIRONMENT_IS_NODE && !ENVIRONMENT_IS_WORKER; + + + + + + +(function(Module){ + const sIMS = + globalThis.sqlite3InitModuleState + || Object.assign(Object.create(null),{ + + debugModule: function(){ + console.warn("globalThis.sqlite3InitModuleState is missing",arguments); + } + }); + delete globalThis.sqlite3InitModuleState; + sIMS.debugModule('pre-js.js sqlite3InitModuleState =',sIMS); + + + Module['locateFile'] = function(path, prefix) { + if( this.emscriptenLocateFile instanceof Function ){ + + return this.emscriptenLocateFile(path, prefix); + } + return new URL(path, import.meta.url).href; + }.bind(sIMS); + + + Module['instantiateWasm'] = function callee(imports, onSuccess){ + if( this.emscriptenInstantiateWasm instanceof Function ){ + + return this.emscriptenInstantiateWasm(imports, onSuccess); + } + const sims = this; + const finalThen = (arg)=>{ + arg.imports = imports; + sims.instantiateWasm = arg; + onSuccess(arg.instance, arg.module); + }; + const uri = Module.locateFile( + sims.wasmFilename, ( + ('undefined'===typeof scriptDirectory) + ? "" : scriptDirectory) + ); + sims.debugModule("instantiateWasm() uri =", uri, "sIMS =",this); + const wfetch = ()=>fetch(uri, {credentials: 'same-origin'}); + const loadWasm = WebAssembly.instantiateStreaming + ? async ()=> + WebAssembly + .instantiateStreaming(wfetch(), imports) + .then(finalThen) + : async ()=> + wfetch() + .then(response => response.arrayBuffer()) + .then(bytes => WebAssembly.instantiate(bytes, imports)) + .then(finalThen) + return loadWasm(); + }.bind(sIMS); +})(Module); + + + + +var programArgs = []; +var thisProgram = './this.program'; +var quit_ = (status, toThrow) => { + throw toThrow; +}; + +var _scriptName = import.meta.url; + + +var scriptDirectory = ''; +function locateFile(path) { + if (Module['locateFile']) { + return Module['locateFile'](path, scriptDirectory); + } + return scriptDirectory + path; +} + + +var readAsync, readBinary; + + + + +if (ENVIRONMENT_IS_WEB || ENVIRONMENT_IS_WORKER) { + try { + scriptDirectory = new URL('.', _scriptName).href; + } catch { + + + } + + { + +if (ENVIRONMENT_IS_WORKER) { + readBinary = (url) => { + var xhr = new XMLHttpRequest(); + xhr.open('GET', url, false); + xhr.responseType = 'arraybuffer'; + xhr.send(null); + return new Uint8Array((xhr.response)); + }; + } + + readAsync = async (url) => { + var response = await fetch(url, { credentials: 'same-origin' }); + if (response.ok) { + return response.arrayBuffer(); + } + throw new Error(response.status + ' : ' + response.url); + }; + + } +} else +{ +} + +var out = console.log.bind(console); +var err = console.error.bind(console); + + + + + + + + + + + + + + +var wasmBinary; + + + + + + + + + +var ABORT = false; + + + + +var EXITSTATUS; + + + + + + +function assert(condition, text) { + if (!condition) { + + + + abort(text); + } +} + + +var isFileURI = (filename) => filename.startsWith('file://'); + + + + +class EmscriptenEH {} + +class EmscriptenSjLj extends EmscriptenEH {} + + + + + + +var runtimeInitialized = false; + + + + + +function getMemoryBuffer() { + return wasmMemory.buffer; +} + +function updateMemoryViews() { + + + if (HEAP8?.buffer?.resizable) return; + var b = getMemoryBuffer(); + HEAP8 = new Int8Array(b); + HEAP16 = new Int16Array(b); + HEAPU8 = new Uint8Array(b); + HEAPU16 = new Uint16Array(b); + HEAP32 = new Int32Array(b); + HEAPU32 = new Uint32Array(b); + HEAPF32 = new Float32Array(b); + HEAPF64 = new Float64Array(b); + HEAP64 = new BigInt64Array(b); + HEAPU64 = new BigUint64Array(b); +} + + + + + + + +function initMemory() { + + + + if (Module['wasmMemory']) { + wasmMemory = Module['wasmMemory']; + } else + { + var INITIAL_MEMORY = Module['INITIAL_MEMORY'] || 8388608; + + + wasmMemory = new WebAssembly.Memory({ + 'initial': INITIAL_MEMORY / 65536, + + + + + + 'maximum': 32768, + }); + } + + updateMemoryViews(); +} + + + + + + +function preRun() { + var preRun = Module['preRun']; + if (preRun) { + if (typeof preRun == 'function') preRun = [preRun]; + onPreRuns.push(...preRun); + } + + callRuntimeCallbacks(onPreRuns); + +} + +function initRuntime() { + runtimeInitialized = true; + + + if (!Module['noFSInit'] && !FS.initialized) FS.init(); +TTY.init(); + + + wasmExports['__wasm_call_ctors'](); + + + FS.ignorePermissions = false; + + +} + +function postRun() { + + var postRun = Module['postRun']; + if (postRun) { + if (typeof postRun == 'function') postRun = [postRun]; + onPostRuns.push(...postRun); + } + + + callRuntimeCallbacks(onPostRuns); + +} + + +function abort(what) { + Module['onAbort']?.(what); + + what = `Aborted(${what})`; + + + err(what); + + ABORT = true; + + what += '. Build with -sASSERTIONS for more info.'; + + + + + + + + + + + + + + + + var e = new WebAssembly.RuntimeError(what); + + + + + throw e; +} + +var wasmBinaryFile; + +function findWasmBinary() { + + if (Module['locateFile']) { + return locateFile('sqlite3.wasm'); + } + + + return new URL('sqlite3.wasm', import.meta.url).href; + +} + +function getBinarySync(file) { + if (readBinary) { + return readBinary(file); + } + + + throw 'both async and sync fetching of the wasm failed'; +} + +async function getWasmBinary(binaryFile) { + + if (!wasmBinary) { + + try { + var response = await readAsync(binaryFile); + return new Uint8Array(response); + } catch { + + } + } + + + return getBinarySync(binaryFile); +} + +async function instantiateArrayBuffer(binaryFile, imports) { + try { + var binary = await getWasmBinary(binaryFile); + var instance = await WebAssembly.instantiate(binary, imports); + return instance; + } catch (reason) { + err(`failed to asynchronously prepare wasm: ${reason}`); + + abort(reason); + } +} + +async function instantiateAsync(binary, binaryFile, imports) { + if (!binary + ) { + try { + var response = fetch(binaryFile, { credentials: 'same-origin' }); + var instantiationResult = await WebAssembly.instantiateStreaming(response, imports); + return instantiationResult; + } catch (reason) { + + + err(`wasm streaming compile failed: ${reason}`); + err('falling back to ArrayBuffer instantiation'); + + }; + } + return instantiateArrayBuffer(binaryFile, imports); +} + +function getWasmImports() { + + var imports = { + 'env': wasmImports, + 'wasi_snapshot_preview1': wasmImports, + }; + return imports; +} + + + +async function createWasm() { + + + + function receiveInstance(instance) { + wasmExports = instance.exports; + + assignWasmExports(wasmExports); + + return wasmExports; + } + + + function receiveInstantiationResult(result) { + + + + + return receiveInstance(result['instance']); + } + + var info = getWasmImports(); + + + + + + + + var instantiateWasm = Module['instantiateWasm']; + if (instantiateWasm) { + return new Promise((resolve) => { + instantiateWasm(info, (inst) => resolve(receiveInstance(inst))); + }); + } + + wasmBinaryFile ??= findWasmBinary(); + var result = await instantiateAsync(wasmBinary, wasmBinaryFile, info); + var exports = receiveInstantiationResult(result); + return exports; +} + + + + + + + class ExitStatus { + name = 'ExitStatus'; + constructor(status) { + this.message = `Program terminated with exit(${status})`; + this.status = status; + } + } + + + var HEAP16; + + + var HEAP32; + + + var HEAP64; + + + var HEAP8; + + + var HEAPF32; + + + var HEAPF64; + + + var HEAPU16; + + + var HEAPU32; + + + var HEAPU64; + + + var HEAPU8; + + var callRuntimeCallbacks = (callbacks) => { + while (callbacks.length > 0) { + + callbacks.shift()(Module); + } + }; + var onPostRuns = []; + var addOnPostRun = (cb) => onPostRuns.push(cb); + + var onPreRuns = []; + var addOnPreRun = (cb) => onPreRuns.push(cb); + + + + + function getValue(ptr, type = 'i8') { + if (type.endsWith('*')) type = '*'; + switch (type) { + case 'i1': return HEAP8[ptr]; + case 'i8': return HEAP8[ptr]; + case 'i16': return HEAP16[((ptr)>>1)]; + case 'i32': return HEAP32[((ptr)>>2)]; + case 'i64': return HEAP64[((ptr)>>3)]; + case 'float': return HEAPF32[((ptr)>>2)]; + case 'double': return HEAPF64[((ptr)>>3)]; + case '*': return HEAPU32[((ptr)>>2)]; + default: abort(`invalid type for getValue: ${type}`); + } + } + + var noExitRuntime = true; + + + + function setValue(ptr, value, type = 'i8') { + if (type.endsWith('*')) type = '*'; + switch (type) { + case 'i1': HEAP8[ptr] = value; break; + case 'i8': HEAP8[ptr] = value; break; + case 'i16': HEAP16[((ptr)>>1)] = value; break; + case 'i32': HEAP32[((ptr)>>2)] = value; break; + case 'i64': HEAP64[((ptr)>>3)] = BigInt(value); break; + case 'float': HEAPF32[((ptr)>>2)] = value; break; + case 'double': HEAPF64[((ptr)>>3)] = value; break; + case '*': HEAPU32[((ptr)>>2)] = value; break; + default: abort(`invalid type for setValue: ${type}`); + } + } + + var stackRestore = (val) => __emscripten_stack_restore(val); + + var stackSave = () => _emscripten_stack_get_current(); + + var wasmMemory; + + var PATH = { + isAbs:(path) => path.charAt(0) === '/', + splitPath:(filename) => { + var splitPathRe = /^(\/?|)([\s\S]*?)((?:\.{1,2}|[^\/]+?|)(\.[^.\/]*|))(?:[\/]*)$/; + return splitPathRe.exec(filename).slice(1); + }, + normalizeArray:(parts, allowAboveRoot) => { + + var up = 0; + for (var i = parts.length - 1; i >= 0; i--) { + var last = parts[i]; + if (last === '.') { + parts.splice(i, 1); + } else if (last === '..') { + parts.splice(i, 1); + up++; + } else if (up) { + parts.splice(i, 1); + up--; + } + } + + if (allowAboveRoot) { + for (; up; up--) { + parts.unshift('..'); + } + } + return parts; + }, + normalize:(path) => { + var isAbsolute = PATH.isAbs(path), + trailingSlash = path.slice(-1) === '/'; + + path = PATH.normalizeArray(path.split('/').filter((p) => !!p), !isAbsolute).join('/'); + if (!path && !isAbsolute) { + path = '.'; + } + if (path && trailingSlash) { + path += '/'; + } + return (isAbsolute ? '/' : '') + path; + }, + dirname:(path) => { + var result = PATH.splitPath(path), + root = result[0], + dir = result[1]; + if (!root && !dir) { + + return '.'; + } + if (dir) { + + dir = dir.slice(0, -1); + } + return root + dir; + }, + basename:(path) => path && path.match(/([^\/]+|\/)\/*$/)[1], +join:(...paths) => PATH.normalize(paths.join('/')), +join2:(l, r) => PATH.normalize(l + '/' + r), +}; + +var initRandomFill = () => { + + return (view) => (crypto.getRandomValues(view), 0); + }; +var randomFill = (view) => (randomFill = initRandomFill())(view); + + + +var PATH_FS = { +resolve:(...args) => { + var resolvedPath = '', + resolvedAbsolute = false; + for (var i = args.length - 1; i >= -1 && !resolvedAbsolute; i--) { + var path = (i >= 0) ? args[i] : FS.cwd(); + + if (typeof path != 'string') { + throw new TypeError('Arguments to path.resolve must be strings'); + } else if (!path) { + return ''; + } + resolvedPath = path + '/' + resolvedPath; + resolvedAbsolute = PATH.isAbs(path); + } + + + resolvedPath = PATH.normalizeArray(resolvedPath.split('/').filter((p) => !!p), !resolvedAbsolute).join('/'); + return ((resolvedAbsolute ? '/' : '') + resolvedPath) || '.'; + }, +relative:(from, to) => { + from = PATH_FS.resolve(from).slice(1); + to = PATH_FS.resolve(to).slice(1); + function trim(arr) { + var start = 0; + for (; start < arr.length; start++) { + if (arr[start] !== '') break; + } + var end = arr.length - 1; + for (; end >= 0; end--) { + if (arr[end] !== '') break; + } + if (start > end) return []; + return arr.slice(start, end - start + 1); + } + var fromParts = trim(from.split('/')); + var toParts = trim(to.split('/')); + var length = Math.min(fromParts.length, toParts.length); + var samePartsLength = length; + for (var i = 0; i < length; i++) { + if (fromParts[i] !== toParts[i]) { + samePartsLength = i; + break; + } + } + var outputParts = []; + for (var i = samePartsLength; i < fromParts.length; i++) { + outputParts.push('..'); + } + outputParts = outputParts.concat(toParts.slice(samePartsLength)); + return outputParts.join('/'); + }, +}; + + +var UTF8Decoder = new TextDecoder(); + + + + var findStringEnd = (heapOrArray, idx, maxBytesToRead, ignoreNul) => { + var maxIdx = idx + maxBytesToRead; + if (ignoreNul) return maxIdx; + + + + + while (heapOrArray[idx] && !(idx >= maxIdx)) ++idx; + return idx; + }; + + + var UTF8ArrayToString = (heapOrArray, idx = 0, maxBytesToRead, ignoreNul) => { + + var endPtr = findStringEnd(heapOrArray, idx, maxBytesToRead, ignoreNul); + + return UTF8Decoder.decode(heapOrArray.buffer ? heapOrArray.subarray(idx, endPtr) : new Uint8Array(heapOrArray.slice(idx, endPtr))); + }; + + var FS_stdin_getChar_buffer = []; + + var lengthBytesUTF8 = (str) => { + var len = 0; + for (var i = 0; i < str.length; ++i) { + + + + + var c = str.charCodeAt(i); + if (c <= 0x7F) { + len++; + } else if (c <= 0x7FF) { + len += 2; + } else if (c >= 0xD800 && c <= 0xDFFF) { + len += 4; ++i; + } else { + len += 3; + } + } + return len; + }; + + var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { + + + if (!(maxBytesToWrite > 0)) + return 0; + + var startIdx = outIdx; + var endIdx = outIdx + maxBytesToWrite - 1; + for (var i = 0; i < str.length; ++i) { + + + + var u = str.codePointAt(i); + if (u <= 0x7F) { + if (outIdx >= endIdx) break; + heap[outIdx++] = u; + } else if (u <= 0x7FF) { + if (outIdx + 1 >= endIdx) break; + heap[outIdx++] = 0xC0 | (u >> 6); + heap[outIdx++] = 0x80 | (u & 63); + } else if (u <= 0xFFFF) { + if (outIdx + 2 >= endIdx) break; + heap[outIdx++] = 0xE0 | (u >> 12); + heap[outIdx++] = 0x80 | ((u >> 6) & 63); + heap[outIdx++] = 0x80 | (u & 63); + } else { + if (outIdx + 3 >= endIdx) break; + heap[outIdx++] = 0xF0 | (u >> 18); + heap[outIdx++] = 0x80 | ((u >> 12) & 63); + heap[outIdx++] = 0x80 | ((u >> 6) & 63); + heap[outIdx++] = 0x80 | (u & 63); + + + i++; + } + } + + heap[outIdx] = 0; + return outIdx - startIdx; + }; + + var intArrayFromString = (stringy, dontAddNull, length) => { + var len = length > 0 ? length : lengthBytesUTF8(stringy)+1; + var u8array = new Array(len); + var numBytesWritten = stringToUTF8Array(stringy, u8array, 0, u8array.length); + if (dontAddNull) u8array.length = numBytesWritten; + return u8array; + }; + var FS_stdin_getChar = () => { + if (!FS_stdin_getChar_buffer.length) { + var result = null; + if (globalThis.window?.prompt) { + + result = window.prompt('Input: '); + if (result !== null) { + result += '\n'; + } + } else + {} + if (!result) { + return null; + } + FS_stdin_getChar_buffer = intArrayFromString(result, true); + } + return FS_stdin_getChar_buffer.shift(); + }; + var TTY = { + ttys:[], + init() { + + + + + + + + + }, + shutdown() { + + + + + + + + + + }, + register(dev, ops) { + TTY.ttys[dev] = { input: [], output: [], ops: ops }; + FS.registerDevice(dev, TTY.stream_ops); + }, + stream_ops:{ + open(stream) { + var tty = TTY.ttys[stream.node.rdev]; + if (!tty) { + throw new FS.ErrnoError(43); + } + stream.tty = tty; + stream.seekable = false; + }, + close(stream) { + + stream.tty.ops.fsync(stream.tty); + }, + fsync(stream) { + stream.tty.ops.fsync(stream.tty); + }, + read(stream, buffer, offset, length, pos ) { + if (!stream.tty || !stream.tty.ops.get_char) { + throw new FS.ErrnoError(60); + } + var bytesRead = 0; + for (var i = 0; i < length; i++) { + var result; + try { + result = stream.tty.ops.get_char(stream.tty); + } catch (e) { + throw new FS.ErrnoError(29); + } + if (result === undefined && bytesRead === 0) { + throw new FS.ErrnoError(6); + } + if (result === null || result === undefined) break; + bytesRead++; + buffer[offset+i] = result; + } + if (bytesRead) { + stream.node.atime = Date.now(); + } + return bytesRead; + }, + write(stream, buffer, offset, length, pos) { + if (!stream.tty || !stream.tty.ops.put_char) { + throw new FS.ErrnoError(60); + } + try { + for (var i = 0; i < length; i++) { + stream.tty.ops.put_char(stream.tty, buffer[offset+i]); + } + } catch (e) { + throw new FS.ErrnoError(29); + } + if (length) { + stream.node.mtime = stream.node.ctime = Date.now(); + } + return i; + }, + }, + default_tty_ops:{ + get_char(tty) { + return FS_stdin_getChar(); + }, + put_char(tty, val) { + if (val === null || val === 10) { + out(UTF8ArrayToString(tty.output)); + tty.output = []; + } else { + if (val != 0) tty.output.push(val); + } + }, + fsync(tty) { + if (tty.output?.length > 0) { + out(UTF8ArrayToString(tty.output)); + tty.output = []; + } + }, + ioctl_tcgets(tty) { + + return { + c_iflag: 25856, + c_oflag: 5, + c_cflag: 191, + c_lflag: 35387, + c_cc: [ + 0x03, 0x1c, 0x7f, 0x15, 0x04, 0x00, 0x01, 0x00, 0x11, 0x13, 0x1a, 0x00, + 0x12, 0x0f, 0x17, 0x16, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ] + }; + }, + ioctl_tcsets(tty, optional_actions, data) { + + return 0; + }, + ioctl_tiocgwinsz(tty) { + return [24, 80]; + }, + }, + default_tty1_ops:{ + put_char(tty, val) { + if (val === null || val === 10) { + err(UTF8ArrayToString(tty.output)); + tty.output = []; + } else { + if (val != 0) tty.output.push(val); + } + }, + fsync(tty) { + if (tty.output?.length > 0) { + err(UTF8ArrayToString(tty.output)); + tty.output = []; + } + }, + }, + }; + + + var zeroMemory = (ptr, size) => HEAPU8.fill(0, ptr, ptr + size); + + var alignMemory = (size, alignment) => { + return Math.ceil(size / alignment) * alignment; + }; + var mmapAlloc = (size) => { + size = alignMemory(size, 65536); + var ptr = _emscripten_builtin_memalign(65536, size); + if (ptr) zeroMemory(ptr, size); + return ptr; + }; + var MEMFS = { + ops_table:null, + mount(mount) { + return MEMFS.createNode(null, '/', 16895, 0); + }, + createNode(parent, name, mode, dev) { + if (FS.isBlkdev(mode) || FS.isFIFO(mode)) { + + throw new FS.ErrnoError(63); + } + MEMFS.ops_table ||= { + dir: { + node: { + getattr: MEMFS.node_ops.getattr, + setattr: MEMFS.node_ops.setattr, + lookup: MEMFS.node_ops.lookup, + mknod: MEMFS.node_ops.mknod, + rename: MEMFS.node_ops.rename, + unlink: MEMFS.node_ops.unlink, + rmdir: MEMFS.node_ops.rmdir, + readdir: MEMFS.node_ops.readdir, + symlink: MEMFS.node_ops.symlink + }, + stream: { + llseek: MEMFS.stream_ops.llseek + } + }, + file: { + node: { + getattr: MEMFS.node_ops.getattr, + setattr: MEMFS.node_ops.setattr + }, + stream: { + llseek: MEMFS.stream_ops.llseek, + read: MEMFS.stream_ops.read, + write: MEMFS.stream_ops.write, + mmap: MEMFS.stream_ops.mmap, + msync: MEMFS.stream_ops.msync + } + }, + link: { + node: { + getattr: MEMFS.node_ops.getattr, + setattr: MEMFS.node_ops.setattr, + readlink: MEMFS.node_ops.readlink + }, + stream: {} + }, + chrdev: { + node: { + getattr: MEMFS.node_ops.getattr, + setattr: MEMFS.node_ops.setattr + }, + stream: FS.chrdev_stream_ops + } + }; + var node = FS.createNode(parent, name, mode, dev); + if (FS.isDir(node.mode)) { + node.node_ops = MEMFS.ops_table.dir.node; + node.stream_ops = MEMFS.ops_table.dir.stream; + node.contents = {}; + } else if (FS.isFile(node.mode)) { + node.node_ops = MEMFS.ops_table.file.node; + node.stream_ops = MEMFS.ops_table.file.stream; + + + node.usedBytes = 0; + + + + + node.contents = MEMFS.emptyFileContents ??= new Uint8Array(0); + } else if (FS.isLink(node.mode)) { + node.node_ops = MEMFS.ops_table.link.node; + node.stream_ops = MEMFS.ops_table.link.stream; + } else if (FS.isChrdev(node.mode)) { + node.node_ops = MEMFS.ops_table.chrdev.node; + node.stream_ops = MEMFS.ops_table.chrdev.stream; + } + node.atime = node.mtime = node.ctime = Date.now(); + + if (parent) { + parent.contents[name] = node; + parent.atime = parent.mtime = parent.ctime = node.atime; + } + return node; + }, + getFileDataAsTypedArray(node) { + return node.contents.subarray(0, node.usedBytes); + }, + expandFileStorage(node, newCapacity) { + var prevCapacity = node.contents.length; + if (prevCapacity >= newCapacity) return; + + + + + + var CAPACITY_DOUBLING_MAX = 1024 * 1024; + newCapacity = Math.max(newCapacity, (prevCapacity * (prevCapacity < CAPACITY_DOUBLING_MAX ? 2.0 : 1.125)) >>> 0); + if (prevCapacity) newCapacity = Math.max(newCapacity, 256); + var oldContents = MEMFS.getFileDataAsTypedArray(node); + node.contents = new Uint8Array(newCapacity); + node.contents.set(oldContents); + }, + resizeFileStorage(node, newSize) { + if (node.usedBytes == newSize) return; + var oldContents = node.contents; + node.contents = new Uint8Array(newSize); + node.contents.set(oldContents.subarray(0, Math.min(newSize, node.usedBytes))); + node.usedBytes = newSize; + }, + node_ops:{ + getattr(node) { + var attr = {}; + + attr.dev = FS.isChrdev(node.mode) ? node.id : 1; + attr.ino = node.id; + attr.mode = node.mode; + attr.nlink = 1; + attr.uid = 0; + attr.gid = 0; + attr.rdev = node.rdev; + if (FS.isDir(node.mode)) { + attr.size = 4096; + } else if (FS.isFile(node.mode)) { + attr.size = node.usedBytes; + } else if (FS.isLink(node.mode)) { + attr.size = node.link.length; + } else { + attr.size = 0; + } + attr.atime = new Date(node.atime); + attr.mtime = new Date(node.mtime); + attr.ctime = new Date(node.ctime); + + + attr.blksize = 4096; + attr.blocks = Math.ceil(attr.size / attr.blksize); + return attr; + }, + setattr(node, attr) { + for (const key of ["mode", "atime", "mtime", "ctime"]) { + if (attr[key] != null) { + node[key] = attr[key]; + } + } + if (attr.size !== undefined) { + MEMFS.resizeFileStorage(node, attr.size); + } + }, + lookup(parent, name) { + + + if (!MEMFS.doesNotExistError) { + MEMFS.doesNotExistError = new FS.ErrnoError(44); + + MEMFS.doesNotExistError.stack = ''; + } + throw MEMFS.doesNotExistError; + }, + mknod(parent, name, mode, dev) { + return MEMFS.createNode(parent, name, mode, dev); + }, + rename(old_node, new_dir, new_name) { + var new_node; + try { + new_node = FS.lookupNode(new_dir, new_name); + } catch (e) {} + if (new_node) { + if (FS.isDir(old_node.mode)) { + + for (var i in new_node.contents) { + throw new FS.ErrnoError(55); + } + } + FS.hashRemoveNode(new_node); + } + + delete old_node.parent.contents[old_node.name]; + new_dir.contents[new_name] = old_node; + old_node.name = new_name; + new_dir.ctime = new_dir.mtime = old_node.parent.ctime = old_node.parent.mtime = Date.now(); + }, + unlink(parent, name) { + delete parent.contents[name]; + parent.ctime = parent.mtime = Date.now(); + }, + rmdir(parent, name) { + var node = FS.lookupNode(parent, name); + for (var i in node.contents) { + throw new FS.ErrnoError(55); + } + delete parent.contents[name]; + parent.ctime = parent.mtime = Date.now(); + }, + readdir(node) { + return ['.', '..', ...Object.keys(node.contents)]; + }, + symlink(parent, newname, oldpath) { + var node = MEMFS.createNode(parent, newname, 0o777 | 40960, 0); + node.link = oldpath; + return node; + }, + readlink(node) { + if (!FS.isLink(node.mode)) { + throw new FS.ErrnoError(28); + } + return node.link; + }, + }, + stream_ops:{ + read(stream, buffer, offset, length, position) { + var contents = stream.node.contents; + if (position >= stream.node.usedBytes) return 0; + var size = Math.min(stream.node.usedBytes - position, length); + buffer.set(contents.subarray(position, position + size), offset); + return size; + }, + write(stream, buffer, offset, length, position, canOwn) { + + + + + if (buffer.buffer === HEAP8.buffer) { + canOwn = false; + } + + if (!length) return 0; + var node = stream.node; + node.mtime = node.ctime = Date.now(); + + if (canOwn) { + node.contents = buffer.subarray(offset, offset + length); + node.usedBytes = length; + } else if (node.usedBytes === 0 && position === 0) { + node.contents = buffer.slice(offset, offset + length); + node.usedBytes = length; + } else { + MEMFS.expandFileStorage(node, position+length); + + node.contents.set(buffer.subarray(offset, offset + length), position); + node.usedBytes = Math.max(node.usedBytes, position + length); + } + return length; + }, + llseek(stream, offset, whence) { + var position = offset; + if (whence === 1) { + position += stream.position; + } else if (whence === 2) { + if (FS.isFile(stream.node.mode)) { + position += stream.node.usedBytes; + } + } + if (position < 0) { + throw new FS.ErrnoError(28); + } + return position; + }, + mmap(stream, length, position, prot, flags) { + if (!FS.isFile(stream.node.mode)) { + throw new FS.ErrnoError(43); + } + var ptr; + var allocated; + var contents = stream.node.contents; + + if (!(flags & 2) && contents.buffer === HEAP8.buffer) { + + + allocated = false; + ptr = contents.byteOffset; + } else { + allocated = true; + ptr = mmapAlloc(length); + if (!ptr) { + throw new FS.ErrnoError(48); + } + if (contents) { + + if (position > 0 || position + length < contents.length) { + if (contents.subarray) { + contents = contents.subarray(position, position + length); + } else { + contents = Array.prototype.slice.call(contents, position, position + length); + } + } + HEAP8.set(contents, ptr); + } + } + return { ptr, allocated }; + }, + msync(stream, buffer, offset, length, mmapFlags) { + MEMFS.stream_ops.write(stream, buffer, 0, length, offset, false); + + return 0; + }, + }, + }; + + var FS_modeStringToFlags = (str) => { + if (typeof str != 'string') return str; + var flagModes = { + 'r': 0, + 'r+': 2, + 'w': 512 | 64 | 1, + 'w+': 512 | 64 | 2, + 'a': 1024 | 64 | 1, + 'a+': 1024 | 64 | 2, + }; + var flags = flagModes[str]; + if (typeof flags == 'undefined') { + throw new Error(`Unknown file open mode: ${str}`); + } + return flags; + }; + + var FS_fileDataToTypedArray = (data) => { + if (typeof data == 'string') { + data = intArrayFromString(data, true); + } + if (!data.subarray) { + data = new Uint8Array(data); + } + return data; + }; + + var FS_getMode = (canRead, canWrite) => { + var mode = 0; + if (canRead) mode |= 292 | 73; + if (canWrite) mode |= 146; + return mode; + }; + + + var asyncLoad = async (url) => { + var arrayBuffer = await readAsync(url); + return new Uint8Array(arrayBuffer); + }; + + + var FS_createDataFile = (...args) => FS.createDataFile(...args); + + var getUniqueRunDependency = (id) => { + return id; + }; + + var dependenciesPromise = null; + var resolveRunDependencies = async () => dependenciesPromise; + var runDependencies = 0; + + + var dependenciesPromiseResolve = null; + var removeRunDependency = (id) => { + runDependencies--; + + Module['monitorRunDependencies']?.(runDependencies); + + if (!runDependencies) { + dependenciesPromiseResolve(); + } + }; + + + var addRunDependency = (id) => { + if (!runDependencies) { + dependenciesPromise = new Promise((resolve) => dependenciesPromiseResolve = resolve); + } + runDependencies++; + + Module['monitorRunDependencies']?.(runDependencies); + + }; + + + var preloadPlugins = []; + var FS_handledByPreloadPlugin = async (byteArray, fullname) => { + + if (typeof Browser != 'undefined') Browser.init(); + + for (var plugin of preloadPlugins) { + if (plugin['canHandle'](fullname)) { + return plugin['handle'](byteArray, fullname); + } + } + + + return byteArray; + }; + var FS_preloadFile = async (parent, name, url, canRead, canWrite, dontCreateFile, canOwn, preFinish) => { + + + var fullname = name ? PATH_FS.resolve(PATH.join2(parent, name)) : parent; + var dep = getUniqueRunDependency(`cp ${fullname}`); + addRunDependency(dep); + + try { + var byteArray = url; + if (typeof url == 'string') { + byteArray = await asyncLoad(url); + } + + byteArray = await FS_handledByPreloadPlugin(byteArray, fullname); + preFinish?.(); + if (!dontCreateFile) { + FS_createDataFile(parent, name, byteArray, canRead, canWrite, canOwn); + } + } finally { + removeRunDependency(dep); + } + }; + var FS_createPreloadedFile = (parent, name, url, canRead, canWrite, onload, onerror, dontCreateFile, canOwn, preFinish) => { + FS_preloadFile(parent, name, url, canRead, canWrite, dontCreateFile, canOwn, preFinish).then(onload).catch(onerror); + }; + var FS = { + root:null, + mounts:[], + devices:{ + }, + streams:[], + nextInode:1, + nameTable:null, + currentPath:"/", + initialized:false, + ignorePermissions:true, + filesystems:null, + syncFSRequests:0, + ErrnoError:class { + name = 'ErrnoError'; + + + + + + + constructor(errno) { + this.errno = errno; + } + }, + FSStream:class { + shared = {}; + get object() { + return this.node; + } + set object(val) { + this.node = val; + } + get isRead() { + return (this.flags & 2097155) !== 1; + } + get isWrite() { + return (this.flags & 2097155) !== 0; + } + get isAppend() { + return (this.flags & 1024); + } + get flags() { + return this.shared.flags; + } + set flags(val) { + this.shared.flags = val; + } + get position() { + return this.shared.position; + } + set position(val) { + this.shared.position = val; + } + }, + FSNode:class { + node_ops = {}; + stream_ops = {}; + readMode = 292 | 73; + writeMode = 146; + mounted = null; + constructor(parent, name, mode, rdev) { + if (!parent) { + parent = this; + } + this.parent = parent; + this.mount = parent.mount; + this.id = FS.nextInode++; + this.name = name; + this.mode = mode; + this.rdev = rdev; + this.atime = this.mtime = this.ctime = Date.now(); + } + get read() { + return (this.mode & this.readMode) === this.readMode; + } + set read(val) { + val ? this.mode |= this.readMode : this.mode &= ~this.readMode; + } + get write() { + return (this.mode & this.writeMode) === this.writeMode; + } + set write(val) { + val ? this.mode |= this.writeMode : this.mode &= ~this.writeMode; + } + get isFolder() { + return FS.isDir(this.mode); + } + get isDevice() { + return FS.isChrdev(this.mode); + } + + + + + + + addListener(cb, exclusive = false) { + var entry = {cb, exclusive}; + var listeners = (this.listeners ??= new Set()); + listeners.add(entry); + return {listeners, entry}; + } + notifyListeners(flags) { + + + + + + + + + + + + + + if (!this.listeners) return; + + + + + var excl; + for (var entry of this.listeners) { + if (entry.exclusive) (excl ||= []).push(entry); + else entry.cb(flags); + } + if (excl) { + var i = (this.exclTurn || 0) % excl.length; + this.exclTurn = i + 1; + excl[i].cb(flags); + } + } + }, + lookupPath(path, opts = {}) { + if (!path) { + throw new FS.ErrnoError(44); + } + opts.follow_mount ??= true + + if (!PATH.isAbs(path)) { + path = FS.cwd() + '/' + path; + } + + + linkloop: for (var nlinks = 0; nlinks < 40; nlinks++) { + + var parts = path.split('/').filter((p) => !!p); + + + var current = FS.root; + var current_path = '/'; + + for (var i = 0; i < parts.length; i++) { + var islast = (i === parts.length-1); + if (islast && opts.parent) { + + break; + } + + if (parts[i] === '.') { + continue; + } + + if (parts[i] === '..') { + current_path = PATH.dirname(current_path); + if (FS.isRoot(current)) { + path = current_path + '/' + parts.slice(i + 1).join('/'); + + + nlinks--; + continue linkloop; + } else { + current = current.parent; + } + continue; + } + + current_path = PATH.join2(current_path, parts[i]); + try { + current = FS.lookupNode(current, parts[i]); + } catch (e) { + + + + if ((e?.errno === 44) && islast && opts.noent_okay) { + return { path: current_path }; + } + throw e; + } + + + if (FS.isMountpoint(current) && (!islast || opts.follow_mount)) { + current = current.mounted.root; + } + + + + if (FS.isLink(current.mode) && (!islast || opts.follow)) { + if (!current.node_ops.readlink) { + throw new FS.ErrnoError(52); + } + var link = current.node_ops.readlink(current); + if (!PATH.isAbs(link)) { + link = PATH.dirname(current_path) + '/' + link; + } + path = link + '/' + parts.slice(i + 1).join('/'); + continue linkloop; + } + } + return { path: current_path, node: current }; + } + throw new FS.ErrnoError(32); + }, + getPath(node) { + var path; + while (true) { + if (FS.isRoot(node)) { + var mount = node.mount.mountpoint; + if (!path) return mount; + return mount[mount.length-1] !== '/' ? `${mount}/${path}` : mount + path; + } + path = path ? `${node.name}/${path}` : node.name; + node = node.parent; + } + }, + hashName(parentid, name) { + var hash = 0; + + for (var i = 0; i < name.length; i++) { + hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0; + } + return ((parentid + hash) >>> 0) % FS.nameTable.length; + }, + hashAddNode(node) { + var hash = FS.hashName(node.parent.id, node.name); + node.name_next = FS.nameTable[hash]; + FS.nameTable[hash] = node; + }, + hashRemoveNode(node) { + var hash = FS.hashName(node.parent.id, node.name); + if (FS.nameTable[hash] === node) { + FS.nameTable[hash] = node.name_next; + } else { + var current = FS.nameTable[hash]; + while (current) { + if (current.name_next === node) { + current.name_next = node.name_next; + break; + } + current = current.name_next; + } + } + }, + lookupNode(parent, name) { + var errCode = FS.mayLookup(parent); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + var hash = FS.hashName(parent.id, name); + for (var node = FS.nameTable[hash]; node; node = node.name_next) { + var nodeName = node.name; + if (node.parent.id === parent.id && nodeName === name) { + return node; + } + } + + return FS.lookup(parent, name); + }, + createNode(parent, name, mode, rdev) { + var node = new FS.FSNode(parent, name, mode, rdev); + + FS.hashAddNode(node); + + return node; + }, + destroyNode(node) { + FS.hashRemoveNode(node); + }, + isRoot(node) { + return node === node.parent; + }, + isMountpoint(node) { + return !!node.mounted; + }, + isFile(mode) { + return (mode & 61440) === 32768; + }, + isDir(mode) { + return (mode & 61440) === 16384; + }, + isLink(mode) { + return (mode & 61440) === 40960; + }, + isChrdev(mode) { + return (mode & 61440) === 8192; + }, + isBlkdev(mode) { + return (mode & 61440) === 24576; + }, + isFIFO(mode) { + return (mode & 61440) === 4096; + }, + isSocket(mode) { + return (mode & 49152) === 49152; + }, + flagsToPermissionString(flag) { + var perms = ['r', 'w', 'rw'][flag & 3]; + if ((flag & 512)) { + perms += 'w'; + } + return perms; + }, + nodePermissions(node, perms) { + if (FS.ignorePermissions) { + return 0; + } + + if (perms.includes('r') && !(node.mode & 292)) { + return 2; + } + if (perms.includes('w') && !(node.mode & 146)) { + return 2; + } + if (perms.includes('x') && !(node.mode & 73)) { + return 2; + } + return 0; + }, + mayLookup(dir) { + if (!FS.isDir(dir.mode)) return 54; + var errCode = FS.nodePermissions(dir, 'x'); + if (errCode) return errCode; + if (!dir.node_ops.lookup) return 2; + return 0; + }, + mayCreate(dir, name) { + if (!FS.isDir(dir.mode)) { + return 54; + } + try { + var node = FS.lookupNode(dir, name); + return 20; + } catch (e) { + } + return FS.nodePermissions(dir, 'wx'); + }, + mayDelete(dir, name, isdir) { + var node; + try { + node = FS.lookupNode(dir, name); + } catch (e) { + return e.errno; + } + var errCode = FS.nodePermissions(dir, 'wx'); + if (errCode) { + return errCode; + } + if (isdir) { + if (!FS.isDir(node.mode)) { + return 54; + } + if (FS.isRoot(node) || FS.getPath(node) === FS.cwd()) { + return 10; + } + } else if (FS.isDir(node.mode)) { + return 31; + } + return 0; + }, + mayOpen(node, flags) { + if (!node) { + return 44; + } + if (FS.isLink(node.mode)) { + return 32; + } + var mode = FS.flagsToPermissionString(flags); + if (FS.isDir(node.mode)) { + + + if (mode !== 'r' || (flags & (512 | 64))) { + return 31; + } + } + return FS.nodePermissions(node, mode); + }, + checkOpExists(op, err) { + if (!op) { + throw new FS.ErrnoError(err); + } + return op; + }, + MAX_OPEN_FDS:4096, + nextfd() { + for (var fd = 0; fd <= FS.MAX_OPEN_FDS; fd++) { + if (!FS.streams[fd]) { + return fd; + } + } + throw new FS.ErrnoError(33); + }, + getStreamChecked(fd) { + var stream = FS.getStream(fd); + if (!stream) { + throw new FS.ErrnoError(8); + } + return stream; + }, + getStream:(fd) => FS.streams[fd], + createStream(stream, fd = -1) { + + + stream = Object.assign(new FS.FSStream(), stream); + if (fd == -1) { + fd = FS.nextfd(); + } + stream.fd = fd; + FS.streams[fd] = stream; + return stream; + }, + closeStream(fd) { + FS.streams[fd] = null; + }, + dupStream(origStream, fd = -1) { + var stream = FS.createStream(origStream, fd); + stream.stream_ops?.dup?.(stream); + return stream; + }, + doSetAttr(stream, node, attr) { + var setattr = stream?.stream_ops.setattr; + var arg = setattr ? stream : node; + setattr ??= node.node_ops.setattr; + FS.checkOpExists(setattr, 63) + try { + setattr(arg, attr); + } catch (e) { + if (e instanceof RangeError) { + throw new FS.ErrnoError(22); + } + throw e; + } + }, + chrdev_stream_ops:{ + open(stream) { + var device = FS.getDevice(stream.node.rdev); + + stream.stream_ops = device.stream_ops; + + stream.stream_ops.open?.(stream); + }, + llseek() { + throw new FS.ErrnoError(70); + }, + }, + major:(dev) => ((dev) >> 8), + minor:(dev) => ((dev) & 0xff), + makedev:(ma, mi) => ((ma) << 8 | (mi)), + registerDevice(dev, ops) { + FS.devices[dev] = { stream_ops: ops }; + }, + getDevice:(dev) => FS.devices[dev], + getMounts(mount) { + var mounts = []; + var check = [mount]; + + while (check.length) { + var m = check.pop(); + + mounts.push(m); + + check.push(...m.mounts); + } + + return mounts; + }, + syncfs(populate, callback) { + if (typeof populate == 'function') { + callback = populate; + populate = false; + } + + FS.syncFSRequests++; + + if (FS.syncFSRequests > 1) { + err(`warning: ${FS.syncFSRequests} FS.syncfs operations in flight at once, probably just doing extra work`); + } + + var mounts = FS.getMounts(FS.root.mount); + var completed = 0; + + function doCallback(errCode) { + FS.syncFSRequests--; + return callback(errCode); + } + + function done(errCode) { + if (errCode) { + if (!done.errored) { + done.errored = true; + return doCallback(errCode); + } + return; + } + if (++completed >= mounts.length) { + doCallback(null); + } + }; + + + for (var mount of mounts) { + if (mount.type.syncfs) { + mount.type.syncfs(mount, populate, done); + } else { + done(null); + } + } + }, + mount(type, opts, mountpoint) { + var root = mountpoint === '/'; + var pseudo = !mountpoint; + var node; + + if (root && FS.root) { + throw new FS.ErrnoError(10); + } else if (!root && !pseudo) { + var lookup = FS.lookupPath(mountpoint, { follow_mount: false }); + + mountpoint = lookup.path; + node = lookup.node; + + if (FS.isMountpoint(node)) { + throw new FS.ErrnoError(10); + } + + if (!FS.isDir(node.mode)) { + throw new FS.ErrnoError(54); + } + } + + var mount = { + type, + opts, + mountpoint, + mounts: [] + }; + + + var mountRoot = type.mount(mount); + mountRoot.mount = mount; + mount.root = mountRoot; + + if (root) { + FS.root = mountRoot; + } else if (node) { + + node.mounted = mount; + + + if (node.mount) { + node.mount.mounts.push(mount); + } + } + + return mountRoot; + }, + unmount(mountpoint) { + var lookup = FS.lookupPath(mountpoint, { follow_mount: false }); + + if (!FS.isMountpoint(lookup.node)) { + throw new FS.ErrnoError(28); + } + + + var node = lookup.node; + var mount = node.mounted; + var mounts = FS.getMounts(mount); + + for (var [hash, current] of Object.entries(FS.nameTable)) { + while (current) { + var next = current.name_next; + + if (mounts.includes(current.mount)) { + FS.destroyNode(current); + } + + current = next; + } + } + + + node.mounted = null; + + + var idx = node.mount.mounts.indexOf(mount); + node.mount.mounts.splice(idx, 1); + }, + lookup(parent, name) { + return parent.node_ops.lookup(parent, name); + }, + mknod(path, mode, dev) { + var lookup = FS.lookupPath(path, { parent: true }); + var parent = lookup.node; + var name = PATH.basename(path); + if (!name) { + throw new FS.ErrnoError(28); + } + if (name === '.' || name === '..') { + throw new FS.ErrnoError(20); + } + var errCode = FS.mayCreate(parent, name); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + if (!parent.node_ops.mknod) { + throw new FS.ErrnoError(63); + } + return parent.node_ops.mknod(parent, name, mode, dev); + }, + statfs(path) { + return FS.statfsNode(FS.lookupPath(path, {follow: true}).node); + }, + statfsStream(stream) { + + + + return FS.statfsNode(stream.node); + }, + statfsNode(node) { + + + + var rtn = { + bsize: 4096, + frsize: 4096, + blocks: 1e6, + bfree: 5e5, + bavail: 5e5, + files: FS.nextInode, + ffree: FS.nextInode - 1, + fsid: 42, + flags: 2, + namelen: 255, + }; + + if (node.node_ops.statfs) { + Object.assign(rtn, node.node_ops.statfs(node.mount.opts.root)); + } + return rtn; + }, + create(path, mode = 0o666) { + mode &= 4095; + mode |= 32768; + return FS.mknod(path, mode, 0); + }, + mkdir(path, mode = 0o777) { + mode &= 511 | 512; + mode |= 16384; + return FS.mknod(path, mode, 0); + }, + mkdirTree(path, mode) { + var dirs = path.split('/'); + var d = ''; + for (var dir of dirs) { + if (!dir) continue; + if (d || PATH.isAbs(path)) d += '/'; + d += dir; + try { + FS.mkdir(d, mode); + } catch(e) { + if (e.errno != 20) throw e; + } + } + }, + mkdev(path, mode, dev) { + if (typeof dev == 'undefined') { + dev = mode; + mode = 0o666; + } + mode |= 8192; + return FS.mknod(path, mode, dev); + }, + symlink(oldpath, newpath) { + if (!PATH_FS.resolve(oldpath)) { + throw new FS.ErrnoError(44); + } + var lookup = FS.lookupPath(newpath, { parent: true }); + var parent = lookup.node; + if (!parent) { + throw new FS.ErrnoError(44); + } + var newname = PATH.basename(newpath); + var errCode = FS.mayCreate(parent, newname); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + if (!parent.node_ops.symlink) { + throw new FS.ErrnoError(63); + } + return parent.node_ops.symlink(parent, newname, oldpath); + }, + link(oldpath, newpath, flags) { + var lookup = FS.lookupPath(newpath, { parent: true }); + var parent = lookup.node; + if (!parent) { + throw new FS.ErrnoError(44); + } + var newname = PATH.basename(newpath); + var errCode = FS.mayCreate(parent, newname); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + + + + if (!parent.node_ops.link) { + throw new FS.ErrnoError(34); + } + return parent.node_ops.link(parent, newname, oldpath, flags); + }, + rename(old_path, new_path) { + var old_dirname = PATH.dirname(old_path); + var new_dirname = PATH.dirname(new_path); + var old_name = PATH.basename(old_path); + var new_name = PATH.basename(new_path); + + var lookup, old_dir, new_dir; + + + lookup = FS.lookupPath(old_path, { parent: true }); + old_dir = lookup.node; + lookup = FS.lookupPath(new_path, { parent: true }); + new_dir = lookup.node; + + if (!old_dir || !new_dir) throw new FS.ErrnoError(44); + + if (old_dir.mount !== new_dir.mount) { + throw new FS.ErrnoError(75); + } + + var old_node = FS.lookupNode(old_dir, old_name); + + var relative = PATH_FS.relative(old_path, new_dirname); + if (relative.charAt(0) !== '.') { + throw new FS.ErrnoError(28); + } + + relative = PATH_FS.relative(new_path, old_dirname); + if (relative.charAt(0) !== '.') { + throw new FS.ErrnoError(55); + } + + var new_node; + try { + new_node = FS.lookupNode(new_dir, new_name); + } catch (e) { + + } + + if (old_node === new_node) { + return; + } + + var isdir = FS.isDir(old_node.mode); + var errCode = FS.mayDelete(old_dir, old_name, isdir); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + + + errCode = new_node ? + FS.mayDelete(new_dir, new_name, isdir) : + FS.mayCreate(new_dir, new_name); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + if (!old_dir.node_ops.rename) { + throw new FS.ErrnoError(63); + } + if (FS.isMountpoint(old_node) || (new_node && FS.isMountpoint(new_node))) { + throw new FS.ErrnoError(10); + } + + if (new_dir !== old_dir) { + errCode = FS.nodePermissions(old_dir, 'w'); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + } + + FS.hashRemoveNode(old_node); + + try { + old_dir.node_ops.rename(old_node, new_dir, new_name); + + + old_node.parent = new_dir; + } catch (e) { + throw e; + } finally { + + + FS.hashAddNode(old_node); + } + }, + rmdir(path) { + var lookup = FS.lookupPath(path, { parent: true }); + var parent = lookup.node; + var name = PATH.basename(path); + var node = FS.lookupNode(parent, name); + var errCode = FS.mayDelete(parent, name, true); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + if (!parent.node_ops.rmdir) { + throw new FS.ErrnoError(63); + } + if (FS.isMountpoint(node)) { + throw new FS.ErrnoError(10); + } + parent.node_ops.rmdir(parent, name); + FS.destroyNode(node); + }, + readdir(path) { + var lookup = FS.lookupPath(path, { follow: true }); + var node = lookup.node; + var readdir = FS.checkOpExists(node.node_ops.readdir, 54); + return readdir(node); + }, + unlink(path) { + var lookup = FS.lookupPath(path, { parent: true }); + var parent = lookup.node; + if (!parent) { + throw new FS.ErrnoError(44); + } + var name = PATH.basename(path); + var node = FS.lookupNode(parent, name); + var errCode = FS.mayDelete(parent, name, false); + if (errCode) { + + + + throw new FS.ErrnoError(errCode); + } + if (!parent.node_ops.unlink) { + throw new FS.ErrnoError(63); + } + if (FS.isMountpoint(node)) { + throw new FS.ErrnoError(10); + } + parent.node_ops.unlink(parent, name); + FS.destroyNode(node); + }, + readlink(path) { + var lookup = FS.lookupPath(path); + var link = lookup.node; + if (!link) { + throw new FS.ErrnoError(44); + } + if (!link.node_ops.readlink) { + throw new FS.ErrnoError(28); + } + return link.node_ops.readlink(link); + }, + stat(path, dontFollow) { + var lookup = FS.lookupPath(path, { follow: !dontFollow }); + var node = lookup.node; + var getattr = FS.checkOpExists(node.node_ops.getattr, 63); + return getattr(node); + }, + fstat(fd) { + var stream = FS.getStreamChecked(fd); + var node = stream.node; + var getattr = stream.stream_ops.getattr; + var arg = getattr ? stream : node; + getattr ??= node.node_ops.getattr; + FS.checkOpExists(getattr, 63) + return getattr(arg); + }, + lstat(path) { + return FS.stat(path, true); + }, + doChmod(stream, node, mode, dontFollow) { + FS.doSetAttr(stream, node, { + mode: (mode & 4095) | (node.mode & ~4095), + ctime: Date.now(), + dontFollow + }); + }, + chmod(path, mode, dontFollow) { + var node; + if (typeof path == 'string') { + var lookup = FS.lookupPath(path, { follow: !dontFollow }); + node = lookup.node; + } else { + node = path; + } + FS.doChmod(null, node, mode, dontFollow); + }, + lchmod(path, mode) { + FS.chmod(path, mode, true); + }, + fchmod(fd, mode) { + var stream = FS.getStreamChecked(fd); + FS.doChmod(stream, stream.node, mode, false); + }, + doChown(stream, node, dontFollow) { + FS.doSetAttr(stream, node, { + timestamp: Date.now(), + dontFollow + + }); + }, + chown(path, uid, gid, dontFollow) { + var node; + if (typeof path == 'string') { + var lookup = FS.lookupPath(path, { follow: !dontFollow }); + node = lookup.node; + } else { + node = path; + } + FS.doChown(null, node, dontFollow); + }, + lchown(path, uid, gid) { + FS.chown(path, uid, gid, true); + }, + fchown(fd, uid, gid) { + var stream = FS.getStreamChecked(fd); + FS.doChown(stream, stream.node, false); + }, + doTruncate(stream, node, len) { + if (FS.isDir(node.mode)) { + throw new FS.ErrnoError(31); + } + if (!FS.isFile(node.mode)) { + throw new FS.ErrnoError(28); + } + var errCode = FS.nodePermissions(node, 'w'); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + FS.doSetAttr(stream, node, { + size: len, + timestamp: Date.now() + }); + }, + truncate(path, len) { + if (len < 0) { + throw new FS.ErrnoError(28); + } + var node; + if (typeof path == 'string') { + var lookup = FS.lookupPath(path, { follow: true }); + node = lookup.node; + } else { + node = path; + } + FS.doTruncate(null, node, len); + }, + ftruncate(fd, len) { + var stream = FS.getStreamChecked(fd); + if (len < 0 || (stream.flags & 2097155) === 0) { + throw new FS.ErrnoError(28); + } + FS.doTruncate(stream, stream.node, len); + }, + utime(path, atime, mtime, dontFollow) { + var lookup = FS.lookupPath(path, { follow: !dontFollow }); + FS.doSetAttr(null, lookup.node, { + atime: atime, + mtime: mtime, + dontFollow + }); + }, + open(path, flags, mode = 0o666) { + if (path === "") { + throw new FS.ErrnoError(44); + } + flags = FS_modeStringToFlags(flags); + if ((flags & 64)) { + mode = (mode & 4095) | 32768; + } else { + mode = 0; + } + var node; + var isDirPath; + if (typeof path == 'object') { + node = path; + } else { + isDirPath = path.endsWith("/"); + + + + var lookup = FS.lookupPath(path, { + follow: !(flags & 131072), + noent_okay: true + }); + node = lookup.node; + path = lookup.path; + } + + var created = false; + if ((flags & 64)) { + if (node) { + + if ((flags & 128)) { + throw new FS.ErrnoError(20); + } + } else if (isDirPath) { + throw new FS.ErrnoError(31); + } else { + + + + + node = FS.mknod(path, mode | 0o777, 0); + created = true; + } + } + if (!node) { + throw new FS.ErrnoError(44); + } + + if (FS.isChrdev(node.mode)) { + flags &= ~512; + } + + if ((flags & 65536) && !FS.isDir(node.mode)) { + throw new FS.ErrnoError(54); + } + + + + if (!created) { + var errCode = FS.mayOpen(node, flags); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + } + + if ((flags & 512) && !created) { + FS.truncate(node, 0); + } + + flags &= ~(128 | 512 | 131072); + + + var stream = FS.createStream({ + node, + path: FS.getPath(node), + flags, + seekable: true, + position: 0, + stream_ops: node.stream_ops, + + ungotten: [], + error: false + }); + + if (stream.stream_ops.open) { + stream.stream_ops.open(stream); + } + if (created) { + FS.chmod(node, mode & 0o777); + } + return stream; + }, + close(stream) { + if (FS.isClosed(stream)) { + throw new FS.ErrnoError(8); + } + if (stream.getdents) stream.getdents = null; + + + + + stream.node?.notifyListeners(32); + try { + if (stream.stream_ops.close) { + stream.stream_ops.close(stream); + } + } catch (e) { + throw e; + } finally { + FS.closeStream(stream.fd); + } + stream.fd = null; + }, + isClosed(stream) { + return stream.fd === null; + }, + llseek(stream, offset, whence) { + if (FS.isClosed(stream)) { + throw new FS.ErrnoError(8); + } + if (!stream.seekable || !stream.stream_ops.llseek) { + throw new FS.ErrnoError(70); + } + if (whence != 0 && whence != 1 && whence != 2) { + throw new FS.ErrnoError(28); + } + stream.position = stream.stream_ops.llseek(stream, offset, whence); + stream.ungotten = []; + return stream.position; + }, + read(stream, buffer, offset, length, position) { + if (length < 0 || position < 0) { + throw new FS.ErrnoError(28); + } + if (FS.isClosed(stream)) { + throw new FS.ErrnoError(8); + } + if ((stream.flags & 2097155) === 1) { + throw new FS.ErrnoError(8); + } + if (FS.isDir(stream.node.mode)) { + throw new FS.ErrnoError(31); + } + if (!stream.stream_ops.read) { + throw new FS.ErrnoError(28); + } + var seeking = typeof position != 'undefined'; + if (!seeking) { + position = stream.position; + } else if (!stream.seekable) { + throw new FS.ErrnoError(70); + } + var bytesRead = stream.stream_ops.read(stream, buffer, offset, length, position); + if (!seeking) stream.position += bytesRead; + return bytesRead; + }, + write(stream, buffer, offset, length, position, canOwn) { + if (length < 0 || position < 0) { + throw new FS.ErrnoError(28); + } + if (FS.isClosed(stream)) { + throw new FS.ErrnoError(8); + } + if ((stream.flags & 2097155) === 0) { + throw new FS.ErrnoError(8); + } + if (FS.isDir(stream.node.mode)) { + throw new FS.ErrnoError(31); + } + if (!stream.stream_ops.write) { + throw new FS.ErrnoError(28); + } + if (stream.seekable && stream.flags & 1024) { + + FS.llseek(stream, 0, 2); + } + var seeking = typeof position != 'undefined'; + if (!seeking) { + position = stream.position; + } else if (!stream.seekable) { + throw new FS.ErrnoError(70); + } + var bytesWritten = stream.stream_ops.write(stream, buffer, offset, length, position, canOwn); + if (!seeking) stream.position += bytesWritten; + return bytesWritten; + }, + mmap(stream, length, position, prot, flags) { + + + + + + + if ((prot & 2) !== 0 + && (flags & 2) === 0 + && (stream.flags & 2097155) !== 2) { + throw new FS.ErrnoError(2); + } + if ((stream.flags & 2097155) === 1) { + throw new FS.ErrnoError(2); + } + if (!stream.stream_ops.mmap) { + throw new FS.ErrnoError(43); + } + if (!length) { + throw new FS.ErrnoError(28); + } + return stream.stream_ops.mmap(stream, length, position, prot, flags); + }, + msync(stream, buffer, offset, length, mmapFlags) { + if (!stream.stream_ops.msync) { + return 0; + } + return stream.stream_ops.msync(stream, buffer, offset, length, mmapFlags); + }, + ioctl(stream, cmd, arg) { + if (!stream.stream_ops.ioctl) { + throw new FS.ErrnoError(59); + } + return stream.stream_ops.ioctl(stream, cmd, arg); + }, + readFile(path, opts = {}) { + opts.flags = opts.flags ?? 0; + opts.encoding = opts.encoding ?? 'binary'; + if (opts.encoding !== 'utf8' && opts.encoding !== 'binary') { + abort(`Invalid encoding type "${opts.encoding}"`); + } + var stream = FS.open(path, opts.flags); + var stat = FS.stat(path); + var length = stat.size; + var buf = new Uint8Array(length); + FS.read(stream, buf, 0, length, 0); + if (opts.encoding === 'utf8') { + buf = UTF8ArrayToString(buf); + } + FS.close(stream); + return buf; + }, + writeFile(path, data, opts = {}) { + opts.flags = opts.flags ?? 577; + var stream = FS.open(path, opts.flags, opts.mode); + data = FS_fileDataToTypedArray(data); + FS.write(stream, data, 0, data.byteLength, undefined, opts.canOwn); + FS.close(stream); + }, + cwd:() => FS.currentPath, + chdir(path) { + var lookup = FS.lookupPath(path, { follow: true }); + if (lookup.node === null) { + throw new FS.ErrnoError(44); + } + if (!FS.isDir(lookup.node.mode)) { + throw new FS.ErrnoError(54); + } + var errCode = FS.nodePermissions(lookup.node, 'x'); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + FS.currentPath = lookup.path; + }, + createDefaultDirectories() { + FS.mkdir('/tmp'); + FS.mkdir('/home'); + FS.mkdir('/home/web_user'); + }, + createDefaultDevices() { + + FS.mkdir('/dev'); + + FS.registerDevice(FS.makedev(1, 3), { + read: () => 0, + write: (stream, buffer, offset, length, pos) => length, + llseek: () => 0, + }); + FS.mkdev('/dev/null', FS.makedev(1, 3)); + + + + TTY.register(FS.makedev(5, 0), TTY.default_tty_ops); + TTY.register(FS.makedev(6, 0), TTY.default_tty1_ops); + FS.mkdev('/dev/tty', FS.makedev(5, 0)); + FS.mkdev('/dev/tty1', FS.makedev(6, 0)); + + + var randomBuffer = new Uint8Array(1024), randomLeft = 0; + var randomByte = () => { + if (randomLeft === 0) { + randomFill(randomBuffer); + randomLeft = randomBuffer.byteLength; + } + return randomBuffer[--randomLeft]; + }; + FS.createDevice('/dev', 'random', randomByte); + FS.createDevice('/dev', 'urandom', randomByte); + + + FS.mkdir('/dev/shm'); + FS.mkdir('/dev/shm/tmp'); + }, + createSpecialDirectories() { + + + FS.mkdir('/proc'); + var proc_self = FS.mkdir('/proc/self'); + FS.mkdir('/proc/self/fd'); + FS.mount({ + mount() { + var node = FS.createNode(proc_self, 'fd', 16895, 73); + node.stream_ops = { + llseek: MEMFS.stream_ops.llseek, + }; + node.node_ops = { + lookup(parent, name) { + var fd = +name; + var stream = FS.getStreamChecked(fd); + var ret = { + parent: null, + mount: { mountpoint: 'fake' }, + node_ops: { readlink: () => stream.path }, + id: fd + 1, + }; + ret.parent = ret; + return ret; + }, + readdir() { + return Array.from(FS.streams.entries()) + .filter(([k, v]) => v) + .map(([k, v]) => k.toString()); + } + }; + return node; + } + }, {}, '/proc/self/fd'); + }, + createStandardStreams(input, output, error) { + + + + + + + + + if (input) { + FS.createDevice('/dev', 'stdin', input); + } else { + FS.symlink('/dev/tty', '/dev/stdin'); + } + if (output) { + FS.createDevice('/dev', 'stdout', null, output); + } else { + FS.symlink('/dev/tty', '/dev/stdout'); + } + if (error) { + FS.createDevice('/dev', 'stderr', null, error); + } else { + FS.symlink('/dev/tty1', '/dev/stderr'); + } + + + var stdin = FS.open('/dev/stdin', 0); + var stdout = FS.open('/dev/stdout', 1); + var stderr = FS.open('/dev/stderr', 1); + }, + staticInit() { + FS.nameTable = new Array(4096); + + FS.mount(MEMFS, {}, '/'); + + FS.createDefaultDirectories(); + FS.createDefaultDevices(); + FS.createSpecialDirectories(); + + FS.filesystems = { + 'MEMFS': MEMFS, + }; + }, + init(input, output, error) { + FS.initialized = true; + + + input ??= Module['stdin']; + output ??= Module['stdout']; + error ??= Module['stderr']; + + FS.createStandardStreams(input, output, error); + }, + quit() { + FS.initialized = false; + + + for (var stream of FS.streams) { + if (stream) { + FS.close(stream); + } + } + }, + findObject(path, dontResolveLastLink) { + var ret = FS.analyzePath(path, dontResolveLastLink); + if (!ret.exists) { + return null; + } + return ret.object; + }, + analyzePath(path, dontResolveLastLink) { + + try { + var lookup = FS.lookupPath(path, { follow: !dontResolveLastLink }); + path = lookup.path; + } catch (e) { + } + var ret = { + isRoot: false, exists: false, error: 0, name: null, path: null, object: null, + parentExists: false, parentPath: null, parentObject: null + }; + try { + var lookup = FS.lookupPath(path, { parent: true }); + ret.parentExists = true; + ret.parentPath = lookup.path; + ret.parentObject = lookup.node; + ret.name = PATH.basename(path); + lookup = FS.lookupPath(path, { follow: !dontResolveLastLink }); + ret.exists = true; + ret.path = lookup.path; + ret.object = lookup.node; + ret.name = lookup.node.name; + ret.isRoot = lookup.path === '/'; + } catch (e) { + ret.error = e.errno; + }; + return ret; + }, + createPath(parent, path, canRead, canWrite) { + parent = typeof parent == 'string' ? parent : FS.getPath(parent); + var parts = path.split('/').reverse(); + while (parts.length) { + var part = parts.pop(); + if (!part) continue; + var current = PATH.join2(parent, part); + try { + FS.mkdir(current); + } catch (e) { + if (e.errno != 20) throw e; + } + parent = current; + } + return current; + }, + createFile(parent, name, properties, canRead, canWrite) { + var path = PATH.join2(typeof parent == 'string' ? parent : FS.getPath(parent), name); + var mode = FS_getMode(canRead, canWrite); + return FS.create(path, mode); + }, + createDataFile(parent, name, data, canRead, canWrite, canOwn) { + var path = name; + if (parent) { + parent = typeof parent == 'string' ? parent : FS.getPath(parent); + path = name ? PATH.join2(parent, name) : parent; + } + var mode = FS_getMode(canRead, canWrite); + var node = FS.create(path, mode); + if (data) { + data = FS_fileDataToTypedArray(data); + + FS.chmod(node, mode | 146); + var stream = FS.open(node, 577); + FS.write(stream, data, 0, data.length, 0, canOwn); + FS.close(stream); + FS.chmod(node, mode); + } + }, + createDevice(parent, name, input, output) { + var path = PATH.join2(typeof parent == 'string' ? parent : FS.getPath(parent), name); + var mode = FS_getMode(!!input, !!output); + FS.createDevice.major ??= 64; + var dev = FS.makedev(FS.createDevice.major++, 0); + + + FS.registerDevice(dev, { + open(stream) { + stream.seekable = false; + }, + close(stream) { + + if (output?.buffer?.length) { + output(10); + } + }, + read(stream, buffer, offset, length, pos ) { + var bytesRead = 0; + for (var i = 0; i < length; i++) { + var result; + try { + result = input(); + } catch (e) { + throw new FS.ErrnoError(29); + } + if (result === undefined && bytesRead === 0) { + throw new FS.ErrnoError(6); + } + if (result === null || result === undefined) break; + bytesRead++; + buffer[offset+i] = result; + } + if (bytesRead) { + stream.node.atime = Date.now(); + } + return bytesRead; + }, + write(stream, buffer, offset, length, pos) { + for (var i = 0; i < length; i++) { + try { + output(buffer[offset+i]); + } catch (e) { + throw new FS.ErrnoError(29); + } + } + if (length) { + stream.node.mtime = stream.node.ctime = Date.now(); + } + return i; + } + }); + return FS.mkdev(path, mode, dev); + }, + forceLoadFile(obj) { + if (obj.isDevice || obj.isFolder || obj.link || obj.contents) return true; + if (globalThis.XMLHttpRequest) { + abort("Lazy loading should have been performed (contents set) in createLazyFile, but it was not. Lazy loading only works in web workers. Use --embed-file or --preload-file in emcc on the main thread."); + } else { + try { + obj.contents = readBinary(obj.url); + } catch (e) { + throw new FS.ErrnoError(29); + } + } + }, + createLazyFile(parent, name, url, canRead, canWrite) { + + + class LazyUint8Array { + lengthKnown = false; + chunks = []; + get(idx) { + if (idx > this.length-1 || idx < 0) { + return undefined; + } + var chunkOffset = idx % this.chunkSize; + var chunkNum = (idx / this.chunkSize)|0; + return this.getter(chunkNum)[chunkOffset]; + } + setDataGetter(getter) { + this.getter = getter; + } + cacheLength() { + + var xhr = new XMLHttpRequest(); + xhr.open('HEAD', url, false); + xhr.send(null); + if (!(xhr.status >= 200 && xhr.status < 300 || xhr.status === 304)) abort("Couldn't load " + url + ". Status: " + xhr.status); + var datalength = Number(xhr.getResponseHeader("Content-length")); + var header; + var hasByteServing = (header = xhr.getResponseHeader("Accept-Ranges")) && header === "bytes"; + var usesGzip = (header = xhr.getResponseHeader("Content-Encoding")) && header === "gzip"; + + var chunkSize = 1024*1024; + + if (!hasByteServing) chunkSize = datalength; + + + var doXHR = (from, to) => { + if (from > to) abort(`invalid range (${from}, ${to}) or no bytes requested!`); + if (to > datalength-1) abort(`only ${datalength} bytes available! programmer error!`); + + + var xhr = new XMLHttpRequest(); + xhr.open('GET', url, false); + if (datalength !== chunkSize) xhr.setRequestHeader("Range", "bytes=" + from + "-" + to); + + + xhr.responseType = 'arraybuffer'; + if (xhr.overrideMimeType) { + xhr.overrideMimeType('text/plain; charset=x-user-defined'); + } + + xhr.send(null); + if (!(xhr.status >= 200 && xhr.status < 300 || xhr.status === 304)) abort("Couldn't load " + url + ". Status: " + xhr.status); + if (xhr.response !== undefined) { + return new Uint8Array((xhr.response || [])); + } + return intArrayFromString(xhr.responseText ?? '', true); + }; + var lazyArray = this; + lazyArray.setDataGetter((chunkNum) => { + var start = chunkNum * chunkSize; + var end = (chunkNum+1) * chunkSize - 1; + end = Math.min(end, datalength-1); + if (typeof lazyArray.chunks[chunkNum] == 'undefined') { + lazyArray.chunks[chunkNum] = doXHR(start, end); + } + if (typeof lazyArray.chunks[chunkNum] == 'undefined') abort('doXHR failed!'); + return lazyArray.chunks[chunkNum]; + }); + + if (usesGzip || !datalength) { + + chunkSize = datalength = 1; + datalength = this.getter(0).length; + chunkSize = datalength; + out("LazyFiles on gzip forces download of the whole file when length is accessed"); + } + + this._length = datalength; + this._chunkSize = chunkSize; + this.lengthKnown = true; + } + get length() { + if (!this.lengthKnown) { + this.cacheLength(); + } + return this._length; + } + get chunkSize() { + if (!this.lengthKnown) { + this.cacheLength(); + } + return this._chunkSize; + } + } + + if (globalThis.XMLHttpRequest) { + if (!ENVIRONMENT_IS_WORKER) abort('Cannot do synchronous binary XHRs outside webworkers in modern browsers. Use --embed-file or --preload-file in emcc'); + var lazyArray = new LazyUint8Array(); + var properties = { isDevice: false, contents: lazyArray }; + } else { + var properties = { isDevice: false, url: url }; + } + + var node = FS.createFile(parent, name, properties, canRead, canWrite); + + + + if (properties.contents) { + node.contents = properties.contents; + } else if (properties.url) { + node.contents = null; + node.url = properties.url; + } + + Object.defineProperties(node, { + usedBytes: { + get: function() { return this.contents.length; } + } + }); + + var stream_ops = {}; + for (const [key, fn] of Object.entries(node.stream_ops)) { + stream_ops[key] = (...args) => { + FS.forceLoadFile(node); + return fn(...args); + }; + } + function writeChunks(stream, buffer, offset, length, position) { + var contents = stream.node.contents; + if (position >= contents.length) + return 0; + var size = Math.min(contents.length - position, length); + if (contents.slice) { + for (var i = 0; i < size; i++) { + buffer[offset + i] = contents[position + i]; + } + } else { + for (var i = 0; i < size; i++) { + buffer[offset + i] = contents.get(position + i); + } + } + return size; + } + + stream_ops.read = (stream, buffer, offset, length, position) => { + FS.forceLoadFile(node); + return writeChunks(stream, buffer, offset, length, position) + }; + + stream_ops.mmap = (stream, length, position, prot, flags) => { + FS.forceLoadFile(node); + var ptr = mmapAlloc(length); + if (!ptr) { + throw new FS.ErrnoError(48); + } + writeChunks(stream, HEAP8, ptr, length, position); + return { ptr, allocated: true }; + }; + node.stream_ops = stream_ops; + return node; + }, + }; + + + + + var UTF8ToString = (ptr, maxBytesToRead, ignoreNul) => { + if (!ptr) return ''; + var end = findStringEnd(HEAPU8, ptr, maxBytesToRead, ignoreNul); + return UTF8Decoder.decode(HEAPU8.subarray(ptr, end)); + }; + var SYSCALLS = { + currentUmask:18, + calculateAt(dirfd, path, allowEmpty) { + if (PATH.isAbs(path)) { + return path; + } + + var dir; + if (dirfd === -100) { + dir = FS.cwd(); + } else { + var dirstream = SYSCALLS.getStreamFromFD(dirfd); + dir = dirstream.path; + } + if (path.length == 0) { + if (!allowEmpty) { + throw new FS.ErrnoError(44);; + } + return dir; + } + return dir + '/' + path; + }, + writeStat(buf, stat) { + HEAPU32[((buf)>>2)] = stat.dev; + HEAPU32[(((buf)+(4))>>2)] = stat.mode; + HEAPU32[(((buf)+(8))>>2)] = stat.nlink; + HEAPU32[(((buf)+(12))>>2)] = stat.uid; + HEAPU32[(((buf)+(16))>>2)] = stat.gid; + HEAPU32[(((buf)+(20))>>2)] = stat.rdev; + HEAP64[(((buf)+(24))>>3)] = BigInt(stat.size); + HEAP32[(((buf)+(32))>>2)] = 4096; + HEAP32[(((buf)+(36))>>2)] = stat.blocks; + var atime = stat.atime.getTime(); + var mtime = stat.mtime.getTime(); + var ctime = stat.ctime.getTime(); + HEAP64[(((buf)+(40))>>3)] = BigInt(Math.floor(atime / 1000)); + HEAPU32[(((buf)+(48))>>2)] = (atime % 1000) * 1000 * 1000; + HEAP64[(((buf)+(56))>>3)] = BigInt(Math.floor(mtime / 1000)); + HEAPU32[(((buf)+(64))>>2)] = (mtime % 1000) * 1000 * 1000; + HEAP64[(((buf)+(72))>>3)] = BigInt(Math.floor(ctime / 1000)); + HEAPU32[(((buf)+(80))>>2)] = (ctime % 1000) * 1000 * 1000; + HEAP64[(((buf)+(88))>>3)] = BigInt(stat.ino); + return 0; + }, + writeStatFs(buf, stats) { + HEAPU32[(((buf)+(4))>>2)] = stats.bsize; + HEAPU32[(((buf)+(60))>>2)] = stats.bsize; + HEAP64[(((buf)+(8))>>3)] = BigInt(stats.blocks); + HEAP64[(((buf)+(16))>>3)] = BigInt(stats.bfree); + HEAP64[(((buf)+(24))>>3)] = BigInt(stats.bavail); + HEAP64[(((buf)+(32))>>3)] = BigInt(stats.files); + HEAP64[(((buf)+(40))>>3)] = BigInt(stats.ffree); + HEAPU32[(((buf)+(48))>>2)] = stats.fsid; + HEAPU32[(((buf)+(64))>>2)] = stats.flags; + HEAPU32[(((buf)+(56))>>2)] = stats.namelen; + }, + doMsync(addr, stream, len, flags, offset) { + if (!FS.isFile(stream.node.mode)) { + throw new FS.ErrnoError(43); + } + if (flags & 2) { + + return 0; + } + var buffer = HEAPU8.subarray(addr, addr + len); + FS.msync(stream, buffer, offset, len, flags); + }, + getStreamFromFD(fd) { + var stream = FS.getStreamChecked(fd); + return stream; + }, + varargs:undefined, + getStr(ptr) { + var ret = UTF8ToString(ptr); + return ret; + }, + }; + function ___syscall_chmod(path, mode) { + try { + + path = SYSCALLS.getStr(path); + FS.chmod(path, mode); + return 0; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + + function ___syscall_faccessat(dirfd, path, amode, flags) { + try { + + path = SYSCALLS.getStr(path); + path = SYSCALLS.calculateAt(dirfd, path); + if (amode & ~7) { + + return -28; + } + var lookup = FS.lookupPath(path, { follow: true }); + var node = lookup.node; + if (!node) { + return -44; + } + var perms = ''; + if (amode & 4) perms += 'r'; + if (amode & 2) perms += 'w'; + if (amode & 1) perms += 'x'; + if (perms && FS.nodePermissions(node, perms)) { + return -2; + } + return 0; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + + function ___syscall_fchmod(fd, mode) { + try { + + FS.fchmod(fd, mode); + return 0; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + + function ___syscall_fchown32(fd, owner, group) { + try { + + FS.fchown(fd, owner, group); + return 0; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + + var syscallGetVarargI = () => { + + var ret = HEAP32[((+SYSCALLS.varargs)>>2)]; + SYSCALLS.varargs += 4; + return ret; + }; + var syscallGetVarargP = syscallGetVarargI; + + + function ___syscall_fcntl64(fd, cmd, varargs) { + SYSCALLS.varargs = varargs; + try { + + var stream = SYSCALLS.getStreamFromFD(fd); + switch (cmd) { + case 0: { + var arg = syscallGetVarargI(); + if (arg < 0) { + return -28; + } + while (FS.streams[arg]) { + arg++; + } + var newStream; + newStream = FS.dupStream(stream, arg); + return newStream.fd; + } + case 1: + case 2: + return 0; + case 3: + return stream.flags; + case 4: { + var arg = syscallGetVarargI(); + var mask = 289792; + stream.flags = (stream.flags & ~mask) | (arg & mask); + return 0; + } + case 12: { + var arg = syscallGetVarargP(); + var offset = 0; + + HEAP16[(((arg)+(offset))>>1)] = 2; + return 0; + } + case 13: + case 14: + + + + + return 0; + } + return -28; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + + function ___syscall_fstat64(fd, buf) { + try { + + return SYSCALLS.writeStat(buf, FS.fstat(fd)); + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + + var INT53_MAX = 9007199254740992; + + var INT53_MIN = -9007199254740992; + var bigintToI53Checked = (num) => (num < INT53_MIN || num > INT53_MAX) ? NaN : Number(num); + function ___syscall_ftruncate64(fd, length) { + length = bigintToI53Checked(length); + + + try { + + if (isNaN(length)) return -22; + FS.ftruncate(fd, length); + return 0; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + ; + } + + + var stringToUTF8 = (str, outPtr, maxBytesToWrite) => { + return stringToUTF8Array(str, HEAPU8, outPtr, maxBytesToWrite); + }; + function ___syscall_getcwd(buf, size) { + try { + + if (size === 0) return -28; + var cwd = FS.cwd(); + var cwdLengthInBytes = lengthBytesUTF8(cwd) + 1; + if (size < cwdLengthInBytes) return -68; + stringToUTF8(cwd, buf, size); + return cwdLengthInBytes; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + + + function ___syscall_ioctl(fd, op, varargs) { + SYSCALLS.varargs = varargs; + try { + + var stream = SYSCALLS.getStreamFromFD(fd); + switch (op) { + case 21509: { + if (!stream.tty) return -59; + return 0; + } + case 21505: { + if (!stream.tty) return -59; + if (stream.tty.ops.ioctl_tcgets) { + var termios = stream.tty.ops.ioctl_tcgets(stream); + var argp = syscallGetVarargP(); + HEAP32[((argp)>>2)] = termios.c_iflag || 0; + HEAP32[(((argp)+(4))>>2)] = termios.c_oflag || 0; + HEAP32[(((argp)+(8))>>2)] = termios.c_cflag || 0; + HEAP32[(((argp)+(12))>>2)] = termios.c_lflag || 0; + for (var i = 0; i < 32; i++) { + HEAP8[(argp + i)+(17)] = termios.c_cc[i] || 0; + } + return 0; + } + return 0; + } + case 21510: + case 21511: + case 21512: { + if (!stream.tty) return -59; + return 0; + } + case 21506: + case 21507: + case 21508: { + if (!stream.tty) return -59; + if (stream.tty.ops.ioctl_tcsets) { + var argp = syscallGetVarargP(); + var c_iflag = HEAP32[((argp)>>2)]; + var c_oflag = HEAP32[(((argp)+(4))>>2)]; + var c_cflag = HEAP32[(((argp)+(8))>>2)]; + var c_lflag = HEAP32[(((argp)+(12))>>2)]; + var c_cc = [] + for (var i = 0; i < 32; i++) { + c_cc.push(HEAP8[(argp + i)+(17)]); + } + return stream.tty.ops.ioctl_tcsets(stream.tty, op, { c_iflag, c_oflag, c_cflag, c_lflag, c_cc }); + } + return 0; + } + case 21519: { + if (!stream.tty) return -59; + var argp = syscallGetVarargP(); + HEAP32[((argp)>>2)] = 0; + return 0; + } + case 21520: { + if (!stream.tty) return -59; + return -28; + } + case 21537: + case 21531: { + var argp = syscallGetVarargP(); + return FS.ioctl(stream, op, argp); + } + case 21523: { + + + if (!stream.tty) return -59; + if (stream.tty.ops.ioctl_tiocgwinsz) { + var winsize = stream.tty.ops.ioctl_tiocgwinsz(stream.tty); + var argp = syscallGetVarargP(); + HEAP16[((argp)>>1)] = winsize[0]; + HEAP16[(((argp)+(2))>>1)] = winsize[1]; + } + return 0; + } + case 21524: { + + + + if (!stream.tty) return -59; + return 0; + } + case 21515: { + if (!stream.tty) return -59; + return 0; + } + default: return -28; + } + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + + function ___syscall_lstat64(path, buf) { + try { + + path = SYSCALLS.getStr(path); + return SYSCALLS.writeStat(buf, FS.lstat(path)); + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + + function ___syscall_mkdirat(dirfd, path, mode) { + try { + + path = SYSCALLS.getStr(path); + path = SYSCALLS.calculateAt(dirfd, path); + mode &= ~SYSCALLS.currentUmask; + FS.mkdir(path, mode, 0); + return 0; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + + function ___syscall_newfstatat(dirfd, path, buf, flags) { + try { + + path = SYSCALLS.getStr(path); + var nofollow = flags & 256; + var allowEmpty = flags & 4096; + flags = flags & (~6400); + path = SYSCALLS.calculateAt(dirfd, path, allowEmpty); + return SYSCALLS.writeStat(buf, nofollow ? FS.lstat(path) : FS.stat(path)); + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + + + function ___syscall_openat(dirfd, path, flags, varargs) { + SYSCALLS.varargs = varargs; + try { + + path = SYSCALLS.getStr(path); + path = SYSCALLS.calculateAt(dirfd, path); + var mode = varargs ? syscallGetVarargI() : 0; + if (flags & 64) { + mode &= ~SYSCALLS.currentUmask; + } + return FS.open(path, flags, mode).fd; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + + + + function ___syscall_readlinkat(dirfd, path, buf, bufsize) { + try { + + path = SYSCALLS.getStr(path); + path = SYSCALLS.calculateAt(dirfd, path); + if (bufsize <= 0) return -28; + var ret = FS.readlink(path); + + var len = Math.min(bufsize, lengthBytesUTF8(ret)); + var endChar = HEAP8[buf+len]; + stringToUTF8(ret, buf, bufsize+1); + + + HEAP8[buf+len] = endChar; + return len; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + + function ___syscall_rmdir(path) { + try { + + path = SYSCALLS.getStr(path); + FS.rmdir(path); + return 0; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + + function ___syscall_stat64(path, buf) { + try { + + path = SYSCALLS.getStr(path); + return SYSCALLS.writeStat(buf, FS.stat(path)); + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + + function ___syscall_unlinkat(dirfd, path, flags) { + try { + + path = SYSCALLS.getStr(path); + path = SYSCALLS.calculateAt(dirfd, path); + if (!flags) { + FS.unlink(path); + } else if (flags === 512) { + FS.rmdir(path); + } else { + return -28; + } + return 0; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + + var readI53FromI64 = (ptr) => { + return HEAPU32[((ptr)>>2)] + HEAP32[(((ptr)+(4))>>2)] * 4294967296; + }; + + function ___syscall_utimensat(dirfd, path, times, flags) { + try { + + var nofollow = flags & 256; + path = SYSCALLS.getStr(path); + path = SYSCALLS.calculateAt(dirfd, path, true); + var now = Date.now(), atime, mtime; + if (!times) { + atime = now; + mtime = now; + } else { + var seconds = readI53FromI64(times); + var nanoseconds = HEAP32[(((times)+(8))>>2)]; + if (nanoseconds == 1073741823) { + atime = now; + } else if (nanoseconds == 1073741822) { + atime = null; + } else { + atime = (seconds*1000) + (nanoseconds/(1000*1000)); + } + times += 16; + seconds = readI53FromI64(times); + nanoseconds = HEAP32[(((times)+(8))>>2)]; + if (nanoseconds == 1073741823) { + mtime = now; + } else if (nanoseconds == 1073741822) { + mtime = null; + } else { + mtime = (seconds*1000) + (nanoseconds/(1000*1000)); + } + } + + + if ((mtime ?? atime) !== null) { + FS.utime(path, atime, mtime, nofollow); + } + return 0; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + + var __abort_js = () => + abort(''); + + var isLeapYear = (year) => year%4 === 0 && (year%100 !== 0 || year%400 === 0); + + var MONTH_DAYS_LEAP_CUMULATIVE = [0,31,60,91,121,152,182,213,244,274,305,335]; + + var MONTH_DAYS_REGULAR_CUMULATIVE = [0,31,59,90,120,151,181,212,243,273,304,334]; + var ydayFromDate = (date) => { + var leap = isLeapYear(date.getFullYear()); + var monthDaysCumulative = (leap ? MONTH_DAYS_LEAP_CUMULATIVE : MONTH_DAYS_REGULAR_CUMULATIVE); + var yday = monthDaysCumulative[date.getMonth()] + date.getDate() - 1; + + return yday; + }; + + function __localtime_js(time, tmPtr) { + time = bigintToI53Checked(time); + + + var date = new Date(time*1000); + if (isNaN(date.getTime())) { + return 1; + } + HEAP32[((tmPtr)>>2)] = date.getSeconds(); + HEAP32[(((tmPtr)+(4))>>2)] = date.getMinutes(); + HEAP32[(((tmPtr)+(8))>>2)] = date.getHours(); + HEAP32[(((tmPtr)+(12))>>2)] = date.getDate(); + HEAP32[(((tmPtr)+(16))>>2)] = date.getMonth(); + HEAP32[(((tmPtr)+(20))>>2)] = date.getFullYear()-1900; + HEAP32[(((tmPtr)+(24))>>2)] = date.getDay(); + + var yday = ydayFromDate(date)|0; + HEAP32[(((tmPtr)+(28))>>2)] = yday; + HEAP32[(((tmPtr)+(36))>>2)] = -(date.getTimezoneOffset() * 60); + + + var start = new Date(date.getFullYear(), 0, 1); + var summerOffset = new Date(date.getFullYear(), 6, 1).getTimezoneOffset(); + var winterOffset = start.getTimezoneOffset(); + var dst = (summerOffset != winterOffset && date.getTimezoneOffset() == Math.min(winterOffset, summerOffset))|0; + HEAP32[(((tmPtr)+(32))>>2)] = dst; + return 0; + ; + } + + + + + + + function __mmap_js(len, prot, flags, fd, offset, allocated, addr) { + offset = bigintToI53Checked(offset); + + + try { + + var stream = SYSCALLS.getStreamFromFD(fd); + var res = FS.mmap(stream, len, offset, prot, flags); + var ptr = res.ptr; + HEAP32[((allocated)>>2)] = res.allocated; + HEAPU32[((addr)>>2)] = ptr; + return 0; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + ; + } + + + function __munmap_js(addr, len, prot, flags, fd, offset) { + offset = bigintToI53Checked(offset); + + + try { + + var stream = SYSCALLS.getStreamFromFD(fd); + if (prot & 2) { + SYSCALLS.doMsync(addr, stream, len, flags, offset); + } + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + ; + } + + var __tzset_js = (timezone, daylight, std_name, dst_name) => { + + var currentYear = new Date().getFullYear(); + var winter = new Date(currentYear, 0, 1); + var summer = new Date(currentYear, 6, 1); + var winterOffset = winter.getTimezoneOffset(); + var summerOffset = summer.getTimezoneOffset(); + + + + + + + + var stdTimezoneOffset = Math.max(winterOffset, summerOffset); + + + + + + + HEAPU32[((timezone)>>2)] = stdTimezoneOffset * 60; + + HEAP32[((daylight)>>2)] = Number(winterOffset != summerOffset); + + var extractZone = (timezoneOffset) => { + + + var sign = timezoneOffset >= 0 ? "-" : "+"; + + var absOffset = Math.abs(timezoneOffset) + var hours = String(Math.floor(absOffset / 60)).padStart(2, "0"); + var minutes = String(absOffset % 60).padStart(2, "0"); + + return `UTC${sign}${hours}${minutes}`; + } + + var winterName = extractZone(winterOffset); + var summerName = extractZone(summerOffset); + if (summerOffset < winterOffset) { + + stringToUTF8(winterName, std_name, 17); + stringToUTF8(summerName, dst_name, 17); + } else { + stringToUTF8(winterName, dst_name, 17); + stringToUTF8(summerName, std_name, 17); + } + }; + + var _emscripten_get_now = () => performance.now(); + + var _emscripten_date_now = () => Date.now(); + + var nowIsMonotonic = 1; + + var checkWasiClock = (clock_id) => clock_id >= 0 && clock_id <= 3; + + function _clock_time_get(clk_id, ignored_precision, ptime) { + ignored_precision = bigintToI53Checked(ignored_precision); + + + if (!checkWasiClock(clk_id)) { + return 28; + } + var now; + + if (clk_id === 0) { + now = _emscripten_date_now(); + } else if (nowIsMonotonic) { + now = _emscripten_get_now(); + } else { + return 52; + } + + var nsec = Math.round(now * 1000 * 1000); + HEAP64[((ptime)>>3)] = BigInt(nsec); + return 0; + ; + } + + + var getHeapMax = () => + + + + + 2147483648; + var _emscripten_get_heap_max = () => getHeapMax(); + + + + + var growMemory = (size) => { + var oldHeapSize = wasmMemory.buffer.byteLength; + var pages = ((size - oldHeapSize + 65535) / 65536) | 0; + try { + + wasmMemory.grow(pages); + updateMemoryViews(); + return 1 ; + } catch(e) { + } + + + }; + var _emscripten_resize_heap = (requestedSize) => { + var oldSize = HEAPU8.length; + + requestedSize >>>= 0; + + + + + + + + + + + + + + + + + + + + + + + var maxHeapSize = getHeapMax(); + if (requestedSize > maxHeapSize) { + return false; + } + + + + + for (var cutDown = 1; cutDown <= 4; cutDown *= 2) { + var overGrownHeapSize = oldSize * (1 + 0.2 / cutDown); + + overGrownHeapSize = Math.min(overGrownHeapSize, requestedSize + 100663296 ); + + var newSize = Math.min(maxHeapSize, alignMemory(Math.max(requestedSize, overGrownHeapSize), 65536)); + + var replacement = growMemory(newSize); + if (replacement) { + + return true; + } + } + return false; + }; + + var ENV = { + }; + + var getExecutableName = () => thisProgram; + var getEnvStrings = () => { + if (!getEnvStrings.strings) { + + var lang = (globalThis.navigator?.language ?? 'C').replace('-', '_') + '.UTF-8'; + var env = { + 'USER': 'web_user', + 'LOGNAME': 'web_user', + 'PATH': '/', + 'PWD': '/', + 'HOME': '/home/web_user', + 'LANG': lang, + '_': getExecutableName() + }; + + for (var x in ENV) { + + + + if (ENV[x] === undefined) delete env[x]; + else env[x] = ENV[x]; + } + var strings = []; + for (var x in env) { + strings.push(`${x}=${env[x]}`); + } + getEnvStrings.strings = strings; + } + return getEnvStrings.strings; + }; + + var _environ_get = (__environ, environ_buf) => { + var bufSize = 0; + var envp = 0; + for (var string of getEnvStrings()) { + var ptr = environ_buf + bufSize; + HEAPU32[(((__environ)+(envp))>>2)] = ptr; + bufSize += stringToUTF8(string, ptr, Infinity) + 1; + envp += 4; + } + return 0; + }; + + + var _environ_sizes_get = (penviron_count, penviron_buf_size) => { + var strings = getEnvStrings(); + HEAPU32[((penviron_count)>>2)] = strings.length; + var bufSize = 0; + for (var string of strings) { + bufSize += lengthBytesUTF8(string) + 1; + } + HEAPU32[((penviron_buf_size)>>2)] = bufSize; + return 0; + }; + + + var runtimeKeepaliveCounter = 0; + var keepRuntimeAlive = () => noExitRuntime || runtimeKeepaliveCounter > 0; + var _proc_exit = (code) => { + EXITSTATUS = code; + if (!keepRuntimeAlive()) { + Module['onExit']?.(code); + ABORT = true; + } + quit_(code, new ExitStatus(code)); + }; + + var exitJS = (status, implicit) => { + EXITSTATUS = status; + + _proc_exit(status); + }; + var _exit = exitJS; + + function _fd_close(fd) { + try { + + var stream = SYSCALLS.getStreamFromFD(fd); + FS.close(stream); + return 0; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return e.errno; + } + } + + + function _fd_fdstat_get(fd, pbuf) { + try { + + var rightsBase = 0; + var rightsInheriting = 0; + var flags = 0; + { + var stream = SYSCALLS.getStreamFromFD(fd); + + + var type = stream.tty ? 2 : + FS.isDir(stream.mode) ? 3 : + FS.isLink(stream.mode) ? 7 : + 4; + } + HEAP8[pbuf] = type; + HEAP16[(((pbuf)+(2))>>1)] = flags; + HEAP64[(((pbuf)+(8))>>3)] = BigInt(rightsBase); + HEAP64[(((pbuf)+(16))>>3)] = BigInt(rightsInheriting); + return 0; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return e.errno; + } + } + + + + var doReadv = (stream, iov, iovcnt, offset) => { + var ret = 0; + for (var i = 0; i < iovcnt; i++) { + var ptr = HEAPU32[((iov)>>2)]; + var len = HEAPU32[(((iov)+(4))>>2)]; + iov += 8; + try { + var curr = FS.read(stream, HEAP8, ptr, len, offset); + } catch (e) { + + + + if (ret > 0 && e instanceof FS.ErrnoError && + (e.errno == 6 || e.errno == 6)) { + break; + } + throw e; + } + if (curr < 0) return -1; + ret += curr; + if (curr < len) break; + if (typeof offset != 'undefined') { + offset += curr; + } + } + return ret; + }; + + function _fd_read(fd, iov, iovcnt, pnum) { + try { + + var stream = SYSCALLS.getStreamFromFD(fd); + var num = doReadv(stream, iov, iovcnt); + HEAPU32[((pnum)>>2)] = num; + return 0; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return e.errno; + } + } + + + + function _fd_seek(fd, offset, whence, newOffset) { + offset = bigintToI53Checked(offset); + + + try { + + if (isNaN(offset)) return 22; + var stream = SYSCALLS.getStreamFromFD(fd); + FS.llseek(stream, offset, whence); + HEAP64[((newOffset)>>3)] = BigInt(stream.position); + if (stream.getdents && offset === 0 && whence === 0) stream.getdents = null; + return 0; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return e.errno; + } + ; + } + + function _fd_sync(fd) { + try { + + var stream = SYSCALLS.getStreamFromFD(fd); + var rtn = stream.stream_ops?.fsync?.(stream); + return rtn; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return e.errno; + } + } + + + + var doWritev = (stream, iov, iovcnt, offset) => { + + + + + if (iovcnt == 1) { + + return FS.write(stream, HEAP8, HEAPU32[((iov)>>2)], HEAPU32[(((iov)+(4))>>2)], offset); + } + var total = 0; + for (var i = 0, p = iov; i < iovcnt; i++, p += 8) { + total += HEAPU32[(((p)+(4))>>2)]; + } + var view = new Uint8Array(total); + var voff = 0; + for (var i = 0; i < iovcnt; i++, iov += 8) { + var ptr = HEAPU32[((iov)>>2)]; + var len = HEAPU32[(((iov)+(4))>>2)]; + view.set(HEAPU8.subarray(ptr, ptr + len), voff); + voff += len; + } + return FS.write(stream, view, 0, total, offset); + }; + + function _fd_write(fd, iov, iovcnt, pnum) { + try { + + var stream = SYSCALLS.getStreamFromFD(fd); + var num = doWritev(stream, iov, iovcnt); + HEAPU32[((pnum)>>2)] = num; + return 0; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return e.errno; + } + } + + + + FS.createPreloadedFile = FS_createPreloadedFile; + FS.preloadFile = FS_preloadFile; + FS.staticInit();; + + + + + + +{ + + + initMemory(); + + + if (Module['noExitRuntime']) noExitRuntime = Module['noExitRuntime']; + +if (Module['print']) out = Module['print']; +if (Module['printErr']) err = Module['printErr']; + + + if (Module['arguments']) programArgs = Module['arguments']; + if (Module['thisProgram']) thisProgram = Module['thisProgram']; + + var preInit = Module['preInit']; + if (preInit) { + if (typeof preInit == 'function') Module['preInit'] = preInit = [preInit]; + + + while (preInit.length > 0) { + preInit.shift()(); + } + } +} + + + Module['wasmMemory'] = wasmMemory; + + + + + + +function wasm_crypto_getrandom(buf,n) { if (typeof crypto === 'undefined' || !crypto.getRandomValues) return -1; try { var view = new Uint8Array(HEAPU8.buffer, buf, n); var chunk = 65536; for (var i = 0; i < n; i += chunk) { crypto.getRandomValues(view.subarray(i, Math.min(i + chunk, n))); } return 0; } catch (e) { return -1; } } + + +var _sqlite3_status64, + _sqlite3_status, + _sqlite3_db_status64, + _sqlite3_msize, + _sqlite3_db_status, + _sqlite3_vfs_find, + _sqlite3_initialize, + _sqlite3_vfs_register, + _sqlite3_vfs_unregister, + _sqlite3_malloc, + _sqlite3_malloc64, + _sqlite3_free, + _sqlite3_realloc, + _sqlite3_realloc64, + _sqlite3_value_text, + _sqlite3_randomness, + _sqlite3_stricmp, + _sqlite3_strnicmp, + _sqlite3_uri_parameter, + _sqlite3_uri_boolean, + _sqlite3_serialize, + _sqlite3_prepare_v2, + _sqlite3_step, + _sqlite3_column_int64, + _sqlite3_reset, + _sqlite3_exec, + _sqlite3_column_int, + _sqlite3_finalize, + _sqlite3_file_control, + _sqlite3_column_name, + _sqlite3_column_text, + _sqlite3_column_type, + _sqlite3_errmsg, + _sqlite3_deserialize, + _sqlite3_backup_init, + _sqlite3_backup_step, + _sqlite3_backup_finish, + _sqlite3_backup_remaining, + _sqlite3_backup_pagecount, + _sqlite3_clear_bindings, + _sqlite3_value_blob, + _sqlite3_value_bytes, + _sqlite3_value_double, + _sqlite3_value_int, + _sqlite3_value_int64, + _sqlite3_value_subtype, + _sqlite3_value_pointer, + _sqlite3_value_type, + _sqlite3_value_nochange, + _sqlite3_value_frombind, + _sqlite3_value_dup, + _sqlite3_value_free, + _sqlite3_result_blob, + _sqlite3_result_error_toobig, + _sqlite3_result_error_nomem, + _sqlite3_result_double, + _sqlite3_result_error, + _sqlite3_result_int, + _sqlite3_result_int64, + _sqlite3_result_null, + _sqlite3_result_pointer, + _sqlite3_result_subtype, + _sqlite3_result_text, + _sqlite3_result_zeroblob, + _sqlite3_result_zeroblob64, + _sqlite3_result_error_code, + _sqlite3_user_data, + _sqlite3_context_db_handle, + _sqlite3_vtab_nochange, + _sqlite3_vtab_in_first, + _sqlite3_vtab_in_next, + _sqlite3_aggregate_context, + _sqlite3_get_auxdata, + _sqlite3_set_auxdata, + _sqlite3_column_count, + _sqlite3_data_count, + _sqlite3_column_blob, + _sqlite3_column_bytes, + _sqlite3_column_double, + _sqlite3_column_value, + _sqlite3_column_decltype, + _sqlite3_column_database_name, + _sqlite3_column_table_name, + _sqlite3_column_origin_name, + _sqlite3_bind_blob, + _sqlite3_bind_double, + _sqlite3_bind_int, + _sqlite3_bind_int64, + _sqlite3_bind_null, + _sqlite3_bind_pointer, + _sqlite3_bind_text, + _sqlite3_bind_zeroblob, + _sqlite3_bind_parameter_count, + _sqlite3_bind_parameter_name, + _sqlite3_bind_parameter_index, + _sqlite3_db_handle, + _sqlite3_stmt_readonly, + _sqlite3_stmt_isexplain, + _sqlite3_stmt_explain, + _sqlite3_stmt_busy, + _sqlite3_next_stmt, + _sqlite3_stmt_status, + _sqlite3_sql, + _sqlite3_expanded_sql, + _sqlite3_preupdate_old, + _sqlite3_preupdate_count, + _sqlite3_preupdate_depth, + _sqlite3_preupdate_blobwrite, + _sqlite3_preupdate_new, + _sqlite3_value_numeric_type, + _sqlite3_set_authorizer, + _sqlite3_strglob, + _sqlite3_strlike, + _sqlite3_auto_extension, + _sqlite3_cancel_auto_extension, + _sqlite3_reset_auto_extension, + _sqlite3_prepare_v3, + _sqlite3_create_module, + _sqlite3_create_module_v2, + _sqlite3_drop_modules, + _sqlite3_declare_vtab, + _sqlite3_vtab_on_conflict, + _sqlite3_vtab_collation, + _sqlite3_vtab_in, + _sqlite3_vtab_rhs_value, + _sqlite3_vtab_distinct, + _sqlite3_keyword_name, + _sqlite3_keyword_count, + _sqlite3_keyword_check, + _sqlite3_complete, + _sqlite3_libversion, + _sqlite3_libversion_number, + _sqlite3mc_vfs_create, + _sqlite3_shutdown, + _sqlite3mc_vfs_shutdown, + _sqlite3_last_insert_rowid, + _sqlite3_set_last_insert_rowid, + _sqlite3_changes64, + _sqlite3_changes, + _sqlite3_total_changes64, + _sqlite3_total_changes, + _sqlite3_txn_state, + _sqlite3_close_v2, + _sqlite3_busy_handler, + _sqlite3_progress_handler, + _sqlite3_busy_timeout, + _sqlite3_interrupt, + _sqlite3_is_interrupted, + _sqlite3_create_function, + _sqlite3_create_function_v2, + _sqlite3_create_window_function, + _sqlite3_overload_function, + _sqlite3_trace_v2, + _sqlite3_commit_hook, + _sqlite3_update_hook, + _sqlite3_rollback_hook, + _sqlite3_preupdate_hook, + _sqlite3_set_errmsg, + _sqlite3_error_offset, + _sqlite3_errcode, + _sqlite3_extended_errcode, + _sqlite3_errstr, + _sqlite3_limit, + _sqlite3_open, + _sqlite3_open_v2, + _sqlite3_create_collation, + _sqlite3_create_collation_v2, + _sqlite3_collation_needed, + _sqlite3_get_autocommit, + _sqlite3_table_column_metadata, + _sqlite3_extended_result_codes, + _sqlite3_uri_key, + _sqlite3_uri_int64, + _sqlite3_db_name, + _sqlite3_db_filename, + _sqlite3_db_readonly, + _sqlite3_compileoption_used, + _sqlite3_compileoption_get, + _sqlite3session_diff, + _sqlite3session_attach, + _sqlite3session_create, + _sqlite3session_delete, + _sqlite3session_table_filter, + _sqlite3session_changeset, + _sqlite3session_changeset_strm, + _sqlite3session_patchset_strm, + _sqlite3session_patchset, + _sqlite3session_enable, + _sqlite3session_indirect, + _sqlite3session_isempty, + _sqlite3session_memory_used, + _sqlite3session_object_config, + _sqlite3session_changeset_size, + _sqlite3changeset_start, + _sqlite3changeset_start_v2, + _sqlite3changeset_start_strm, + _sqlite3changeset_start_v2_strm, + _sqlite3changeset_next, + _sqlite3changeset_op, + _sqlite3changeset_pk, + _sqlite3changeset_old, + _sqlite3changeset_new, + _sqlite3changeset_conflict, + _sqlite3changeset_fk_conflicts, + _sqlite3changeset_finalize, + _sqlite3changeset_invert, + _sqlite3changeset_invert_strm, + _sqlite3changeset_apply_v2, + _sqlite3changeset_apply_v3, + _sqlite3changeset_apply, + _sqlite3changeset_apply_v3_strm, + _sqlite3changeset_apply_v2_strm, + _sqlite3changeset_apply_strm, + _sqlite3changegroup_new, + _sqlite3changegroup_add, + _sqlite3changegroup_output, + _sqlite3changegroup_add_strm, + _sqlite3changegroup_output_strm, + _sqlite3changegroup_delete, + _sqlite3changeset_concat, + _sqlite3changeset_concat_strm, + _sqlite3session_config, + _sqlite3_sourceid, + _sqlite3mc_version, + _sqlite3mc_config, + _sqlite3mc_cipher_count, + _sqlite3mc_cipher_index, + _sqlite3mc_cipher_name, + _sqlite3mc_cipher_name_copy, + _sqlite3mc_config_cipher, + _sqlite3mc_codec_data, + _sqlite3_activate_see, + _sqlite3_key, + _sqlite3_key_v2, + _sqlite3_rekey_v2, + _sqlite3_rekey, + _sqlite3mc_vfs_destroy, + _sqlite3__wasm_pstack_ptr, + _sqlite3__wasm_pstack_restore, + _sqlite3__wasm_pstack_alloc, + _sqlite3__wasm_pstack_remaining, + _sqlite3__wasm_pstack_quota, + _sqlite3__wasm_test_struct, + _sqlite3__wasm_enum_json, + _sqlite3__wasm_vfs_unlink, + _sqlite3__wasm_db_vfs, + _sqlite3__wasm_db_reset, + _sqlite3__wasm_db_export_chunked, + _sqlite3__wasm_db_serialize, + _sqlite3__wasm_vfs_create_file, + _sqlite3__wasm_posix_create_file, + _sqlite3__wasm_kvvfsMakeKey, + _sqlite3__wasm_kvvfs_methods, + _sqlite3__wasm_vtab_config, + _sqlite3__wasm_db_config_ip, + _sqlite3__wasm_db_config_pii, + _sqlite3__wasm_db_config_s, + _sqlite3__wasm_config_i, + _sqlite3__wasm_config_ii, + _sqlite3__wasm_config_j, + _sqlite3__wasm_qfmt_token, + _sqlite3__wasm_kvvfs_decode, + _sqlite3__wasm_kvvfs_encode, + _sqlite3__wasm_init_wasmfs, + _sqlite3__wasm_test_intptr, + _sqlite3__wasm_test_voidptr, + _sqlite3__wasm_test_int64_max, + _sqlite3__wasm_test_int64_min, + _sqlite3__wasm_test_int64_times2, + _sqlite3__wasm_test_int64_minmax, + _sqlite3__wasm_test_int64ptr, + _sqlite3__wasm_test_stack_overflow, + _sqlite3__wasm_test_str_hello, + _sqlite3__wasm_SQLTester_strglob, + _malloc, + _free, + _realloc, + _emscripten_builtin_memalign, + __emscripten_stack_restore, + __emscripten_stack_alloc, + _emscripten_stack_get_current, + __indirect_function_table; + + +function assignWasmExports(wasmExports) { + _sqlite3_status64 = Module['_sqlite3_status64'] = wasmExports['sqlite3_status64']; + _sqlite3_status = Module['_sqlite3_status'] = wasmExports['sqlite3_status']; + _sqlite3_db_status64 = Module['_sqlite3_db_status64'] = wasmExports['sqlite3_db_status64']; + _sqlite3_msize = Module['_sqlite3_msize'] = wasmExports['sqlite3_msize']; + _sqlite3_db_status = Module['_sqlite3_db_status'] = wasmExports['sqlite3_db_status']; + _sqlite3_vfs_find = Module['_sqlite3_vfs_find'] = wasmExports['sqlite3_vfs_find']; + _sqlite3_initialize = Module['_sqlite3_initialize'] = wasmExports['sqlite3_initialize']; + _sqlite3_vfs_register = Module['_sqlite3_vfs_register'] = wasmExports['sqlite3_vfs_register']; + _sqlite3_vfs_unregister = Module['_sqlite3_vfs_unregister'] = wasmExports['sqlite3_vfs_unregister']; + _sqlite3_malloc = Module['_sqlite3_malloc'] = wasmExports['sqlite3_malloc']; + _sqlite3_malloc64 = Module['_sqlite3_malloc64'] = wasmExports['sqlite3_malloc64']; + _sqlite3_free = Module['_sqlite3_free'] = wasmExports['sqlite3_free']; + _sqlite3_realloc = Module['_sqlite3_realloc'] = wasmExports['sqlite3_realloc']; + _sqlite3_realloc64 = Module['_sqlite3_realloc64'] = wasmExports['sqlite3_realloc64']; + _sqlite3_value_text = Module['_sqlite3_value_text'] = wasmExports['sqlite3_value_text']; + _sqlite3_randomness = Module['_sqlite3_randomness'] = wasmExports['sqlite3_randomness']; + _sqlite3_stricmp = Module['_sqlite3_stricmp'] = wasmExports['sqlite3_stricmp']; + _sqlite3_strnicmp = Module['_sqlite3_strnicmp'] = wasmExports['sqlite3_strnicmp']; + _sqlite3_uri_parameter = Module['_sqlite3_uri_parameter'] = wasmExports['sqlite3_uri_parameter']; + _sqlite3_uri_boolean = Module['_sqlite3_uri_boolean'] = wasmExports['sqlite3_uri_boolean']; + _sqlite3_serialize = Module['_sqlite3_serialize'] = wasmExports['sqlite3_serialize']; + _sqlite3_prepare_v2 = Module['_sqlite3_prepare_v2'] = wasmExports['sqlite3_prepare_v2']; + _sqlite3_step = Module['_sqlite3_step'] = wasmExports['sqlite3_step']; + _sqlite3_column_int64 = Module['_sqlite3_column_int64'] = wasmExports['sqlite3_column_int64']; + _sqlite3_reset = Module['_sqlite3_reset'] = wasmExports['sqlite3_reset']; + _sqlite3_exec = Module['_sqlite3_exec'] = wasmExports['sqlite3_exec']; + _sqlite3_column_int = Module['_sqlite3_column_int'] = wasmExports['sqlite3_column_int']; + _sqlite3_finalize = Module['_sqlite3_finalize'] = wasmExports['sqlite3_finalize']; + _sqlite3_file_control = Module['_sqlite3_file_control'] = wasmExports['sqlite3_file_control']; + _sqlite3_column_name = Module['_sqlite3_column_name'] = wasmExports['sqlite3_column_name']; + _sqlite3_column_text = Module['_sqlite3_column_text'] = wasmExports['sqlite3_column_text']; + _sqlite3_column_type = Module['_sqlite3_column_type'] = wasmExports['sqlite3_column_type']; + _sqlite3_errmsg = Module['_sqlite3_errmsg'] = wasmExports['sqlite3_errmsg']; + _sqlite3_deserialize = Module['_sqlite3_deserialize'] = wasmExports['sqlite3_deserialize']; + _sqlite3_backup_init = Module['_sqlite3_backup_init'] = wasmExports['sqlite3_backup_init']; + _sqlite3_backup_step = Module['_sqlite3_backup_step'] = wasmExports['sqlite3_backup_step']; + _sqlite3_backup_finish = Module['_sqlite3_backup_finish'] = wasmExports['sqlite3_backup_finish']; + _sqlite3_backup_remaining = Module['_sqlite3_backup_remaining'] = wasmExports['sqlite3_backup_remaining']; + _sqlite3_backup_pagecount = Module['_sqlite3_backup_pagecount'] = wasmExports['sqlite3_backup_pagecount']; + _sqlite3_clear_bindings = Module['_sqlite3_clear_bindings'] = wasmExports['sqlite3_clear_bindings']; + _sqlite3_value_blob = Module['_sqlite3_value_blob'] = wasmExports['sqlite3_value_blob']; + _sqlite3_value_bytes = Module['_sqlite3_value_bytes'] = wasmExports['sqlite3_value_bytes']; + _sqlite3_value_double = Module['_sqlite3_value_double'] = wasmExports['sqlite3_value_double']; + _sqlite3_value_int = Module['_sqlite3_value_int'] = wasmExports['sqlite3_value_int']; + _sqlite3_value_int64 = Module['_sqlite3_value_int64'] = wasmExports['sqlite3_value_int64']; + _sqlite3_value_subtype = Module['_sqlite3_value_subtype'] = wasmExports['sqlite3_value_subtype']; + _sqlite3_value_pointer = Module['_sqlite3_value_pointer'] = wasmExports['sqlite3_value_pointer']; + _sqlite3_value_type = Module['_sqlite3_value_type'] = wasmExports['sqlite3_value_type']; + _sqlite3_value_nochange = Module['_sqlite3_value_nochange'] = wasmExports['sqlite3_value_nochange']; + _sqlite3_value_frombind = Module['_sqlite3_value_frombind'] = wasmExports['sqlite3_value_frombind']; + _sqlite3_value_dup = Module['_sqlite3_value_dup'] = wasmExports['sqlite3_value_dup']; + _sqlite3_value_free = Module['_sqlite3_value_free'] = wasmExports['sqlite3_value_free']; + _sqlite3_result_blob = Module['_sqlite3_result_blob'] = wasmExports['sqlite3_result_blob']; + _sqlite3_result_error_toobig = Module['_sqlite3_result_error_toobig'] = wasmExports['sqlite3_result_error_toobig']; + _sqlite3_result_error_nomem = Module['_sqlite3_result_error_nomem'] = wasmExports['sqlite3_result_error_nomem']; + _sqlite3_result_double = Module['_sqlite3_result_double'] = wasmExports['sqlite3_result_double']; + _sqlite3_result_error = Module['_sqlite3_result_error'] = wasmExports['sqlite3_result_error']; + _sqlite3_result_int = Module['_sqlite3_result_int'] = wasmExports['sqlite3_result_int']; + _sqlite3_result_int64 = Module['_sqlite3_result_int64'] = wasmExports['sqlite3_result_int64']; + _sqlite3_result_null = Module['_sqlite3_result_null'] = wasmExports['sqlite3_result_null']; + _sqlite3_result_pointer = Module['_sqlite3_result_pointer'] = wasmExports['sqlite3_result_pointer']; + _sqlite3_result_subtype = Module['_sqlite3_result_subtype'] = wasmExports['sqlite3_result_subtype']; + _sqlite3_result_text = Module['_sqlite3_result_text'] = wasmExports['sqlite3_result_text']; + _sqlite3_result_zeroblob = Module['_sqlite3_result_zeroblob'] = wasmExports['sqlite3_result_zeroblob']; + _sqlite3_result_zeroblob64 = Module['_sqlite3_result_zeroblob64'] = wasmExports['sqlite3_result_zeroblob64']; + _sqlite3_result_error_code = Module['_sqlite3_result_error_code'] = wasmExports['sqlite3_result_error_code']; + _sqlite3_user_data = Module['_sqlite3_user_data'] = wasmExports['sqlite3_user_data']; + _sqlite3_context_db_handle = Module['_sqlite3_context_db_handle'] = wasmExports['sqlite3_context_db_handle']; + _sqlite3_vtab_nochange = Module['_sqlite3_vtab_nochange'] = wasmExports['sqlite3_vtab_nochange']; + _sqlite3_vtab_in_first = Module['_sqlite3_vtab_in_first'] = wasmExports['sqlite3_vtab_in_first']; + _sqlite3_vtab_in_next = Module['_sqlite3_vtab_in_next'] = wasmExports['sqlite3_vtab_in_next']; + _sqlite3_aggregate_context = Module['_sqlite3_aggregate_context'] = wasmExports['sqlite3_aggregate_context']; + _sqlite3_get_auxdata = Module['_sqlite3_get_auxdata'] = wasmExports['sqlite3_get_auxdata']; + _sqlite3_set_auxdata = Module['_sqlite3_set_auxdata'] = wasmExports['sqlite3_set_auxdata']; + _sqlite3_column_count = Module['_sqlite3_column_count'] = wasmExports['sqlite3_column_count']; + _sqlite3_data_count = Module['_sqlite3_data_count'] = wasmExports['sqlite3_data_count']; + _sqlite3_column_blob = Module['_sqlite3_column_blob'] = wasmExports['sqlite3_column_blob']; + _sqlite3_column_bytes = Module['_sqlite3_column_bytes'] = wasmExports['sqlite3_column_bytes']; + _sqlite3_column_double = Module['_sqlite3_column_double'] = wasmExports['sqlite3_column_double']; + _sqlite3_column_value = Module['_sqlite3_column_value'] = wasmExports['sqlite3_column_value']; + _sqlite3_column_decltype = Module['_sqlite3_column_decltype'] = wasmExports['sqlite3_column_decltype']; + _sqlite3_column_database_name = Module['_sqlite3_column_database_name'] = wasmExports['sqlite3_column_database_name']; + _sqlite3_column_table_name = Module['_sqlite3_column_table_name'] = wasmExports['sqlite3_column_table_name']; + _sqlite3_column_origin_name = Module['_sqlite3_column_origin_name'] = wasmExports['sqlite3_column_origin_name']; + _sqlite3_bind_blob = Module['_sqlite3_bind_blob'] = wasmExports['sqlite3_bind_blob']; + _sqlite3_bind_double = Module['_sqlite3_bind_double'] = wasmExports['sqlite3_bind_double']; + _sqlite3_bind_int = Module['_sqlite3_bind_int'] = wasmExports['sqlite3_bind_int']; + _sqlite3_bind_int64 = Module['_sqlite3_bind_int64'] = wasmExports['sqlite3_bind_int64']; + _sqlite3_bind_null = Module['_sqlite3_bind_null'] = wasmExports['sqlite3_bind_null']; + _sqlite3_bind_pointer = Module['_sqlite3_bind_pointer'] = wasmExports['sqlite3_bind_pointer']; + _sqlite3_bind_text = Module['_sqlite3_bind_text'] = wasmExports['sqlite3_bind_text']; + _sqlite3_bind_zeroblob = Module['_sqlite3_bind_zeroblob'] = wasmExports['sqlite3_bind_zeroblob']; + _sqlite3_bind_parameter_count = Module['_sqlite3_bind_parameter_count'] = wasmExports['sqlite3_bind_parameter_count']; + _sqlite3_bind_parameter_name = Module['_sqlite3_bind_parameter_name'] = wasmExports['sqlite3_bind_parameter_name']; + _sqlite3_bind_parameter_index = Module['_sqlite3_bind_parameter_index'] = wasmExports['sqlite3_bind_parameter_index']; + _sqlite3_db_handle = Module['_sqlite3_db_handle'] = wasmExports['sqlite3_db_handle']; + _sqlite3_stmt_readonly = Module['_sqlite3_stmt_readonly'] = wasmExports['sqlite3_stmt_readonly']; + _sqlite3_stmt_isexplain = Module['_sqlite3_stmt_isexplain'] = wasmExports['sqlite3_stmt_isexplain']; + _sqlite3_stmt_explain = Module['_sqlite3_stmt_explain'] = wasmExports['sqlite3_stmt_explain']; + _sqlite3_stmt_busy = Module['_sqlite3_stmt_busy'] = wasmExports['sqlite3_stmt_busy']; + _sqlite3_next_stmt = Module['_sqlite3_next_stmt'] = wasmExports['sqlite3_next_stmt']; + _sqlite3_stmt_status = Module['_sqlite3_stmt_status'] = wasmExports['sqlite3_stmt_status']; + _sqlite3_sql = Module['_sqlite3_sql'] = wasmExports['sqlite3_sql']; + _sqlite3_expanded_sql = Module['_sqlite3_expanded_sql'] = wasmExports['sqlite3_expanded_sql']; + _sqlite3_preupdate_old = Module['_sqlite3_preupdate_old'] = wasmExports['sqlite3_preupdate_old']; + _sqlite3_preupdate_count = Module['_sqlite3_preupdate_count'] = wasmExports['sqlite3_preupdate_count']; + _sqlite3_preupdate_depth = Module['_sqlite3_preupdate_depth'] = wasmExports['sqlite3_preupdate_depth']; + _sqlite3_preupdate_blobwrite = Module['_sqlite3_preupdate_blobwrite'] = wasmExports['sqlite3_preupdate_blobwrite']; + _sqlite3_preupdate_new = Module['_sqlite3_preupdate_new'] = wasmExports['sqlite3_preupdate_new']; + _sqlite3_value_numeric_type = Module['_sqlite3_value_numeric_type'] = wasmExports['sqlite3_value_numeric_type']; + _sqlite3_set_authorizer = Module['_sqlite3_set_authorizer'] = wasmExports['sqlite3_set_authorizer']; + _sqlite3_strglob = Module['_sqlite3_strglob'] = wasmExports['sqlite3_strglob']; + _sqlite3_strlike = Module['_sqlite3_strlike'] = wasmExports['sqlite3_strlike']; + _sqlite3_auto_extension = Module['_sqlite3_auto_extension'] = wasmExports['sqlite3_auto_extension']; + _sqlite3_cancel_auto_extension = Module['_sqlite3_cancel_auto_extension'] = wasmExports['sqlite3_cancel_auto_extension']; + _sqlite3_reset_auto_extension = Module['_sqlite3_reset_auto_extension'] = wasmExports['sqlite3_reset_auto_extension']; + _sqlite3_prepare_v3 = Module['_sqlite3_prepare_v3'] = wasmExports['sqlite3_prepare_v3']; + _sqlite3_create_module = Module['_sqlite3_create_module'] = wasmExports['sqlite3_create_module']; + _sqlite3_create_module_v2 = Module['_sqlite3_create_module_v2'] = wasmExports['sqlite3_create_module_v2']; + _sqlite3_drop_modules = Module['_sqlite3_drop_modules'] = wasmExports['sqlite3_drop_modules']; + _sqlite3_declare_vtab = Module['_sqlite3_declare_vtab'] = wasmExports['sqlite3_declare_vtab']; + _sqlite3_vtab_on_conflict = Module['_sqlite3_vtab_on_conflict'] = wasmExports['sqlite3_vtab_on_conflict']; + _sqlite3_vtab_collation = Module['_sqlite3_vtab_collation'] = wasmExports['sqlite3_vtab_collation']; + _sqlite3_vtab_in = Module['_sqlite3_vtab_in'] = wasmExports['sqlite3_vtab_in']; + _sqlite3_vtab_rhs_value = Module['_sqlite3_vtab_rhs_value'] = wasmExports['sqlite3_vtab_rhs_value']; + _sqlite3_vtab_distinct = Module['_sqlite3_vtab_distinct'] = wasmExports['sqlite3_vtab_distinct']; + _sqlite3_keyword_name = Module['_sqlite3_keyword_name'] = wasmExports['sqlite3_keyword_name']; + _sqlite3_keyword_count = Module['_sqlite3_keyword_count'] = wasmExports['sqlite3_keyword_count']; + _sqlite3_keyword_check = Module['_sqlite3_keyword_check'] = wasmExports['sqlite3_keyword_check']; + _sqlite3_complete = Module['_sqlite3_complete'] = wasmExports['sqlite3_complete']; + _sqlite3_libversion = Module['_sqlite3_libversion'] = wasmExports['sqlite3_libversion']; + _sqlite3_libversion_number = Module['_sqlite3_libversion_number'] = wasmExports['sqlite3_libversion_number']; + _sqlite3mc_vfs_create = Module['_sqlite3mc_vfs_create'] = wasmExports['sqlite3mc_vfs_create']; + _sqlite3_shutdown = Module['_sqlite3_shutdown'] = wasmExports['sqlite3_shutdown']; + _sqlite3mc_vfs_shutdown = Module['_sqlite3mc_vfs_shutdown'] = wasmExports['sqlite3mc_vfs_shutdown']; + _sqlite3_last_insert_rowid = Module['_sqlite3_last_insert_rowid'] = wasmExports['sqlite3_last_insert_rowid']; + _sqlite3_set_last_insert_rowid = Module['_sqlite3_set_last_insert_rowid'] = wasmExports['sqlite3_set_last_insert_rowid']; + _sqlite3_changes64 = Module['_sqlite3_changes64'] = wasmExports['sqlite3_changes64']; + _sqlite3_changes = Module['_sqlite3_changes'] = wasmExports['sqlite3_changes']; + _sqlite3_total_changes64 = Module['_sqlite3_total_changes64'] = wasmExports['sqlite3_total_changes64']; + _sqlite3_total_changes = Module['_sqlite3_total_changes'] = wasmExports['sqlite3_total_changes']; + _sqlite3_txn_state = Module['_sqlite3_txn_state'] = wasmExports['sqlite3_txn_state']; + _sqlite3_close_v2 = Module['_sqlite3_close_v2'] = wasmExports['sqlite3_close_v2']; + _sqlite3_busy_handler = Module['_sqlite3_busy_handler'] = wasmExports['sqlite3_busy_handler']; + _sqlite3_progress_handler = Module['_sqlite3_progress_handler'] = wasmExports['sqlite3_progress_handler']; + _sqlite3_busy_timeout = Module['_sqlite3_busy_timeout'] = wasmExports['sqlite3_busy_timeout']; + _sqlite3_interrupt = Module['_sqlite3_interrupt'] = wasmExports['sqlite3_interrupt']; + _sqlite3_is_interrupted = Module['_sqlite3_is_interrupted'] = wasmExports['sqlite3_is_interrupted']; + _sqlite3_create_function = Module['_sqlite3_create_function'] = wasmExports['sqlite3_create_function']; + _sqlite3_create_function_v2 = Module['_sqlite3_create_function_v2'] = wasmExports['sqlite3_create_function_v2']; + _sqlite3_create_window_function = Module['_sqlite3_create_window_function'] = wasmExports['sqlite3_create_window_function']; + _sqlite3_overload_function = Module['_sqlite3_overload_function'] = wasmExports['sqlite3_overload_function']; + _sqlite3_trace_v2 = Module['_sqlite3_trace_v2'] = wasmExports['sqlite3_trace_v2']; + _sqlite3_commit_hook = Module['_sqlite3_commit_hook'] = wasmExports['sqlite3_commit_hook']; + _sqlite3_update_hook = Module['_sqlite3_update_hook'] = wasmExports['sqlite3_update_hook']; + _sqlite3_rollback_hook = Module['_sqlite3_rollback_hook'] = wasmExports['sqlite3_rollback_hook']; + _sqlite3_preupdate_hook = Module['_sqlite3_preupdate_hook'] = wasmExports['sqlite3_preupdate_hook']; + _sqlite3_set_errmsg = Module['_sqlite3_set_errmsg'] = wasmExports['sqlite3_set_errmsg']; + _sqlite3_error_offset = Module['_sqlite3_error_offset'] = wasmExports['sqlite3_error_offset']; + _sqlite3_errcode = Module['_sqlite3_errcode'] = wasmExports['sqlite3_errcode']; + _sqlite3_extended_errcode = Module['_sqlite3_extended_errcode'] = wasmExports['sqlite3_extended_errcode']; + _sqlite3_errstr = Module['_sqlite3_errstr'] = wasmExports['sqlite3_errstr']; + _sqlite3_limit = Module['_sqlite3_limit'] = wasmExports['sqlite3_limit']; + _sqlite3_open = Module['_sqlite3_open'] = wasmExports['sqlite3_open']; + _sqlite3_open_v2 = Module['_sqlite3_open_v2'] = wasmExports['sqlite3_open_v2']; + _sqlite3_create_collation = Module['_sqlite3_create_collation'] = wasmExports['sqlite3_create_collation']; + _sqlite3_create_collation_v2 = Module['_sqlite3_create_collation_v2'] = wasmExports['sqlite3_create_collation_v2']; + _sqlite3_collation_needed = Module['_sqlite3_collation_needed'] = wasmExports['sqlite3_collation_needed']; + _sqlite3_get_autocommit = Module['_sqlite3_get_autocommit'] = wasmExports['sqlite3_get_autocommit']; + _sqlite3_table_column_metadata = Module['_sqlite3_table_column_metadata'] = wasmExports['sqlite3_table_column_metadata']; + _sqlite3_extended_result_codes = Module['_sqlite3_extended_result_codes'] = wasmExports['sqlite3_extended_result_codes']; + _sqlite3_uri_key = Module['_sqlite3_uri_key'] = wasmExports['sqlite3_uri_key']; + _sqlite3_uri_int64 = Module['_sqlite3_uri_int64'] = wasmExports['sqlite3_uri_int64']; + _sqlite3_db_name = Module['_sqlite3_db_name'] = wasmExports['sqlite3_db_name']; + _sqlite3_db_filename = Module['_sqlite3_db_filename'] = wasmExports['sqlite3_db_filename']; + _sqlite3_db_readonly = Module['_sqlite3_db_readonly'] = wasmExports['sqlite3_db_readonly']; + _sqlite3_compileoption_used = Module['_sqlite3_compileoption_used'] = wasmExports['sqlite3_compileoption_used']; + _sqlite3_compileoption_get = Module['_sqlite3_compileoption_get'] = wasmExports['sqlite3_compileoption_get']; + _sqlite3session_diff = Module['_sqlite3session_diff'] = wasmExports['sqlite3session_diff']; + _sqlite3session_attach = Module['_sqlite3session_attach'] = wasmExports['sqlite3session_attach']; + _sqlite3session_create = Module['_sqlite3session_create'] = wasmExports['sqlite3session_create']; + _sqlite3session_delete = Module['_sqlite3session_delete'] = wasmExports['sqlite3session_delete']; + _sqlite3session_table_filter = Module['_sqlite3session_table_filter'] = wasmExports['sqlite3session_table_filter']; + _sqlite3session_changeset = Module['_sqlite3session_changeset'] = wasmExports['sqlite3session_changeset']; + _sqlite3session_changeset_strm = Module['_sqlite3session_changeset_strm'] = wasmExports['sqlite3session_changeset_strm']; + _sqlite3session_patchset_strm = Module['_sqlite3session_patchset_strm'] = wasmExports['sqlite3session_patchset_strm']; + _sqlite3session_patchset = Module['_sqlite3session_patchset'] = wasmExports['sqlite3session_patchset']; + _sqlite3session_enable = Module['_sqlite3session_enable'] = wasmExports['sqlite3session_enable']; + _sqlite3session_indirect = Module['_sqlite3session_indirect'] = wasmExports['sqlite3session_indirect']; + _sqlite3session_isempty = Module['_sqlite3session_isempty'] = wasmExports['sqlite3session_isempty']; + _sqlite3session_memory_used = Module['_sqlite3session_memory_used'] = wasmExports['sqlite3session_memory_used']; + _sqlite3session_object_config = Module['_sqlite3session_object_config'] = wasmExports['sqlite3session_object_config']; + _sqlite3session_changeset_size = Module['_sqlite3session_changeset_size'] = wasmExports['sqlite3session_changeset_size']; + _sqlite3changeset_start = Module['_sqlite3changeset_start'] = wasmExports['sqlite3changeset_start']; + _sqlite3changeset_start_v2 = Module['_sqlite3changeset_start_v2'] = wasmExports['sqlite3changeset_start_v2']; + _sqlite3changeset_start_strm = Module['_sqlite3changeset_start_strm'] = wasmExports['sqlite3changeset_start_strm']; + _sqlite3changeset_start_v2_strm = Module['_sqlite3changeset_start_v2_strm'] = wasmExports['sqlite3changeset_start_v2_strm']; + _sqlite3changeset_next = Module['_sqlite3changeset_next'] = wasmExports['sqlite3changeset_next']; + _sqlite3changeset_op = Module['_sqlite3changeset_op'] = wasmExports['sqlite3changeset_op']; + _sqlite3changeset_pk = Module['_sqlite3changeset_pk'] = wasmExports['sqlite3changeset_pk']; + _sqlite3changeset_old = Module['_sqlite3changeset_old'] = wasmExports['sqlite3changeset_old']; + _sqlite3changeset_new = Module['_sqlite3changeset_new'] = wasmExports['sqlite3changeset_new']; + _sqlite3changeset_conflict = Module['_sqlite3changeset_conflict'] = wasmExports['sqlite3changeset_conflict']; + _sqlite3changeset_fk_conflicts = Module['_sqlite3changeset_fk_conflicts'] = wasmExports['sqlite3changeset_fk_conflicts']; + _sqlite3changeset_finalize = Module['_sqlite3changeset_finalize'] = wasmExports['sqlite3changeset_finalize']; + _sqlite3changeset_invert = Module['_sqlite3changeset_invert'] = wasmExports['sqlite3changeset_invert']; + _sqlite3changeset_invert_strm = Module['_sqlite3changeset_invert_strm'] = wasmExports['sqlite3changeset_invert_strm']; + _sqlite3changeset_apply_v2 = Module['_sqlite3changeset_apply_v2'] = wasmExports['sqlite3changeset_apply_v2']; + _sqlite3changeset_apply_v3 = Module['_sqlite3changeset_apply_v3'] = wasmExports['sqlite3changeset_apply_v3']; + _sqlite3changeset_apply = Module['_sqlite3changeset_apply'] = wasmExports['sqlite3changeset_apply']; + _sqlite3changeset_apply_v3_strm = Module['_sqlite3changeset_apply_v3_strm'] = wasmExports['sqlite3changeset_apply_v3_strm']; + _sqlite3changeset_apply_v2_strm = Module['_sqlite3changeset_apply_v2_strm'] = wasmExports['sqlite3changeset_apply_v2_strm']; + _sqlite3changeset_apply_strm = Module['_sqlite3changeset_apply_strm'] = wasmExports['sqlite3changeset_apply_strm']; + _sqlite3changegroup_new = Module['_sqlite3changegroup_new'] = wasmExports['sqlite3changegroup_new']; + _sqlite3changegroup_add = Module['_sqlite3changegroup_add'] = wasmExports['sqlite3changegroup_add']; + _sqlite3changegroup_output = Module['_sqlite3changegroup_output'] = wasmExports['sqlite3changegroup_output']; + _sqlite3changegroup_add_strm = Module['_sqlite3changegroup_add_strm'] = wasmExports['sqlite3changegroup_add_strm']; + _sqlite3changegroup_output_strm = Module['_sqlite3changegroup_output_strm'] = wasmExports['sqlite3changegroup_output_strm']; + _sqlite3changegroup_delete = Module['_sqlite3changegroup_delete'] = wasmExports['sqlite3changegroup_delete']; + _sqlite3changeset_concat = Module['_sqlite3changeset_concat'] = wasmExports['sqlite3changeset_concat']; + _sqlite3changeset_concat_strm = Module['_sqlite3changeset_concat_strm'] = wasmExports['sqlite3changeset_concat_strm']; + _sqlite3session_config = Module['_sqlite3session_config'] = wasmExports['sqlite3session_config']; + _sqlite3_sourceid = Module['_sqlite3_sourceid'] = wasmExports['sqlite3_sourceid']; + _sqlite3mc_version = Module['_sqlite3mc_version'] = wasmExports['sqlite3mc_version']; + _sqlite3mc_config = Module['_sqlite3mc_config'] = wasmExports['sqlite3mc_config']; + _sqlite3mc_cipher_count = Module['_sqlite3mc_cipher_count'] = wasmExports['sqlite3mc_cipher_count']; + _sqlite3mc_cipher_index = Module['_sqlite3mc_cipher_index'] = wasmExports['sqlite3mc_cipher_index']; + _sqlite3mc_cipher_name = Module['_sqlite3mc_cipher_name'] = wasmExports['sqlite3mc_cipher_name']; + _sqlite3mc_cipher_name_copy = Module['_sqlite3mc_cipher_name_copy'] = wasmExports['sqlite3mc_cipher_name_copy']; + _sqlite3mc_config_cipher = Module['_sqlite3mc_config_cipher'] = wasmExports['sqlite3mc_config_cipher']; + _sqlite3mc_codec_data = Module['_sqlite3mc_codec_data'] = wasmExports['sqlite3mc_codec_data']; + _sqlite3_activate_see = Module['_sqlite3_activate_see'] = wasmExports['sqlite3_activate_see']; + _sqlite3_key = Module['_sqlite3_key'] = wasmExports['sqlite3_key']; + _sqlite3_key_v2 = Module['_sqlite3_key_v2'] = wasmExports['sqlite3_key_v2']; + _sqlite3_rekey_v2 = Module['_sqlite3_rekey_v2'] = wasmExports['sqlite3_rekey_v2']; + _sqlite3_rekey = Module['_sqlite3_rekey'] = wasmExports['sqlite3_rekey']; + _sqlite3mc_vfs_destroy = Module['_sqlite3mc_vfs_destroy'] = wasmExports['sqlite3mc_vfs_destroy']; + _sqlite3__wasm_pstack_ptr = Module['_sqlite3__wasm_pstack_ptr'] = wasmExports['sqlite3__wasm_pstack_ptr']; + _sqlite3__wasm_pstack_restore = Module['_sqlite3__wasm_pstack_restore'] = wasmExports['sqlite3__wasm_pstack_restore']; + _sqlite3__wasm_pstack_alloc = Module['_sqlite3__wasm_pstack_alloc'] = wasmExports['sqlite3__wasm_pstack_alloc']; + _sqlite3__wasm_pstack_remaining = Module['_sqlite3__wasm_pstack_remaining'] = wasmExports['sqlite3__wasm_pstack_remaining']; + _sqlite3__wasm_pstack_quota = Module['_sqlite3__wasm_pstack_quota'] = wasmExports['sqlite3__wasm_pstack_quota']; + _sqlite3__wasm_test_struct = Module['_sqlite3__wasm_test_struct'] = wasmExports['sqlite3__wasm_test_struct']; + _sqlite3__wasm_enum_json = Module['_sqlite3__wasm_enum_json'] = wasmExports['sqlite3__wasm_enum_json']; + _sqlite3__wasm_vfs_unlink = Module['_sqlite3__wasm_vfs_unlink'] = wasmExports['sqlite3__wasm_vfs_unlink']; + _sqlite3__wasm_db_vfs = Module['_sqlite3__wasm_db_vfs'] = wasmExports['sqlite3__wasm_db_vfs']; + _sqlite3__wasm_db_reset = Module['_sqlite3__wasm_db_reset'] = wasmExports['sqlite3__wasm_db_reset']; + _sqlite3__wasm_db_export_chunked = Module['_sqlite3__wasm_db_export_chunked'] = wasmExports['sqlite3__wasm_db_export_chunked']; + _sqlite3__wasm_db_serialize = Module['_sqlite3__wasm_db_serialize'] = wasmExports['sqlite3__wasm_db_serialize']; + _sqlite3__wasm_vfs_create_file = Module['_sqlite3__wasm_vfs_create_file'] = wasmExports['sqlite3__wasm_vfs_create_file']; + _sqlite3__wasm_posix_create_file = Module['_sqlite3__wasm_posix_create_file'] = wasmExports['sqlite3__wasm_posix_create_file']; + _sqlite3__wasm_kvvfsMakeKey = Module['_sqlite3__wasm_kvvfsMakeKey'] = wasmExports['sqlite3__wasm_kvvfsMakeKey']; + _sqlite3__wasm_kvvfs_methods = Module['_sqlite3__wasm_kvvfs_methods'] = wasmExports['sqlite3__wasm_kvvfs_methods']; + _sqlite3__wasm_vtab_config = Module['_sqlite3__wasm_vtab_config'] = wasmExports['sqlite3__wasm_vtab_config']; + _sqlite3__wasm_db_config_ip = Module['_sqlite3__wasm_db_config_ip'] = wasmExports['sqlite3__wasm_db_config_ip']; + _sqlite3__wasm_db_config_pii = Module['_sqlite3__wasm_db_config_pii'] = wasmExports['sqlite3__wasm_db_config_pii']; + _sqlite3__wasm_db_config_s = Module['_sqlite3__wasm_db_config_s'] = wasmExports['sqlite3__wasm_db_config_s']; + _sqlite3__wasm_config_i = Module['_sqlite3__wasm_config_i'] = wasmExports['sqlite3__wasm_config_i']; + _sqlite3__wasm_config_ii = Module['_sqlite3__wasm_config_ii'] = wasmExports['sqlite3__wasm_config_ii']; + _sqlite3__wasm_config_j = Module['_sqlite3__wasm_config_j'] = wasmExports['sqlite3__wasm_config_j']; + _sqlite3__wasm_qfmt_token = Module['_sqlite3__wasm_qfmt_token'] = wasmExports['sqlite3__wasm_qfmt_token']; + _sqlite3__wasm_kvvfs_decode = Module['_sqlite3__wasm_kvvfs_decode'] = wasmExports['sqlite3__wasm_kvvfs_decode']; + _sqlite3__wasm_kvvfs_encode = Module['_sqlite3__wasm_kvvfs_encode'] = wasmExports['sqlite3__wasm_kvvfs_encode']; + _sqlite3__wasm_init_wasmfs = Module['_sqlite3__wasm_init_wasmfs'] = wasmExports['sqlite3__wasm_init_wasmfs']; + _sqlite3__wasm_test_intptr = Module['_sqlite3__wasm_test_intptr'] = wasmExports['sqlite3__wasm_test_intptr']; + _sqlite3__wasm_test_voidptr = Module['_sqlite3__wasm_test_voidptr'] = wasmExports['sqlite3__wasm_test_voidptr']; + _sqlite3__wasm_test_int64_max = Module['_sqlite3__wasm_test_int64_max'] = wasmExports['sqlite3__wasm_test_int64_max']; + _sqlite3__wasm_test_int64_min = Module['_sqlite3__wasm_test_int64_min'] = wasmExports['sqlite3__wasm_test_int64_min']; + _sqlite3__wasm_test_int64_times2 = Module['_sqlite3__wasm_test_int64_times2'] = wasmExports['sqlite3__wasm_test_int64_times2']; + _sqlite3__wasm_test_int64_minmax = Module['_sqlite3__wasm_test_int64_minmax'] = wasmExports['sqlite3__wasm_test_int64_minmax']; + _sqlite3__wasm_test_int64ptr = Module['_sqlite3__wasm_test_int64ptr'] = wasmExports['sqlite3__wasm_test_int64ptr']; + _sqlite3__wasm_test_stack_overflow = Module['_sqlite3__wasm_test_stack_overflow'] = wasmExports['sqlite3__wasm_test_stack_overflow']; + _sqlite3__wasm_test_str_hello = Module['_sqlite3__wasm_test_str_hello'] = wasmExports['sqlite3__wasm_test_str_hello']; + _sqlite3__wasm_SQLTester_strglob = Module['_sqlite3__wasm_SQLTester_strglob'] = wasmExports['sqlite3__wasm_SQLTester_strglob']; + _malloc = Module['_malloc'] = wasmExports['malloc']; + _free = Module['_free'] = wasmExports['free']; + _realloc = Module['_realloc'] = wasmExports['realloc']; + _emscripten_builtin_memalign = wasmExports['emscripten_builtin_memalign']; + __emscripten_stack_restore = wasmExports['_emscripten_stack_restore']; + __emscripten_stack_alloc = wasmExports['_emscripten_stack_alloc']; + _emscripten_stack_get_current = wasmExports['emscripten_stack_get_current']; + __indirect_function_table = wasmExports['__indirect_function_table']; +} + +var wasmImports = { + + __syscall_chmod: ___syscall_chmod, + + __syscall_faccessat: ___syscall_faccessat, + + __syscall_fchmod: ___syscall_fchmod, + + __syscall_fchown32: ___syscall_fchown32, + + __syscall_fcntl64: ___syscall_fcntl64, + + __syscall_fstat64: ___syscall_fstat64, + + __syscall_ftruncate64: ___syscall_ftruncate64, + + __syscall_getcwd: ___syscall_getcwd, + + __syscall_ioctl: ___syscall_ioctl, + + __syscall_lstat64: ___syscall_lstat64, + + __syscall_mkdirat: ___syscall_mkdirat, + + __syscall_newfstatat: ___syscall_newfstatat, + + __syscall_openat: ___syscall_openat, + + __syscall_readlinkat: ___syscall_readlinkat, + + __syscall_rmdir: ___syscall_rmdir, + + __syscall_stat64: ___syscall_stat64, + + __syscall_unlinkat: ___syscall_unlinkat, + + __syscall_utimensat: ___syscall_utimensat, + + _abort_js: __abort_js, + + _localtime_js: __localtime_js, + + _mmap_js: __mmap_js, + + _munmap_js: __munmap_js, + + _tzset_js: __tzset_js, + + clock_time_get: _clock_time_get, + + emscripten_date_now: _emscripten_date_now, + + emscripten_get_heap_max: _emscripten_get_heap_max, + + emscripten_get_now: _emscripten_get_now, + + emscripten_resize_heap: _emscripten_resize_heap, + + environ_get: _environ_get, + + environ_sizes_get: _environ_sizes_get, + + exit: _exit, + + fd_close: _fd_close, + + fd_fdstat_get: _fd_fdstat_get, + + fd_read: _fd_read, + + fd_seek: _fd_seek, + + fd_sync: _fd_sync, + + fd_write: _fd_write, + + memory: wasmMemory, + + wasm_crypto_getrandom +}; + + + + + +async function run() { + + preRun(); + + if (runDependencies) { + await resolveRunDependencies(); + } + + var setStatus = Module['setStatus']; + if (setStatus) { + setStatus('Running...'); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + setTimeout(setStatus, 1, ''); + } + + if (ABORT) return; + + initRuntime(); + + Module['onRuntimeInitialized']?.(); + + postRun(); +} + +var wasmExports; + + + +wasmExports = await createWasm(); +await run(); + + + + + +Module.runSQLite3PostLoadInit = async function( + sqlite3InitScriptInfo, + EmscriptenModule, + sqlite3IsUnderTest +){ + + 'use strict'; + delete EmscriptenModule.runSQLite3PostLoadInit; + + + + + + + +'use strict'; +globalThis.sqlite3ApiBootstrap = async function sqlite3ApiBootstrap( + apiConfig = (globalThis.sqlite3ApiConfig || sqlite3ApiBootstrap.defaultConfig) +){ + if(sqlite3ApiBootstrap.sqlite3){ + (sqlite3ApiBootstrap.sqlite3.config || console).warn( + "sqlite3ApiBootstrap() called multiple times.", + "Config and external initializers are ignored on calls after the first." + ); + return sqlite3ApiBootstrap.sqlite3; + } + const nu = (...obj)=>Object.assign(Object.create(null),...obj); + const config = nu({ + exports: undefined, + memory: undefined, + bigIntEnabled: !!globalThis.BigInt64Array, + debug: console.debug.bind(console), + warn: console.warn.bind(console), + error: console.error.bind(console), + log: console.log.bind(console), + wasmfsOpfsDir: '/opfs', + + useStdAlloc: false + }, apiConfig); + + Object.assign(config, { + allocExportName: config.useStdAlloc ? 'malloc' : 'sqlite3_malloc', + deallocExportName: config.useStdAlloc ? 'free' : 'sqlite3_free', + reallocExportName: config.useStdAlloc ? 'realloc' : 'sqlite3_realloc' + }); + + [ + + + 'exports', 'memory', 'functionTable', 'wasmfsOpfsDir' + ].forEach((k)=>{ + if('function' === typeof config[k]){ + config[k] = config[k](); + } + }); + + + const capi = nu(); + + const wasm = nu(); + + + const __rcStr = (rc)=>{ + return (capi.sqlite3_js_rc_str && capi.sqlite3_js_rc_str(rc)) + || ("Unknown result code #"+rc); + }; + + + const isInt32 = (n)=> + 'number'===typeof n + && n===(n | 0) + && n<=2147483647 && n>=-2147483648; + + + class SQLite3Error extends Error { + + constructor(...args){ + let rc; + if(args.length){ + if(isInt32(args[0])){ + rc = args[0]; + if(1===args.length){ + super(__rcStr(args[0])); + }else{ + const rcStr = __rcStr(rc); + if('object'===typeof args[1]){ + super(rcStr,args[1]); + }else{ + args[0] = rcStr+':'; + super(args.join(' ')); + } + } + }else{ + if(2===args.length && 'object'===typeof args[1]){ + super(...args); + }else{ + super(args.join(' ')); + } + } + } + this.resultCode = rc || capi.SQLITE_ERROR; + this.name = 'SQLite3Error'; + } + }; + + + SQLite3Error.toss = (...args)=>{ + throw new SQLite3Error(...args); + }; + const toss3 = SQLite3Error.toss; + + if(config.wasmfsOpfsDir && !/^\/[^/]+$/.test(config.wasmfsOpfsDir)){ + toss3("config.wasmfsOpfsDir must be falsy or in the form '/dir-name'."); + } + + + const bigIntFits64 = function f(b){ + if(!f._max){ + f._max = BigInt("0x7fffffffffffffff"); + f._min = ~f._max; + } + return b >= f._min && b <= f._max; + }; + + + const bigIntFits32 = (b)=>(b >= (-0x7fffffffn - 1n) && b <= 0x7fffffffn); + + + const bigIntFitsDouble = function f(b){ + if(!f._min){ + f._min = Number.MIN_SAFE_INTEGER; + f._max = Number.MAX_SAFE_INTEGER; + } + return b >= f._min && b <= f._max; + }; + + + const isTypedArray = (v)=>{ + return (v && v.constructor && isInt32(v.constructor.BYTES_PER_ELEMENT)) ? v : false; + }; + + + const isBindableTypedArray = (v)=> + v && (v instanceof Uint8Array + || v instanceof Int8Array + || v instanceof ArrayBuffer); + + + const isSQLableTypedArray = (v)=> + v && (v instanceof Uint8Array + || v instanceof Int8Array + || v instanceof ArrayBuffer); + + + const affirmBindableTypedArray = (v)=> + isBindableTypedArray(v) + || toss3("Value is not of a supported TypedArray type."); + + + const flexibleString = function(v){ + if(isSQLableTypedArray(v)){ + return wasm.typedArrayToString( + (v instanceof ArrayBuffer) ? new Uint8Array(v) : v, + 0, v.length + ); + } + else if(Array.isArray(v)) return v.join(""); + else if(wasm.isPtr(v)) v = wasm.cstrToJs(v); + return v; + }; + + + class WasmAllocError extends Error { + + constructor(...args){ + if(2===args.length && 'object'===typeof args[1]){ + super(...args); + }else if(args.length){ + super(args.join(' ')); + }else{ + super("Allocation failed."); + } + this.resultCode = capi.SQLITE_NOMEM; + this.name = 'WasmAllocError'; + } + }; + + WasmAllocError.toss = (...args)=>{ + throw new WasmAllocError(...args); + }; + + Object.assign(capi, { + + sqlite3_bind_blob: undefined, + + + sqlite3_bind_text: undefined, + + + sqlite3_create_function_v2: ( + pDb, funcName, nArg, eTextRep, pApp, + xFunc, xStep, xFinal, xDestroy + )=>{}, + + sqlite3_create_function: ( + pDb, funcName, nArg, eTextRep, pApp, + xFunc, xStep, xFinal + )=>{}, + + sqlite3_create_window_function: ( + pDb, funcName, nArg, eTextRep, pApp, + xStep, xFinal, xValue, xInverse, xDestroy + )=>{}, + + sqlite3_prepare_v3: (dbPtr, sql, sqlByteLen, prepFlags, + stmtPtrPtr, strPtrPtr)=>{}, + + + sqlite3_prepare_v2: (dbPtr, sql, sqlByteLen, + stmtPtrPtr,strPtrPtr)=>{}, + + + sqlite3_exec: (pDb, sql, callback, pVoid, pErrMsg)=>{}, + + + sqlite3_randomness: (n, outPtr)=>{}, + }); + + + const util = { + affirmBindableTypedArray, flexibleString, + bigIntFits32, bigIntFits64, bigIntFitsDouble, + isBindableTypedArray, + isInt32, isSQLableTypedArray, isTypedArray, + isUIThread: ()=>(globalThis.window===globalThis && !!globalThis.document), + + toss: function(...args){throw new Error(args.join(' '))}, + toss3, + typedArrayPart: wasm.typedArrayPart, + nu, + assert: function(arg,msg){ + if( !arg ){ + util.toss("Assertion failed:",msg); + } + }, + + affirmDbHeader: function(bytes){ + if(bytes instanceof ArrayBuffer) bytes = new Uint8Array(bytes); + const header = "SQLite format 3"; + if( header.length > bytes.byteLength ){ + toss3("Input does not contain an SQLite3 database header."); + } + for(let i = 0; i < header.length; ++i){ + if( header.charCodeAt(i) !== bytes[i] ){ + break ; + } + } + }, + + affirmIsDb: function(bytes){ + if(bytes instanceof ArrayBuffer) bytes = new Uint8Array(bytes); + const n = bytes.byteLength; + if(n<512 || n%512!==0) { + toss3("Byte array size",n,"is invalid for an SQLite3 db."); + } + util.affirmDbHeader(bytes); + } + }; + + + Object.assign(wasm, { + + + exports: config.exports + || toss3("Missing API config.exports (WASM module exports)."), + + + memory: config.memory + || config.exports['memory'] + || toss3("API config object requires a WebAssembly.Memory object", + "in either config.exports.memory (exported)", + "or config.memory (imported)."), + + + pointerSize: ('number'===typeof config.exports.sqlite3_libversion()) ? 4 : 8, + + + bigIntEnabled: !!config.bigIntEnabled, + + + functionTable: config.functionTable, + + + alloc: undefined, + + + realloc: undefined, + + + dealloc: undefined + + + }); + + + wasm.allocFromTypedArray = function(srcTypedArray){ + if(srcTypedArray instanceof ArrayBuffer){ + srcTypedArray = new Uint8Array(srcTypedArray); + } + affirmBindableTypedArray(srcTypedArray); + const pRet = wasm.alloc(srcTypedArray.byteLength || 1); + wasm.heapForSize(srcTypedArray.constructor) + .set(srcTypedArray.byteLength ? srcTypedArray : [0], Number(pRet)) + ; + return pRet; + }; + + { + + const keyAlloc = config.allocExportName, + keyDealloc = config.deallocExportName, + keyRealloc = config.reallocExportName; + for(const key of [keyAlloc, keyDealloc, keyRealloc]){ + const f = wasm.exports[key]; + if(!(f instanceof Function)) toss3("Missing required exports[",key,"] function."); + } + + wasm.alloc = function f(n){ + return f.impl(n) || WasmAllocError.toss("Failed to allocate",n," bytes."); + }; + wasm.alloc.impl = wasm.exports[keyAlloc]; + wasm.realloc = function f(m,n){ + const m2 = f.impl(wasm.ptr.coerce(m),n); + return n ? (m2 || WasmAllocError.toss("Failed to reallocate",n," bytes.")) : wasm.ptr.null; + }; + wasm.realloc.impl = wasm.exports[keyRealloc]; + wasm.dealloc = function f(m){ + f.impl(wasm.ptr.coerce(m)) + ; + }; + wasm.dealloc.impl = wasm.exports[keyDealloc]; + } + + + wasm.compileOptionUsed = function f(optName){ + if(!arguments.length){ + if(f._result) return f._result; + else if(!f._opt){ + f._rx = /^([^=]+)=(.+)/; + f._rxInt = /^-?\d+$/; + f._opt = function(opt, rv){ + const m = f._rx.exec(opt); + rv[0] = (m ? m[1] : opt); + rv[1] = m ? (f._rxInt.test(m[2]) ? +m[2] : m[2]) : true; + }; + } + const rc = nu(), ov = [0,0]; + let i = 0, k; + while((k = capi.sqlite3_compileoption_get(i++))){ + f._opt(k,ov); + rc[ov[0]] = ov[1]; + } + return f._result = rc; + }else if(Array.isArray(optName)){ + const rc = nu(); + optName.forEach((v)=>{ + rc[v] = capi.sqlite3_compileoption_used(v); + }); + return rc; + }else if('object' === typeof optName){ + Object.keys(optName).forEach((k)=> { + optName[k] = capi.sqlite3_compileoption_used(k); + }); + return optName; + } + return ( + 'string'===typeof optName + ) ? !!capi.sqlite3_compileoption_used(optName) : false; + }; + + + wasm.pstack = nu({ + + restore: wasm.exports.sqlite3__wasm_pstack_restore, + + + alloc: function(n){ + if('string'===typeof n && !(n = wasm.sizeofIR(n))){ + WasmAllocError.toss("Invalid value for pstack.alloc(",arguments[0],")"); + } + return wasm.exports.sqlite3__wasm_pstack_alloc(n) + || WasmAllocError.toss("Could not allocate",n, + "bytes from the pstack."); + }, + + + allocChunks: function(n,sz){ + if('string'===typeof sz && !(sz = wasm.sizeofIR(sz))){ + WasmAllocError.toss("Invalid size value for allocChunks(",arguments[1],")"); + } + const mem = wasm.pstack.alloc(n * sz); + const rc = [mem]; + let i = 1, offset = sz; + for(; i < n; ++i, offset += sz) rc.push(wasm.ptr.add(mem, offset)); + return rc; + }, + + + allocPtr: (n=1,safePtrSize=true)=>{ + return 1===n + ? wasm.pstack.alloc(safePtrSize ? 8 : wasm.ptr.size) + : wasm.pstack.allocChunks(n, safePtrSize ? 8 : wasm.ptr.size); + }, + + + call: function(f){ + const stackPos = wasm.pstack.pointer; + try{ return f(sqlite3) } + finally{ wasm.pstack.restore(stackPos); } + } + + }); + + Object.defineProperties(wasm.pstack, { + + pointer: { + configurable: false, iterable: true, writeable: false, + get: wasm.exports.sqlite3__wasm_pstack_ptr + + + + }, + + + quota: { + configurable: false, iterable: true, writeable: false, + get: wasm.exports.sqlite3__wasm_pstack_quota + }, + + + remaining: { + configurable: false, iterable: true, writeable: false, + get: wasm.exports.sqlite3__wasm_pstack_remaining + } + }); + + + capi.sqlite3_randomness = (...args)=>{ + if(1===args.length + && util.isTypedArray(args[0]) + && 1===args[0].BYTES_PER_ELEMENT){ + const ta = args[0]; + if(0===ta.byteLength){ + wasm.exports.sqlite3_randomness(0,wasm.ptr.null); + return ta; + } + const stack = wasm.pstack.pointer; + try { + let n = ta.byteLength, offset = 0; + const r = wasm.exports.sqlite3_randomness; + const heap = wasm.heap8u(); + const nAlloc = n < 512 ? n : 512; + const ptr = wasm.pstack.alloc(nAlloc); + do{ + const j = (n>nAlloc ? nAlloc : n); + r(j, ptr); + ta.set(wasm.typedArrayPart(heap, ptr, wasm.ptr.add(ptr,j)), offset); + n -= j; + offset += j; + } while(n > 0); + }catch(e){ + config.error("Highly unexpected (and ignored!) "+ + "exception in sqlite3_randomness():",e); + }finally{ + wasm.pstack.restore(stack); + } + return ta; + } + wasm.exports.sqlite3_randomness(...args); + }; + + + capi.sqlite3_wasmfs_opfs_dir = function(){ + if(undefined !== this.dir) return this.dir; + + const pdir = config.wasmfsOpfsDir; + if(!pdir + || !globalThis.FileSystemHandle + || !globalThis.FileSystemDirectoryHandle + || !globalThis.FileSystemFileHandle + || !wasm.exports.sqlite3__wasm_init_wasmfs){ + return this.dir = ""; + } + try{ + if(pdir && 0===wasm.xCallWrapped( + 'sqlite3__wasm_init_wasmfs', 'i32', ['string'], pdir + )){ + return this.dir = pdir; + }else{ + return this.dir = ""; + } + }catch(e){ + + return this.dir = ""; + } + }.bind(nu()); + + + capi.sqlite3_wasmfs_filename_is_persistent = function(name){ + const p = capi.sqlite3_wasmfs_opfs_dir(); + return (p && name) ? name.startsWith(p+'/') : false; + }; + + + capi.sqlite3_js_db_uses_vfs = function(pDb,vfsName,dbName=0){ + try{ + const pK = capi.sqlite3_vfs_find(vfsName); + if(!pK) return false; + else if(!pDb){ + return pK===capi.sqlite3_vfs_find(0) ? pK : false; + }else{ + return pK===capi.sqlite3_js_db_vfs(pDb,dbName) ? pK : false; + } + }catch(e){ + + return false; + } + }; + + + capi.sqlite3_js_vfs_list = function(){ + const rc = []; + let pVfs = capi.sqlite3_vfs_find(wasm.ptr.null); + while(pVfs){ + const oVfs = new capi.sqlite3_vfs(pVfs); + rc.push(wasm.cstrToJs(oVfs.$zName)); + pVfs = oVfs.$pNext; + oVfs.dispose(); + } + return rc; + }; + + + capi.sqlite3_js_db_export = function(pDb, schema=0){ + pDb = wasm.xWrap.testConvertArg('sqlite3*', pDb); + if(!pDb) toss3('Invalid sqlite3* argument.'); + if(!wasm.bigIntEnabled) toss3('BigInt support is not enabled.'); + const scope = wasm.scopedAllocPush(); + let pOut; + try{ + const pSize = wasm.scopedAlloc(8 + wasm.ptr.size); + const ppOut = wasm.ptr.add(pSize, 8); + + const zSchema = schema + ? (wasm.isPtr(schema) ? schema : wasm.scopedAllocCString(''+schema)) + : wasm.ptr.null; + let rc = wasm.exports.sqlite3__wasm_db_serialize( + pDb, zSchema, ppOut, pSize, 0 + ); + if(rc){ + toss3("Database serialization failed with code", + sqlite3.capi.sqlite3_js_rc_str(rc)); + } + pOut = wasm.peekPtr(ppOut); + const nOut = wasm.peek(pSize, 'i64'); + rc = nOut + ? wasm.heap8u().slice(Number(pOut), Number(pOut) + Number(nOut)) + : new Uint8Array(); + return rc; + }finally{ + if(pOut) wasm.exports.sqlite3_free(pOut); + wasm.scopedAllocPop(scope); + } + }; + + + capi.sqlite3_js_db_vfs = + (dbPointer, dbName=wasm.ptr.null)=>util.sqlite3__wasm_db_vfs(dbPointer, dbName); + + + capi.sqlite3_js_aggregate_context = (pCtx, n)=>{ + return capi.sqlite3_aggregate_context(pCtx, n) + || (n ? WasmAllocError.toss("Cannot allocate",n, + "bytes for sqlite3_aggregate_context()") + : 0); + }; + + + capi.sqlite3_js_posix_create_file = function(filename, data, dataLen){ + let pData; + if(data && wasm.isPtr(data)){ + pData = data; + }else if(data instanceof ArrayBuffer || data instanceof Uint8Array){ + pData = wasm.allocFromTypedArray(data); + if(arguments.length<3 || !util.isInt32(dataLen) || dataLen<0){ + dataLen = data.byteLength; + } + }else{ + SQLite3Error.toss("Invalid 2nd argument for sqlite3_js_posix_create_file()."); + } + try{ + if(!util.isInt32(dataLen) || dataLen<0){ + SQLite3Error.toss("Invalid 3rd argument for sqlite3_js_posix_create_file()."); + } + const rc = util.sqlite3__wasm_posix_create_file(filename, pData, dataLen); + if(rc) SQLite3Error.toss("Creation of file failed with sqlite3 result code", + capi.sqlite3_js_rc_str(rc)); + }finally{ + if( pData && pData!==data ) wasm.dealloc(pData); + } + }; + + + capi.sqlite3_js_vfs_create_file = function(vfs, filename, data, dataLen){ + config.warn("sqlite3_js_vfs_create_file() is deprecated and", + "should be avoided because it can lead to C-level crashes.", + "See its documentation for alternatives."); + let pData; + if(data){ + if( wasm.isPtr(data) ){ + pData = data; + }else{ + if( data instanceof ArrayBuffer ){ + data = new Uint8Array(data); + } + if( data instanceof Uint8Array ){ + pData = wasm.allocFromTypedArray(data); + if(arguments.length<4 || !util.isInt32(dataLen) || dataLen<0){ + dataLen = data.byteLength; + } + }else{ + SQLite3Error.toss("Invalid 3rd argument type for sqlite3_js_vfs_create_file()."); + } + } + }else{ + pData = 0; + } + if(!util.isInt32(dataLen) || dataLen<0){ + if( pData && pData!==data ) wasm.dealloc(pData); + SQLite3Error.toss("Invalid 4th argument for sqlite3_js_vfs_create_file()."); + } + try{ + const rc = util.sqlite3__wasm_vfs_create_file(vfs, filename, pData, dataLen); + if(rc) SQLite3Error.toss("Creation of file failed with sqlite3 result code", + capi.sqlite3_js_rc_str(rc)); + }finally{ + if( pData && pData!==data ) wasm.dealloc(pData); + } + }; + + + capi.sqlite3_js_sql_to_string = (sql)=>{ + if('string' === typeof sql){ + return sql; + } + const x = flexibleString(v); + return x===v ? undefined : x; + } + + + capi.sqlite3_db_config = function(pDb, op, ...args){ + switch(op){ + case capi.SQLITE_DBCONFIG_ENABLE_FKEY: + case capi.SQLITE_DBCONFIG_ENABLE_TRIGGER: + case capi.SQLITE_DBCONFIG_ENABLE_FTS3_TOKENIZER: + case capi.SQLITE_DBCONFIG_ENABLE_LOAD_EXTENSION: + case capi.SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE: + case capi.SQLITE_DBCONFIG_ENABLE_QPSG: + case capi.SQLITE_DBCONFIG_TRIGGER_EQP: + case capi.SQLITE_DBCONFIG_RESET_DATABASE: + case capi.SQLITE_DBCONFIG_DEFENSIVE: + case capi.SQLITE_DBCONFIG_WRITABLE_SCHEMA: + case capi.SQLITE_DBCONFIG_LEGACY_ALTER_TABLE: + case capi.SQLITE_DBCONFIG_DQS_DML: + case capi.SQLITE_DBCONFIG_DQS_DDL: + case capi.SQLITE_DBCONFIG_ENABLE_VIEW: + case capi.SQLITE_DBCONFIG_LEGACY_FILE_FORMAT: + case capi.SQLITE_DBCONFIG_TRUSTED_SCHEMA: + case capi.SQLITE_DBCONFIG_STMT_SCANSTATUS: + case capi.SQLITE_DBCONFIG_REVERSE_SCANORDER: + case capi.SQLITE_DBCONFIG_ENABLE_ATTACH_CREATE: + case capi.SQLITE_DBCONFIG_ENABLE_ATTACH_WRITE: + case capi.SQLITE_DBCONFIG_ENABLE_COMMENTS: + case capi.SQLITE_DBCONFIG_FP_DIGITS: + if( !this.ip ){ + this.ip = wasm.xWrap('sqlite3__wasm_db_config_ip','int', + ['sqlite3*', 'int', 'int', '*']); + } + return this.ip(pDb, op, args[0], args[1] || 0); + case capi.SQLITE_DBCONFIG_LOOKASIDE: + if( !this.pii ){ + this.pii = wasm.xWrap('sqlite3__wasm_db_config_pii', 'int', + ['sqlite3*', 'int', '*', 'int', 'int']); + } + return this.pii(pDb, op, args[0], args[1], args[2]); + case capi.SQLITE_DBCONFIG_MAINDBNAME: + if(!this.s){ + this.s = wasm.xWrap('sqlite3__wasm_db_config_s','int', + ['sqlite3*', 'int', 'string:static'] + ); + } + return this.s(pDb, op, args[0]); + default: + return capi.SQLITE_MISUSE; + } + }.bind(nu()); + + + capi.sqlite3_value_to_js = function(pVal,throwIfCannotConvert=true){ + let arg; + const valType = capi.sqlite3_value_type(pVal); + switch(valType){ + case capi.SQLITE_INTEGER: + if(wasm.bigIntEnabled){ + arg = capi.sqlite3_value_int64(pVal); + if(util.bigIntFitsDouble(arg)) arg = Number(arg); + } + else arg = capi.sqlite3_value_double(pVal); + break; + case capi.SQLITE_FLOAT: + arg = capi.sqlite3_value_double(pVal); + break; + case capi.SQLITE_TEXT: + arg = capi.sqlite3_value_text(pVal); + break; + case capi.SQLITE_BLOB:{ + const n = capi.sqlite3_value_bytes(pVal); + const pBlob = capi.sqlite3_value_blob(pVal); + if(n && !pBlob) sqlite3.WasmAllocError.toss( + "Cannot allocate memory for blob argument of",n,"byte(s)" + ); + arg = n + ? wasm.heap8u().slice(Number(pBlob), Number(pBlob) + Number(n)) + : null; + break; + } + case capi.SQLITE_NULL: + arg = null; break; + default: + if(throwIfCannotConvert){ + toss3(capi.SQLITE_MISMATCH, + "Unhandled sqlite3_value_type():",valType); + } + arg = undefined; + } + return arg; + }; + + + capi.sqlite3_values_to_js = function(argc,pArgv,throwIfCannotConvert=true){ + let i; + const tgt = []; + for(i = 0; i < argc; ++i){ + + tgt.push(capi.sqlite3_value_to_js( + wasm.peekPtr(wasm.ptr.add(pArgv, wasm.ptr.size * i)), + throwIfCannotConvert + )); + } + return tgt; + }; + + + capi.sqlite3_result_error_js = function(pCtx,e){ + if(e instanceof WasmAllocError){ + capi.sqlite3_result_error_nomem(pCtx); + }else{ + ; + capi.sqlite3_result_error(pCtx, ''+e, -1); + } + }; + + + capi.sqlite3_result_js = function(pCtx,val){ + if(val instanceof Error){ + capi.sqlite3_result_error_js(pCtx, val); + return; + } + try{ + switch(typeof val) { + case 'undefined': + + break; + case 'boolean': + capi.sqlite3_result_int(pCtx, val ? 1 : 0); + break; + case 'bigint': + if(util.bigIntFits32(val)){ + capi.sqlite3_result_int(pCtx, Number(val)); + }else if(util.bigIntFitsDouble(val)){ + capi.sqlite3_result_double(pCtx, Number(val)); + }else if(wasm.bigIntEnabled){ + if(util.bigIntFits64(val)) capi.sqlite3_result_int64(pCtx, val); + else toss3("BigInt value",val.toString(),"is too BigInt for int64."); + }else{ + toss3("BigInt value",val.toString(),"is too BigInt."); + } + break; + case 'number': { + let f; + if(util.isInt32(val)){ + f = capi.sqlite3_result_int; + }else if(wasm.bigIntEnabled + && Number.isInteger(val) + && util.bigIntFits64(BigInt(val))){ + f = capi.sqlite3_result_int64; + }else{ + f = capi.sqlite3_result_double; + } + f(pCtx, val); + break; + } + case 'string': { + const [p, n] = wasm.allocCString(val,true); + capi.sqlite3_result_text(pCtx, p, n, capi.SQLITE_WASM_DEALLOC); + break; + } + case 'object': + if(null===val) { + capi.sqlite3_result_null(pCtx); + break; + }else if(util.isBindableTypedArray(val)){ + const pBlob = wasm.allocFromTypedArray(val); + capi.sqlite3_result_blob( + pCtx, pBlob, val.byteLength, + capi.SQLITE_WASM_DEALLOC + ); + break; + } + + default: + toss3("Don't not how to handle this UDF result value:",(typeof val), val); + } + }catch(e){ + capi.sqlite3_result_error_js(pCtx, e); + } + }; + + + capi.sqlite3_column_js = function(pStmt, iCol, throwIfCannotConvert=true){ + const v = capi.sqlite3_column_value(pStmt, iCol); + return (0===v) ? undefined : capi.sqlite3_value_to_js(v, throwIfCannotConvert); + }; + + if( true ){ + + const __newOldValue = function(pObj, iCol, impl){ + impl = capi[impl]; + if(!this.ptr) this.ptr = wasm.allocPtr(); + else wasm.pokePtr(this.ptr, 0); + const rc = impl(pObj, iCol, this.ptr); + if(rc) return SQLite3Error.toss(rc,arguments[2]+"() failed with code "+rc); + const pv = wasm.peekPtr(this.ptr); + return pv ? capi.sqlite3_value_to_js( pv, true ) : undefined; + }.bind(nu()); + + + capi.sqlite3_preupdate_new_js = + (pDb, iCol)=>__newOldValue(pDb, iCol, 'sqlite3_preupdate_new'); + + + capi.sqlite3_preupdate_old_js = + (pDb, iCol)=>__newOldValue(pDb, iCol, 'sqlite3_preupdate_old'); + + + capi.sqlite3changeset_new_js = + (pChangesetIter, iCol) => __newOldValue(pChangesetIter, iCol, + 'sqlite3changeset_new'); + + + capi.sqlite3changeset_old_js = + (pChangesetIter, iCol)=>__newOldValue(pChangesetIter, iCol, + 'sqlite3changeset_old'); + } + + + capi.sqlite3_js_retry_busy = function(maxTimes, callback, beforeRetry){ + for(let n = 1; n <= maxTimes; ++n){ + try{ + if( beforeRetry && n>1 ) beforeRetry(n); + const rc = callback(); + if( capi.SQLITE_BUSY===rc ){ + if( n===maxTimes ){ + throw new SQLite3Error(rc, [ + "sqlite3_js_retry_busy() max retry attempts (", + maxTimes, + ") reached." + ].join('')); + } + continue; + } + return rc; + }catch(e){ + if( n{ + if(!sqlite3.__isUnderTest){ + + delete sqlite3.util; + delete sqlite3.StructBinder; + delete sqlite3.opfs; + } + return sqlite3; + }; + const catcher = (e)=>{ + config.error("an async sqlite3 initializer failed:",e); + throw e; + }; + if(!lia || !lia.length){ + return ff.isReady = postInit().catch(catcher); + } + lia = lia.map((f)=>{ + return (f instanceof Function) ? async x=>f(sqlite3) : f; + }); + lia.push(postInit); + let p = Promise.resolve(sqlite3); + while(lia.length) p = p.then(lia.shift()); + return ff.isReady = p.catch(catcher); + }.bind(sqlite3ApiBootstrap), + + scriptInfo: undefined + }; + if( 'undefined'!==typeof sqlite3IsUnderTest ){ + sqlite3.__isUnderTest = !!sqlite3IsUnderTest; + } + try{ + sqlite3ApiBootstrap.initializers.forEach((f)=>{ + f(sqlite3); + }); + }catch(e){ + + console.error("sqlite3 bootstrap initializer threw:",e); + throw e; + } + delete sqlite3ApiBootstrap.initializers; + sqlite3ApiBootstrap.sqlite3 = sqlite3; + if( 'undefined'!==typeof sqlite3InitScriptInfo ){ + sqlite3InitScriptInfo.debugModule( + "sqlite3ApiBootstrap() complete", sqlite3 + ); + sqlite3.scriptInfo + = sqlite3InitScriptInfo; + } + if( sqlite3.__isUnderTest ){ + if( 'undefined'!==typeof EmscriptenModule ){ + sqlite3.config.emscripten = EmscriptenModule; + } + + const iw = sqlite3.scriptInfo?.instantiateWasm; + if( iw ){ + + sqlite3.wasm.module = iw.module; + sqlite3.wasm.instance = iw.instance; + sqlite3.wasm.imports = iw.imports; + } + } + + + delete globalThis.sqlite3ApiConfig; + delete globalThis.sqlite3ApiBootstrap; + delete sqlite3ApiBootstrap.defaultConfig; + return sqlite3.asyncPostInit().then((s)=>{ + if( 'undefined'!==typeof sqlite3InitScriptInfo ){ + sqlite3InitScriptInfo.debugModule( + "sqlite3.asyncPostInit() complete", s + ); + } + delete s.asyncPostInit; + delete s.scriptInfo; + delete s.emscripten; + return s; + }); +}; + + +globalThis.sqlite3ApiBootstrap.initializers = []; + + +globalThis.sqlite3ApiBootstrap.initializersAsync = []; + + +globalThis.sqlite3ApiBootstrap.defaultConfig = Object.create(null); + + +globalThis.sqlite3ApiBootstrap.sqlite3 = undefined; +globalThis.sqlite3ApiBootstrap.initializers.push(function(sqlite3){ + sqlite3.version = {"libVersion": "3.54.0", "libVersionNumber": 3054000, "sourceId": "2026-10-09 15:46:58 be8d059e9a49089ab2dce5ed26dd87aaf598fdc5fbe0b107c0dd758464bcd5e3","downloadVersion": 3540000,"scm":{ "sha3-256": "be8d059e9a49089ab2dce5ed26dd87aaf598fdc5fbe0b107c0dd758464bcd5e3","branch": "trunk","tags": "release major-release version-3.54.0","datetime": "2026-10-09T15:46:58.543Z"}}; +}); + + +'use strict'; +globalThis.WhWasmUtilInstaller = +function WhWasmUtilInstaller(target){ + 'use strict'; + if(undefined===target.bigIntEnabled){ + target.bigIntEnabled = !!globalThis['BigInt64Array']; + } + + + const toss = (...args)=>{throw new Error(args.join(' '))}; + + if( !target.pointerSize && !target.pointerIR + && target.alloc && target.dealloc ){ + + const ptr = target.alloc(1); + target.pointerSize = ('bigint'===typeof ptr ? 8 : 4); + target.dealloc(ptr); + } + + + if( target.pointerSize && !target.pointerIR ){ + target.pointerIR = (4===target.pointerSize ? 'i32' : 'i64'); + } + const __ptrIR = (target.pointerIR ??= 'i32'); + const __ptrSize = (target.pointerSize ??= + ('i32'===__ptrIR ? 4 : ('i64'===__ptrIR ? 8 : 0))); + delete target.pointerSize; + delete target.pointerIR; + + if( 'i32'!==__ptrIR && 'i64'!==__ptrIR ){ + toss("Invalid pointerIR:",__ptrIR); + }else if( 8!==__ptrSize && 4!==__ptrSize ){ + toss("Invalid pointerSize:",__ptrSize); + } + + + const __BigInt = target.bigIntEnabled + ? (v)=>BigInt(v || 0) + : (v)=>toss("BigInt support is disabled in this build."); + + const __Number = (v)=>Number(v||0); + + + const __asPtrType = (4===__ptrSize) ? __Number : __BigInt; + + + const __NullPtr = __asPtrType(0); + + + const __ptrAdd = function(...args){ + let rc = __asPtrType(0); + for(const v of args) rc += __asPtrType(v); + return rc; + }; + + + { + const __ptr = Object.create(null); + Object.defineProperty(target, 'ptr', { + enumerable: true, + get: ()=>__ptr, + set: ()=>toss("The ptr property is read-only.") + }); + (function f(name, val){ + Object.defineProperty(__ptr, name, { + enumerable: true, + get: ()=>val, + set: ()=>toss("ptr["+name+"] is read-only.") + }); + return f; + })( + 'null', __NullPtr + )( + 'size', __ptrSize + )( + 'ir', __ptrIR + )( + 'coerce', __asPtrType + )( + 'add', __ptrAdd + )( + 'addn', (4===__ptrIR) ? __ptrAdd : (...args)=>Number(__ptrAdd(...args)) + ); + } + + if(!target.exports){ + Object.defineProperty(target, 'exports', { + enumerable: true, configurable: true, + get: ()=>(target.instance?.exports) + }); + } + + + const cache = Object.create(null); + + cache.heapSize = 0; + + cache.memory = null; + + cache.freeFuncIndexes = []; + + cache.scopedAlloc = []; + + + cache.scopedAlloc.pushPtr = (ptr)=>{ + cache.scopedAlloc[cache.scopedAlloc.length-1].push(ptr); + return ptr; + }; + + cache.utf8Decoder = new TextDecoder(); + cache.utf8Encoder = new TextEncoder('utf-8'); + + + target.sizeofIR = (n)=>{ + switch(n){ + case 'i8': return 1; + case 'i16': return 2; + case 'i32': case 'f32': case 'float': return 4; + case 'i64': case 'f64': case 'double': return 8; + case '*': return __ptrSize; + default: + return (''+n).endsWith('*') ? __ptrSize : undefined; + } + }; + + + const heapWrappers = function(){ + if(!cache.memory){ + cache.memory = (target.memory instanceof WebAssembly.Memory) + ? target.memory : target.exports.memory; + }else if(cache.heapSize === cache.memory.buffer.byteLength){ + return cache; + } + + const b = cache.memory.buffer; + cache.HEAP8 = new Int8Array(b); cache.HEAP8U = new Uint8Array(b); + cache.HEAP16 = new Int16Array(b); cache.HEAP16U = new Uint16Array(b); + cache.HEAP32 = new Int32Array(b); cache.HEAP32U = new Uint32Array(b); + cache.HEAP32F = new Float32Array(b); cache.HEAP64F = new Float64Array(b); + if(target.bigIntEnabled){ + if( 'undefined'!==typeof BigInt64Array ){ + cache.HEAP64 = new BigInt64Array(b); cache.HEAP64U = new BigUint64Array(b); + }else{ + toss("BigInt support is enabled, but the BigInt64Array type is missing."); + } + } + cache.heapSize = b.byteLength; + return cache; + }; + + + target.heap8 = ()=>heapWrappers().HEAP8; + + + target.heap8u = ()=>heapWrappers().HEAP8U; + + + target.heap16 = ()=>heapWrappers().HEAP16; + + + target.heap16u = ()=>heapWrappers().HEAP16U; + + + target.heap32 = ()=>heapWrappers().HEAP32; + + + target.heap32u = ()=>heapWrappers().HEAP32U; + + + target.heapForSize = function(n,unsigned = true){ + let ctor; + const c = (cache.memory && cache.heapSize === cache.memory.buffer.byteLength) + ? cache : heapWrappers(); + switch(n){ + case Int8Array: return c.HEAP8; case Uint8Array: return c.HEAP8U; + case Int16Array: return c.HEAP16; case Uint16Array: return c.HEAP16U; + case Int32Array: return c.HEAP32; case Uint32Array: return c.HEAP32U; + case 8: return unsigned ? c.HEAP8U : c.HEAP8; + case 16: return unsigned ? c.HEAP16U : c.HEAP16; + case 32: return unsigned ? c.HEAP32U : c.HEAP32; + case 64: + if(c.HEAP64) return unsigned ? c.HEAP64U : c.HEAP64; + break; + default: + if(target.bigIntEnabled){ + if(n===globalThis['BigUint64Array']) return c.HEAP64U; + else if(n===globalThis['BigInt64Array']) return c.HEAP64; + break; + } + } + toss("Invalid heapForSize() size: expecting 8, 16, 32,", + "or (if BigInt is enabled) 64."); + }; + + const __funcTable = target.functionTable; + delete target.functionTable; + + + target.functionTable = __funcTable + ? ()=>__funcTable + : ()=>target.exports.__indirect_function_table + ; + + + target.functionEntry = function(fptr){ + const ft = target.functionTable(); + + + + return fptr < ft.length ? ft.get(__asPtrType(fptr)) : undefined; + }; + + + target.jsFuncToWasm = function f(func, sig){ + + if(!f._){ + f._ = { + + sigTypes: Object.assign(Object.create(null),{ + i: 'i32', p: __ptrIR, P: __ptrIR, s: __ptrIR, + j: 'i64', f: 'f32', d: 'f64' + }), + + + typeCodes: Object.assign(Object.create(null),{ + f64: 0x7c, f32: 0x7d, i64: 0x7e, i32: 0x7f + }), + + + uleb128Encode: (tgt, method, n)=>{ + if(n<128) tgt[method](n); + else tgt[method]( (n % 128) | 128, n>>7); + }, + + + rxJSig: /^(\w)\((\w*)\)$/, + + + sigParams: (sig)=>{ + const m = f._.rxJSig.exec(sig); + return m ? m[2] : sig.substr(1); + }, + + + letterType: (x)=>f._.sigTypes[x] || toss("Invalid signature letter:",x), + + + pushSigType: (dest, letter)=>dest.push(f._.typeCodes[f._.letterType(letter)]) + + + + }; + } + if('string'===typeof func){ + const x = sig; + sig = func; + func = x; + } + const _ = f._; + const sigParams = _.sigParams(sig); + const wasmCode = [0x01, 0x60]; + _.uleb128Encode(wasmCode, 'push', sigParams.length); + for(const x of sigParams) _.pushSigType(wasmCode, x); + if('v'===sig[0]) wasmCode.push(0); + else{ + wasmCode.push(1); + _.pushSigType(wasmCode, sig[0]); + } + _.uleb128Encode(wasmCode, 'unshift', wasmCode.length); + wasmCode.unshift( + 0x00, 0x61, 0x73, 0x6d, + 0x01, 0x00, 0x00, 0x00, + 0x01 + ); + wasmCode.push( + 0x02, 0x07, + + 0x01, 0x01, 0x65, 0x01, 0x66, 0x00, 0x00, + 0x07, 0x05, + + 0x01, 0x01, 0x66, 0x00, 0x00 + ); + return (new WebAssembly.Instance( + new WebAssembly.Module(new Uint8Array(wasmCode)), { + e: { f: func } + })).exports['f']; + }; + + + const __installFunction = function f(func, sig, scoped){ + if(scoped && !cache.scopedAlloc.length){ + toss("No scopedAllocPush() scope is active."); + } + if('string'===typeof func){ + const x = sig; + sig = func; + func = x; + } + if('string'!==typeof sig || !(func instanceof Function)){ + toss("Invalid arguments: expecting (function,signature) "+ + "or (signature,function)."); + } + const ft = target.functionTable(); + const oldLen = __asPtrType(ft.length); + let ptr; + while( (ptr = cache.freeFuncIndexes.pop()) ){ + if(ft.get(ptr)){ + + ptr = null; + continue; + }else{ + + break; + } + } + if(!ptr){ + ptr = __asPtrType(oldLen); + ft.grow(__asPtrType(1)); + } + try{ + + ft.set(ptr, func); + if(scoped){ + cache.scopedAlloc.pushPtr(ptr); + } + return ptr; + }catch(e){ + if(!(e instanceof TypeError)){ + if(ptr===oldLen) cache.freeFuncIndexes.push(oldLen); + throw e; + } + } + + try { + const fptr = target.jsFuncToWasm(func, sig); + ft.set(ptr, fptr); + if(scoped){ + cache.scopedAlloc.pushPtr(ptr); + } + }catch(e){ + if(ptr===oldLen) cache.freeFuncIndexes.push(oldLen); + throw e; + } + return ptr; + }; + + + target.installFunction = (func, sig)=>__installFunction(func, sig, false); + + + target.scopedInstallFunction = (func, sig)=>__installFunction(func, sig, true); + + + target.uninstallFunction = function(ptr){ + if(!ptr && __NullPtr!==ptr) return undefined; + + const ft = target.functionTable(); + cache.freeFuncIndexes.push(ptr); + const rc = ft.get(ptr); + ft.set(ptr, null); + return rc; + }; + + + target.peek = function f(ptr, type='i8'){ + if(type.endsWith('*')) type = __ptrIR; + const c = (cache.memory && cache.heapSize === cache.memory.buffer.byteLength) + ? cache : heapWrappers(); + const list = Array.isArray(ptr) ? [] : undefined; + let rc; + do{ + if(list) ptr = arguments[0].shift(); + switch(type){ + case 'i1': + case 'i8': rc = c.HEAP8[Number(ptr)>>0]; break; + case 'i16': rc = c.HEAP16[Number(ptr)>>1]; break; + case 'i32': rc = c.HEAP32[Number(ptr)>>2]; break; + case 'float': case 'f32': rc = c.HEAP32F[Number(ptr)>>2]; break; + case 'double': case 'f64': rc = Number(c.HEAP64F[Number(ptr)>>3]); break; + case 'i64': + if(c.HEAP64){ + rc = __BigInt(c.HEAP64[Number(ptr)>>3]); + break; + } + + default: + toss('Invalid type for peek():',type); + } + if(list) list.push(rc); + }while(list && arguments[0].length); + return list || rc; + }; + + + target.poke = function(ptr, value, type='i8'){ + if (type.endsWith('*')) type = __ptrIR; + const c = (cache.memory && cache.heapSize === cache.memory.buffer.byteLength) + ? cache : heapWrappers(); + for(const p of (Array.isArray(ptr) ? ptr : [ptr])){ + switch (type) { + case 'i1': + case 'i8': c.HEAP8[Number(p)>>0] = value; continue; + case 'i16': c.HEAP16[Number(p)>>1] = value; continue; + case 'i32': c.HEAP32[Number(p)>>2] = value; continue; + case 'float': case 'f32': c.HEAP32F[Number(p)>>2] = value; continue; + case 'double': case 'f64': c.HEAP64F[Number(p)>>3] = value; continue; + case 'i64': + if(c.HEAP64){ + c.HEAP64[Number(p)>>3] = __BigInt(value); + continue; + } + + default: + toss('Invalid type for poke(): ' + type); + } + } + return this; + }; + + + target.peekPtr = (...ptr)=>target.peek( (1===ptr.length ? ptr[0] : ptr), __ptrIR ); + + + target.pokePtr = (ptr, value=0)=>target.poke(ptr, value, __ptrIR); + + + target.peek8 = (...ptr)=>target.peek( (1===ptr.length ? ptr[0] : ptr), 'i8' ); + + target.poke8 = (ptr, value)=>target.poke(ptr, value, 'i8'); + + target.peek16 = (...ptr)=>target.peek( (1===ptr.length ? ptr[0] : ptr), 'i16' ); + + target.poke16 = (ptr, value)=>target.poke(ptr, value, 'i16'); + + target.peek32 = (...ptr)=>target.peek( (1===ptr.length ? ptr[0] : ptr), 'i32' ); + + target.poke32 = (ptr, value)=>target.poke(ptr, value, 'i32'); + + target.peek64 = (...ptr)=>target.peek( (1===ptr.length ? ptr[0] : ptr), 'i64' ); + + target.poke64 = (ptr, value)=>target.poke(ptr, value, 'i64'); + + target.peek32f = (...ptr)=>target.peek( (1===ptr.length ? ptr[0] : ptr), 'f32' ); + + target.poke32f = (ptr, value)=>target.poke(ptr, value, 'f32'); + + target.peek64f = (...ptr)=>target.peek( (1===ptr.length ? ptr[0] : ptr), 'f64' ); + + target.poke64f = (ptr, value)=>target.poke(ptr, value, 'f64'); + + + target.getMemValue = target.peek; + + target.getPtrValue = target.peekPtr; + + target.setMemValue = target.poke; + + target.setPtrValue = target.pokePtr; + + + target.isPtr32 = (ptr)=>( + 'number'===typeof ptr && ptr>=0 && ptr===(ptr|0) + ); + + + target.isPtr64 = (ptr)=>( + ('bigint'===typeof ptr) ? ptr >= 0 : target.isPtr32(ptr) + ); + + + target.isPtr = (4===__ptrSize) ? target.isPtr32 : target.isPtr64; + + + target.cstrlen = function(ptr){ + if(!ptr || !target.isPtr(ptr)) return null; + ptr = Number(ptr) ; + const h = heapWrappers().HEAP8U; + let pos = ptr; + for( ; h[pos] !== 0; ++pos ){} + return pos - ptr; + }; + + + const __SAB = ('undefined'===typeof SharedArrayBuffer) + ? function(){} : SharedArrayBuffer; + + const isSharedTypedArray = (aTypedArray)=>(aTypedArray.buffer instanceof __SAB); + + target.isSharedTypedArray = isSharedTypedArray; + + + const typedArrayPart = (aTypedArray, begin, end)=>{ + if( 8===__ptrSize ){ + + if( 'bigint'===typeof begin ) begin = Number(begin); + if( 'bigint'===typeof end ) end = Number(end); + } + return isSharedTypedArray(aTypedArray) + ? aTypedArray.slice(begin, end) + : aTypedArray.subarray(begin, end); + }; + + target.typedArrayPart = typedArrayPart; + + + const typedArrayToString = (typedArray, begin, end)=> + cache.utf8Decoder.decode( + typedArrayPart(typedArray, begin, end) + ); + + target.typedArrayToString = typedArrayToString; + + + target.cstrToJs = function(ptr){ + const n = target.cstrlen(ptr); + return n + ? typedArrayToString(heapWrappers().HEAP8U, Number(ptr), Number(ptr)+n) + : (null===n ? n : ""); + }; + + + target.jstrlen = function(str){ + + if('string'!==typeof str) return null; + const n = str.length; + let len = 0; + for(let i = 0; i < n; ++i){ + let u = str.charCodeAt(i); + if(u>=0xd800 && u<=0xdfff){ + u = 0x10000 + ((u & 0x3FF) << 10) | (str.charCodeAt(++i) & 0x3FF); + } + if(u<=0x7f) ++len; + else if(u<=0x7ff) len += 2; + else if(u<=0xffff) len += 3; + else len += 4; + } + return len; + }; + + + target.jstrcpy = function(jstr, tgt, offset = 0, maxBytes = -1, addNul = true){ + + if(!tgt || (!(tgt instanceof Int8Array) && !(tgt instanceof Uint8Array))){ + toss("jstrcpy() target must be an Int8Array or Uint8Array."); + } + maxBytes = Number(maxBytes); + offset = Number(offset); + if(maxBytes<0) maxBytes = tgt.length - offset; + if(!(maxBytes>0) || !(offset>=0)) return 0; + let i = 0, max = jstr.length; + const begin = offset, end = offset + maxBytes - (addNul ? 1 : 0); + for(; i < max && offset < end; ++i){ + let u = jstr.charCodeAt(i); + if(u>=0xd800 && u<=0xdfff){ + u = 0x10000 + ((u & 0x3FF) << 10) | (jstr.charCodeAt(++i) & 0x3FF); + } + if(u<=0x7f){ + if(offset >= end) break; + tgt[offset++] = u; + }else if(u<=0x7ff){ + if(offset + 1 >= end) break; + tgt[offset++] = 0xC0 | (u >> 6); + tgt[offset++] = 0x80 | (u & 0x3f); + }else if(u<=0xffff){ + if(offset + 2 >= end) break; + tgt[offset++] = 0xe0 | (u >> 12); + tgt[offset++] = 0x80 | ((u >> 6) & 0x3f); + tgt[offset++] = 0x80 | (u & 0x3f); + }else{ + if(offset + 3 >= end) break; + tgt[offset++] = 0xf0 | (u >> 18); + tgt[offset++] = 0x80 | ((u >> 12) & 0x3f); + tgt[offset++] = 0x80 | ((u >> 6) & 0x3f); + tgt[offset++] = 0x80 | (u & 0x3f); + } + } + if(addNul) tgt[offset++] = 0; + return offset - begin; + }; + + + target.cstrncpy = function(tgtPtr, srcPtr, n){ + if(!tgtPtr || !srcPtr) toss("cstrncpy() does not accept NULL strings."); + if(n<0) n = target.cstrlen(strPtr)+1; + else if(!(n>0)) return 0; + const heap = target.heap8u(); + let i = 0, ch; + const tgtNumber = Number(tgtPtr), srcNumber = Number(srcPtr); + for(; i < n && (ch = heap[srcNumber+i]); ++i){ + heap[tgtNumber+i] = ch; + } + if(i{ + return cache.utf8Encoder.encode(addNul ? (str+"\0") : str); + }; + + const __affirmAlloc = (obj,funcName)=>{ + if(!(obj.alloc instanceof Function) || + !(obj.dealloc instanceof Function)){ + toss("Object is missing alloc() and/or dealloc() function(s)", + "required by",funcName+"()."); + } + }; + + const __allocCStr = function(jstr, returnWithLength, allocator, funcName){ + __affirmAlloc(target, funcName); + if('string'!==typeof jstr) return null; + const u = cache.utf8Encoder.encode(jstr), + ptr = allocator(u.length+1); + let toFree = ptr; + try{ + const heap = heapWrappers().HEAP8U; + heap.set(u, Number(ptr)); + heap[__ptrAdd(ptr, u.length)] = 0; + toFree = __NullPtr; + return returnWithLength ? [ptr, u.length] : ptr; + }finally{ + if( toFree ) target.dealloc(toFree); + } + }; + + + target.allocCString = + (jstr, returnWithLength=false)=>__allocCStr(jstr, returnWithLength, + target.alloc, 'allocCString()'); + + + target.scopedAllocPush = function(){ + __affirmAlloc(target, 'scopedAllocPush'); + const a = []; + cache.scopedAlloc.push(a); + return a; + }; + + + target.scopedAllocPop = function(state){ + __affirmAlloc(target, 'scopedAllocPop'); + const n = arguments.length + ? cache.scopedAlloc.indexOf(state) + : cache.scopedAlloc.length-1; + if(n<0) toss("Invalid state object for scopedAllocPop()."); + if(0===arguments.length) state = cache.scopedAlloc[n]; + cache.scopedAlloc.splice(n,1); + for(let p; (p = state.pop()); ){ + if(target.functionEntry(p)){ + + target.uninstallFunction(p); + }else{ + target.dealloc(p); + } + } + }; + + + target.scopedAlloc = function(n){ + if(!cache.scopedAlloc.length){ + toss("No scopedAllocPush() scope is active."); + } + const p = __asPtrType(target.alloc(n)); + return cache.scopedAlloc.pushPtr(p); + }; + + Object.defineProperty(target.scopedAlloc, 'level', { + configurable: false, enumerable: false, + get: ()=>cache.scopedAlloc.length, + set: ()=>toss("The 'active' property is read-only.") + }); + + + target.scopedAllocCString = + (jstr, returnWithLength=false)=>__allocCStr(jstr, returnWithLength, + target.scopedAlloc, + 'scopedAllocCString()'); + + + const __allocMainArgv = function(isScoped, list){ + const pList = target[ + isScoped ? 'scopedAlloc' : 'alloc' + ]((list.length + 1) * target.ptr.size); + let i = 0; + list.forEach((e)=>{ + target.pokePtr(__ptrAdd(pList, target.ptr.size * i++), + target[ + isScoped ? 'scopedAllocCString' : 'allocCString' + ](""+e)); + }); + target.pokePtr(__ptrAdd(pList, target.ptr.size * i), 0); + return pList; + }; + + + target.scopedAllocMainArgv = (list)=>__allocMainArgv(true, list); + + + target.allocMainArgv = (list)=>__allocMainArgv(false, list); + + + target.cArgvToJs = (argc, pArgv)=>{ + const list = []; + for(let i = 0; i < argc; ++i){ + const arg = target.peekPtr(__ptrAdd(pArgv, target.ptr.size * i)); + list.push( arg ? target.cstrToJs(arg) : null ); + } + return list; + }; + + + target.scopedAllocCall = function(func){ + target.scopedAllocPush(); + try{ return func() } finally{ target.scopedAllocPop() } + }; + + + const __allocPtr = function(howMany, safePtrSize, method){ + __affirmAlloc(target, method); + const pIr = safePtrSize ? 'i64' : __ptrIR; + let m = target[method](howMany * (safePtrSize ? 8 : __ptrSize)); + target.poke(m, 0, pIr) + if(1===howMany){ + return m; + } + const a = [m]; + for(let i = 1; i < howMany; ++i){ + m = __ptrAdd(m, (safePtrSize ? 8 : __ptrSize)); + a[i] = m; + target.poke(m, 0, pIr); + } + return a; + }; + + + target.allocPtr = + (howMany=1, safePtrSize=true)=>__allocPtr(howMany, safePtrSize, 'alloc'); + + + target.scopedAllocPtr = + (howMany=1, safePtrSize=true)=>__allocPtr(howMany, safePtrSize, 'scopedAlloc'); + + + target.xGet = function(name){ + return target.exports[name] || toss("Cannot find exported symbol:",name); + }; + + const __argcMismatch = + (f,n)=>toss(f+"() requires",n,"argument(s)."); + + + target.xCall = function(fname, ...args){ + const f = (fname instanceof Function) ? fname : target.xGet(fname); + if(!(f instanceof Function)) toss("Exported symbol",fname,"is not a function."); + if(f.length!==args.length){ + __argcMismatch(((f===fname) ? f.name : fname),f.length) + ; + } + return (2===arguments.length && Array.isArray(arguments[1])) + ? f.apply(null, arguments[1]) + : f.apply(null, args); + }; + + + cache.xWrap = Object.create(null); + cache.xWrap.convert = Object.create(null); + + cache.xWrap.convert.arg = new Map; + + cache.xWrap.convert.result = new Map; + + const xArg = cache.xWrap.convert.arg, xResult = cache.xWrap.convert.result; + + const __xArgPtr = __asPtrType; + xArg + .set('i64', __BigInt) + .set('i32', (i)=>i|0) + .set('i16', (i)=>((i | 0) & 0xFFFF)) + .set('i8', (i)=>((i | 0) & 0xFF)) + .set('f32', (i)=>Number(i).valueOf()) + .set('float', xArg.get('f32')) + .set('f64', xArg.get('f32')) + .set('double', xArg.get('f64')) + .set('int', xArg.get('i32')) + .set('null', (i)=>i) + .set(null, xArg.get('null')) + .set('**', __xArgPtr) + .set('*', __xArgPtr) + ; + xResult.set('*', __xArgPtr) + .set('pointer', __xArgPtr) + .set('number', (v)=>Number(v)) + .set('void', (v)=>undefined) + .set(undefined, xResult.get('void')) + .set('null', (v)=>v) + .set(null, xResult.get('null')) + ; + + + for(const t of [ + 'i8', 'i16', 'i32', 'i64', 'int', + 'f32', 'float', 'f64', 'double' + ]){ + xArg.set(t+'*', __xArgPtr); + xResult.set(t+'*', __xArgPtr); + xResult.set( + t, xArg.get(t) + || toss("Maintenance required: missing arg converter for",t) + ); + } + + + const __xArgString = (v)=>{ + return ('string'===typeof v) + ? target.scopedAllocCString(v) + : __asPtrType(v); + }; + + xArg.set('string', __xArgString) + .set('utf8', __xArgString) + + ; + + xResult + .set('string', (i)=>target.cstrToJs(i)) + .set('utf8', xResult.get('string')) + .set('string:dealloc', (i)=>{ + try { return i ? target.cstrToJs(i) : null } + finally{ target.dealloc(i) } + }) + .set('utf8:dealloc', xResult.get('string:dealloc')) + .set('json', (i)=>JSON.parse(target.cstrToJs(i))) + .set('json:dealloc', (i)=>{ + try{ return i ? JSON.parse(target.cstrToJs(i)) : null } + finally{ target.dealloc(i) } + }); + + + const AbstractArgAdapter = class { + constructor(opt){ + this.name = opt.name || 'unnamed adapter'; + } + + convertArg(v,argv,argIndex){ + toss("AbstractArgAdapter must be subclassed."); + } + }; + + + xArg.FuncPtrAdapter = class FuncPtrAdapter extends AbstractArgAdapter { + constructor(opt) { + super(opt); + if(xArg.FuncPtrAdapter.warnOnUse){ + console.warn('xArg.FuncPtrAdapter is an internal-only API', + 'and is not intended to be invoked from', + 'client-level code. Invoked with:',opt); + } + this.name = opt.name || "unnamed"; + this.signature = opt.signature; + if(opt.contextKey instanceof Function){ + this.contextKey = opt.contextKey; + if(!opt.bindScope) opt.bindScope = 'context'; + } + this.bindScope = opt.bindScope + || toss("FuncPtrAdapter options requires a bindScope (explicit or implied)."); + if(FuncPtrAdapter.bindScopes.indexOf(opt.bindScope)<0){ + toss("Invalid options.bindScope ("+opt.bindMod+") for FuncPtrAdapter. "+ + "Expecting one of: ("+FuncPtrAdapter.bindScopes.join(', ')+')'); + } + this.isTransient = 'transient'===this.bindScope; + this.isContext = 'context'===this.bindScope; + this.isPermanent = 'permanent'===this.bindScope; + this.singleton = ('singleton'===this.bindScope) ? [] : undefined; + + this.callProxy = (opt.callProxy instanceof Function) + ? opt.callProxy : undefined; + } + + + + + contextKey(argv,argIndex){ + return this; + } + + + contextMap(key){ + const cm = (this.__cmap || (this.__cmap = new Map)); + let rc = cm.get(key); + if(undefined===rc) cm.set(key, (rc = [])); + return rc; + } + + + convertArg(v,argv,argIndex){ + let pair = this.singleton; + if(!pair && this.isContext){ + pair = this.contextMap(this.contextKey(argv,argIndex)); + + } + if( 0 ){ + FuncPtrAdapter.debugOut("FuncPtrAdapter.convertArg()",this.name, + 'signature =',this.signature, + 'transient ?=',this.transient, + 'pair =',pair, + 'v =',v); + } + if(pair && 2===pair.length && pair[0]===v){ + + return pair[1]; + } + if(v instanceof Function){ + + + if(this.callProxy){ + v = this.callProxy(v); + } + const fp = __installFunction(v, this.signature, this.isTransient); + if(FuncPtrAdapter.debugFuncInstall){ + FuncPtrAdapter.debugOut("FuncPtrAdapter installed", this, + this.contextKey(argv,argIndex), '@'+fp, v); + } + if(pair){ + + if(pair[1]){ + if(FuncPtrAdapter.debugFuncInstall){ + FuncPtrAdapter.debugOut("FuncPtrAdapter uninstalling", this, + this.contextKey(argv,argIndex), '@'+pair[1], v); + } + try{ + + cache.scopedAlloc.pushPtr(pair[1]); + } + catch(e){} + } + pair[0] = arguments[0] || __NullPtr; + pair[1] = fp; + } + return fp; + }else if(target.isPtr(v) || null===v || undefined===v){ + if(pair && pair[1] && pair[1]!==v){ + + if(FuncPtrAdapter.debugFuncInstall){ + FuncPtrAdapter.debugOut("FuncPtrAdapter uninstalling", this, + this.contextKey(argv,argIndex), '@'+pair[1], v); + } + try{cache.scopedAlloc.pushPtr(pair[1]);} + catch(e){} + pair[0] = pair[1] = (v || __NullPtr); + } + return v || __NullPtr; + }else{ + throw new TypeError("Invalid FuncPtrAdapter argument type. "+ + "Expecting a function pointer or a "+ + (this.name ? this.name+' ' : '')+ + "function matching signature "+ + this.signature+"."); + } + } + }; + + + xArg.FuncPtrAdapter.warnOnUse = false; + + + xArg.FuncPtrAdapter.debugFuncInstall = false; + + + xArg.FuncPtrAdapter.debugOut = console.debug.bind(console); + + + xArg.FuncPtrAdapter.bindScopes = [ + 'transient', 'context', 'singleton', 'permanent' + ]; + + + const __xArgAdapterCheck = + (t)=>xArg.get(t) || toss("Argument adapter not found:",t); + + + const __xResultAdapterCheck = + (t)=>xResult.get(t) || toss("Result adapter not found:",t); + + + cache.xWrap.convertArg = (t,...args)=>__xArgAdapterCheck(t)(...args); + + cache.xWrap.convertArgNoCheck = (t,...args)=>xArg.get(t)(...args); + + + cache.xWrap.convertResult = + (t,v)=>(null===t ? v : (t ? __xResultAdapterCheck(t)(v) : undefined)); + + cache.xWrap.convertResultNoCheck = + (t,v)=>(null===t ? v : (t ? xResult.get(t)(v) : undefined)); + + + target.xWrap = function callee(fArg, resultType, ...argTypes){ + if(3===arguments.length && Array.isArray(arguments[2])){ + argTypes = arguments[2]; + } + if(target.isPtr(fArg)){ + fArg = target.functionEntry(fArg) + || toss("Function pointer not found in WASM function table."); + } + const fIsFunc = (fArg instanceof Function); + const xf = fIsFunc ? fArg : target.xGet(fArg); + if(fIsFunc) fArg = xf.name || 'unnamed function'; + if(argTypes.length!==xf.length) __argcMismatch(fArg, xf.length); + if( 0===xf.length + && (null===resultType || 'null'===resultType) ){ + + return xf; + } + ; + __xResultAdapterCheck(resultType); + for(const t of argTypes){ + if(t instanceof AbstractArgAdapter) xArg.set(t, (...args)=>t.convertArg(...args)); + else __xArgAdapterCheck(t); + } + const cxw = cache.xWrap; + if(0===xf.length){ + + return (...args)=>(args.length + ? __argcMismatch(fArg, xf.length) + : cxw.convertResult(resultType, xf.call(null))); + } + return function(...args){ + if(args.length!==xf.length) __argcMismatch(fArg, xf.length); + const scope = target.scopedAllocPush(); + try{ + + let i = 0; + if( callee.debug ){ + console.debug("xWrap() preparing: resultType ",resultType, 'xf',xf,"argTypes",argTypes,"args",args); + } + for(; i < args.length; ++i) args[i] = cxw.convertArgNoCheck( + argTypes[i], args[i], args, i + ); + if( callee.debug ){ + console.debug("xWrap() calling: resultType ",resultType, 'xf',xf,"argTypes",argTypes,"args",args); + } + return cxw.convertResultNoCheck(resultType, xf.apply(null,args)); + }finally{ + target.scopedAllocPop(scope); + } + }; + }; + + + const __xAdapter = function(func, argc, typeName, adapter, modeName, xcvPart){ + if('string'===typeof typeName){ + if(1===argc) return xcvPart.get(typeName); + else if(2===argc){ + if(!adapter){ + xcvPart.delete(typeName); + return func; + }else if(!(adapter instanceof Function)){ + toss(modeName,"requires a function argument."); + } + xcvPart.set(typeName, adapter); + return func; + } + } + toss("Invalid arguments to",modeName); + }; + + + target.xWrap.resultAdapter = function f(typeName, adapter){ + return __xAdapter(f, arguments.length, typeName, adapter, + 'resultAdapter()', xResult); + }; + + + target.xWrap.argAdapter = function f(typeName, adapter){ + return __xAdapter(f, arguments.length, typeName, adapter, + 'argAdapter()', xArg); + }; + + target.xWrap.FuncPtrAdapter = xArg.FuncPtrAdapter; + + + target.xCallWrapped = function(fArg, resultType, argTypes, ...args){ + if(Array.isArray(arguments[3])) args = arguments[3]; + return target.xWrap(fArg, resultType, argTypes||[]).apply(null, args||[]); + }; + + + target.xWrap.testConvertArg = cache.xWrap.convertArg; + + + target.xWrap.testConvertResult = cache.xWrap.convertResult; + + return target; +}; + + +globalThis.WhWasmUtilInstaller +.yawl = function yawl(config){ + 'use strict'; + const wfetch = ()=>fetch(config.uri, {credentials: 'same-origin'}); + const wui = this; + const finalThen = function(arg){ + + if(config.wasmUtilTarget){ + const toss = (...args)=>{throw new Error(args.join(' '))}; + const tgt = config.wasmUtilTarget; + tgt.module = arg.module; + tgt.instance = arg.instance; + + if(!tgt.instance.exports.memory){ + + tgt.memory = config?.imports?.env?.memory + || toss("Missing 'memory' object!"); + } + if(!tgt.alloc && arg.instance.exports.malloc){ + const exports = arg.instance.exports; + tgt.alloc = function(n){ + return exports.malloc(n) || toss("Allocation of",n,"bytes failed."); + }; + tgt.dealloc = function(m){m && exports.free(m)}; + } + wui(tgt); + } + arg.config = config; + if(config.onload) config.onload(arg); + return arg ; + }; + const loadWasm = WebAssembly.instantiateStreaming + ? ()=>WebAssembly + .instantiateStreaming(wfetch(), config.imports||{}) + .then(finalThen) + : ()=> wfetch() + .then(response => response.arrayBuffer()) + .then(bytes => WebAssembly.instantiate(bytes, config.imports||{})) + .then(finalThen) + ; + return loadWasm; +}.bind( +globalThis.WhWasmUtilInstaller +); + +'use strict'; +globalThis.Jaccwabyt = +function StructBinderFactory(config){ + 'use strict'; + + + + const toss = (...args)=>{throw new Error(args.join(' '))}; + + { + let h = config.heap; + if( h instanceof WebAssembly.Memory ){ + h = function(){return new Uint8Array(this.buffer)}.bind(h); + }else if( !(h instanceof Function) ){ + + toss("config.heap must be WebAssembly.Memory instance or", + "a function which returns one."); + } + config.heap = h; + } + ['alloc','dealloc'].forEach(function(k){ + (config[k] instanceof Function) || + toss("Config option '"+k+"' must be a function."); + }); + const SBF = StructBinderFactory; + const heap = config.heap, + alloc = config.alloc, + dealloc = config.dealloc, + realloc = (config.realloc || function(){ + toss("This StructBinderFactory was configured without realloc()"); + + }), + log = config.log || console.debug.bind(console), + memberPrefix = (config.memberPrefix || ""), + memberSuffix = (config.memberSuffix || ""), + BigInt = globalThis['BigInt'], + BigInt64Array = globalThis['BigInt64Array'], + bigIntEnabled = config.bigIntEnabled ?? !!BigInt64Array; + + + let ptr; + const ptrSize = config.pointerSize + || config.ptrSize + || ('bigint'===typeof (ptr = alloc(1)) ? 8 : 4); + const ptrIR = config.pointerIR + || config.ptrIR + || (4===ptrSize ? 'i32' : 'i64'); + if( ptr ){ + dealloc(ptr); + ptr = undefined; + } + + + if(ptrSize!==4 && ptrSize!==8) toss("Invalid pointer size:",ptrSize); + if(ptrIR!=='i32' && ptrIR!=='i64') toss("Invalid pointer representation:",ptrIR); + + + const __BigInt = (bigIntEnabled && BigInt) + ? (v)=>BigInt(v || 0) + : (v)=>toss("BigInt support is disabled in this build."); + const __asPtrType = ('i32'==ptrIR) ? Number : __BigInt; + const __NullPtr = __asPtrType(0); + + + const __ptrAdd = function(...args){ + let rc = __NullPtr; + for( let i = 0; i < args.length; ++i ){ + rc += __asPtrType(args[i]); + } + return rc; + }; + + const __ptrAddSelf = function(...args){ + return __ptrAdd(this.pointer,...args); + }; + + if(!SBF.debugFlags){ + SBF.__makeDebugFlags = function(deriveFrom=null){ + + if(deriveFrom && deriveFrom.__flags) deriveFrom = deriveFrom.__flags; + const f = function f(flags){ + if(0===arguments.length){ + return f.__flags; + } + if(flags<0){ + delete f.__flags.getter; delete f.__flags.setter; + delete f.__flags.alloc; delete f.__flags.dealloc; + }else{ + f.__flags.getter = 0!==(0x01 & flags); + f.__flags.setter = 0!==(0x02 & flags); + f.__flags.alloc = 0!==(0x04 & flags); + f.__flags.dealloc = 0!==(0x08 & flags); + } + return f._flags; + }; + Object.defineProperty(f,'__flags', { + iterable: false, writable: false, + value: Object.create(deriveFrom) + }); + if(!deriveFrom) f(0); + return f; + }; + SBF.debugFlags = SBF.__makeDebugFlags(); + } + + const isLittleEndian = true || (function() { + const buffer = new ArrayBuffer(2); + new DataView(buffer).setInt16(0, 256, true ); + + return new Int16Array(buffer)[0] === 256; + })() ; + + + + + const isFuncSig = (s)=>'('===s[1]; + + const isPtrSig = (s)=>'p'===s || 'P'===s || 's'===s; + const isAutoPtrSig = (s)=>'P'===s ; + + const sigLetter = (s)=>s ? (isFuncSig(s) ? 'p' : s[0]) : undefined; + + + const sigIR = function(s){ + switch(sigLetter(s)){ + case 'c': case 'C': return 'i8'; + case 'i': return 'i32'; + case 'p': case 'P': case 's': return ptrIR; + case 'j': return 'i64'; + case 'f': return 'float'; + case 'd': return 'double'; + } + toss("Unhandled signature IR:",s); + }; + + + const sigSize = function(s){ + switch(sigLetter(s)){ + case 'c': case 'C': return 1; + case 'i': return 4; + case 'p': case 'P': case 's': return ptrSize; + case 'j': return 8; + case 'f': return 4; + case 'd': return 8; + } + toss("Unhandled signature sizeof:",s); + }; + + const affirmBigIntArray = BigInt64Array + ? ()=>true : ()=>toss('BigInt64Array is not available.'); + + + const sigDVGetter = function(s){ + switch(sigLetter(s)) { + case 'p': case 'P': case 's': { + switch(ptrSize){ + case 4: return 'getInt32'; + case 8: return affirmBigIntArray() && 'getBigInt64'; + } + break; + } + case 'i': return 'getInt32'; + case 'c': return 'getInt8'; + case 'C': return 'getUint8'; + case 'j': return affirmBigIntArray() && 'getBigInt64'; + case 'f': return 'getFloat32'; + case 'd': return 'getFloat64'; + } + toss("Unhandled DataView getter for signature:",s); + }; + + + const sigDVSetter = function(s){ + switch(sigLetter(s)){ + case 'p': case 'P': case 's': { + switch(ptrSize){ + case 4: return 'setInt32'; + case 8: return affirmBigIntArray() && 'setBigInt64'; + } + break; + } + case 'i': return 'setInt32'; + case 'c': return 'setInt8'; + case 'C': return 'setUint8'; + case 'j': return affirmBigIntArray() && 'setBigInt64'; + case 'f': return 'setFloat32'; + case 'd': return 'setFloat64'; + } + toss("Unhandled DataView setter for signature:",s); + }; + + + const sigDVSetWrapper = function(s){ + switch(sigLetter(s)) { + case 'i': case 'f': case 'c': case 'C': case 'd': return Number; + case 'j': return __BigInt; + case 'p': case 'P': case 's': + switch(ptrSize){ + case 4: return Number; + case 8: return __BigInt; + } + break; + } + toss("Unhandled DataView set wrapper for signature:",s); + }; + + + const sPropName = (s,k)=>s+'::'+k; + + const __propThrowOnSet = function(structName,propName){ + return ()=>toss(sPropName(structName,propName),"is read-only."); + }; + + + const getInstanceHandle = function f(obj, create=true){ + let ii = f.map.get(obj); + if( !ii && create ){ + f.map.set(obj, (ii=f.create(obj))); + } + return ii; + }; + getInstanceHandle.map = new WeakMap; + getInstanceHandle.create = (forObj)=>{ + return Object.assign(Object.create(null),{ + o: forObj, + p: undefined, + ownsPointer: false, + zod: false, + xb: 0 + }); + }; + + + const rmInstanceHandle = (obj)=>getInstanceHandle.map.delete(obj) + ; + + const __isPtr32 = (ptr)=>('number'===typeof ptr && (ptr===(ptr|0)) && ptr>=0); + const __isPtr64 = (ptr)=>( + ('bigint'===typeof ptr && ptr >= 0) || __isPtr32(ptr) + ); + + + const __isPtr = (4===ptrSize) ? __isPtr32 : __isPtr64; + + const __isNonNullPtr = (v)=>__isPtr(v) && (v>0); + + + const __freeStruct = function(ctor, obj, m){ + const ii = getInstanceHandle(obj, false); + if( !ii || ii.isDisposing ) return; + ii.isDisposing = true; + if( !m && !(m = ii.p) ){ + console.warn("Cannot(?) happen: __freeStruct() found no instanceInfo"); + return; + } + if(Array.isArray(obj.ondispose)){ + let x; + while((x = obj.ondispose.pop())){ + try{ + if(x instanceof Function) x.call(obj); + else if(x instanceof StructType) x.dispose(); + else if(__isPtr(x)) dealloc(x); + + + }catch(e){ + console.warn("ondispose() for",ctor.structName,'@', + m,'threw. NOT propagating it.',e); + } + } + }else if(obj.ondispose instanceof Function){ + try{obj.ondispose()} + catch(e){ + + console.warn("ondispose() for",ctor.structName,'@', + m,'threw. NOT propagating it.',e); + } + } + rmInstanceHandle(obj); + delete obj.ondispose; + if(ctor.debugFlags.__flags.dealloc){ + log("debug.dealloc:",(ii.ownsPointer?"":"EXTERNAL"), + ctor.structName,"instance:", + ctor.structInfo.sizeof,"bytes @"+m); + } + if(ii.ownsPointer){ + if( ii.zod || ctor.structInfo.zeroOnDispose ){ + heap().fill(0, Number(m), + Number(m) + ctor.structInfo.sizeof + ii.xb); + } + dealloc(m); + } + }; + + + const rop0 = ()=>{return {configurable: false, writable: false, + iterable: false}}; + + + const rop = (v)=>{return {...rop0(), value: v}}; + + + const __allocStruct = function f(ctor, obj, xm){ + let opt; + const checkPtr = (ptr)=>{ + __isNonNullPtr(ptr) || + toss("Invalid pointer value",arguments[0],"for",ctor.structName,"constructor."); + }; + if( arguments.length>=3 ){ + if( xm && ('object'===typeof xm) ){ + opt = xm; + xm = opt?.wrap; + }else{ + checkPtr(xm); + opt = {wrap: xm}; + } + }else{ + opt = {} + } + + const fill = !xm ; + let nAlloc = 0; + let ownsPointer = false; + if(xm){ + + checkPtr(xm); + ownsPointer = !!opt?.takeOwnership; + }else{ + const nX = opt?.extraBytes ?? 0; + if( nX<0 || (nX!==(nX|0)) ){ + toss("Invalid extraBytes value:",opt?.extraBytes); + } + nAlloc = ctor.structInfo.sizeof + nX; + xm = alloc(nAlloc) + || toss("Allocation of",ctor.structName,"structure failed."); + ownsPointer = true; + } + try { + if( opt?.debugFlags ){ + + obj.debugFlags(opt.debugFlags); + } + if(ctor.debugFlags.__flags.alloc){ + log("debug.alloc:",(fill?"":"EXTERNAL"), + ctor.structName,"instance:", + ctor.structInfo.sizeof,"bytes @"+xm); + } + if(fill){ + heap().fill(0, Number(xm), Number(xm) + nAlloc); + } + const ii = getInstanceHandle(obj); + ii.p = xm; + ii.ownsPointer = ownsPointer; + ii.xb = nAlloc ? (nAlloc-ctor.structInfo.sizeof) : 0; + ii.zod = !!opt?.zeroOnDispose; + if( opt?.ondispose && opt.ondispose!==xm ){ + obj.addOnDispose( opt.ondispose ); + } + }catch(e){ + __freeStruct(ctor, obj, xm); + throw e; + } + }; + + + const looksLikeASig = function f(sig){ + f.rxSig1 ??= /^[ipPsjfdcC]$/; + f.rxSig2 ??= /^[vipPsjfdcC]\([ipPsjfdcC]*\)$/; + return f.rxSig1.test(sig) || f.rxSig2.test(sig); + }; + + + const __adaptorsFor = function(who){ + let x = this.get(who); + if( !x ){ + x = [ Object.create(null), Object.create(null), Object.create(null) ]; + this.set(who, x); + } + return x; + }.bind(new WeakMap); + + + const __adaptor = function(who, which, key, proxy){ + const a = __adaptorsFor(who)[which]; + if(3===arguments.length) return a[key]; + if( proxy ) return a[key] = proxy; + return delete a[key]; + }; + + const noopAdapter = (x)=>x; + + + const __adaptGet = function(key, ...args){ + return __adaptor(this, 0, key, ...args); + }; + + const __affirmNotASig = function(ctx,key){ + looksLikeASig(key) && + toss(ctx,"(",key,") collides with a data type signature."); + }; + + + const __adaptSet = function(key, ...args){ + __affirmNotASig('Setter adaptor',key); + return __adaptor(this, 1, key, ...args); + }; + + + const __adaptStruct = function(key, ...args){ + __affirmNotASig('Struct adaptor',key); + return __adaptor(this, 2, key, ...args); + }; + + + const __adaptStruct2 = function(who,key){ + const si = ('string'===typeof key) + ? __adaptor(who, 2, key) : key; + if( 'object'!==typeof si ){ + toss("Invalid struct mapping object. Arg =",key,JSON.stringify(si)); + } + return si; + }; + + const __memberKey = (k)=>memberPrefix + k + memberSuffix; + const __memberKeyProp = rop(__memberKey); + + + + const __lookupMember = function(structInfo, memberName, tossIfNotFound=true){ + let m = structInfo.members[memberName]; + if(!m && (memberPrefix || memberSuffix)){ + + for(const v of Object.values(structInfo.members)){ + if(v.key===memberName){ m = v; break; } + } + if(!m && tossIfNotFound){ + toss(sPropName(structInfo.name || structInfo.structName, memberName), + 'is not a mapped struct member.'); + } + } + return m; + }; + + + const __memberSignature = function f(obj,memberName,emscriptenFormat=false){ + if(!f._) f._ = (x)=>x.replace(/[^vipPsjrdcC]/g,"").replace(/[pPscC]/g,'i'); + const m = __lookupMember(obj.structInfo, memberName, true); + return emscriptenFormat ? f._(m.signature) : m.signature; + }; + + + const __structMemberKeys = rop(function(){ + const a = []; + for(const k of Object.keys(this.structInfo.members)){ + a.push(this.memberKey(k)); + } + return a; + }); + + const __utf8Decoder = new TextDecoder('utf-8'); + const __utf8Encoder = new TextEncoder(); + + const __SAB = ('undefined'===typeof SharedArrayBuffer) + ? function(){} : SharedArrayBuffer; + const __utf8Decode = function(arrayBuffer, begin, end){ + if( 8===ptrSize ){ + begin = Number(begin); + end = Number(end); + } + return __utf8Decoder.decode( + (arrayBuffer.buffer instanceof __SAB) + ? arrayBuffer.slice(begin, end) + : arrayBuffer.subarray(begin, end) + ); + }; + + + const __memberIsString = function(obj,memberName, tossIfNotFound=false){ + const m = __lookupMember(obj.structInfo, memberName, tossIfNotFound); + return (m && 1===m.signature.length && 's'===m.signature[0]) ? m : false; + }; + + + const __affirmCStringSignature = function(member){ + if('s'===member.signature) return; + toss("Invalid member type signature for C-string value:", + JSON.stringify(member)); + }; + + + const __memberToJsString = function f(obj,memberName){ + const m = __lookupMember(obj.structInfo, memberName, true); + __affirmCStringSignature(m); + const addr = obj[m.key]; + + if(!addr) return null; + let pos = addr; + const mem = heap(); + for( ; mem[pos]!==0; ++pos ) { + + }; + + return (addr===pos) ? "" : __utf8Decode(mem, addr, pos); + }; + + + const __addOnDispose = function(obj, ...v){ + if(obj.ondispose){ + if(!Array.isArray(obj.ondispose)){ + obj.ondispose = [obj.ondispose]; + } + }else{ + obj.ondispose = []; + } + obj.ondispose.push(...v); + }; + + + const __allocCString = function(str){ + const u = __utf8Encoder.encode(str); + const mem = alloc(u.length+1); + if(!mem) toss("Allocation error while duplicating string:",str); + const h = heap(); + + + h.set(u, Number(mem)); + h[__ptrAdd(mem, u.length)] = 0; + + return mem; + }; + + + const __setMemberCString = function(obj, memberName, str){ + const m = __lookupMember(obj.structInfo, memberName, true); + __affirmCStringSignature(m); + + const mem = __allocCString(str); + obj[m.key] = mem; + __addOnDispose(obj, mem); + return obj; + }; + + + const StructType = function StructType(structName, structInfo){ + if(arguments[2]!==rop){ + toss("Do not call the StructType constructor", + "from client-level code."); + } + Object.defineProperties(this,{ + + structName: rop(structName), + structInfo: rop(structInfo) + }); + }; + + + StructType.prototype = Object.create(null, { + dispose: rop(function(){__freeStruct(this.constructor, this)}), + lookupMember: rop(function(memberName, tossIfNotFound=true){ + return __lookupMember(this.structInfo, memberName, tossIfNotFound); + }), + memberToJsString: rop(function(memberName){ + return __memberToJsString(this, memberName); + }), + memberIsString: rop(function(memberName, tossIfNotFound=true){ + return __memberIsString(this, memberName, tossIfNotFound); + }), + memberKey: __memberKeyProp, + memberKeys: __structMemberKeys, + memberSignature: rop(function(memberName, emscriptenFormat=false){ + return __memberSignature(this, memberName, emscriptenFormat); + }), + memoryDump: rop(function(){ + const p = this.pointer; + return p + ? new Uint8Array(heap().slice(Number(p), Number(p) + this.structInfo.sizeof)) + : null; + }), + extraBytes: { + configurable: false, enumerable: false, + get: function(){return getInstanceHandle(this, false)?.xb ?? 0;} + }, + zeroOnDispose: { + configurable: false, enumerable: false, + get: function(){ + return getInstanceHandle(this, false)?.zod + ?? !!this.structInfo.zeroOnDispose; + } + }, + pointer: { + configurable: false, enumerable: false, + get: function(){return getInstanceHandle(this, false)?.p}, + set: ()=>toss("Cannot assign the 'pointer' property of a struct.") + + + + }, + setMemberCString: rop(function(memberName, str){ + return __setMemberCString(this, memberName, str); + }) + }); + + Object.assign(StructType.prototype,{ + addOnDispose: function(...v){ + __addOnDispose(this,...v); + return this; + } + }); + + + Object.defineProperties(StructType, { + allocCString: rop(__allocCString), + isA: rop((v)=>v instanceof StructType), + hasExternalPointer: rop((v)=>{ + const ii = getInstanceHandle(v, false); + return !!(ii?.p && !ii?.ownsPointer); + }), + memberKey: __memberKeyProp + + }); + + + const memberGetterProxy = function(si){ + return si.get || (si.adaptGet + ? StructBinder.adaptGet(si.adaptGet) + : undefined); + }; + + + const memberSetterProxy = function(si){ + return si.set || (si.adaptSet + ? StructBinder.adaptSet(si.adaptSet) + : undefined); + }; + + + const makeMemberStructWrapper = function callee(ctor, name, si){ + + const __innerStructs = (callee.innerStructs ??= new Map()); + const key = ctor.memberKey(name); + if( undefined!==si.signature ){ + toss("'signature' cannot be used on an embedded struct (", + ctor.structName,".",key,")."); + } + if( memberSetterProxy(si) ){ + toss("'set' and 'adaptSet' are not permitted for nested struct members."); + } + + si.structName ??= ctor.structName+'::'+name; + si.key = key; + si.name = name; + si.constructor = this.call(this, si.structName, si); + + + const getterProxy = memberGetterProxy(si); + const prop = Object.assign(Object.create(null),{ + configurable: false, + enumerable: false, + set: __propThrowOnSet(ctor.structName, key), + get: function(){ + const dbg = this.debugFlags.__flags; + const p = this.pointer; + const k = p+'.'+key; + let s = __innerStructs.get(k); + if(dbg.getter){ log("debug.getter: k =",k); } + if( !s ){ + s = new si.constructor(__ptrAdd(p, si.offset)); + __innerStructs.set(k, s); + this.addOnDispose(()=>s.dispose()); + s.addOnDispose(()=>__innerStructs.delete(k)); + + } + if(getterProxy) s = getterProxy.apply(this,[s,key]); + if(dbg.getter) log("debug.getter: result =",s); + return s; + } + }); + Object.defineProperty(ctor.prototype, key, prop); + }; + + + const makeMemberWrapper = function f(ctor, name, si){ + si = __adaptStruct2(this, si); + if( si.members ){ + return makeMemberStructWrapper.call(this, ctor, name, si); + } + + if(!f.cache){ + + f.cache = {getters: {}, setters: {}, sw:{}}; + const a = ['i','c','C','p','P','s','f','d','v()']; + if(bigIntEnabled) a.push('j'); + a.forEach(function(v){ + f.cache.getters[v] = sigDVGetter(v) ; + f.cache.setters[v] = sigDVSetter(v) ; + f.cache.sw[v] = sigDVSetWrapper(v) ; + }); + f.sigCheck = function(obj, name, key,sig){ + if(Object.prototype.hasOwnProperty.call(obj, key)){ + toss(obj.structName,'already has a property named',key+'.'); + } + looksLikeASig(sig) + || toss("Malformed signature for", + sPropName(obj.structName,name)+":",sig); + }; + } + const key = ctor.memberKey(name); + f.sigCheck(ctor.prototype, name, key, si.signature); + si.key = key; + si.name = name; + const sigGlyph = sigLetter(si.signature); + const xPropName = sPropName(ctor.structName,key); + const dbg = ctor.debugFlags.__flags; + + const getterProxy = memberGetterProxy(si); + const prop = Object.create(null); + prop.configurable = false; + prop.enumerable = false; + prop.get = function(){ + + if(dbg.getter){ + log("debug.getter:",f.cache.getters[sigGlyph],"for", sigIR(sigGlyph), + xPropName,'@', this.pointer,'+',si.offset,'sz',si.sizeof); + } + let rc = ( + new DataView(heap().buffer, Number(this.pointer) + si.offset, si.sizeof) + )[f.cache.getters[sigGlyph]](0, isLittleEndian); + + if(getterProxy) rc = getterProxy.apply(this,[key,rc]); + if(dbg.getter) log("debug.getter:",xPropName,"result =",rc); + return rc; + }; + if(si.readOnly){ + prop.set = __propThrowOnSet(ctor.prototype.structName,key); + }else{ + const setterProxy = memberSetterProxy(si); + prop.set = function(v){ + + if(dbg.setter){ + log("debug.setter:",f.cache.setters[sigGlyph],"for", sigIR(sigGlyph), + xPropName,'@', this.pointer,'+',si.offset,'sz',si.sizeof, v); + } + if(!this.pointer){ + toss("Cannot set native property on a disposed", + this.structName,"instance."); + } + if( setterProxy ) v = setterProxy.apply(this,[key,v]); + if( null===v || undefined===v ) v = __NullPtr; + else if( isPtrSig(si.signature) && !__isPtr(v) ){ + if(isAutoPtrSig(si.signature) && (v instanceof StructType)){ + + v = v.pointer || __NullPtr; + if(dbg.setter) log("debug.setter:",xPropName,"resolved to",v); + }else{ + toss("Invalid value for pointer-type",xPropName+'.'); + } + } + ( + new DataView(heap().buffer, Number(this.pointer) + si.offset, + si.sizeof) + )[f.cache.setters[sigGlyph]](0, f.cache.sw[sigGlyph](v), isLittleEndian); + }; + } + Object.defineProperty(ctor.prototype, key, prop); + }; + + + const StructBinderImpl = function StructBinderImpl( + structName, si, opt = Object.create(null) + ){ + + const StructCtor = function StructCtor(arg){ + + if(!(this instanceof StructCtor)){ + toss("The",structName,"constructor may only be called via 'new'."); + } + __allocStruct(StructCtor, this, ...arguments); + }; + const self = this; + + const ads = (x)=>{ + + return (('string'===typeof x) && looksLikeASig(x)) + ? {signature: x} : __adaptStruct2(self,x); + }; + if(1===arguments.length){ + si = ads(structName); + structName = si.structName || si.name; + }else if(2===arguments.length){ + si = ads(si); + si.name ??= structName; + }else{ + si = ads(si); + } + structName ??= si.structName; + + structName ??= opt.structName; + if( !structName ) toss("One of 'name' or 'structName' are required."); + if( si.adapt ){ + + Object.keys(si.adapt.struct||{}).forEach((k)=>{ + __adaptStruct.call(StructBinderImpl, k, si.adapt.struct[k]); + }); + Object.keys(si.adapt.set||{}).forEach((k)=>{ + __adaptSet.call(StructBinderImpl, k, si.adapt.set[k]); + }); + Object.keys(si.adapt.get||{}).forEach((k)=>{ + __adaptGet.call(StructBinderImpl, k, si.adapt.get[k]); + }); + } + if(!si.members && !si.sizeof){ + si.sizeof = sigSize(si.signature); + } + + const debugFlags = rop(SBF.__makeDebugFlags(StructBinder.debugFlags)); + Object.defineProperties(StructCtor,{ + debugFlags: debugFlags, + isA: rop((v)=>v instanceof StructCtor), + memberKey: __memberKeyProp, + memberKeys: __structMemberKeys, + + structInfo: rop(si), + structName: rop(structName), + ptrAdd: rop(__ptrAdd) + }); + StructCtor.prototype = new StructType(structName, si, rop); + Object.defineProperties(StructCtor.prototype,{ + debugFlags: debugFlags, + constructor: rop(StructCtor) + , + ptrAdd: rop(__ptrAddSelf) + }); + let lastMember = false; + let offset = 0; + const autoCalc = !!si.autoCalcSizeOffset; + + if( !autoCalc ){ + if( !si.sizeof ){ + toss(structName,"description is missing its sizeof property."); + } + + si.offset ??= 0; + }else{ + si.offset ??= 0; + } + Object.keys(si.members || {}).forEach((k)=>{ + + let m = ads(si.members[k]); + if(!m.members && !m.sizeof){ + + m.sizeof = sigSize(m.signature); + if(!m.sizeof){ + toss(sPropName(structName,k), "is missing a sizeof property.",m); + } + } + if( undefined===m.offset ){ + if( autoCalc ) m.offset = offset; + else{ + toss(sPropName(structName,k),"is missing its offset.", + JSON.stringify(m)); + } + + } + si.members[k] = m ; + if(!lastMember || lastMember.offset < m.offset) lastMember = m; + const oldAutoCalc = !!m.autoCalc; + if( autoCalc ) m.autoCalcSizeOffset = true; + makeMemberWrapper.call(self, StructCtor, k, m); + if( oldAutoCalc ) m.autoCalcSizeOffset = true; + else delete m.autoCalcSizeOffset; + offset += m.sizeof; + + }); + + if( !lastMember ) toss("No member property descriptions found."); + if( !si.sizeof ) si.sizeof = offset; + if(si.sizeof===1){ + (si.signature === 'c' || si.signature === 'C') || + toss("Unexpected sizeof==1 member", + sPropName(structName,k), + "with signature",si.signature); + }else{ + + + if(0!==(si.sizeof%4)){ + console.warn("Invalid struct member description",si); + toss(structName,"sizeof is not aligned. sizeof="+si.sizeof); + } + if(0!==(si.offset%4)){ + console.warn("Invalid struct member description",si); + toss(structName,"offset is not aligned. offset="+si.offset); + } + } + if( si.sizeof < offset ){ + console.warn("Suspect struct description:",si,"offset =",offset); + toss("Mismatch in the calculated vs. the provided sizeof/offset info.", + "Expected sizeof",offset,"but got",si.sizeof,"for",si); + + } + delete si.autoCalcSizeOffset; + return StructCtor; + }; + + const StructBinder = function StructBinder(structName, structInfo){ + return (1==arguments.length) + ? StructBinderImpl.call(StructBinder, structName) + : StructBinderImpl.call(StructBinder, structName, structInfo); + }; + StructBinder.StructType = StructType; + StructBinder.config = config; + StructBinder.allocCString = __allocCString; + StructBinder.adaptGet = __adaptGet; + StructBinder.adaptSet = __adaptSet; + StructBinder.adaptStruct = __adaptStruct; + StructBinder.ptrAdd = __ptrAdd; + if(!StructBinder.debugFlags){ + StructBinder.debugFlags = SBF.__makeDebugFlags(SBF.debugFlags); + } + return StructBinder; +}; + +globalThis.sqlite3ApiBootstrap.initializers.push(function(sqlite3){ + 'use strict'; + const toss = (...args)=>{throw new Error(args.join(' '))}; + const capi = sqlite3.capi, wasm = sqlite3.wasm, util = sqlite3.util; + globalThis.WhWasmUtilInstaller(wasm); + delete globalThis.WhWasmUtilInstaller; + + if(0){ + + + const dealloc = wasm.exports[sqlite3.config.deallocExportName]; + const nFunc = wasm.functionTable().length; + let i; + for(i = 0; i < nFunc; ++i){ + const e = wasm.functionEntry(i); + if(dealloc === e){ + capi.SQLITE_WASM_DEALLOC = i; + break; + } + } + if(dealloc !== wasm.functionEntry(capi.SQLITE_WASM_DEALLOC)){ + toss("Internal error: cannot find function pointer for SQLITE_WASM_DEALLOC."); + } + } + + + const bindingSignatures = { + core: [ + + ["sqlite3_aggregate_context","void*", "sqlite3_context*", "int"], + + ["sqlite3_backup_init", "sqlite3_backup*", "sqlite3*", "string", "sqlite3*", "string"], + ["sqlite3_backup_step", "int", "sqlite3_backup*", "int"], + ["sqlite3_backup_finish", "int", "sqlite3_backup*"], + ["sqlite3_backup_remaining", "int", "sqlite3_backup*"], + ["sqlite3_backup_pagecount", "int", "sqlite3_backup*"], + + ["sqlite3_bind_double","int", "sqlite3_stmt*", "int", "f64"], + ["sqlite3_bind_int","int", "sqlite3_stmt*", "int", "int"], + ["sqlite3_bind_null",undefined, "sqlite3_stmt*", "int"], + ["sqlite3_bind_parameter_count", "int", "sqlite3_stmt*"], + ["sqlite3_bind_parameter_index","int", "sqlite3_stmt*", "string"], + ["sqlite3_bind_parameter_name", "string", "sqlite3_stmt*", "int"], + ["sqlite3_bind_pointer", "int", + "sqlite3_stmt*", "int", "*", "string:static", "*"], + + ["sqlite3_bind_zeroblob", "int", "sqlite3_stmt*", "int", "int"], + ["sqlite3_busy_handler","int", [ + "sqlite3*", + new wasm.xWrap.FuncPtrAdapter({ + signature: 'i(pi)', + contextKey: (argv,argIndex)=>argv[0] + }), + "*" + ]], + ["sqlite3_busy_timeout","int", "sqlite3*", "int"], + + + ["sqlite3_changes", "int", "sqlite3*"], + ["sqlite3_clear_bindings","int", "sqlite3_stmt*"], + ["sqlite3_collation_needed", "int", "sqlite3*", "*", "*"], + ["sqlite3_column_blob","*", "sqlite3_stmt*", "int"], + ["sqlite3_column_bytes","int", "sqlite3_stmt*", "int"], + ["sqlite3_column_count", "int", "sqlite3_stmt*"], + ["sqlite3_column_decltype", "string", "sqlite3_stmt*", "int"], + ["sqlite3_column_double","f64", "sqlite3_stmt*", "int"], + ["sqlite3_column_int","int", "sqlite3_stmt*", "int"], + ["sqlite3_column_name","string", "sqlite3_stmt*", "int"], + ["sqlite3_column_type","int", "sqlite3_stmt*", "int"], + ["sqlite3_column_value","sqlite3_value*", "sqlite3_stmt*", "int"], + ["sqlite3_commit_hook", "void*", [ + "sqlite3*", + new wasm.xWrap.FuncPtrAdapter({ + name: 'sqlite3_commit_hook', + signature: 'i(p)', + contextKey: (argv)=>argv[0] + }), + '*' + ]], + ["sqlite3_compileoption_get", "string", "int"], + ["sqlite3_compileoption_used", "int", "string"], + ["sqlite3_complete", "int", "string:flexible"], + ["sqlite3_context_db_handle", "sqlite3*", "sqlite3_context*"], + + + ["sqlite3_data_count", "int", "sqlite3_stmt*"], + ["sqlite3_db_filename", "string", "sqlite3*", "string"], + ["sqlite3_db_handle", "sqlite3*", "sqlite3_stmt*"], + ["sqlite3_db_name", "string", "sqlite3*", "int"], + ["sqlite3_db_readonly", "int", "sqlite3*", "string"], + ["sqlite3_db_status", "int", "sqlite3*", "int", "*", "*", "int"], + ["sqlite3_errcode", "int", "sqlite3*"], + ["sqlite3_errmsg", "string", "sqlite3*"], + ["sqlite3_error_offset", "int", "sqlite3*"], + ["sqlite3_errstr", "string", "int"], + ["sqlite3_exec", "int", [ + "sqlite3*", "string:flexible", + new wasm.xWrap.FuncPtrAdapter({ + signature: 'i(pipp)', + bindScope: 'transient', + callProxy: (callback)=>{ + let aNames; + return (pVoid, nCols, pColVals, pColNames)=>{ + try { + const aVals = wasm.cArgvToJs(nCols, pColVals); + if(!aNames) aNames = wasm.cArgvToJs(nCols, pColNames); + return callback(aVals, aNames) | 0; + }catch(e){ + + return e.resultCode || capi.SQLITE_ERROR; + } + } + } + }), + "*", "**" + ]], + ["sqlite3_expanded_sql", "string", "sqlite3_stmt*"], + ["sqlite3_extended_errcode", "int", "sqlite3*"], + ["sqlite3_extended_result_codes", "int", "sqlite3*", "int"], + ["sqlite3_file_control", "int", "sqlite3*", "string", "int", "*"], + ["sqlite3_finalize", "int", "sqlite3_stmt*"], + ["sqlite3_free", undefined,"*"], + ["sqlite3_get_autocommit", "int", "sqlite3*"], + ["sqlite3_get_auxdata", "*", "sqlite3_context*", "int"], + ["sqlite3_initialize", undefined], + ["sqlite3_interrupt", undefined, "sqlite3*"], + ["sqlite3_is_interrupted", "int", "sqlite3*"], + ["sqlite3_keyword_count", "int"], + ["sqlite3_keyword_name", "int", ["int", "**", "*"]], + ["sqlite3_keyword_check", "int", ["string", "int"]], + ["sqlite3_libversion", "string"], + ["sqlite3_libversion_number", "int"], + ["sqlite3_limit", "int", ["sqlite3*", "int", "int"]], + ["sqlite3_malloc", "*","int"], + ["sqlite3_next_stmt", "sqlite3_stmt*", ["sqlite3*","sqlite3_stmt*"]], + ["sqlite3_open", "int", "string", "*"], + ["sqlite3_open_v2", "int", "string", "*", "int", "string"], + + + ["sqlite3_realloc", "*","*","int"], + ["sqlite3_reset", "int", "sqlite3_stmt*"], + + ["sqlite3_result_blob", undefined, "sqlite3_context*", "*", "int", "*"], + ["sqlite3_result_double", undefined, "sqlite3_context*", "f64"], + ["sqlite3_result_error", undefined, "sqlite3_context*", "string", "int"], + ["sqlite3_result_error_code", undefined, "sqlite3_context*", "int"], + ["sqlite3_result_error_nomem", undefined, "sqlite3_context*"], + ["sqlite3_result_error_toobig", undefined, "sqlite3_context*"], + ["sqlite3_result_int", undefined, "sqlite3_context*", "int"], + ["sqlite3_result_null", undefined, "sqlite3_context*"], + ["sqlite3_result_pointer", undefined, + "sqlite3_context*", "*", "string:static", "*"], + ["sqlite3_result_subtype", undefined, "sqlite3_value*", "int"], + ["sqlite3_result_text", undefined, "sqlite3_context*", "string", "int", "*"], + ["sqlite3_result_zeroblob", undefined, "sqlite3_context*", "int"], + ["sqlite3_rollback_hook", "void*", [ + "sqlite3*", + new wasm.xWrap.FuncPtrAdapter({ + name: 'sqlite3_rollback_hook', + signature: 'v(p)', + contextKey: (argv)=>argv[0] + }), + '*' + ]], + + ["sqlite3_set_auxdata", undefined, [ + "sqlite3_context*", "int", "*", + true + ? "*" + : new wasm.xWrap.FuncPtrAdapter({ + + name: 'xDestroyAuxData', + signature: 'v(p)', + contextKey: (argv, argIndex)=>argv[0] + }) + ]], + ['sqlite3_set_errmsg', 'int', 'sqlite3*', 'int', 'string'], + ["sqlite3_shutdown", undefined], + ["sqlite3_sourceid", "string"], + ["sqlite3_sql", "string", "sqlite3_stmt*"], + ["sqlite3_status", "int", "int", "*", "*", "int"], + ["sqlite3_step", "int", "sqlite3_stmt*"], + ["sqlite3_stmt_busy", "int", "sqlite3_stmt*"], + ["sqlite3_stmt_readonly", "int", "sqlite3_stmt*"], + ["sqlite3_stmt_status", "int", "sqlite3_stmt*", "int", "int"], + ["sqlite3_strglob", "int", "string","string"], + ["sqlite3_stricmp", "int", "string", "string"], + ["sqlite3_strlike", "int", "string", "string","int"], + ["sqlite3_strnicmp", "int", "string", "string", "int"], + ["sqlite3_table_column_metadata", "int", + "sqlite3*", "string", "string", "string", + "**", "**", "*", "*", "*"], + ["sqlite3_total_changes", "int", "sqlite3*"], + ["sqlite3_trace_v2", "int", [ + "sqlite3*", "int", + new wasm.xWrap.FuncPtrAdapter({ + name: 'sqlite3_trace_v2::callback', + signature: 'i(ippp)', + contextKey: (argv,argIndex)=>argv[0] + }), + "*" + ]], + ["sqlite3_txn_state", "int", ["sqlite3*","string"]], + + ["sqlite3_uri_boolean", "int", "sqlite3_filename", "string", "int"], + ["sqlite3_uri_key", "string", "sqlite3_filename", "int"], + ["sqlite3_uri_parameter", "string", "sqlite3_filename", "string"], + ["sqlite3_user_data","void*", "sqlite3_context*"], + ["sqlite3_value_blob", "*", "sqlite3_value*"], + ["sqlite3_value_bytes","int", "sqlite3_value*"], + ["sqlite3_value_double","f64", "sqlite3_value*"], + ["sqlite3_value_dup", "sqlite3_value*", "sqlite3_value*"], + ["sqlite3_value_free", undefined, "sqlite3_value*"], + ["sqlite3_value_frombind", "int", "sqlite3_value*"], + ["sqlite3_value_int","int", "sqlite3_value*"], + ["sqlite3_value_nochange", "int", "sqlite3_value*"], + ["sqlite3_value_numeric_type", "int", "sqlite3_value*"], + ["sqlite3_value_pointer", "*", "sqlite3_value*", "string:static"], + ["sqlite3_value_subtype", "int", "sqlite3_value*"], + ["sqlite3_value_type", "int", "sqlite3_value*"], + ["sqlite3_vfs_find", "*", "string"], + ["sqlite3_vfs_register", "int", "sqlite3_vfs*", "int"], + ["sqlite3_vfs_unregister", "int", "sqlite3_vfs*"] + + + ], + + int64: [ + ["sqlite3_bind_int64","int", ["sqlite3_stmt*", "int", "i64"]], + ["sqlite3_changes64","i64", ["sqlite3*"]], + ["sqlite3_column_int64","i64", ["sqlite3_stmt*", "int"]], + ["sqlite3_deserialize", "int", "sqlite3*", "string", "*", "i64", "i64", "int"] + , + ["sqlite3_last_insert_rowid", "i64", ["sqlite3*"]], + ["sqlite3_malloc64", "*","i64"], + ["sqlite3_msize", "i64", "*"], + ["sqlite3_overload_function", "int", ["sqlite3*","string","int"]], + ["sqlite3_realloc64", "*","*", "i64"], + ["sqlite3_result_int64", undefined, "*", "i64"], + ["sqlite3_result_zeroblob64", "int", "*", "i64"], + ["sqlite3_serialize","*", "sqlite3*", "string", "*", "int"], + ["sqlite3_set_last_insert_rowid", undefined, ["sqlite3*", "i64"]], + ["sqlite3_status64", "int", "int", "*", "*", "int"], + ["sqlite3_db_status64", "int", "sqlite3*", "int", "*", "*", "int"], + ["sqlite3_total_changes64", "i64", ["sqlite3*"]], + ["sqlite3_update_hook", "*", [ + "sqlite3*", + new wasm.xWrap.FuncPtrAdapter({ + name: 'sqlite3_update_hook::callback', + signature: "v(pippj)", + contextKey: (argv)=>argv[0], + callProxy: (callback)=>{ + return (p,op,z0,z1,rowid)=>{ + callback(p, op, wasm.cstrToJs(z0), wasm.cstrToJs(z1), rowid); + }; + } + }), + "*" + ]], + ["sqlite3_uri_int64", "i64", ["sqlite3_filename", "string", "i64"]], + ["sqlite3_value_int64","i64", "sqlite3_value*"] + + ], + + wasmInternal: [ + ["sqlite3__wasm_db_reset", "int", "sqlite3*"], + ["sqlite3__wasm_db_vfs", "sqlite3_vfs*", "sqlite3*","string"], + [ + "sqlite3__wasm_vfs_create_file", "int", "sqlite3_vfs*","string","*", "int" + ], + ["sqlite3__wasm_posix_create_file", "int", "string","*", "int"], + ["sqlite3__wasm_vfs_unlink", "int", "sqlite3_vfs*","string"], + ["sqlite3__wasm_qfmt_token","string:dealloc", "string","int"] + ] + } ; + + if( !!wasm.exports.sqlite3_progress_handler ){ + bindingSignatures.core.push( + ["sqlite3_progress_handler", undefined, [ + "sqlite3*", "int", new wasm.xWrap.FuncPtrAdapter({ + name: 'xProgressHandler', + signature: 'i(p)', + bindScope: 'context', + contextKey: (argv,argIndex)=>argv[0] + }), "*" + ]] + ); + } + + if( !!wasm.exports.sqlite3_stmt_explain ){ + bindingSignatures.core.push( + ["sqlite3_stmt_explain", "int", "sqlite3_stmt*", "int"], + ["sqlite3_stmt_isexplain", "int", "sqlite3_stmt*"] + ); + } + + if( !!wasm.exports.sqlite3_set_authorizer ){ + bindingSignatures.core.push( + ["sqlite3_set_authorizer", "int", [ + "sqlite3*", + new wasm.xWrap.FuncPtrAdapter({ + name: "sqlite3_set_authorizer::xAuth", + signature: "i(pi"+"ssss)", + contextKey: (argv, argIndex)=>argv[0], + + callProxy: (callback)=>{ + return (pV, iCode, s0, s1, s2, s3)=>{ + try{ + s0 = s0 && wasm.cstrToJs(s0); s1 = s1 && wasm.cstrToJs(s1); + s2 = s2 && wasm.cstrToJs(s2); s3 = s3 && wasm.cstrToJs(s3); + return callback(pV, iCode, s0, s1, s2, s3) | 0; + }catch(e){ + return e.resultCode || capi.SQLITE_ERROR; + } + } + } + }), + "*" + ]] + ); + } + + if( !!wasm.exports.sqlite3_column_origin_name ){ + bindingSignatures.core.push( + ["sqlite3_column_database_name","string", "sqlite3_stmt*", "int"], + ["sqlite3_column_origin_name","string", "sqlite3_stmt*", "int"], + ["sqlite3_column_table_name","string", "sqlite3_stmt*", "int"] + ); + } + + if(false && wasm.compileOptionUsed('SQLITE_ENABLE_NORMALIZE')){ + + bindingSignatures.core.push(["sqlite3_normalized_sql", "string", "sqlite3_stmt*"]); + } + + if( !!wasm.exports.sqlite3_key_v2 ){ + + bindingSignatures.core.push( + ["sqlite3_key", "int", "sqlite3*", "string", "int"], + ["sqlite3_key_v2","int","sqlite3*","string","*","int"], + ["sqlite3_rekey", "int", "sqlite3*", "string", "int"], + ["sqlite3_rekey_v2", "int", "sqlite3*", "string", "*", "int"], + ["sqlite3_activate_see", undefined, "string"], + ["sqlite3mc_cipher_count", "int"], + ["sqlite3mc_cipher_index", "int", "string"], + ["sqlite3mc_cipher_name", "string", "int"], + ["sqlite3mc_cipher_name_copy", "int", "int", "string", "int"], + ["sqlite3mc_config", "int", "sqlite3*", "string", "int"], + ["sqlite3mc_config_cipher", "int", "sqlite3*", "string", "string", "int"], + ["sqlite3mc_codec_data", "string", "sqlite3*", "string", "string"], + ["sqlite3mc_version", "string"], + ["sqlite3mc_vfs_create", "int", "string", "int"], + ["sqlite3mc_vfs_destroy", undefined, "string"], + ["sqlite3mc_vfs_shutdown", undefined] + ); + } + + if( wasm.bigIntEnabled && !!wasm.exports.sqlite3_declare_vtab ){ + bindingSignatures.int64.push( + ["sqlite3_create_module", "int", + ["sqlite3*","string","sqlite3_module*","*"]], + ["sqlite3_create_module_v2", "int", + ["sqlite3*","string","sqlite3_module*","*","*"]], + ["sqlite3_declare_vtab", "int", ["sqlite3*", "string:flexible"]], + ["sqlite3_drop_modules", "int", ["sqlite3*", "**"]], + ["sqlite3_vtab_collation","string","sqlite3_index_info*","int"], + + ["sqlite3_vtab_distinct","int", "sqlite3_index_info*"], + ["sqlite3_vtab_in","int", "sqlite3_index_info*", "int", "int"], + ["sqlite3_vtab_in_first", "int", "sqlite3_value*", "**"], + ["sqlite3_vtab_in_next", "int", "sqlite3_value*", "**"], + ["sqlite3_vtab_nochange","int", "sqlite3_context*"], + ["sqlite3_vtab_on_conflict","int", "sqlite3*"], + ["sqlite3_vtab_rhs_value","int", "sqlite3_index_info*", "int", "**"] + ); + } + + if(wasm.bigIntEnabled && !!wasm.exports.sqlite3_preupdate_hook){ + bindingSignatures.int64.push( + ["sqlite3_preupdate_blobwrite", "int", "sqlite3*"], + ["sqlite3_preupdate_count", "int", "sqlite3*"], + ["sqlite3_preupdate_depth", "int", "sqlite3*"], + ["sqlite3_preupdate_hook", "*", [ + "sqlite3*", + new wasm.xWrap.FuncPtrAdapter({ + name: 'sqlite3_preupdate_hook', + signature: "v(ppippjj)", + contextKey: (argv)=>argv[0], + callProxy: (callback)=>{ + return (p,db,op,zDb,zTbl,iKey1,iKey2)=>{ + callback(p, db, op, wasm.cstrToJs(zDb), wasm.cstrToJs(zTbl), + iKey1, iKey2); + }; + } + }), + "*" + ]], + ["sqlite3_preupdate_new", "int", ["sqlite3*", "int", "**"]], + ["sqlite3_preupdate_old", "int", ["sqlite3*", "int", "**"]] + ); + } + + + if(wasm.bigIntEnabled + && !!wasm.exports.sqlite3changegroup_add + && !!wasm.exports.sqlite3session_create + && !!wasm.exports.sqlite3_preupdate_hook ){ + + const __ipsProxy = { + signature: 'i(ps)', + callProxy:(callback)=>{ + return (p,s)=>{ + try{return callback(p, wasm.cstrToJs(s)) | 0} + catch(e){return e.resultCode || capi.SQLITE_ERROR} + } + } + }; + + bindingSignatures.int64.push( + ['sqlite3changegroup_add', 'int', ['sqlite3_changegroup*', 'int', 'void*']], + ['sqlite3changegroup_add_strm', 'int', [ + 'sqlite3_changegroup*', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xInput', signature: 'i(ppp)', bindScope: 'transient' + }), + 'void*' + ]], + ['sqlite3changegroup_delete', undefined, ['sqlite3_changegroup*']], + ['sqlite3changegroup_new', 'int', ['**']], + ['sqlite3changegroup_output', 'int', ['sqlite3_changegroup*', 'int*', '**']], + ['sqlite3changegroup_output_strm', 'int', [ + 'sqlite3_changegroup*', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xOutput', signature: 'i(ppi)', bindScope: 'transient' + }), + 'void*' + ]], + ['sqlite3changeset_apply', 'int', [ + 'sqlite3*', 'int', 'void*', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xFilter', bindScope: 'transient', ...__ipsProxy + }), + new wasm.xWrap.FuncPtrAdapter({ + name: 'xConflict', signature: 'i(pip)', bindScope: 'transient' + }), + 'void*' + ]], + ['sqlite3changeset_apply_strm', 'int', [ + 'sqlite3*', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xInput', signature: 'i(ppp)', bindScope: 'transient' + }), + 'void*', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xFilter', bindScope: 'transient', ...__ipsProxy + }), + new wasm.xWrap.FuncPtrAdapter({ + name: 'xConflict', signature: 'i(pip)', bindScope: 'transient' + }), + 'void*' + ]], + ['sqlite3changeset_apply_v2', 'int', [ + 'sqlite3*', 'int', 'void*', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xFilter', bindScope: 'transient', ...__ipsProxy + }), + new wasm.xWrap.FuncPtrAdapter({ + name: 'xConflict', signature: 'i(pip)', bindScope: 'transient' + }), + 'void*', '**', 'int*', 'int' + + ]], + ['sqlite3changeset_apply_v2_strm', 'int', [ + 'sqlite3*', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xInput', signature: 'i(ppp)', bindScope: 'transient' + }), + 'void*', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xFilter', bindScope: 'transient', ...__ipsProxy + }), + new wasm.xWrap.FuncPtrAdapter({ + name: 'xConflict', signature: 'i(pip)', bindScope: 'transient' + }), + 'void*', '**', 'int*', 'int' + ]], + ['sqlite3changeset_apply_v3', 'int', [ + 'sqlite3*', 'int', 'void*', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xFilter', signature: 'i(pp)', bindScope: 'transient' + }), + new wasm.xWrap.FuncPtrAdapter({ + name: 'xConflict', signature: 'i(pip)', bindScope: 'transient' + }), + 'void*', '**', 'int*', 'int' + + ]], + ['sqlite3changeset_apply_v3_strm', 'int', [ + 'sqlite3*', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xInput', signature: 'i(ppp)', bindScope: 'transient' + }), + 'void*', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xFilter', signature: 'i(pp)', bindScope: 'transient' + }), + new wasm.xWrap.FuncPtrAdapter({ + name: 'xConflict', signature: 'i(pip)', bindScope: 'transient' + }), + 'void*', '**', 'int*', 'int' + ]], + ['sqlite3changeset_concat', 'int', ['int','void*', 'int', 'void*', 'int*', '**']], + ['sqlite3changeset_concat_strm', 'int', [ + new wasm.xWrap.FuncPtrAdapter({ + name: 'xInputA', signature: 'i(ppp)', bindScope: 'transient' + }), + 'void*', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xInputB', signature: 'i(ppp)', bindScope: 'transient' + }), + 'void*', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xOutput', signature: 'i(ppi)', bindScope: 'transient' + }), + 'void*' + ]], + ['sqlite3changeset_conflict', 'int', ['sqlite3_changeset_iter*', 'int', '**']], + ['sqlite3changeset_finalize', 'int', ['sqlite3_changeset_iter*']], + ['sqlite3changeset_fk_conflicts', 'int', ['sqlite3_changeset_iter*', 'int*']], + ['sqlite3changeset_invert', 'int', ['int', 'void*', 'int*', '**']], + ['sqlite3changeset_invert_strm', 'int', [ + new wasm.xWrap.FuncPtrAdapter({ + name: 'xInput', signature: 'i(ppp)', bindScope: 'transient' + }), + 'void*', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xOutput', signature: 'i(ppi)', bindScope: 'transient' + }), + 'void*' + ]], + ['sqlite3changeset_new', 'int', ['sqlite3_changeset_iter*', 'int', '**']], + ['sqlite3changeset_next', 'int', ['sqlite3_changeset_iter*']], + ['sqlite3changeset_old', 'int', ['sqlite3_changeset_iter*', 'int', '**']], + ['sqlite3changeset_op', 'int', [ + 'sqlite3_changeset_iter*', '**', 'int*', 'int*','int*' + ]], + ['sqlite3changeset_pk', 'int', ['sqlite3_changeset_iter*', '**', 'int*']], + ['sqlite3changeset_start', 'int', ['**', 'int', '*']], + ['sqlite3changeset_start_strm', 'int', [ + '**', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xInput', signature: 'i(ppp)', bindScope: 'transient' + }), + 'void*' + ]], + ['sqlite3changeset_start_v2', 'int', ['**', 'int', '*', 'int']], + ['sqlite3changeset_start_v2_strm', 'int', [ + '**', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xInput', signature: 'i(ppp)', bindScope: 'transient' + }), + 'void*', 'int' + ]], + ['sqlite3session_attach', 'int', ['sqlite3_session*', 'string']], + ['sqlite3session_changeset', 'int', ['sqlite3_session*', 'int*', '**']], + ['sqlite3session_changeset_size', 'i64', ['sqlite3_session*']], + ['sqlite3session_changeset_strm', 'int', [ + 'sqlite3_session*', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xOutput', signature: 'i(ppp)', bindScope: 'transient' + }), + 'void*' + ]], + ['sqlite3session_config', 'int', ['int', 'void*']], + ['sqlite3session_create', 'int', ['sqlite3*', 'string', '**']], + + ['sqlite3session_diff', 'int', ['sqlite3_session*', 'string', 'string', '**']], + ['sqlite3session_enable', 'int', ['sqlite3_session*', 'int']], + ['sqlite3session_indirect', 'int', ['sqlite3_session*', 'int']], + ['sqlite3session_isempty', 'int', ['sqlite3_session*']], + ['sqlite3session_memory_used', 'i64', ['sqlite3_session*']], + ['sqlite3session_object_config', 'int', ['sqlite3_session*', 'int', 'void*']], + ['sqlite3session_patchset', 'int', ['sqlite3_session*', '*', '**']], + ['sqlite3session_patchset_strm', 'int', [ + 'sqlite3_session*', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xOutput', signature: 'i(ppp)', bindScope: 'transient' + }), + 'void*' + ]], + ['sqlite3session_table_filter', undefined, [ + 'sqlite3_session*', + new wasm.xWrap.FuncPtrAdapter({ + name: 'xFilter', ...__ipsProxy, + contextKey: (argv,argIndex)=>argv[0] + }), + '*' + ]] + ); + } + + + sqlite3.StructBinder = globalThis.Jaccwabyt({ + heap: wasm.heap8u, + alloc: wasm.alloc, + dealloc: wasm.dealloc, + bigIntEnabled: wasm.bigIntEnabled, + pointerIR: wasm.ptr.ir, + memberPrefix: '$' + }); + delete globalThis.Jaccwabyt; + + { + + + const __xString = wasm.xWrap.argAdapter('string'); + wasm.xWrap.argAdapter( + 'string:flexible', (v)=>__xString(util.flexibleString(v)) + ); + + + wasm.xWrap.argAdapter( + 'string:static', + function(v){ + if(wasm.isPtr(v)) return v; + v = ''+v; + let rc = this[v]; + return rc || (this[v] = wasm.allocCString(v)); + }.bind(Object.create(null)) + ); + + + const __xArgPtr = wasm.xWrap.argAdapter('*'); + const nilType = function(){ + + }; + wasm.xWrap.argAdapter('sqlite3_filename', __xArgPtr) + ('sqlite3_backup*', __xArgPtr) + ('sqlite3_context*', __xArgPtr) + ('sqlite3_value*', __xArgPtr) + ('void*', __xArgPtr) + ('sqlite3_changegroup*', __xArgPtr) + ('sqlite3_changeset_iter*', __xArgPtr) + ('sqlite3_session*', __xArgPtr) + ('sqlite3_stmt*', (v)=> + __xArgPtr((v instanceof (sqlite3?.oo1?.Stmt || nilType)) + ? v.pointer : v)) + ('sqlite3*', (v)=> + __xArgPtr((v instanceof (sqlite3?.oo1?.DB || nilType)) + ? v.pointer : v)) + + ('sqlite3_vfs*', (v)=>{ + if('string'===typeof v){ + + return capi.sqlite3_vfs_find(v) + || sqlite3.SQLite3Error.toss( + capi.SQLITE_NOTFOUND, + "Unknown sqlite3_vfs name:", v + ); + } + return __xArgPtr((v instanceof (capi.sqlite3_vfs || nilType)) + ? v.pointer : v); + }); + if( wasm.exports.sqlite3_declare_vtab ){ + wasm.xWrap.argAdapter('sqlite3_index_info*', (v)=> + __xArgPtr((v instanceof (capi.sqlite3_index_info || nilType)) + ? v.pointer : v)) + ('sqlite3_module*', (v)=> + __xArgPtr((v instanceof (capi.sqlite3_module || nilType)) + ? v.pointer : v) + ); + } + + + const __xRcPtr = wasm.xWrap.resultAdapter('*'); + wasm.xWrap.resultAdapter('sqlite3*', __xRcPtr) + ('sqlite3_backup*', __xRcPtr) + ('sqlite3_context*', __xRcPtr) + ('sqlite3_stmt*', __xRcPtr) + ('sqlite3_value*', __xRcPtr) + ('sqlite3_vfs*', __xRcPtr) + ('void*', __xRcPtr); + + + for(const e of bindingSignatures.core){ + capi[e[0]] = wasm.xWrap.apply(null, e); + } + for(const e of bindingSignatures.wasmInternal){ + util[e[0]] = wasm.xWrap.apply(null, e); + } + + + for(const e of bindingSignatures.int64){ + capi[e[0]] = wasm.bigIntEnabled + ? wasm.xWrap.apply(null, e) + : ()=>toss(e[0]+"() is unavailable due to lack", + "of BigInt support in this build."); + } + + + delete bindingSignatures.core; + delete bindingSignatures.int64; + delete bindingSignatures.wasmInternal; + + + util.sqlite3__wasm_db_error = function(pDb, resultCode, message){ + if( !pDb ) return capi.SQLITE_MISUSE; + if(resultCode instanceof sqlite3.WasmAllocError){ + resultCode = capi.SQLITE_NOMEM; + message = 0 ; + }else if(resultCode instanceof Error){ + message = message || ''+resultCode; + resultCode = (resultCode.resultCode || capi.SQLITE_ERROR); + } + return capi.sqlite3_set_errmsg(pDb, resultCode, message) || resultCode; + }; + } + + { + const cJson = wasm.xCall('sqlite3__wasm_enum_json'); + if(!cJson){ + toss("Maintenance required: increase sqlite3__wasm_enum_json()'s", + "static buffer size!"); + } + wasm.ctype = JSON.parse(wasm.cstrToJs(cJson)); + + const defineGroups = ['access', 'authorizer', + 'blobFinalizers', 'changeset', + 'config', 'dataTypes', + 'dbConfig', 'dbStatus', + 'encodings', 'fcntl', 'flock', 'ioCap', + 'limits', 'openFlags', + 'prepareFlags', 'resultCodes', + 'sqlite3Status', + 'stmtStatus', 'syncFlags', + 'trace', 'txnState', 'udfFlags', + 'version']; + if(wasm.bigIntEnabled){ + defineGroups.push('serialize', 'session', 'vtab'); + } + for(const t of defineGroups){ + for(const e of Object.entries(wasm.ctype[t])){ + + + capi[e[0]] = e[1]; + } + } + if(!wasm.functionEntry(capi.SQLITE_WASM_DEALLOC)){ + toss("Internal error: cannot resolve exported function", + "entry SQLITE_WASM_DEALLOC (=="+capi.SQLITE_WASM_DEALLOC+")."); + } + const __rcMap = Object.create(null); + for(const e of Object.entries(wasm.ctype['resultCodes'])){ + __rcMap[e[1]] = e[0]; + } + + capi.sqlite3_js_rc_str = (rc)=>__rcMap[rc]; + + + const notThese = Object.assign(Object.create(null),{ + + WasmTestStruct: true, + + sqlite3_index_info: !wasm.bigIntEnabled, + sqlite3_index_constraint: !wasm.bigIntEnabled, + sqlite3_index_orderby: !wasm.bigIntEnabled, + sqlite3_index_constraint_usage: !wasm.bigIntEnabled + }); + for(const s of wasm.ctype.structs){ + if(!notThese[s.name]){ + capi[s.name] = sqlite3.StructBinder(s); + } + } + if(capi.sqlite3_index_info){ + + for(const k of ['sqlite3_index_constraint', + 'sqlite3_index_orderby', + 'sqlite3_index_constraint_usage']){ + capi.sqlite3_index_info[k] = capi[k]; + delete capi[k]; + } + capi.sqlite3_vtab_config = wasm.xWrap( + 'sqlite3__wasm_vtab_config','int',[ + 'sqlite3*', 'int', 'int'] + ); + } + } + + + const __dbArgcMismatch = (pDb,f,n)=>{ + return util.sqlite3__wasm_db_error(pDb, capi.SQLITE_MISUSE, + f+"() requires "+n+" argument"+ + (1===n?"":'s')+"."); + }; + + + const __errEncoding = (pDb)=>{ + return util.sqlite3__wasm_db_error( + pDb, capi.SQLITE_FORMAT, "SQLITE_UTF8 is the only supported encoding." + ); + }; + + + const __argPDb = (pDb)=>wasm.xWrap.argAdapter('sqlite3*')(pDb); + const __argStr = (str)=>wasm.isPtr(str) ? wasm.cstrToJs(str) : str; + const __dbCleanupMap = function( + pDb, mode + ){ + pDb = __argPDb(pDb); + let m = this.dbMap.get(pDb); + if(!mode){ + this.dbMap.delete(pDb); + return m; + }else if(!m && mode>0){ + this.dbMap.set(pDb, (m = Object.create(null))); + } + return m; + }.bind(Object.assign(Object.create(null),{ + dbMap: new Map + })); + + __dbCleanupMap.addCollation = function(pDb, name){ + const m = __dbCleanupMap(pDb, 1); + if(!m.collation) m.collation = new Set; + m.collation.add(__argStr(name).toLowerCase()); + }; + + __dbCleanupMap._addUDF = function(pDb, name, arity, map){ + + name = __argStr(name).toLowerCase(); + let u = map.get(name); + if(!u) map.set(name, (u = new Set)); + u.add((arity<0) ? -1 : arity); + }; + + __dbCleanupMap.addFunction = function(pDb, name, arity){ + const m = __dbCleanupMap(pDb, 1); + if(!m.udf) m.udf = new Map; + this._addUDF(pDb, name, arity, m.udf); + }; + + if( wasm.exports.sqlite3_create_window_function ){ + __dbCleanupMap.addWindowFunc = function(pDb, name, arity){ + const m = __dbCleanupMap(pDb, 1); + if(!m.wudf) m.wudf = new Map; + this._addUDF(pDb, name, arity, m.wudf); + }; + } + + + __dbCleanupMap.cleanup = function(pDb){ + pDb = __argPDb(pDb); + + + for(const obj of [ + + ['sqlite3_busy_handler',3], + ['sqlite3_commit_hook',3], + ['sqlite3_preupdate_hook',3], + ['sqlite3_progress_handler',4], + ['sqlite3_rollback_hook',3], + ['sqlite3_set_authorizer',3], + ['sqlite3_trace_v2', 4], + ['sqlite3_update_hook',3] + + ]){ + const [name, arity] = obj; + const x = wasm.exports[name]; + if( !x ){ + + continue; + } + const closeArgs = [pDb]; + closeArgs.length = arity + ; + + try{ capi[name](...closeArgs) } + catch(e){ + + sqlite3.config.warn("close-time call of",name+"(",closeArgs,") threw:",e); + } + + } + const m = __dbCleanupMap(pDb, 0); + if(!m) return; + if(m.collation){ + for(const name of m.collation){ + try{ + capi.sqlite3_create_collation_v2( + pDb, name, capi.SQLITE_UTF8, 0, 0, 0 + ); + }catch(e){ + + } + } + delete m.collation; + } + let i; + for(i = 0; i < 2; ++i){ + const fmap = i ? m.wudf : m.udf; + if(!fmap) continue; + const func = i + ? capi.sqlite3_create_window_function + : capi.sqlite3_create_function_v2; + for(const e of fmap){ + const name = e[0], arities = e[1]; + const fargs = [pDb, name, 0, capi.SQLITE_UTF8, 0, 0, 0, 0, 0]; + if(i) fargs.push(0); + for(const arity of arities){ + try{ fargs[2] = arity; func.apply(null, fargs); } + catch(e){} + } + arities.clear(); + } + fmap.clear(); + } + delete m.udf; + delete m.wudf; + }; + + { + const __sqlite3CloseV2 = wasm.xWrap("sqlite3_close_v2", "int", "sqlite3*"); + capi.sqlite3_close_v2 = function(pDb){ + if(1!==arguments.length) return __dbArgcMismatch(pDb, 'sqlite3_close_v2', 1); + if(pDb){ + try{__dbCleanupMap.cleanup(pDb)} catch(e){} + } + return __sqlite3CloseV2(pDb); + }; + } + + if(capi.sqlite3session_create){ + const __sqlite3SessionDelete = wasm.xWrap( + 'sqlite3session_delete', undefined, ['sqlite3_session*'] + ); + capi.sqlite3session_delete = function(pSession){ + if(1!==arguments.length){ + return __dbArgcMismatch(pDb, 'sqlite3session_delete', 1); + + } + else if(pSession){ + + capi.sqlite3session_table_filter(pSession, 0, 0); + } + __sqlite3SessionDelete(pSession); + }; + } + + { + + const contextKey = (argv,argIndex)=>{ + return 'argv['+argIndex+']:'+argv[0]+ + ':'+wasm.cstrToJs(argv[1]).toLowerCase() + }; + const __sqlite3CreateCollationV2 = wasm.xWrap( + 'sqlite3_create_collation_v2', 'int', [ + 'sqlite3*', 'string', 'int', '*', + new wasm.xWrap.FuncPtrAdapter({ + + name: 'xCompare', signature: 'i(pipip)', contextKey + }), + new wasm.xWrap.FuncPtrAdapter({ + + name: 'xDestroy', signature: 'v(p)', contextKey + }) + ] + ); + + + capi.sqlite3_create_collation_v2 = function(pDb,zName,eTextRep,pArg,xCompare,xDestroy){ + if(6!==arguments.length) return __dbArgcMismatch(pDb, 'sqlite3_create_collation_v2', 6); + else if( 0 === (eTextRep & 0xf) ){ + eTextRep |= capi.SQLITE_UTF8; + }else if( capi.SQLITE_UTF8 !== (eTextRep & 0xf) ){ + return __errEncoding(pDb); + } + try{ + const rc = __sqlite3CreateCollationV2(pDb, zName, eTextRep, pArg, xCompare, xDestroy); + if(0===rc && xCompare instanceof Function){ + __dbCleanupMap.addCollation(pDb, zName); + } + return rc; + }catch(e){ + return util.sqlite3__wasm_db_error(pDb, e); + } + }; + + capi.sqlite3_create_collation = (pDb,zName,eTextRep,pArg,xCompare)=>{ + return (5===arguments.length) + ? capi.sqlite3_create_collation_v2(pDb,zName,eTextRep,pArg,xCompare,0) + : __dbArgcMismatch(pDb, 'sqlite3_create_collation', 5); + }; + + } + + { + + const contextKey = function(argv,argIndex){ + return ( + argv[0] + +':'+(argv[2] < 0 ? -1 : argv[2]) + +':'+argIndex + +':'+wasm.cstrToJs(argv[1]).toLowerCase() + ) + }; + + + const __cfProxy = Object.assign(Object.create(null), { + xInverseAndStep: { + signature:'v(pip)', contextKey, + callProxy: (callback)=>{ + return (pCtx, argc, pArgv)=>{ + try{ callback(pCtx, ...capi.sqlite3_values_to_js(argc, pArgv)) } + catch(e){ capi.sqlite3_result_error_js(pCtx, e) } + }; + } + }, + xFinalAndValue: { + signature:'v(p)', contextKey, + callProxy: (callback)=>{ + return (pCtx)=>{ + try{ capi.sqlite3_result_js(pCtx, callback(pCtx)) } + catch(e){ capi.sqlite3_result_error_js(pCtx, e) } + }; + } + }, + xFunc: { + signature:'v(pip)', contextKey, + callProxy: (callback)=>{ + return (pCtx, argc, pArgv)=>{ + try{ + capi.sqlite3_result_js( + pCtx, + callback(pCtx, ...capi.sqlite3_values_to_js(argc, pArgv)) + ); + }catch(e){ + + capi.sqlite3_result_error_js(pCtx, e); + } + }; + } + }, + xDestroy: { + signature:'v(p)', contextKey, + + callProxy: (callback)=>{ + return (pVoid)=>{ + try{ callback(pVoid) } + catch(e){ console.error("UDF xDestroy method threw:",e) } + }; + } + } + }); + + const __sqlite3CreateFunction = wasm.xWrap( + "sqlite3_create_function_v2", "int", [ + "sqlite3*", "string", "int", + "int", "*", + new wasm.xWrap.FuncPtrAdapter({name: 'xFunc', ...__cfProxy.xFunc}), + new wasm.xWrap.FuncPtrAdapter({name: 'xStep', ...__cfProxy.xInverseAndStep}), + new wasm.xWrap.FuncPtrAdapter({name: 'xFinal', ...__cfProxy.xFinalAndValue}), + new wasm.xWrap.FuncPtrAdapter({name: 'xDestroy', ...__cfProxy.xDestroy}) + ] + ); + + const __sqlite3CreateWindowFunction = + wasm.exports.sqlite3_create_window_function + ? wasm.xWrap( + "sqlite3_create_window_function", "int", [ + "sqlite3*", "string", "int", + "int", "*", + new wasm.xWrap.FuncPtrAdapter({name: 'xStep', ...__cfProxy.xInverseAndStep}), + new wasm.xWrap.FuncPtrAdapter({name: 'xFinal', ...__cfProxy.xFinalAndValue}), + new wasm.xWrap.FuncPtrAdapter({name: 'xValue', ...__cfProxy.xFinalAndValue}), + new wasm.xWrap.FuncPtrAdapter({name: 'xInverse', ...__cfProxy.xInverseAndStep}), + new wasm.xWrap.FuncPtrAdapter({name: 'xDestroy', ...__cfProxy.xDestroy}) + ] + ) + : undefined; + + + capi.sqlite3_create_function_v2 = function f( + pDb, funcName, nArg, eTextRep, pApp, + xFunc, + xStep, + xFinal, + xDestroy + ){ + if( f.length!==arguments.length ){ + return __dbArgcMismatch(pDb,"sqlite3_create_function_v2",f.length); + }else if( 0 === (eTextRep & 0xf) ){ + eTextRep |= capi.SQLITE_UTF8; + }else if( capi.SQLITE_UTF8 !== (eTextRep & 0xf) ){ + return __errEncoding(pDb); + } + try{ + const rc = __sqlite3CreateFunction(pDb, funcName, nArg, eTextRep, + pApp, xFunc, xStep, xFinal, xDestroy); + if(0===rc && (xFunc instanceof Function + || xStep instanceof Function + || xFinal instanceof Function + || xDestroy instanceof Function)){ + __dbCleanupMap.addFunction(pDb, funcName, nArg); + } + return rc; + }catch(e){ + console.error("sqlite3_create_function_v2() setup threw:",e); + return util.sqlite3__wasm_db_error(pDb, e, "Creation of UDF threw: "+e); + } + }; + + + capi.sqlite3_create_function = function f( + pDb, funcName, nArg, eTextRep, pApp, + xFunc, xStep, xFinal + ){ + return (f.length===arguments.length) + ? capi.sqlite3_create_function_v2(pDb, funcName, nArg, eTextRep, + pApp, xFunc, xStep, xFinal, 0) + : __dbArgcMismatch(pDb,"sqlite3_create_function",f.length); + }; + + + if( __sqlite3CreateWindowFunction ){ + capi.sqlite3_create_window_function = function f( + pDb, funcName, nArg, eTextRep, pApp, + xStep, + xFinal, + xValue, + xInverse, + xDestroy + ){ + if( f.length!==arguments.length ){ + return __dbArgcMismatch(pDb,"sqlite3_create_window_function",f.length); + }else if( 0 === (eTextRep & 0xf) ){ + eTextRep |= capi.SQLITE_UTF8; + }else if( capi.SQLITE_UTF8 !== (eTextRep & 0xf) ){ + return __errEncoding(pDb); + } + try{ + const rc = __sqlite3CreateWindowFunction(pDb, funcName, nArg, eTextRep, + pApp, xStep, xFinal, xValue, + xInverse, xDestroy); + if(0===rc && (xStep instanceof Function + || xFinal instanceof Function + || xValue instanceof Function + || xInverse instanceof Function + || xDestroy instanceof Function)){ + __dbCleanupMap.addWindowFunc(pDb, funcName, nArg); + } + return rc; + }catch(e){ + console.error("sqlite3_create_window_function() setup threw:",e); + return util.sqlite3__wasm_db_error(pDb, e, "Creation of UDF threw: "+e); + } + }; + }else{ + delete capi.sqlite3_create_window_function; + } + + capi.sqlite3_create_function_v2.udfSetResult = + capi.sqlite3_create_function.udfSetResult = capi.sqlite3_result_js; + if(capi.sqlite3_create_window_function){ + capi.sqlite3_create_window_function.udfSetResult = capi.sqlite3_result_js; + } + + + capi.sqlite3_create_function_v2.udfConvertArgs = + capi.sqlite3_create_function.udfConvertArgs = capi.sqlite3_values_to_js; + if(capi.sqlite3_create_window_function){ + capi.sqlite3_create_window_function.udfConvertArgs = capi.sqlite3_values_to_js; + } + + + capi.sqlite3_create_function_v2.udfSetError = + capi.sqlite3_create_function.udfSetError = capi.sqlite3_result_error_js; + if(capi.sqlite3_create_window_function){ + capi.sqlite3_create_window_function.udfSetError = capi.sqlite3_result_error_js; + } + + }; + + { + + + const __flexiString = (v,n)=>{ + if('string'===typeof v){ + n = -1; + }else if(util.isSQLableTypedArray(v)){ + n = v.byteLength; + v = wasm.typedArrayToString( + (v instanceof ArrayBuffer) ? new Uint8Array(v) : v + ); + }else if(Array.isArray(v)){ + v = v.join(""); + n = -1; + } + return [v, n]; + }; + + + const __prepare = { + + basic: wasm.xWrap('sqlite3_prepare_v3', + "int", ["sqlite3*", "string", + "int", + "int", "**", + "**"]), + + full: wasm.xWrap('sqlite3_prepare_v3', + "int", ["sqlite3*", "*", "int", "int", + "**", "**"]) + }; + + + capi.sqlite3_prepare_v3 = function f(pDb, sql, sqlLen, prepFlags, ppStmt, pzTail){ + if(f.length!==arguments.length){ + return __dbArgcMismatch(pDb,"sqlite3_prepare_v3",f.length); + } + const [xSql, xSqlLen] = __flexiString(sql, Number(sqlLen)); + switch(typeof xSql){ + case 'string': return __prepare.basic(pDb, xSql, xSqlLen, prepFlags, ppStmt, null); + case (typeof wasm.ptr.null): + return __prepare.full(pDb, wasm.ptr.coerce(xSql), xSqlLen, prepFlags, + ppStmt, pzTail); + default: + return util.sqlite3__wasm_db_error( + pDb, capi.SQLITE_MISUSE, + "Invalid SQL argument type for sqlite3_prepare_v2/v3(). typeof="+(typeof xSql) + ); + } + }; + + + capi.sqlite3_prepare_v2 = function f(pDb, sql, sqlLen, ppStmt, pzTail){ + return (f.length===arguments.length) + ? capi.sqlite3_prepare_v3(pDb, sql, sqlLen, 0, ppStmt, pzTail) + : __dbArgcMismatch(pDb,"sqlite3_prepare_v2",f.length); + }; + + } + + { + const __bindText = wasm.xWrap("sqlite3_bind_text", "int", [ + "sqlite3_stmt*", "int", "string", "int", "*" + ]); + const __bindBlob = wasm.xWrap("sqlite3_bind_blob", "int", [ + "sqlite3_stmt*", "int", "*", "int", "*" + ]); + + + capi.sqlite3_bind_text = function f(pStmt, iCol, text, nText, xDestroy){ + if(f.length!==arguments.length){ + return __dbArgcMismatch(capi.sqlite3_db_handle(pStmt), + "sqlite3_bind_text", f.length); + }else if(wasm.isPtr(text) || null===text){ + return __bindText(pStmt, iCol, text, nText, xDestroy); + }else if(text instanceof ArrayBuffer){ + text = new Uint8Array(text); + }else if(Array.isArray(pMem)){ + text = pMem.join(''); + } + let p, n; + try{ + if(util.isSQLableTypedArray(text)){ + p = wasm.allocFromTypedArray(text); + n = text.byteLength; + }else if('string'===typeof text){ + [p, n] = wasm.allocCString(text); + }else{ + return util.sqlite3__wasm_db_error( + capi.sqlite3_db_handle(pStmt), capi.SQLITE_MISUSE, + "Invalid 3rd argument type for sqlite3_bind_text()." + ); + } + return __bindText(pStmt, iCol, p, n, capi.SQLITE_WASM_DEALLOC); + }catch(e){ + wasm.dealloc(p); + return util.sqlite3__wasm_db_error( + capi.sqlite3_db_handle(pStmt), e + ); + } + }; + + + capi.sqlite3_bind_blob = function f(pStmt, iCol, pMem, nMem, xDestroy){ + if(f.length!==arguments.length){ + return __dbArgcMismatch(capi.sqlite3_db_handle(pStmt), + "sqlite3_bind_blob", f.length); + }else if(wasm.isPtr(pMem) || null===pMem){ + return __bindBlob(pStmt, iCol, pMem, nMem, xDestroy); + }else if(pMem instanceof ArrayBuffer){ + pMem = new Uint8Array(pMem); + }else if(Array.isArray(pMem)){ + pMem = pMem.join(''); + } + let p, n; + try{ + if(util.isBindableTypedArray(pMem)){ + p = wasm.allocFromTypedArray(pMem); + n = nMem>=0 ? nMem : pMem.byteLength; + }else if('string'===typeof pMem){ + [p, n] = wasm.allocCString(pMem); + }else{ + return util.sqlite3__wasm_db_error( + capi.sqlite3_db_handle(pStmt), capi.SQLITE_MISUSE, + "Invalid 3rd argument type for sqlite3_bind_blob()." + ); + } + return __bindBlob(pStmt, iCol, p, n, capi.SQLITE_WASM_DEALLOC); + }catch(e){ + wasm.dealloc(p); + return util.sqlite3__wasm_db_error( + capi.sqlite3_db_handle(pStmt), e + ); + } + }; + + } + + if(!capi.sqlite3_column_text){ + + const argStmt = wasm.xWrap.argAdapter('sqlite3_stmt*'), + argInt = wasm.xWrap.argAdapter('int'), + argValue = wasm.xWrap.argAdapter('sqlite3_value*'), + newStr = + (cstr,n)=>wasm.typedArrayToString(wasm.heap8u(), + Number(cstr), Number(cstr)+n) + capi.sqlite3_column_text = function(stmt, colIndex){ + const a0 = argStmt(stmt), a1 = argInt(colIndex); + const cstr = wasm.exports.sqlite3_column_text(a0, a1); + return cstr + ? newStr(cstr,wasm.exports.sqlite3_column_bytes(a0, a1)) + : null; + }; + capi.sqlite3_value_text = function(val){ + const a0 = argValue(val); + const cstr = wasm.exports.sqlite3_value_text(a0); + return cstr + ? newStr(cstr,wasm.exports.sqlite3_value_bytes(a0)) + : null; + }; + } + + { + + capi.sqlite3_config = function(op, ...args){ + if(arguments.length<2) return capi.SQLITE_MISUSE; + switch(op){ + case capi.SQLITE_CONFIG_COVERING_INDEX_SCAN: + case capi.SQLITE_CONFIG_MEMSTATUS: + case capi.SQLITE_CONFIG_SMALL_MALLOC: + case capi.SQLITE_CONFIG_SORTERREF_SIZE: + case capi.SQLITE_CONFIG_STMTJRNL_SPILL: + case capi.SQLITE_CONFIG_URI: + return wasm.exports.sqlite3__wasm_config_i(op, args[0]); + case capi.SQLITE_CONFIG_LOOKASIDE: + return wasm.exports.sqlite3__wasm_config_ii(op, args[0], args[1]); + case capi.SQLITE_CONFIG_MEMDB_MAXSIZE: + return wasm.exports.sqlite3__wasm_config_j(op, args[0]); + case capi.SQLITE_CONFIG_GETMALLOC: + case capi.SQLITE_CONFIG_GETMUTEX: + case capi.SQLITE_CONFIG_GETPCACHE2: + case capi.SQLITE_CONFIG_GETPCACHE: + case capi.SQLITE_CONFIG_HEAP: + case capi.SQLITE_CONFIG_LOG: + case capi.SQLITE_CONFIG_MALLOC: + case capi.SQLITE_CONFIG_MMAP_SIZE: + case capi.SQLITE_CONFIG_MULTITHREAD: + case capi.SQLITE_CONFIG_MUTEX: + case capi.SQLITE_CONFIG_PAGECACHE: + case capi.SQLITE_CONFIG_PCACHE2: + case capi.SQLITE_CONFIG_PCACHE: + case capi.SQLITE_CONFIG_PCACHE_HDRSZ: + case capi.SQLITE_CONFIG_PMASZ: + case capi.SQLITE_CONFIG_SERIALIZED: + case capi.SQLITE_CONFIG_SINGLETHREAD: + case capi.SQLITE_CONFIG_SQLLOG: + case capi.SQLITE_CONFIG_WIN32_HEAPSIZE: + default: + + return capi.SQLITE_NOTFOUND; + } + }; + } + + { + const __autoExtFptr = new Set; + + capi.sqlite3_auto_extension = function(fPtr){ + if( fPtr instanceof Function ){ + fPtr = wasm.installFunction('i(ppp)', fPtr); + }else if( 1!==arguments.length || !wasm.isPtr(fPtr) ){ + return capi.SQLITE_MISUSE; + } + const rc = wasm.exports.sqlite3_auto_extension(fPtr); + if( fPtr!==arguments[0] ){ + if(0===rc) __autoExtFptr.add(fPtr); + else wasm.uninstallFunction(fPtr); + } + return rc; + }; + + capi.sqlite3_cancel_auto_extension = function(fPtr){ + ; + if(!fPtr || 1!==arguments.length || !wasm.isPtr(fPtr)) return 0; + return wasm.exports.sqlite3_cancel_auto_extension(fPtr); + + }; + + capi.sqlite3_reset_auto_extension = function(){ + wasm.exports.sqlite3_reset_auto_extension(); + for(const fp of __autoExtFptr) wasm.uninstallFunction(fp); + __autoExtFptr.clear(); + }; + } + + + wasm.xWrap.FuncPtrAdapter.warnOnUse = true; + + const StructBinder = sqlite3.StructBinder + ; + + const installMethod = function callee( + tgt, name, func, applyArgcCheck = callee.installMethodArgcCheck + ){ + if(!(tgt instanceof StructBinder.StructType)){ + toss("Usage error: target object is-not-a StructType."); + }else if(!(func instanceof Function) && !wasm.isPtr(func)){ + toss("Usage error: expecting a Function or WASM pointer to one."); + } + if(1===arguments.length){ + return (n,f)=>callee(tgt, n, f, applyArgcCheck); + } + if(!callee.argcProxy){ + callee.argcProxy = function(tgt, funcName, func,sig){ + return function(...args){ + if(func.length!==arguments.length){ + toss("Argument mismatch for", + tgt.structInfo.name+"::"+funcName + +": Native signature is:",sig); + } + return func.apply(this, args); + } + }; + + callee.removeFuncList = function(){ + if(this.ondispose.__removeFuncList){ + this.ondispose.__removeFuncList.forEach( + (v,ndx)=>{ + if(wasm.isPtr(v)){ + try{wasm.uninstallFunction(v)} + catch(e){} + } + + } + ); + delete this.ondispose.__removeFuncList; + } + }; + } + const sigN = tgt.memberSignature(name); + if(sigN.length<2){ + toss("Member",name,"does not have a function pointer signature:",sigN); + } + const memKey = tgt.memberKey(name); + const fProxy = (applyArgcCheck && !wasm.isPtr(func)) + + ? callee.argcProxy(tgt, memKey, func, sigN) + : func; + if(wasm.isPtr(fProxy)){ + if(fProxy && !wasm.functionEntry(fProxy)){ + toss("Pointer",fProxy,"is not a WASM function table entry."); + } + tgt[memKey] = fProxy; + }else{ + const pFunc = wasm.installFunction(fProxy, sigN); + tgt[memKey] = pFunc; + if(!tgt.ondispose || !tgt.ondispose.__removeFuncList){ + tgt.addOnDispose('ondispose.__removeFuncList handler', + callee.removeFuncList); + tgt.ondispose.__removeFuncList = []; + } + tgt.ondispose.__removeFuncList.push(memKey, pFunc); + } + return (n,f)=>callee(tgt, n, f, applyArgcCheck); + }; + installMethod.installMethodArgcCheck = false; + + + const installMethods = function( + structInstance, methods, applyArgcCheck = installMethod.installMethodArgcCheck + ){ + const seen = new Map ; + for(const k of Object.keys(methods)){ + const m = methods[k]; + const prior = seen.get(m); + if(prior){ + const mkey = structInstance.memberKey(k); + structInstance[mkey] = structInstance[structInstance.memberKey(prior)]; + }else{ + installMethod(structInstance, k, m, applyArgcCheck); + seen.set(m, k); + } + } + return structInstance; + }; + + + StructBinder.StructType.prototype.installMethod = function callee( + name, func, applyArgcCheck = installMethod.installMethodArgcCheck + ){ + return (arguments.length < 3 && name && 'object'===typeof name) + ? installMethods(this, ...arguments) + : installMethod(this, ...arguments); + }; + + + StructBinder.StructType.prototype.installMethods = function( + methods, applyArgcCheck = installMethod.installMethodArgcCheck + ){ + return installMethods(this, methods, applyArgcCheck); + }; + +}); + +globalThis.sqlite3ApiBootstrap.initializers.push(function(sqlite3){ + const toss3 = (...args)=>{throw new sqlite3.SQLite3Error(...args)}; + + const capi = sqlite3.capi, wasm = sqlite3.wasm, util = sqlite3.util; + + + const outWrapper = function(f){ + return (...args)=>f("sqlite3.oo1:",...args); + }; + + const debug = sqlite3.__isUnderTest + ? outWrapper(console.debug.bind(console)) + : outWrapper(sqlite3.config.debug); + const warn = sqlite3.__isUnderTest + ? outWrapper(console.warn.bind(console)) + : outWrapper(sqlite3.config.warn); + const error = sqlite3.__isUnderTest + ? outWrapper(console.error.bind(console)) + : outWrapper(sqlite3.config.error); + + + const __ptrMap = new WeakMap(); + + const __doesNotOwnHandle = new Set(); + + const __stmtMap = new WeakMap(); + + + const getOwnOption = (opts, p, dflt)=>{ + const d = Object.getOwnPropertyDescriptor(opts,p); + return d ? d.value : dflt; + }; + + + const checkSqlite3Rc = function(dbPtr, sqliteResultCode){ + if(sqliteResultCode){ + if(dbPtr instanceof DB) dbPtr = dbPtr.pointer; + toss3( + sqliteResultCode, + "sqlite3 result code",sqliteResultCode+":", + (dbPtr + ? capi.sqlite3_errmsg(dbPtr) + : capi.sqlite3_errstr(sqliteResultCode)) + ); + } + return arguments[0]; + }; + + + const __dbTraceToConsole = + wasm.installFunction('i(ippp)', function(t,c,p,x){ + if(capi.SQLITE_TRACE_STMT===t){ + + console.log("SQL TRACE #"+(++this.counter), + 'via sqlite3@'+c+'['+capi.sqlite3_db_filename(c,null)+']', + wasm.cstrToJs(x)); + } + }.bind({counter: 0})); + + + const __vfsPostOpenCallback = Object.create(null); + + + const byteArrayToHex = function(ba){ + if( ba instanceof ArrayBuffer ){ + ba = new Uint8Array(ba); + } + const li = []; + const digits = "0123456789abcdef"; + for( const d of ba ){ + li.push( digits[(d & 0xf0) >> 4], digits[d & 0x0f] ); + } + return li.join(''); + }; + + + const dbCtorApplySEEKey = function(db,opt){ + if( !capi.sqlite3_key_v2 ) return; + let keytype; + let key; + const check = (opt.key ? 1 : 0) + (opt.hexkey ? 1 : 0) + (opt.textkey ? 1 : 0); + if( !check ) return; + else if( check>1 ){ + toss3(capi.SQLITE_MISUSE, + "Only ONE of (key, hexkey, textkey) may be provided."); + } + if( opt.key ){ + + keytype = 'key'; + key = opt.key; + if('string'===typeof key){ + key = new TextEncoder('utf-8').encode(key); + } + if((key instanceof ArrayBuffer) || (key instanceof Uint8Array)){ + key = byteArrayToHex(key); + keytype = 'hexkey'; + }else{ + toss3(capi.SQLITE_MISUSE, + "Invalid value for the 'key' option. Expecting a string,", + "ArrayBuffer, or Uint8Array."); + return; + } + }else if( opt.textkey ){ + + keytype = 'textkey'; + key = opt.textkey; + if(key instanceof ArrayBuffer){ + key = new Uint8Array(key); + } + if(key instanceof Uint8Array){ + key = new TextDecoder('utf-8').decode(key); + }else if('string'!==typeof key){ + toss3(capi.SQLITE_MISUSE, + "Invalid value for the 'textkey' option. Expecting a string,", + "ArrayBuffer, or Uint8Array."); + } + }else if( opt.hexkey ){ + keytype = 'hexkey'; + key = opt.hexkey; + if((key instanceof ArrayBuffer) || (key instanceof Uint8Array)){ + key = byteArrayToHex(key); + }else if('string'!==typeof key){ + toss3(capi.SQLITE_MISUSE, + "Invalid value for the 'hexkey' option. Expecting a string,", + "ArrayBuffer, or Uint8Array."); + } + + }else{ + return; + } + let stmt; + try{ + stmt = db.prepare("PRAGMA "+keytype+"="+util.sqlite3__wasm_qfmt_token(key, 1)); + stmt.step(); + return true; + }finally{ + if(stmt) stmt.finalize(); + } + }; + + + const dbCtorHelper = function ctor(...args){ + const opt = ctor.normalizeArgs(...args); + + let pDb; + if( (pDb = opt['sqlite3*']) ){ + + + if( !opt['sqlite3*:takeOwnership'] ){ + + __doesNotOwnHandle.add(this); + } + this.filename = capi.sqlite3_db_filename(pDb,'main'); + }else{ + let fn = opt.filename, vfsName = opt.vfs, flagsStr = opt.flags; + if( ('string'!==typeof fn && !wasm.isPtr(fn)) + || 'string'!==typeof flagsStr + || (vfsName && ('string'!==typeof vfsName && !wasm.isPtr(vfsName))) ){ + sqlite3.config.error("Invalid DB ctor args",opt,arguments); + toss3("Invalid arguments for DB constructor:", arguments, "opts:", opt); + } + let oflags = 0; + if( flagsStr.indexOf('c')>=0 ){ + oflags |= capi.SQLITE_OPEN_CREATE | capi.SQLITE_OPEN_READWRITE; + } + if( flagsStr.indexOf('w')>=0 ) oflags |= capi.SQLITE_OPEN_READWRITE; + if( 0===oflags ) oflags |= capi.SQLITE_OPEN_READONLY; + oflags |= capi.SQLITE_OPEN_EXRESCODE; + const stack = wasm.pstack.pointer; + try { + const pPtr = wasm.pstack.allocPtr() ; + let rc = capi.sqlite3_open_v2(fn, pPtr, oflags, vfsName || wasm.ptr.null); + pDb = wasm.peekPtr(pPtr); + checkSqlite3Rc(pDb, rc); + capi.sqlite3_extended_result_codes(pDb, 1); + if(flagsStr.indexOf('t')>=0){ + capi.sqlite3_trace_v2(pDb, capi.SQLITE_TRACE_STMT, + __dbTraceToConsole, pDb); + } + }catch( e ){ + if( pDb ) capi.sqlite3_close_v2(pDb); + throw e; + }finally{ + wasm.pstack.restore(stack); + } + this.filename = + wasm.isPtr(fn) ? wasm.cstrToJs(fn) : fn; + + } + __ptrMap.set(this, pDb); + __stmtMap.set(this, Object.create(null)); + if( !opt['sqlite3*'] ){ + try{ + dbCtorApplySEEKey(this,opt); + + const pVfs = capi.sqlite3_js_db_vfs(pDb) + || toss3("Internal error: cannot get VFS for new db handle."); + const postInitSql = __vfsPostOpenCallback[pVfs]; + if(postInitSql){ + + if(postInitSql instanceof Function){ + postInitSql(this, sqlite3); + }else{ + checkSqlite3Rc( + pDb, capi.sqlite3_exec(pDb, postInitSql, 0, 0, 0) + ); + } + } + }catch(e){ + this.close(); + throw e; + } + } + }; + + + dbCtorHelper.setVfsPostOpenCallback = function(pVfs, callback){ + if( !(callback instanceof Function)){ + toss3("dbCtorHelper.setVfsPostOpenCallback() should not be used with "+ + "a non-function argument.",arguments); + } + __vfsPostOpenCallback[pVfs] = callback; + }; + + + dbCtorHelper.normalizeArgs = function(filename=':memory:',flags = 'c',vfs = null){ + const arg = {}; + if(1===arguments.length && arguments[0] && 'object'===typeof arguments[0]){ + Object.assign(arg, arguments[0]); + if(undefined===arg.flags) arg.flags = 'c'; + if(undefined===arg.vfs) arg.vfs = null; + if(undefined===arg.filename) arg.filename = ':memory:'; + }else{ + arg.filename = filename; + arg.flags = flags; + arg.vfs = vfs; + } + return arg; + }; + + const DB = function(...args){ + dbCtorHelper.apply(this, args); + }; + DB.dbCtorHelper = dbCtorHelper; + + + const BindTypes = { + null: 1, + number: 2, + string: 3, + boolean: 4, + blob: 5 + }; + if(wasm.bigIntEnabled){ + BindTypes.bigint = BindTypes.number; + } + + + const Stmt = function(){ + if(BindTypes!==arguments[2]){ + toss3(capi.SQLITE_MISUSE, "Do not call the Stmt constructor directly. Use DB.prepare()."); + } + this.db = arguments[0]; + __ptrMap.set(this, arguments[1]); + if( arguments.length>3 && !arguments[3] ){ + __doesNotOwnHandle.add(this); + } + }; + + + const affirmDbOpen = function(db){ + if(!db.pointer) toss3("DB has been closed."); + return db; + }; + + + const affirmColIndex = function(stmt,ndx){ + if((ndx !== (ndx|0)) || ndx<0 || ndx>=stmt.columnCount){ + toss3("Column index",ndx,"is out of range."); + } + return stmt; + }; + + + const parseExecArgs = function(db, args){ + const out = Object.create(null); + out.opt = Object.create(null); + switch(args.length){ + case 1: + if('string'===typeof args[0] || util.isSQLableTypedArray(args[0])){ + out.sql = args[0]; + }else if(Array.isArray(args[0])){ + out.sql = args[0]; + }else if(args[0] && 'object'===typeof args[0]){ + out.opt = args[0]; + out.sql = out.opt.sql; + } + break; + case 2: + out.sql = args[0]; + out.opt = args[1]; + break; + default: toss3("Invalid argument count for exec()."); + }; + out.sql = util.flexibleString(out.sql); + if('string'!==typeof out.sql){ + toss3("Missing SQL argument or unsupported SQL value type."); + } + const opt = out.opt; + switch(opt.returnValue){ + case 'resultRows': + if(!opt.resultRows) opt.resultRows = []; + out.returnVal = ()=>opt.resultRows; + break; + case 'saveSql': + if(!opt.saveSql) opt.saveSql = []; + out.returnVal = ()=>opt.saveSql; + break; + case undefined: + case 'this': + out.returnVal = ()=>db; + break; + default: + toss3("Invalid returnValue value:",opt.returnValue); + } + if(!opt.callback && !opt.returnValue && undefined!==opt.rowMode){ + if(!opt.resultRows) opt.resultRows = []; + out.returnVal = ()=>opt.resultRows; + } + if(opt.callback || opt.resultRows){ + switch((undefined===opt.rowMode) ? 'array' : opt.rowMode) { + case 'object': + out.cbArg = (stmt,cache)=>{ + if( !cache.columnNames ) cache.columnNames = stmt.getColumnNames([]); + + const row = stmt.get([]); + const rv = Object.create(null); + for( const i in cache.columnNames ) rv[cache.columnNames[i]] = row[i]; + return rv; + }; + break; + case 'array': out.cbArg = (stmt)=>stmt.get([]); break; + case 'stmt': + if(Array.isArray(opt.resultRows)){ + toss3("exec(): invalid rowMode for a resultRows array: must", + "be one of 'array', 'object',", + "a result column number, or column name reference."); + } + out.cbArg = (stmt)=>stmt; + break; + default: + if(util.isInt32(opt.rowMode)){ + out.cbArg = (stmt)=>stmt.get(opt.rowMode); + break; + }else if('string'===typeof opt.rowMode + && opt.rowMode.length>1 + && '$'===opt.rowMode[0]){ + + const $colName = opt.rowMode.substr(1); + out.cbArg = (stmt)=>{ + const rc = stmt.get(Object.create(null))[$colName]; + return (undefined===rc) + ? toss3(capi.SQLITE_NOTFOUND, + "exec(): unknown result column:",$colName) + : rc; + }; + break; + } + toss3("Invalid rowMode:",opt.rowMode); + } + } + return out; + }; + + + const __selectFirstRow = (db, sql, bind, ...getArgs)=>{ + const stmt = db.prepare(sql); + try { + const rc = stmt.bind(bind).step() ? stmt.get(...getArgs) : undefined; + stmt.reset(); + return rc; + }finally{ + stmt.finalize(); + } + }; + + + const __selectAll = + (db, sql, bind, rowMode)=>db.exec({ + sql, bind, rowMode, returnValue: 'resultRows' + }); + + + const __stmtGetColumn = (stmt, ndx, asType)=>{ + switch(undefined===asType + ? capi.sqlite3_column_type(stmt.pointer, ndx) + : asType){ + case capi.SQLITE_NULL: return null; + case capi.SQLITE_INTEGER:{ + if(wasm.bigIntEnabled){ + const rc = capi.sqlite3_column_int64(stmt.pointer, ndx); + if(rc>=Number.MIN_SAFE_INTEGER && rc<=Number.MAX_SAFE_INTEGER){ + return Number(rc).valueOf(); + } + return rc; + }else{ + const rc = capi.sqlite3_column_double(stmt.pointer, ndx); + if(rc>Number.MAX_SAFE_INTEGER || rccheckSqlite3Rc(db,resultCode); + + DB.prototype = { + + isOpen: function(){ + return !!this.pointer; + }, + + affirmOpen: function(){ + return affirmDbOpen(this); + }, + + close: function(){ + const pDb = this.pointer; + if(pDb){ + if(this.onclose?.before instanceof Function){ + try{this.onclose.before(this)} + catch(e){} + } + for(const s of Object.values(__stmtMap.get(this))){ + if(s?.pointer){ + try{s.finalize()} + catch(ex){} + } + } + __ptrMap.delete(this); + __stmtMap.delete(this); + if( !__doesNotOwnHandle.delete(this) ){ + capi.sqlite3_close_v2(pDb); + } + if(this.onclose?.after instanceof Function){ + try{this.onclose.after(this)} + catch(e){} + } + delete this.filename; + } + }, + + changes: function(total=false,sixtyFour=false){ + const p = affirmDbOpen(this).pointer; + if(total){ + return sixtyFour + ? capi.sqlite3_total_changes64(p) + : capi.sqlite3_total_changes(p); + }else{ + return sixtyFour + ? capi.sqlite3_changes64(p) + : capi.sqlite3_changes(p); + } + }, + + dbFilename: function(dbName='main'){ + return capi.sqlite3_db_filename(affirmDbOpen(this).pointer, dbName); + }, + + dbName: function(dbNumber=0){ + return capi.sqlite3_db_name(affirmDbOpen(this).pointer, dbNumber); + }, + + dbVfsName: function(dbName=0){ + let rc; + const pVfs = capi.sqlite3_js_db_vfs( + affirmDbOpen(this).pointer, dbName + ); + if(pVfs){ + const v = new capi.sqlite3_vfs(pVfs); + try{ rc = wasm.cstrToJs(v.$zName) } + finally { v.dispose() } + } + return rc; + }, + + prepare: function(sql){ + affirmDbOpen(this); + const stack = wasm.pstack.pointer; + let ppStmt, pStmt; + try{ + ppStmt = wasm.pstack.alloc(8); + DB.checkRc(this, capi.sqlite3_prepare_v2(this.pointer, sql, -1, ppStmt, null)); + pStmt = wasm.peekPtr(ppStmt); + } + finally { + wasm.pstack.restore(stack); + } + if(!pStmt) toss3("Cannot prepare empty SQL."); + const stmt = new Stmt(this, pStmt, BindTypes); + __stmtMap.get(this)[pStmt] = stmt; + return stmt; + }, + + exec: function(){ + affirmDbOpen(this); + const arg = parseExecArgs(this, arguments); + if(!arg.sql){ + return toss3("exec() requires an SQL string."); + } + const opt = arg.opt; + const callback = opt.callback; + const resultRows = + Array.isArray(opt.resultRows) ? opt.resultRows : undefined; + let stmt; + let bind = opt.bind; + let evalFirstResult = !!( + arg.cbArg || opt.columnNames || resultRows + ) ; + const stack = wasm.scopedAllocPush(); + const saveSql = Array.isArray(opt.saveSql) ? opt.saveSql : undefined; + try{ + const isTA = util.isSQLableTypedArray(arg.sql) + ; + + let sqlByteLen = isTA ? arg.sql.byteLength : wasm.jstrlen(arg.sql); + const ppStmt = wasm.scopedAlloc( + + (2 * wasm.ptr.size) + (sqlByteLen + 1) + ); + const pzTail = wasm.ptr.add(ppStmt, wasm.ptr.size) ; + let pSql = wasm.ptr.add(pzTail, wasm.ptr.size); + const pSqlEnd = wasm.ptr.add(pSql, sqlByteLen); + if(isTA) wasm.heap8().set(arg.sql, pSql); + else wasm.jstrcpy(arg.sql, wasm.heap8(), pSql, sqlByteLen, false); + wasm.poke8(wasm.ptr.add(pSql, sqlByteLen), 0); + while(pSql && wasm.peek8(pSql) + ){ + wasm.pokePtr([ppStmt, pzTail], 0); + DB.checkRc(this, capi.sqlite3_prepare_v3( + this.pointer, pSql, sqlByteLen, 0, ppStmt, pzTail + )); + const pStmt = wasm.peekPtr(ppStmt); + pSql = wasm.peekPtr(pzTail); + sqlByteLen = Number(wasm.ptr.add(pSqlEnd,-pSql)); + if(!pStmt) continue; + + if(saveSql) saveSql.push(capi.sqlite3_sql(pStmt).trim()); + stmt = new Stmt(this, pStmt, BindTypes); + if(bind && stmt.parameterCount){ + stmt.bind(bind); + bind = null; + } + if(evalFirstResult && stmt.columnCount){ + + let gotColNames = Array.isArray( + opt.columnNames + ) ? 0 : 1; + evalFirstResult = false; + if(arg.cbArg || resultRows){ + const cbArgCache = Object.create(null) + ; + for( ; stmt.step(); __execLock.delete(stmt) ){ + if(0===gotColNames++){ + stmt.getColumnNames(cbArgCache.columnNames = (opt.columnNames || [])); + } + __execLock.add(stmt); + const row = arg.cbArg(stmt,cbArgCache); + if(resultRows) resultRows.push(row); + if(callback && false === callback.call(opt, row, stmt)){ + break; + } + } + __execLock.delete(stmt); + } + if(0===gotColNames){ + + stmt.getColumnNames(opt.columnNames); + } + }else{ + stmt.step(); + } + stmt.reset( + ).finalize(); + stmt = null; + } + }finally{ + if(stmt){ + __execLock.delete(stmt); + stmt.finalize(); + } + wasm.scopedAllocPop(stack); + } + return arg.returnVal(); + }, + + + + createFunction: function f(name, xFunc, opt){ + const isFunc = (f)=>(f instanceof Function); + switch(arguments.length){ + case 1: + opt = name; + name = opt.name; + xFunc = opt.xFunc || 0; + break; + case 2: + if(!isFunc(xFunc)){ + opt = xFunc; + xFunc = opt.xFunc || 0; + } + break; + case 3: + break; + default: break; + } + if(!opt) opt = {}; + if('string' !== typeof name){ + toss3("Invalid arguments: missing function name."); + } + let xStep = opt.xStep || 0; + let xFinal = opt.xFinal || 0; + const xValue = opt.xValue || 0; + const xInverse = opt.xInverse || 0; + let isWindow = undefined; + if(isFunc(xFunc)){ + isWindow = false; + if(isFunc(xStep) || isFunc(xFinal)){ + toss3("Ambiguous arguments: scalar or aggregate?"); + } + xStep = xFinal = null; + }else if(isFunc(xStep)){ + if(!isFunc(xFinal)){ + toss3("Missing xFinal() callback for aggregate or window UDF."); + } + xFunc = null; + }else if(isFunc(xFinal)){ + toss3("Missing xStep() callback for aggregate or window UDF."); + }else{ + toss3("Missing function-type properties."); + } + if(false === isWindow){ + if(isFunc(xValue) || isFunc(xInverse)){ + toss3("xValue and xInverse are not permitted for non-window UDFs."); + } + }else if(isFunc(xValue)){ + if(!isFunc(xInverse)){ + toss3("xInverse must be provided if xValue is."); + } + isWindow = true; + }else if(isFunc(xInverse)){ + toss3("xValue must be provided if xInverse is."); + } + const pApp = opt.pApp; + if( undefined!==pApp + && null!==pApp + && !wasm.isPtr(pApp) ){ + toss3("Invalid value for pApp property. Must be a legal WASM pointer value."); + } + const xDestroy = opt.xDestroy || 0; + if(xDestroy && !isFunc(xDestroy)){ + toss3("xDestroy property must be a function."); + } + let fFlags = 0 ; + if(getOwnOption(opt, 'deterministic')) fFlags |= capi.SQLITE_DETERMINISTIC; + if(getOwnOption(opt, 'directOnly')) fFlags |= capi.SQLITE_DIRECTONLY; + if(getOwnOption(opt, 'innocuous')) fFlags |= capi.SQLITE_INNOCUOUS; + name = name.toLowerCase(); + const xArity = xFunc || xStep; + const arity = getOwnOption(opt, 'arity'); + const arityArg = ('number'===typeof arity + ? arity + : (xArity.length ? xArity.length-1 : 0)); + let rc; + if( isWindow ){ + rc = capi.sqlite3_create_window_function( + this.pointer, name, arityArg, + capi.SQLITE_UTF8 | fFlags, pApp || 0, + xStep, xFinal, xValue, xInverse, xDestroy); + }else{ + rc = capi.sqlite3_create_function_v2( + this.pointer, name, arityArg, + capi.SQLITE_UTF8 | fFlags, pApp || 0, + xFunc, xStep, xFinal, xDestroy); + } + DB.checkRc(this, rc); + return this; + }, + + selectValue: function(sql,bind,asType){ + return __selectFirstRow(this, sql, bind, 0, asType); + }, + + + selectValues: function(sql,bind,asType){ + const stmt = this.prepare(sql), rc = []; + try { + affirmColIndex(stmt, 0); + stmt.bind(bind); + while(stmt.step()) rc.push( + __stmtGetColumn(stmt, 0, asType) + ); + stmt.reset(); + }finally{ + stmt.finalize(); + } + return rc; + }, + + + selectArray: function(sql,bind){ + return __selectFirstRow(this, sql, bind, []); + }, + + + selectObject: function(sql,bind){ + return __selectFirstRow(this, sql, bind, {}); + }, + + + selectArrays: function(sql,bind){ + return __selectAll(this, sql, bind, 'array'); + }, + + + selectObjects: function(sql,bind){ + return __selectAll(this, sql, bind, 'object'); + }, + + + openStatementCount: function(){ + return this.pointer ? Object.keys(__stmtMap.get(this)).length : 0; + }, + + + transaction: function(callback){ + let opener = 'BEGIN'; + if(arguments.length>1){ + if(/[^a-zA-Z]/.test(arguments[0])){ + toss3(capi.SQLITE_MISUSE, "Invalid argument for BEGIN qualifier."); + } + opener += ' '+arguments[0]; + callback = arguments[1]; + } + affirmDbOpen(this).exec(opener); + try { + const rc = callback(this); + this.exec("COMMIT"); + return rc; + }catch(e){ + this.exec("ROLLBACK"); + throw e; + } + }, + + + savepoint: function(callback){ + affirmDbOpen(this).exec("SAVEPOINT oo1"); + try { + const rc = callback(this); + this.exec("RELEASE oo1"); + return rc; + }catch(e){ + this.exec("ROLLBACK to SAVEPOINT oo1; RELEASE SAVEPOINT oo1"); + throw e; + } + }, + + + checkRc: function(resultCode){ + return checkSqlite3Rc(this, resultCode); + }, + }; + + + DB.wrapHandle = function(pDb, takeOwnership=false){ + if( !pDb || !wasm.isPtr(pDb) ){ + throw new sqlite3.SQLite3Error(capi.SQLITE_MISUSE, + "Argument must be a WASM sqlite3 pointer"); + } + return new DB({ + + "sqlite3*": pDb, + "sqlite3*:takeOwnership": !!takeOwnership + }); + }; + + + const affirmStmtOpen = function(stmt){ + if(!stmt.pointer) toss3("Stmt has been closed."); + return stmt; + }; + + + const isSupportedBindType = function(v){ + let t = BindTypes[(null===v||undefined===v) ? 'null' : typeof v]; + switch(t){ + case BindTypes.boolean: + case BindTypes.null: + case BindTypes.number: + case BindTypes.string: + return t; + case BindTypes.bigint: + return wasm.bigIntEnabled ? t : undefined; + default: + return util.isBindableTypedArray(v) ? BindTypes.blob : undefined; + } + }; + + + const affirmSupportedBindType = function(v){ + + return isSupportedBindType(v) || toss3("Unsupported bind() argument type:",typeof v); + }; + + + const affirmParamIndex = function(stmt,key){ + const n = ('number'===typeof key) + ? key : capi.sqlite3_bind_parameter_index(stmt.pointer, key); + if( 0===n || !util.isInt32(n) ) toss3("Invalid bind() parameter name: "+key); + else if( n<1 || n>stmt.parameterCount ) toss3("Bind index",key,"is out of range."); + return n; + }; + + + const __execLock = new Set(); + + const __stmtMayGet = new Set(); + + + const affirmNotLockedByExec = function(stmt,currentOpName){ + if(__execLock.has(stmt)){ + toss3("Operation is illegal when statement is locked:",currentOpName); + } + return stmt; + }; + + + const bindOne = function f(stmt,ndx,bindType,val){ + affirmNotLockedByExec(affirmStmtOpen(stmt), 'bind()'); + if(!f._){ + f._tooBigInt = (v)=>toss3( + "BigInt value is too big to store without precision loss:", v + ); + f._ = { + string: function(stmt, ndx, val, asBlob){ + const [pStr, n] = wasm.allocCString(val, true); + const f = asBlob ? capi.sqlite3_bind_blob : capi.sqlite3_bind_text; + return f(stmt.pointer, ndx, pStr, n, capi.SQLITE_WASM_DEALLOC); + } + }; + } + affirmSupportedBindType(val); + ndx = affirmParamIndex(stmt,ndx); + let rc = 0; + switch((null===val || undefined===val) ? BindTypes.null : bindType){ + case BindTypes.null: + rc = capi.sqlite3_bind_null(stmt.pointer, ndx); + break; + case BindTypes.string: + rc = f._.string(stmt, ndx, val, false); + break; + case BindTypes.number: { + let m; + if(util.isInt32(val)) m = capi.sqlite3_bind_int; + else if('bigint'===typeof val){ + if(!util.bigIntFits64(val)){ + f._tooBigInt(val); + }else if(wasm.bigIntEnabled){ + m = capi.sqlite3_bind_int64; + }else if(util.bigIntFitsDouble(val)){ + val = Number(val); + m = capi.sqlite3_bind_double; + }else{ + f._tooBigInt(val); + } + }else{ + val = Number(val); + if(wasm.bigIntEnabled && Number.isInteger(val)){ + m = capi.sqlite3_bind_int64; + }else{ + m = capi.sqlite3_bind_double; + } + } + rc = m(stmt.pointer, ndx, val); + break; + } + case BindTypes.boolean: + rc = capi.sqlite3_bind_int(stmt.pointer, ndx, val ? 1 : 0); + break; + case BindTypes.blob: { + if('string'===typeof val){ + rc = f._.string(stmt, ndx, val, true); + break; + }else if(val instanceof ArrayBuffer){ + val = new Uint8Array(val); + }else if(!util.isBindableTypedArray(val)){ + toss3("Binding a value as a blob requires", + "that it be a string, Uint8Array, Int8Array, or ArrayBuffer."); + } + const pBlob = wasm.alloc(val.byteLength || 1); + wasm.heap8().set(val.byteLength ? val : [0], Number(pBlob)) + rc = capi.sqlite3_bind_blob(stmt.pointer, ndx, pBlob, val.byteLength, + capi.SQLITE_WASM_DEALLOC); + break; + } + default: + sqlite3.config.warn("Unsupported bind() argument type:",val); + toss3("Unsupported bind() argument type: "+(typeof val)); + } + if(rc) DB.checkRc(stmt.db.pointer, rc); + return stmt; + }; + + Stmt.prototype = { + + finalize: function(){ + const ptr = this.pointer; + if(ptr){ + affirmNotLockedByExec(this,'finalize()'); + const rc = (__doesNotOwnHandle.delete(this) + ? 0 + : capi.sqlite3_finalize(ptr)); + delete __stmtMap.get(this.db)[ptr]; + __ptrMap.delete(this); + __execLock.delete(this); + __stmtMayGet.delete(this); + delete this.parameterCount; + delete this.db; + return rc; + } + }, + + clearBindings: function(){ + affirmNotLockedByExec(affirmStmtOpen(this), 'clearBindings()') + capi.sqlite3_clear_bindings(this.pointer); + __stmtMayGet.delete(this); + return this; + }, + + reset: function(alsoClearBinds){ + affirmNotLockedByExec(this,'reset()'); + if(alsoClearBinds) this.clearBindings(); + const rc = capi.sqlite3_reset(affirmStmtOpen(this).pointer); + __stmtMayGet.delete(this); + checkSqlite3Rc(this.db, rc); + return this; + }, + + + resetNoThrow: function(alsoClearBinds){ + affirmNotLockedByExec(this,'reset()'); + if(alsoClearBinds) this.clearBindings(); + const rc = capi.sqlite3_reset(affirmStmtOpen(this).pointer); + __stmtMayGet.delete(this); + return rc; + }, + + + bind: function(){ + affirmStmtOpen(this); + let ndx, arg; + switch(arguments.length){ + case 1: ndx = 1; arg = arguments[0]; break; + case 2: ndx = arguments[0]; arg = arguments[1]; break; + default: toss3("Invalid bind() arguments."); + } + if(undefined===arg){ + + return this; + }else if(!this.parameterCount){ + toss3("This statement has no bindable parameters."); + } + __stmtMayGet.delete(this); + if(null===arg){ + + return bindOne(this, ndx, BindTypes.null, arg); + } + else if(Array.isArray(arg)){ + + if(1!==arguments.length){ + toss3("When binding an array, an index argument is not permitted."); + } + arg.forEach((v,i)=>bindOne(this, i+1, affirmSupportedBindType(v), v)); + return this; + }else if(arg instanceof ArrayBuffer){ + arg = new Uint8Array(arg); + } + if('object'===typeof arg + && !util.isBindableTypedArray(arg)){ + + if(1!==arguments.length){ + toss3("When binding an object, an index argument is not permitted."); + } + Object.keys(arg) + .forEach(k=>bindOne(this, k, + affirmSupportedBindType(arg[k]), + arg[k])); + return this; + }else{ + return bindOne(this, ndx, affirmSupportedBindType(arg), arg); + } + toss3("Should not reach this point."); + }, + + bindAsBlob: function(ndx,arg){ + affirmStmtOpen(this); + if(1===arguments.length){ + arg = ndx; + ndx = 1; + } + const t = affirmSupportedBindType(arg); + if(BindTypes.string !== t && BindTypes.blob !== t + && BindTypes.null !== t){ + toss3("Invalid value type for bindAsBlob()"); + } + return bindOne(this, ndx, BindTypes.blob, arg); + }, + + step: function(){ + affirmNotLockedByExec(this, 'step()'); + const rc = capi.sqlite3_step(affirmStmtOpen(this).pointer); + switch(rc){ + case capi.SQLITE_DONE: + __stmtMayGet.delete(this); + return false; + case capi.SQLITE_ROW: + __stmtMayGet.add(this); + return true; + default: + __stmtMayGet.delete(this); + sqlite3.config.warn("sqlite3_step() rc=",rc, + capi.sqlite3_js_rc_str(rc), + "SQL =", capi.sqlite3_sql(this.pointer)); + DB.checkRc(this.db.pointer, rc); + } + }, + + stepReset: function(){ + this.step(); + return this.reset(); + }, + + stepFinalize: function(){ + try{ + const rc = this.step(); + this.reset(); + return rc; + }finally{ + try{this.finalize()} + catch(e){} + } + }, + + + get: function(ndx,asType){ + if(!__stmtMayGet.has(affirmStmtOpen(this))){ + toss3("Stmt.step() has not (recently) returned true."); + } + if(Array.isArray(ndx)){ + let i = 0; + const n = this.columnCount; + while(itoss3("The pointer property is read-only.") + } + Object.defineProperty(Stmt.prototype, 'pointer', prop); + Object.defineProperty(DB.prototype, 'pointer', prop); + } + + Object.defineProperty(Stmt.prototype, 'columnCount', { + enumerable: false, + get: function(){return capi.sqlite3_column_count(this.pointer)}, + set: ()=>toss3("The columnCount property is read-only.") + }); + + Object.defineProperty(Stmt.prototype, 'parameterCount', { + enumerable: false, + get: function(){return capi.sqlite3_bind_parameter_count(this.pointer)}, + set: ()=>toss3("The parameterCount property is read-only.") + }); + + + Stmt.wrapHandle = function(oo1db, pStmt, takeOwnership=false){ + let ctor = Stmt; + if( !(oo1db instanceof DB) || !oo1db.pointer ){ + throw new sqlite3.SQLite3Error(sqlite3.SQLITE_MISUSE, + "First argument must be an opened "+ + "sqlite3.oo1.DB instance"); + } + if( !pStmt || !wasm.isPtr(pStmt) ){ + throw new sqlite3.SQLite3Error(sqlite3.SQLITE_MISUSE, + "Second argument must be a WASM "+ + "sqlite3_stmt pointer"); + } + return new Stmt(oo1db, pStmt, BindTypes, !!takeOwnership); + } + + + sqlite3.oo1 = { + DB, + Stmt + }; + +}); + + + +globalThis.sqlite3ApiBootstrap.initializers.push(function(sqlite3){ +const util = sqlite3.util; +sqlite3.initWorker1API = function(){ + 'use strict'; + const toss = (...args)=>{throw new Error(args.join(' '))}; + if(!(globalThis.WorkerGlobalScope instanceof Function)){ + toss("initWorker1API() must be run from a Worker thread."); + } + const sqlite3 = this.sqlite3 || toss("Missing this.sqlite3 object."); + const DB = sqlite3.oo1.DB; + + + const getDbId = function(db){ + let id = wState.idMap.get(db); + if(id) return id; + id = 'db#'+(++wState.idSeq)+':'+ + Math.floor(Math.random() * 100000000)+':'+ + Math.floor(Math.random() * 100000000); + + wState.idMap.set(db, id); + return id; + }; + + + const wState = { + + dbList: [], + + idSeq: 0, + + idMap: new WeakMap, + + xfer: [], + open: function(opt){ + const db = new DB(opt); + this.dbs[getDbId(db)] = db; + if(this.dbList.indexOf(db)<0) this.dbList.push(db); + return db; + }, + close: function(db,alsoUnlink){ + if(db){ + delete this.dbs[getDbId(db)]; + const filename = db.filename; + const pVfs = util.sqlite3__wasm_db_vfs(db.pointer, 0); + db.close(); + const ddNdx = this.dbList.indexOf(db); + if(ddNdx>=0) this.dbList.splice(ddNdx, 1); + if(alsoUnlink && filename && pVfs){ + util.sqlite3__wasm_vfs_unlink(pVfs, filename); + } + } + }, + + post: function(msg,xferList){ + if(xferList && xferList.length){ + globalThis.postMessage( msg, Array.from(xferList) ); + xferList.length = 0; + }else{ + globalThis.postMessage(msg); + } + }, + + dbs: Object.create(null), + + getDb: function(id,require=true){ + return this.dbs[id] + || (require ? toss("Unknown (or closed) DB ID:",id) : undefined); + } + }; + + + const affirmDbOpen = function(db = wState.dbList[0]){ + return (db && db.pointer) ? db : toss("DB is not opened."); + }; + + + const getMsgDb = function(msgData,affirmExists=true){ + const db = wState.getDb(msgData.dbId,false) || wState.dbList[0]; + return affirmExists ? affirmDbOpen(db) : db; + }; + + const getDefaultDbId = function(){ + return wState.dbList[0] && getDbId(wState.dbList[0]); + }; + + const isSpecialDbFilename = (n)=>{ + return ""===n || ':'===n[0]; + }; + + + const wMsgHandler = { + open: function(ev){ + const oargs = Object.create(null), args = (ev.args || Object.create(null)); + if(args.simulateError){ + toss("Throwing because of simulateError flag."); + } + const rc = Object.create(null); + oargs.vfs = args.vfs; + oargs.filename = args.filename || ""; + const db = wState.open(oargs); + rc.filename = db.filename; + rc.persistent = !!sqlite3.capi.sqlite3_js_db_uses_vfs(db.pointer, "opfs"); + rc.dbId = getDbId(db); + rc.vfs = db.dbVfsName(); + return rc; + }, + + close: function(ev){ + const db = getMsgDb(ev,false); + const response = { + filename: db && db.filename + }; + if(db){ + const doUnlink = ((ev.args && 'object'===typeof ev.args) + ? !!ev.args.unlink : false); + wState.close(db, doUnlink); + } + return response; + }, + + exec: function(ev){ + const rc = ( + 'string'===typeof ev.args + ) ? {sql: ev.args} : (ev.args || Object.create(null)); + if('stmt'===rc.rowMode){ + toss("Invalid rowMode for 'exec': stmt mode", + "does not work in the Worker API."); + }else if(!rc.sql){ + toss("'exec' requires input SQL."); + } + const db = getMsgDb(ev); + if(rc.callback || Array.isArray(rc.resultRows)){ + + db._blobXfer = wState.xfer; + } + const theCallback = rc.callback; + let rowNumber = 0; + const hadColNames = !!rc.columnNames; + if('string' === typeof theCallback){ + if(!hadColNames) rc.columnNames = []; + + rc.callback = function(row,stmt){ + wState.post({ + type: theCallback, + columnNames: rc.columnNames, + rowNumber: ++rowNumber, + row: row + }, wState.xfer); + } + } + try { + const changeCount = !!rc.countChanges + ? db.changes(true,(64===rc.countChanges)) + : undefined; + db.exec(rc); + if(undefined !== changeCount){ + rc.changeCount = db.changes(true,64===rc.countChanges) - changeCount; + } + const lastInsertRowId = !!rc.lastInsertRowId + ? sqlite3.capi.sqlite3_last_insert_rowid(db) + : undefined; + if( undefined!==lastInsertRowId ){ + rc.lastInsertRowId = lastInsertRowId; + } + if(rc.callback instanceof Function){ + rc.callback = theCallback; + + wState.post({ + type: theCallback, + columnNames: rc.columnNames, + rowNumber: null , + row: undefined + }); + } + }finally{ + delete db._blobXfer; + if(rc.callback) rc.callback = theCallback; + } + return rc; + }, + + 'config-get': function(){ + const rc = Object.create(null), src = sqlite3.config; + [ + 'bigIntEnabled' + ].forEach(function(k){ + if(Object.getOwnPropertyDescriptor(src, k)) rc[k] = src[k]; + }); + rc.version = sqlite3.version; + rc.vfsList = sqlite3.capi.sqlite3_js_vfs_list(); + return rc; + }, + + + export: function(ev){ + const db = getMsgDb(ev); + const response = { + byteArray: sqlite3.capi.sqlite3_js_db_export(db.pointer), + filename: db.filename, + mimetype: 'application/x-sqlite3' + }; + wState.xfer.push(response.byteArray.buffer); + return response; + }, + + toss: function(ev){ + toss("Testing worker exception"); + } + }; + + globalThis.onmessage = async function(ev){ + ev = ev.data; + let result, dbId = ev.dbId, evType = ev.type; + const arrivalTime = performance.now(); + try { + if(wMsgHandler.hasOwnProperty(evType) && + wMsgHandler[evType] instanceof Function){ + result = await wMsgHandler[evType](ev); + }else{ + toss("Unknown db worker message type:",ev.type); + } + }catch(err){ + evType = 'error'; + result = { + operation: ev.type, + message: err.message, + errorClass: err.name, + input: ev + }; + if(err.stack){ + result.stack = ('string'===typeof err.stack) + ? err.stack.split(/\n\s*/) : err.stack; + } + if(0) sqlite3.config.warn("Worker is propagating an exception to main thread.", + "Reporting it _here_ for the stack trace:",err,result); + } + if(!dbId){ + dbId = result.dbId + || getDefaultDbId(); + } + + + wState.post({ + type: evType, + dbId: dbId, + messageId: ev.messageId, + workerReceivedTime: arrivalTime, + workerRespondTime: performance.now(), + departureTime: ev.departureTime, + + + + + + + result: result + }, wState.xfer); + }; + globalThis.postMessage({type:'sqlite3-api',result:'worker1-ready'}); +}.bind({sqlite3}); +}); + + + +'use strict'; +globalThis.sqlite3ApiBootstrap.initializers.push(function(sqlite3){ + const wasm = sqlite3.wasm, capi = sqlite3.capi, toss = sqlite3.util.toss3; + const vfs = Object.create(null); + sqlite3.vfs = vfs; + + + capi.sqlite3_vfs.prototype.registerVfs = function(asDefault=false){ + if(!(this instanceof sqlite3.capi.sqlite3_vfs)){ + toss("Expecting a sqlite3_vfs-type argument."); + } + const rc = capi.sqlite3_vfs_register(this, asDefault ? 1 : 0); + if(rc){ + toss("sqlite3_vfs_register(",this,") failed with rc",rc); + } + if(this.pointer !== capi.sqlite3_vfs_find(this.$zName)){ + toss("BUG: sqlite3_vfs_find(vfs.$zName) failed for just-installed VFS", + this); + } + return this; + }; + + + vfs.installVfs = function(opt){ + let count = 0; + const propList = ['io','vfs']; + for(const key of propList){ + const o = opt[key]; + if(o){ + ++count; + o.struct.installMethods(o.methods, !!o.applyArgcCheck); + if('vfs'===key){ + if(!o.struct.$zName && 'string'===typeof o.name){ + o.struct.addOnDispose( + o.struct.$zName = wasm.allocCString(o.name) + ); + } + o.struct.registerVfs(!!o.asDefault); + } + } + } + if(!count) toss("Misuse: installVfs() options object requires at least", + "one of:", propList); + return this; + }; +}); + + + +'use strict'; +globalThis.sqlite3ApiBootstrap.initializers.push(function(sqlite3){ + if( !sqlite3.wasm.exports.sqlite3_declare_vtab ){ + return; + } + const wasm = sqlite3.wasm, capi = sqlite3.capi, toss = sqlite3.util.toss3; + const vtab = Object.create(null); + sqlite3.vtab = vtab; + + const sii = capi.sqlite3_index_info; + + sii.prototype.nthConstraint = function(n, asPtr=false){ + if(n<0 || n>=this.$nConstraint) return false; + const ptr = wasm.ptr.add( + this.$aConstraint, + sii.sqlite3_index_constraint.structInfo.sizeof * n + ); + return asPtr ? ptr : new sii.sqlite3_index_constraint(ptr); + }; + + + sii.prototype.nthConstraintUsage = function(n, asPtr=false){ + if(n<0 || n>=this.$nConstraint) return false; + const ptr = wasm.ptr.add( + this.$aConstraintUsage, + sii.sqlite3_index_constraint_usage.structInfo.sizeof * n + ); + return asPtr ? ptr : new sii.sqlite3_index_constraint_usage(ptr); + }; + + + sii.prototype.nthOrderBy = function(n, asPtr=false){ + if(n<0 || n>=this.$nOrderBy) return false; + const ptr = wasm.ptr.add( + this.$aOrderBy, + sii.sqlite3_index_orderby.structInfo.sizeof * n + ); + return asPtr ? ptr : new sii.sqlite3_index_orderby(ptr); + }; + + + const __xWrapFactory = function(methodName,StructType){ + + return function(ptr,removeMapping=false){ + if(0===arguments.length) ptr = new StructType; + if(ptr instanceof StructType){ + + this.set(ptr.pointer, ptr); + return ptr; + }else if(!wasm.isPtr(ptr)){ + sqlite3.SQLite3Error.toss("Invalid argument to",methodName+"()"); + } + let rc = this.get(ptr); + if(removeMapping) this.delete(ptr); + return rc; + }.bind(new Map); + }; + + + const StructPtrMapper = function(name, StructType){ + const __xWrap = __xWrapFactory(name,StructType); + + return Object.assign(Object.create(null),{ + + StructType, + + create: (ppOut)=>{ + const rc = __xWrap(); + wasm.pokePtr(ppOut, rc.pointer); + return rc; + }, + + get: (pCObj)=>__xWrap(pCObj), + + unget: (pCObj)=>__xWrap(pCObj,true), + + dispose: (pCObj)=>__xWrap(pCObj,true)?.dispose?.() + }); + }; + + + vtab.xVtab = StructPtrMapper('xVtab', capi.sqlite3_vtab); + + + vtab.xCursor = StructPtrMapper('xCursor', capi.sqlite3_vtab_cursor); + + + vtab.xIndexInfo = (pIdxInfo)=>new capi.sqlite3_index_info(pIdxInfo); + + + vtab.xError = function f(methodName, err, defaultRc){ + if(f.errorReporter instanceof Function){ + try{f.errorReporter("sqlite3_module::"+methodName+"(): "+err.message);} + catch(e){} + } + let rc; + if(err instanceof sqlite3.WasmAllocError) rc = capi.SQLITE_NOMEM; + else if(arguments.length>2) rc = defaultRc; + else if(err instanceof sqlite3.SQLite3Error) rc = err.resultCode; + return rc || capi.SQLITE_ERROR; + }; + + vtab.xError.errorReporter = 1 ? sqlite3.config.error.bind(sqlite3.config) : false; + + + vtab.xRowid = (ppRowid64, value)=>wasm.poke(ppRowid64, value, 'i64'); + + + vtab.setupModule = function(opt){ + let createdMod = false; + const mod = (this instanceof capi.sqlite3_module) + ? this : (opt.struct || (createdMod = new capi.sqlite3_module())); + try{ + const methods = opt.methods || toss("Missing 'methods' object."); + for(const e of Object.entries({ + + + xConnect: 'xCreate', xDisconnect: 'xDestroy' + })){ + + const k = e[0], v = e[1]; + if(true === methods[k]) methods[k] = methods[v]; + else if(true === methods[v]) methods[v] = methods[k]; + } + if(opt.catchExceptions){ + const fwrap = function(methodName, func){ + if(['xConnect','xCreate'].indexOf(methodName) >= 0){ + return function(pDb, pAux, argc, argv, ppVtab, pzErr){ + try{return func(...arguments) || 0} + catch(e){ + if(!(e instanceof sqlite3.WasmAllocError)){ + wasm.dealloc(wasm.peekPtr(pzErr)); + wasm.pokePtr(pzErr, wasm.allocCString(e.message)); + } + return vtab.xError(methodName, e); + } + }; + }else{ + return function(...args){ + try{return func(...args) || 0} + catch(e){ + return vtab.xError(methodName, e); + } + }; + } + }; + const mnames = [ + 'xCreate', 'xConnect', 'xBestIndex', 'xDisconnect', + 'xDestroy', 'xOpen', 'xClose', 'xFilter', 'xNext', + 'xEof', 'xColumn', 'xRowid', 'xUpdate', + 'xBegin', 'xSync', 'xCommit', 'xRollback', + 'xFindFunction', 'xRename', 'xSavepoint', 'xRelease', + 'xRollbackTo', 'xShadowName' + ]; + const remethods = Object.create(null); + for(const k of mnames){ + const m = methods[k]; + if(!(m instanceof Function)) continue; + else if('xConnect'===k && methods.xCreate===m){ + remethods[k] = methods.xCreate; + }else if('xCreate'===k && methods.xConnect===m){ + remethods[k] = methods.xConnect; + }else{ + remethods[k] = fwrap(k, m); + } + } + mod.installMethods(remethods, false); + }else{ + + + mod.installMethods( + methods, !!opt.applyArgcCheck + ); + } + if(0===mod.$iVersion){ + let v; + if('number'===typeof opt.iVersion) v = opt.iVersion; + else if(mod.$xIntegrity) v = 4; + else if(mod.$xShadowName) v = 3; + else if(mod.$xSavePoint || mod.$xRelease || mod.$xRollbackTo) v = 2; + else v = 1; + mod.$iVersion = v; + } + }catch(e){ + if(createdMod) createdMod.dispose(); + throw e; + } + return mod; + }; + + + capi.sqlite3_module.prototype.setupModule = function(opt){ + return vtab.setupModule.call(this, opt); + }; +}); + + + +globalThis.sqlite3ApiBootstrap.initializers.push(function(sqlite3){ + if( sqlite3.config.disable?.vfs?.kvvfs ){ + return; + } + 'use strict'; + const capi = sqlite3.capi, + sqlite3_kvvfs_methods = capi.sqlite3_kvvfs_methods, + KVVfsFile = capi.KVVfsFile, + pKvvfs = sqlite3.capi.sqlite3_vfs_find("kvvfs") + + + delete capi.sqlite3_kvvfs_methods; + delete capi.KVVfsFile; + + if( !pKvvfs ) return ; + if( 0 ){ + + capi.sqlite3_vfs_register(pKvvfs, 1); + } + + const util = sqlite3.util, + wasm = sqlite3.wasm, + toss3 = util.toss3, + hop = (o,k)=>Object.prototype.hasOwnProperty.call(o,k); + + const kvvfsMethods = new sqlite3_kvvfs_methods( + + wasm.exports.sqlite3__wasm_kvvfs_methods() + ); + util.assert( 32<=kvvfsMethods.$nKeySize, "unexpected kvvfsMethods.$nKeySize: "+kvvfsMethods.$nKeySize); + + + const cache = Object.assign(Object.create(null),{ + + rxJournalSuffix: /-journal$/, + + zKeyJrnl: wasm.allocCString("jrnl"), + + zKeySz: wasm.allocCString("sz"), + + keySize: kvvfsMethods.$nKeySize, + + buffer: Object.assign(Object.create(null),{ + + n: kvvfsMethods.$nBufferSize, + + pool: Object.create(null) + }) + }); + + + cache.memBuffer = (id=0)=>cache.buffer.pool[id] ??= wasm.alloc(cache.buffer.n); + + + cache.memBufferFree = (id)=>{ + const b = cache.buffer.pool[id]; + if( b ){ + wasm.dealloc(b); + delete cache.buffer.pool[id]; + } + }; + + const noop = ()=>{}; + const debug = sqlite3.__isUnderTest + ? (...args)=>sqlite3.config.debug?.("kvvfs:", ...args) + : noop; + const warn = (...args)=>sqlite3.config.warn?.("kvvfs:", ...args); + const error = (...args)=>sqlite3.config.error?.("kvvfs:", ...args); + + + class KVVfsStorage { + #map = Object.create(null); + #keys = null; + #size = 0; + + constructor(){ + this.clear(); + } + + #getKeys(){ + return this.#keys ??= Object.keys(this.#map); + } + + key(n){ + if(n < 0 || n >= this.#size) return null; + return this.#getKeys()[n]; + } + + getItem(k){ + return this.#map[k] ?? null; + } + + setItem(k,v){ + if( !(k in this.#map) ){ + ++this.#size; + this.#keys = null; + } + this.#map[k] = ''+v; + } + + removeItem(k){ + if( k in this.#map ){ + delete this.#map[k]; + --this.#size; + this.#keys = null; + } + } + + clear(){ + this.#map = Object.create(null); + this.#keys = null; + this.#size = 0; + } + + get length() { + return this.#size; + } + }; + + + const kvvfsIsPersistentName = (v)=>'local'===v || 'session'===v; + + + const kvvfsKeyPrefix = (v)=>kvvfsIsPersistentName(v) ? 'kvvfs-'+v+'-' : ''; + + + const validateStorageName = function(n,mayBeJournal=false){ + if( kvvfsIsPersistentName(n) ) return; + const len = (new Blob([n])).size; + if( !len ) toss3(capi.SQLITE_MISUSE, "Empty name is not permitted."); + let maxLen = cache.keySize - 1; + if( cache.rxJournalSuffix.test(n) ){ + if( !mayBeJournal ){ + toss3(capi.SQLITE_MISUSE, + "Storage names may not have a '-journal' suffix."); + } + }else if( ['-wal','-shm'].filter(v=>n.endsWith(v)).length ){ + toss3(capi.SQLITE_MISUSE, + "Storage names may not have a -wal or -shm suffix."); + }else{ + maxLen -= 8 ; + } + if( len > maxLen ){ + toss3(capi.SQLITE_RANGE, "Storage name is too long. Limit =", maxLen); + } + let i; + for( i = 0; i < len; ++i ){ + const ch = n.codePointAt(i); + if( ch<32 ){ + toss3(capi.SQLITE_RANGE, + "Illegal character ("+ch+"d) in storage name:",n); + } + } + }; + + + const newStorageObj = (name,storage=undefined)=>Object.assign(Object.create(null),{ + + jzClass: name, + + refc: 1, + + deleteAtRefc0: false, + + storage: storage || new KVVfsStorage, + + keyPrefix: kvvfsKeyPrefix(name), + + files: [], + + listeners: undefined + }); + + + const kvvfs = sqlite3.kvvfs = Object.create(null); + if( sqlite3.__isUnderTest ){ + + kvvfs.log = Object.assign(Object.create(null),{ + xOpen: false, + xClose: false, + xWrite: false, + xRead: false, + xSync: false, + xAccess: false, + xFileControl: false, + xRcrdRead: false, + xRcrdWrite: false, + xRcrdDelete: false, + }); + } + + + const deleteStorage = function(store){ + const other = cache.rxJournalSuffix.test(store.jzClass) + ? store.jzClass.replace(cache.rxJournalSuffix,'') + : store.jzClass+'-journal'; + kvvfs?.log?.xClose + && debug("cleaning up storage handles [", store.jzClass, other,"]",store); + delete cache.storagePool[store.jzClass]; + delete cache.storagePool[other]; + if( !sqlite3.__isUnderTest ){ + + delete store.storage; + delete store.refc; + } + }; + + + const installStorageAndJournal = (store)=> + cache.storagePool[store.jzClass] = + cache.storagePool[store.jzClass+'-journal'] = store; + + + const nameOfThisThreadStorage = '.'; + + + cache.storagePool = Object.assign(Object.create(null),{ + + [nameOfThisThreadStorage]: newStorageObj(nameOfThisThreadStorage) + }); + + if( globalThis.Storage ){ + + if( globalThis.localStorage instanceof globalThis.Storage ){ + cache.storagePool.local = newStorageObj('local', globalThis.localStorage); + } + if( globalThis.sessionStorage instanceof globalThis.Storage ){ + cache.storagePool.session = newStorageObj('session', globalThis.sessionStorage); + } + } + + cache.builtinStorageNames = Object.keys(cache.storagePool); + + const isBuiltinName = (n)=>cache.builtinStorageNames.indexOf(n)>-1; + + + for(const k of Object.keys(cache.storagePool)){ + + const orig = cache.storagePool[k]; + cache.storagePool[k+'-journal'] = orig; + } + + cache.setError = (e=undefined, dfltErrCode=capi.SQLITE_ERROR)=>{ + if( e ){ + cache.lastError = e; + return (e.resultCode | 0) || dfltErrCode; + } + delete cache.lastError; + return 0; + }; + + cache.popError = ()=>{ + const e = cache.lastError; + delete cache.lastError; + return e; + }; + + + const catchForNotify = (e)=>{ + warn("kvvfs.listener handler threw:",e); + }; + + const kvvfsDecode = wasm.exports.sqlite3__wasm_kvvfs_decode; + const kvvfsEncode = wasm.exports.sqlite3__wasm_kvvfs_encode; + + + const notifyListeners = async function(eventName,store,...args){ + try{ + + if( store.keyPrefix && args[0] ){ + args[0] = args[0].replace(store.keyPrefix,''); + } + let u8enc, z0, z1, wcache; + for(const ear of store.listeners){ + const ev = Object.create(null); + ev.storageName = store.jzClass; + ev.type = eventName; + const decodePages = ear.decodePages; + const f = ear.events[eventName]; + if( f ){ + if( !ear.includeJournal && args[0]==='jrnl' ){ + continue; + } + if( 'write'===eventName && ear.decodePages && +args[0]>0 ){ + + ev.data = [args[0]]; + if( wcache?.[args[0]] ){ + ev.data[1] = wcache[args[0]]; + continue; + } + u8enc ??= new TextEncoder('utf-8'); + z0 ??= cache.memBuffer(10); + z1 ??= cache.memBuffer(11); + const u = u8enc.encode(args[1]); + const heap = wasm.heap8u(); + heap.set(u, Number(z0)); + heap[wasm.ptr.addn(z0, u.length)] = 0; + const rc = kvvfsDecode(z0, z1, cache.buffer.n); + if( rc>0 ){ + wcache ??= Object.create(null); + wcache[args[0]] + = ev.data[1] + = heap.slice(Number(z1), wasm.ptr.addn(z1,rc)); + }else{ + continue; + } + }else{ + ev.data = args.length + ? ((args.length===1) ? args[0] : args) + : undefined; + } + try{f(ev)?.catch?.(catchForNotify)} + catch(e){ + warn("notifyListeners [",store.jzClass,"]",eventName,e); + } + } + } + }catch(e){ + catchForNotify(e); + } + }; + + + const storageForZClass = (zClass)=> + 'string'===typeof zClass + ? cache.storagePool[zClass] + : cache.storagePool[wasm.cstrToJs(zClass)]; + + + const kvvfsMakeKey = wasm.exports.sqlite3__wasm_kvvfsMakeKey; + + const zKeyForStorage = (store, zClass, zKey)=>{ + + return (zClass && store.keyPrefix) ? kvvfsMakeKey(zClass, zKey) : zKey; + }; + + const jsKeyForStorage = (store,zClass,zKey)=> + wasm.cstrToJs(zKeyForStorage(store, zClass, zKey)); + + const storageGetDbSize = (store)=>+store.storage.getItem(store.keyPrefix + "sz"); + + + const pFileHandles = new Map(); + + + const mapPathToLocks = new Map(); + + + const unlockFile = function(fh, lockType){ + if(fh.lockType<=lockType) return; + const lk = mapPathToLocks.get(fh.jzClass); + if(fh.lockType>capi.SQLITE_LOCK_SHARED){ + lk.lockType = capi.SQLITE_LOCK_SHARED; + } + if(lockType===capi.SQLITE_LOCK_NONE && 0===--lk.nShared + ){ + mapPathToLocks.delete(fh.jzClass); + } + fh.lockType = lockType; + }; + + + const originalMethods = { + vfs: Object.create(null), + ioDb: Object.create(null), + ioJrnl: Object.create(null) + }; + + + const originalIoMethods = (kvvfsFile)=> + originalMethods[kvvfsFile.$isJournal ? 'ioJrnl' : 'ioDb']; + + const pVfs = new capi.sqlite3_vfs(kvvfsMethods.$pVfs); + const pIoDb = new capi.sqlite3_io_methods(kvvfsMethods.$pIoDb); + const pIoJrnl = new capi.sqlite3_io_methods(kvvfsMethods.$pIoJrnl); + const recordHandler = + Object.create(null); + const kvvfsInternal = Object.assign(Object.create(null),{ + pFileHandles, + cache, + storageForZClass, + KVVfsStorage, + + disablePageSizeChange: true + }); + if( kvvfs.log ){ + + kvvfs.internal = kvvfsInternal; + } + + + const methodOverrides = { + + + recordHandler: { + xRcrdRead: (zClass, zKey, zBuf, nBuf)=>{ + try{ + const jzClass = wasm.cstrToJs(zClass); + const store = storageForZClass(jzClass); + if( !store ) return -1; + const jXKey = jsKeyForStorage(store, zClass, zKey); + kvvfs?.log?.xRcrdRead && warn("xRcrdRead", jzClass, jXKey, nBuf, store ); + const jV = store.storage.getItem(jXKey); + if(null===jV) return -1; + const nV = jV.length ; + if( 0 ){ + debug("xRcrdRead", jXKey, store, jV); + } + if(nBuf<=0) return nV; + else if(1===nBuf){ + wasm.poke(zBuf, 0); + return nV; + }else if( nBuf nV + 1 ){ + nBuf = nV + 1; + } + if( 0 ){ + debug("xRcrdRead", nBuf, zClass, wasm.cstrToJs(zClass), + wasm.cstrToJs(zKey), nV, jV, store); + } + const nCopy = nV{ + try { + const store = storageForZClass(zClass); + const jxKey = jsKeyForStorage(store, zClass, zKey); + const jData = wasm.cstrToJs(zData); + kvvfs?.log?.xRcrdWrite && warn("xRcrdWrite",jxKey, store); + store.storage.setItem(jxKey, jData); + store.listeners && notifyListeners('write', store, jxKey, jData); + return 0; + }catch(e){ + error("kvrecordWrite()",e); + return cache.setError(e, capi.SQLITE_IOERR); + } + }, + + xRcrdDelete: (zClass, zKey)=>{ + try { + const store = storageForZClass(zClass); + const jxKey = jsKeyForStorage(store, zClass, zKey); + kvvfs?.log?.xRcrdDelete && warn("xRcrdDelete",jxKey, store); + store.storage.removeItem(jxKey); + store.listeners && notifyListeners('delete', store, jxKey); + return 0; + }catch(e){ + error("kvrecordDelete()",e); + return cache.setError(e, capi.SQLITE_IOERR); + } + } + }, + + + vfs:{ + + xOpen: function(pProtoVfs,zName,pProtoFile,flags,pOutFlags){ + cache.popError(); + let zToFree ; + try{ + if( !zName ){ + zToFree = wasm.allocCString(""+pProtoFile+"." + +(Math.random() * 100000 | 0)); + zName = zToFree; + } + const jzClass = wasm.cstrToJs(zName); + kvvfs?.log?.xOpen && debug("xOpen",jzClass,"flags =",flags); + validateStorageName(jzClass, true); + if( (flags & (capi.SQLITE_OPEN_MAIN_DB + | capi.SQLITE_OPEN_TEMP_DB + | capi.SQLITE_OPEN_TRANSIENT_DB)) + && cache.rxJournalSuffix.test(jzClass) ){ + toss3(capi.SQLITE_ERROR, + "DB files may not have a '-journal' suffix."); + } + let s = storageForZClass(jzClass); + if( !s && !(flags & capi.SQLITE_OPEN_CREATE) ){ + toss3(capi.SQLITE_ERROR, "Storage not found:", jzClass); + } + const rc = originalMethods.vfs.xOpen(pProtoVfs, zName, pProtoFile, + flags, pOutFlags); + if( rc ) return rc; + let deleteAt0 = !!(capi.SQLITE_OPEN_DELETEONCLOSE & flags); + if(wasm.isPtr(arguments[1])){ + if(capi.sqlite3_uri_boolean(zName, "delete-on-close", 0)){ + deleteAt0 = true; + } + } + const f = new KVVfsFile(pProtoFile); + util.assert(f.$zClass, "Missing f.$zClass"); + f.addOnDispose(zToFree); + zToFree = undefined; + + if( s ){ + ++s.refc; + + s.files.push(f); + wasm.poke32(pOutFlags, flags); + }else{ + wasm.poke32(pOutFlags, flags | capi.SQLITE_OPEN_CREATE); + util.assert( !f.$isJournal, "Opening a journal before its db? "+jzClass ); + + const nm = jzClass.replace(cache.rxJournalSuffix,''); + s = newStorageObj(nm); + installStorageAndJournal(s); + s.files.push(f); + s.deleteAtRefc0 = deleteAt0; + kvvfs?.log?.xOpen + && debug("xOpen installed storage handle [",nm, nm+"-journal","]", s); + } + pFileHandles.set(pProtoFile, Object.assign(Object.create(null),{ + store: s, + file: f, + jzClass, + lockType: 0 + })); + s.listeners && notifyListeners('open', s, s.files.length); + return 0; + }catch(e){ + warn("xOpen:",e); + return cache.setError(e); + }finally{ + zToFree && wasm.dealloc(zToFree); + } + }, + + xDelete: function(pVfs, zName, iSyncFlag){ + cache.popError(); + try{ + const jzName = wasm.cstrToJs(zName); + if( cache.rxJournalSuffix.test(jzName) ){ + recordHandler.xRcrdDelete(zName, cache.zKeyJrnl); + } + return 0; + }catch(e){ + warn("xDelete",e); + return cache.setError(e); + } + }, + + xAccess: function(pProtoVfs, zPath, flags, pResOut){ + cache.popError(); + try{ + const s = storageForZClass(zPath); + const jzPath = s?.jzClass || wasm.cstrToJs(zPath); + if( kvvfs?.log?.xAccess ){ + debug("xAccess",jzPath,"flags =", + flags,"*pResOut =",wasm.peek32(pResOut), + "store =",s); + } + if( !s ){ + + + + try{validateStorageName(jzPath)} + catch(e){ + + wasm.poke32(pResOut, 0); + return 0; + } + } + if( s ){ + const key = s.keyPrefix+ + (cache.rxJournalSuffix.test(jzPath) ? "jrnl" : "1"); + const res = s.storage.getItem(key) ? 0 : 1; + + + wasm.poke32(pResOut, res); + }else{ + wasm.poke32(pResOut, 0); + } + return 0; + }catch(e){ + error('xAccess',e); + return cache.setError(e); + } + }, + + xRandomness: function(pVfs, nOut, pOut){ + const heap = wasm.heap8u(); + let i = 0; + const npOut = Number(pOut); + for(; i < nOut; ++i) heap[npOut + i] = (Math.random()*255000) & 0xFF; + return nOut; + }, + + xGetLastError: function(pVfs,nOut,pOut){ + const e = cache.popError(); + debug('xGetLastError',e); + if(e){ + const scope = wasm.scopedAllocPush(); + try{ + const [cMsg, n] = wasm.scopedAllocCString(e.message, true); + wasm.cstrncpy(pOut, cMsg, nOut); + if(n > nOut) wasm.poke8(wasm.ptr.add(pOut,nOut,-1), 0); + debug("set xGetLastError",e.message); + return (e.resultCode | 0) || capi.SQLITE_IOERR; + }catch(e){ + return capi.SQLITE_NOMEM; + }finally{ + wasm.scopedAllocPop(scope); + } + } + return 0; + }, + + + xSleep: (pVfs,ms)=>0, + + }, + + + ioDb:{ + + xClose: function(pFile){ + cache.popError(); + try{ + const h = pFileHandles.get(pFile); + kvvfs?.log?.xClose && debug("xClose", pFile, h); + if( h ){ + pFileHandles.delete(pFile); + unlockFile(h, capi.SQLITE_LOCK_NONE); + const s = h.store; + s.files = s.files.filter((v)=>v!==h.file); + if( --s.refc<=0 && s.deleteAtRefc0 ){ + deleteStorage(s); + } + originalMethods.ioDb.xClose(pFile); + h.file.dispose(); + s.listeners && notifyListeners('close', s, s.files.length); + }else{ + + } + return 0; + }catch(e){ + error("xClose",e); + return cache.setError(e); + } + }, + + xFileControl: function(pFile, opId, pArg){ + cache.popError(); + try{ + const h = pFileHandles.get(pFile); + util.assert(h, "Missing KVVfsFile handle"); + kvvfs?.log?.xFileControl && debug("xFileControl",h,'op =',opId); + if( opId===capi.SQLITE_FCNTL_PRAGMA + && kvvfsInternal.disablePageSizeChange ){ + + + const zName = wasm.peekPtr(wasm.ptr.add(pArg, wasm.ptr.size)); + if( "page_size"===wasm.cstrToJs(zName) ){ + kvvfs?.log?.xFileControl + && debug("xFileControl pragma",wasm.cstrToJs(zName)); + const zVal = wasm.peekPtr(wasm.ptr.add(pArg, 2*wasm.ptr.size)); + if( zVal ){ + + kvvfs?.log?.xFileControl + && warn("xFileControl pragma", h, + "NOT setting page size to", wasm.cstrToJs(zVal)); + h.file.$szPage = -1; + return 0; + }else if( h.file.$szPage>0 ){ + kvvfs?.log?.xFileControl && + warn("xFileControl", h, "getting page size",h.file.$szPage); + wasm.pokePtr(pArg, wasm.allocCString(""+h.file.$szPage) + ); + return 0; + } + } + } + const rc = originalMethods.ioDb.xFileControl(pFile, opId, pArg); + if( 0==rc && capi.SQLITE_FCNTL_SYNC===opId ){ + h.store.listeners && notifyListeners('sync', h.store, false); + } + return rc; + }catch(e){ + error("xFileControl",e); + return cache.setError(e); + } + }, + + xSync: function(pFile,flags){ + cache.popError(); + try{ + const h = pFileHandles.get(pFile); + kvvfs?.log?.xSync && debug("xSync", h); + util.assert(h, "Missing KVVfsFile handle"); + const rc = originalMethods.ioDb.xSync(pFile, flags); + if( 0==rc && h.store.listeners ) notifyListeners('sync', h.store, true); + return rc; + }catch(e){ + error("xSync",e); + return cache.setError(e); + } + }, + + xLock: function(pFile, iLock){ + cache.popError(); + try{ + const h = pFileHandles.get(pFile); + if( capi.SQLITE_LOCK_NONE!==iLock ){ + + originalMethods.ioDb.xLock(pFile, iLock); + } + + if(h.lockType>=iLock) return 0; + let lk = mapPathToLocks.get(h.jzClass); + if(!lk){ + lk = Object.assign(Object.create(null),{ + nShared: 0, lockType: capi.SQLITE_LOCK_NONE + }); + mapPathToLocks.set(h.jzClass, lk); + } + if(h.lockType!==lk.lockType + && (lk.lockType>=capi.SQLITE_LOCK_PENDING + || iLock>capi.SQLITE_LOCK_SHARED)){ + return capi.SQLITE_BUSY; + } + if(iLock===capi.SQLITE_LOCK_SHARED){ + ++lk.nShared; + if(lk.lockType===capi.SQLITE_LOCK_NONE) lk.lockType = iLock; + }else if(iLock===capi.SQLITE_LOCK_EXCLUSIVE && lk.nShared>1){ + if(h.lockType===capi.SQLITE_LOCK_RESERVED){ + h.lockType = lk.lockType = capi.SQLITE_LOCK_PENDING; + } + return capi.SQLITE_BUSY; + }else{ + lk.lockType = iLock; + } + h.lockType = iLock; + return 0; + }catch(e){ + error("xLock", e); + return cache.setError(e); + } + }, + + xUnlock: function(pFile, iLock){ + cache.popError(); + try{ + if( capi.SQLITE_LOCK_NONE===iLock ){ + + originalMethods.ioDb.xUnlock(pFile, iLock); + } + const h = pFileHandles.get(pFile); + unlockFile(h, iLock); + return 0; + }catch(e){ + error("xUnlock", e); + return cache.setError(e); + } + }, + + xCheckReservedLock: function(pFile, pOut){ + cache.popError(); + try{ + const h = pFileHandles.get(pFile); + const lk = mapPathToLocks.get(h.jzClass); + wasm.poke32(pOut, + (!!lk && lk.lockType>capi.SQLITE_LOCK_SHARED) + ? 1 : 0) + return 0; + }catch(e){ + error("xCheckReservedLock", e); + return cache.setError(e); + } + }, + + + }, + + ioJrnl:{ + + xClose: true, + xLock: true, + xUnlock: true, + xCheckReservedLock: true, + } + }; + + + try { + util.assert( cache.buffer.n>1024*129, "Heap buffer is not large enough" + ); + for(const e of Object.entries(methodOverrides.recordHandler)){ + + const k = e[0], f = e[1]; + recordHandler[k] = f; + if( 0 ){ + + kvvfsMethods.installMethod(k, f); + }else{ + kvvfsMethods[kvvfsMethods.memberKey(k)] = + wasm.installFunction(kvvfsMethods.memberSignature(k), f); + } + } + for(const e of Object.entries(methodOverrides.vfs)){ + + const k = e[0], f = e[1], km = pVfs.memberKey(k), + member = pVfs.structInfo.members[k] + || util.toss("Missing pVfs.structInfo[",k,"]"); + originalMethods.vfs[k] = wasm.functionEntry(pVfs[km]); + pVfs[km] = wasm.installFunction(member.signature, f); + } + for(const e of Object.entries(methodOverrides.ioDb)){ + + const k = e[0], f = e[1], km = pIoDb.memberKey(k); + originalMethods.ioDb[k] = wasm.functionEntry(pIoDb[km]) + || util.toss("Missing native pIoDb[",km,"]"); + pIoDb[km] = wasm.installFunction(pIoDb.memberSignature(k), f); + } + for(const e of Object.entries(methodOverrides.ioJrnl)){ + + const k = e[0], f = e[1], km = pIoJrnl.memberKey(k); + originalMethods.ioJrnl[k] = wasm.functionEntry(pIoJrnl[km]) + || util.toss("Missing native pIoJrnl[",km,"]"); + if( true===f ){ + + pIoJrnl[km] = pIoDb[km] || util.toss("Missing copied pIoDb[",km,"]"); + }else{ + pIoJrnl[km] = wasm.installFunction(pIoJrnl.memberSignature(k), f); + } + } + }finally{ + kvvfsMethods.dispose(); + pVfs.dispose(); + pIoDb.dispose(); + pIoJrnl.dispose(); + } + + + + + const sqlite3_js_kvvfs_clear = function callee(which){ + if( ''===which ){ + return callee('local') + callee('session'); + } + const store = storageForZClass(which); + if( !store ) return 0; + if( store.files.length ){ + if( globalThis.localStorage===store.storage + || globalThis.sessionStorage===store.storage ){ + + }else{ + + toss3(capi.SQLITE_ACCESS, + "Cannot clear in-use database storage."); + } + } + const s = store.storage; + const toRm = [] ; + let i, n = s.length; + + for( i = 0; i < n; ++i ){ + const k = s.key(i); + + if(!store.keyPrefix || k.startsWith(store.keyPrefix)) toRm.push(k); + } + toRm.forEach((kk)=>s.removeItem(kk)); + + return toRm.length; + }; + + + const sqlite3_js_kvvfs_size = function callee(which){ + if( ''===which ){ + return callee('local') + callee('session'); + } + const store = storageForZClass(which); + if( !store ) return 0; + const s = store.storage; + let i, sz = 0; + for(i = 0; i < s.length; ++i){ + const k = s.key(i); + if(!store.keyPrefix || k.startsWith(store.keyPrefix)){ + sz += k.length; + sz += s.getItem(k).length; + } + } + return sz * 2 ; + }; + + + const sqlite3_js_kvvfs_export = function callee(...args){ + let opt; + if( 1===args.length && 'object'===typeof args[0] ){ + opt = args[0]; + }else if(args.length){ + opt = Object.assign(Object.create(null),{ + name: args[0], + + }); + } + const store = opt ? storageForZClass(opt.name) : null; + if( !store ){ + toss3(capi.SQLITE_NOTFOUND, + "There is no kvvfs storage named",opt?.name); + } + + const s = store.storage; + const rc = Object.assign(Object.create(null),{ + name: store.jzClass, + timestamp: Date.now(), + pages: [] + }); + const pages = Object.create(null); + let xpages; + const keyPrefix = store.keyPrefix; + const rxTail = keyPrefix + ? /^kvvfs-[^-]+-(\w+)/ + : undefined; + let i = 0, n = s.length; + for( ; i < n; ++i ){ + const k = s.key(i); + if( !keyPrefix || k.startsWith(keyPrefix) ){ + let kk = (keyPrefix ? rxTail.exec(k) : undefined)?.[1] ?? k; + switch( kk ){ + case 'jrnl': + if( opt.includeJournal ) rc.journal = s.getItem(k); + break; + case 'sz': + rc.size = +s.getItem(k); + break; + default: + kk = +kk ; + if( !util.isInt32(kk) || kk<=0 ){ + toss3(capi.SQLITE_RANGE, "Malformed kvvfs key: "+k); + } + if( opt.decodePages ){ + const spg = s.getItem(k), + n = spg.length, + z = cache.memBuffer(0), + zDec = cache.memBuffer(1), + heap = wasm.heap8u(); + let i = 0; + for( ; i < n; ++i ){ + heap[wasm.ptr.add(z, i)] = spg.codePointAt(i) & 0xff; + } + heap[wasm.ptr.add(z, i)] = 0; + + const nDec = kvvfsDecode( + z, zDec, cache.buffer.n + ); + + pages[kk] = heap.slice(Number(zDec), wasm.ptr.addn(zDec, nDec)); + }else{ + pages[kk] = s.getItem(k); + } + break; + } + } + } + if( opt.decodePages ) cache.memBufferFree(1); + + Object.keys(pages).map((v)=>+v).sort().forEach( + (v)=>rc.pages.push(pages[v]) + ); + return rc; + }; + + + const sqlite3_js_kvvfs_import = function(exp, overwrite=false){ + if( !exp?.timestamp + || !exp.name + || undefined===exp.size + || !Array.isArray(exp.pages) ){ + toss3(capi.SQLITE_MISUSE, "Malformed export object."); + }else if( !exp.size + || (exp.size !== (exp.size | 0)) + + || exp.size>=0x7fffffff ){ + toss3(capi.SQLITE_RANGE, "Invalid db size: "+exp.size); + } + + validateStorageName(exp.name); + let store = storageForZClass(exp.name); + const isNew = !store; + if( store ){ + if( !overwrite ){ + + toss3(capi.SQLITE_ACCESS, + "Storage '"+exp.name+"' already exists and", + "overwrite was not specified."); + }else if( !store.files || !store.jzClass ){ + toss3(capi.SQLITE_ERROR, + "Internal storage object", exp.name,"seems to be malformed."); + }else if( store.files.length ){ + toss3(capi.SQLITE_IOERR_ACCESS, + "Cannot import db storage while it is in use."); + } + sqlite3_js_kvvfs_clear(exp.name); + }else{ + store = newStorageObj(exp.name); + + } + + + const keyPrefix = kvvfsKeyPrefix(exp.name); + let zEnc; + try{ + ; + const s = store.storage; + s.setItem(keyPrefix+'sz', exp.size); + if( exp.journal ) s.setItem(keyPrefix+'jrnl', exp.journal); + if( exp.pages[0] instanceof Uint8Array ){ + + + exp.pages.forEach((u,ndx)=>{ + const n = u.length; + if( 0 && cache.fixedPageSize !== n ){ + util.toss3(capi.SQLITE_RANGE,"Unexpected page size:", n); + } + zEnc ??= cache.memBuffer(1); + const zBin = cache.memBuffer(0), + heap = wasm.heap8u(); + + heap.set(u, Number(zBin)); + heap[wasm.ptr.addn(zBin,n)] = 0; + const rc = kvvfsEncode(zBin, n, zEnc); + util.assert( rc < cache.buffer.n, + "Impossibly long output - possibly smashed the heap" ); + util.assert( 0===wasm.peek8(wasm.ptr.add(zEnc,rc)), + "Expecting NUL-terminated encoded output" ); + const jenc = wasm.cstrToJs(zEnc); + + s.setItem(keyPrefix+(ndx+1), jenc); + }); + }else if( exp.pages[0] ){ + + exp.pages.forEach((v,ndx)=>s.setItem(keyPrefix+(ndx+1), v)); + } + if( isNew ) installStorageAndJournal(store); + }catch{ + if( !isNew ){ + try{sqlite3_js_kvvfs_clear(exp.name);}catch(ee){} + } + }finally{ + if( zEnc ) cache.memBufferFree(1); + } + return this; + }; + + + const sqlite3_js_kvvfs_reserve = function(name){ + let store = storageForZClass(name); + if( store ){ + ++store.refc; + return; + } + validateStorageName(name); + installStorageAndJournal(newStorageObj(name)); + }; + + + const sqlite3_js_kvvfs_unlink = function(name){ + const store = storageForZClass(name); + if( !store + || kvvfsIsPersistentName(store.jzClass) + || isBuiltinName(store.jzClass) + || cache.rxJournalSuffix.test(name) ) return false; + if( store.refc > store.files.length || 0===store.files.length ){ + if( --store.refc<=0 ){ + + deleteStorage(store); + } + return true; + } + return false; + }; + + + const sqlite3_js_kvvfs_listen = function(opt){ + if( !opt || 'object'!==typeof opt ){ + toss3(capi.SQLITE_MISUSE, "Expecting a listener object."); + } + let store = storageForZClass(opt.storage); + if( !store ){ + if( opt.storage && opt.reserve ){ + sqlite3_js_kvvfs_reserve(opt.storage); + store = storageForZClass(opt.storage); + util.assert(store, + "Unexpectedly cannot fetch reserved storage " + +opt.storage); + }else{ + toss3(capi.SQLITE_NOTFOUND,"No such storage:",opt.storage); + } + } + if( opt.events ){ + (store.listeners ??= []).push(opt); + } + }; + + + const sqlite3_js_kvvfs_unlisten = function(opt){ + const store = storageForZClass(opt?.storage); + if( store?.listeners && opt.events ){ + const n = store.listeners.length; + store.listeners = store.listeners.filter((v)=>v!==opt); + const rc = n>store.listeners.length; + if( !store.listeners.length ){ + + store.listeners = undefined; + } + return rc; + } + return false; + }; + + sqlite3.kvvfs.reserve = sqlite3_js_kvvfs_reserve; + sqlite3.kvvfs.import = sqlite3_js_kvvfs_import; + sqlite3.kvvfs.export = sqlite3_js_kvvfs_export; + sqlite3.kvvfs.unlink = sqlite3_js_kvvfs_unlink; + sqlite3.kvvfs.listen = sqlite3_js_kvvfs_listen; + sqlite3.kvvfs.unlisten = sqlite3_js_kvvfs_unlisten; + sqlite3.kvvfs.exists = (name)=>!!storageForZClass(name); + sqlite3.kvvfs.estimateSize = sqlite3_js_kvvfs_size; + sqlite3.kvvfs.clear = sqlite3_js_kvvfs_clear; + + + if( globalThis.Storage ){ + + capi.sqlite3_js_kvvfs_size = (which="")=>sqlite3_js_kvvfs_size(which); + capi.sqlite3_js_kvvfs_clear = (which="")=>sqlite3_js_kvvfs_clear(which); + } + + if(sqlite3.oo1?.DB){ + + const DB = sqlite3.oo1.DB; + sqlite3.oo1.JsStorageDb = function( + storageName = sqlite3.oo1.JsStorageDb.defaultStorageName + ){ + const opt = DB.dbCtorHelper.normalizeArgs(...arguments); + opt.vfs = 'kvvfs'; + if( 0 ){ + + if( opt.flags ) opt.flags = 'cw'+opt.flags; + else opt.flags = 'cw'; + } + switch( opt.filename ){ + + case ":sessionStorage:": opt.filename = 'session'; break; + case ":localStorage:": opt.filename = 'local'; break; + } + const m = /(file:(\/\/)?)([^?]+)/.exec(opt.filename); + validateStorageName( m ? m[3] : opt.filename); + DB.dbCtorHelper.call(this, opt); + }; + sqlite3.oo1.JsStorageDb.defaultStorageName + = cache.storagePool.session ? 'session' : nameOfThisThreadStorage; + const jdb = sqlite3.oo1.JsStorageDb; + jdb.prototype = Object.create(DB.prototype); + jdb.clearStorage = sqlite3_js_kvvfs_clear; + + jdb.prototype.clearStorage = function(){ + return jdb.clearStorage(this.affirmOpen().dbFilename(), true); + }; + + jdb.storageSize = sqlite3_js_kvvfs_size; + + jdb.prototype.storageSize = function(){ + return jdb.storageSize(this.affirmOpen().dbFilename(), true); + }; + } + + if( sqlite3.__isUnderTest && sqlite3.vtab ){ + + const cols = Object.assign(Object.create(null),{ + rowid: {type: 'INTEGER'}, + name: {type: 'TEXT'}, + nRef: {type: 'INTEGER'}, + nOpen: {type: 'INTEGER'}, + isTransient: {type: 'INTEGER'}, + dbSize: {type: 'INTEGER'} + }); + Object.keys(cols).forEach((v,i)=>cols[v].colId = i); + + const VT = sqlite3.vtab; + const ProtoCursor = Object.assign(Object.create(null),{ + row: function(){ + return cache.storagePool[this.names[this.rowid]]; + } + }); + Object.assign(Object.create(ProtoCursor),{ + rowid: 0, + names: Object.keys(cache.storagePool) + .filter(v=>!cache.rxJournalSuffix.test(v)) + }); + const cursorState = function(cursor, reset){ + const o = (cursor instanceof capi.sqlite3_vtab_cursor) + ? cursor + : VT.xCursor.get(cursor); + if( reset || !o.vTabState ){ + o.vTabState = Object.assign(Object.create(ProtoCursor),{ + rowid: 0, + names: Object.keys(cache.storagePool) + .filter(v=>!cache.rxJournalSuffix.test(v)) + }); + } + return o.vTabState; + }; + + const dbg = 1 ? ()=>{} : (...args)=>debug("vtab",...args); + + const theModule = function f(){ + return f.mod ??= new sqlite3.capi.sqlite3_module().setupModule({ + catchExceptions: true, + methods: { + xConnect: function(pDb, pAux, argc, argv, ppVtab, pzErr){ + dbg("xConnect"); + try{ + const xcol = []; + Object.keys(cols).forEach((k)=>{ + xcol.push(k+" "+cols[k].type); + }); + const rc = capi.sqlite3_declare_vtab( + pDb, "CREATE TABLE ignored("+xcol.join(',')+")" + ); + if(0===rc){ + const t = VT.xVtab.create(ppVtab); + util.assert( + (t === VT.xVtab.get(wasm.peekPtr(ppVtab))), + "output pointer check failed" + ); + } + return rc; + }catch(e){ + return VT.xError('xConnect', e, capi.SQLITE_ERROR); + } + }, + xCreate: wasm.ptr.null, + + xDisconnect: function(pVtab){ + dbg("xDisconnect",...arguments); + VT.xVtab.dispose(pVtab); + return 0; + }, + xOpen: function(pVtab, ppCursor){ + dbg("xOpen",...arguments); + VT.xCursor.create(ppCursor); + return 0; + }, + xClose: function(pCursor){ + dbg("xClose",...arguments); + const c = VT.xCursor.unget(pCursor); + delete c.vTabState; + c.dispose(); + return 0; + }, + xNext: function(pCursor){ + dbg("xNext",...arguments); + const c = VT.xCursor.get(pCursor); + ++cursorState(c).rowid; + return 0; + }, + xColumn: function(pCursor, pCtx, iCol){ + dbg("xColumn",...arguments); + + const st = cursorState(pCursor); + const store = st.row(); + util.assert(store, "Unexpected xColumn call"); + switch(iCol){ + case cols.rowid.colId: + capi.sqlite3_result_int(pCtx, st.rowid); + break; + case cols.name.colId: + capi.sqlite3_result_text(pCtx, store.jzClass, -1, capi.SQLITE_TRANSIENT); + break; + case cols.nRef.colId: + capi.sqlite3_result_int(pCtx, store.refc); + break; + case cols.nOpen.colId: + capi.sqlite3_result_int(pCtx, store.files.length); + break; + case cols.isTransient.colId: + capi.sqlite3_result_int(pCtx, !!store.deleteAtRefc0); + break; + case cols.dbSize.colId: + capi.sqlite3_result_int(pCtx, storageGetDbSize(store)); + break; + default: + capi.sqlite3_result_error(pCtx, "Invalid column id: "+iCol); + return capi.SQLITE_RANGE; + } + return 0; + }, + xRowid: function(pCursor, ppRowid64){ + dbg("xRowid",...arguments); + const st = cursorState(pCursor); + VT.xRowid(ppRowid64, st.rowid); + return 0; + }, + xEof: function(pCursor){ + const st = cursorState(pCursor); + dbg("xEof?="+(!st.row()),...arguments); + return !st.row(); + }, + xFilter: function(pCursor, idxNum, idxCStr, + argc, argv){ + dbg("xFilter",...arguments); + const st = cursorState(pCursor, true); + return 0; + }, + xBestIndex: function(pVtab, pIdxInfo){ + dbg("xBestIndex",...arguments); + + const pii = new capi.sqlite3_index_info(pIdxInfo); + pii.$estimatedRows = cache.storagePool.size; + pii.$estimatedCost = 1.0; + pii.dispose(); + return 0; + } + } + }); + }; + + sqlite3.kvvfs.create_module = function(pDb, name="sqlite_kvvfs"){ + return capi.sqlite3_create_module(pDb, name, theModule(), + wasm.ptr.null); + }; + + } + + +}); + +globalThis.sqlite3ApiBootstrap.initializers.push(function(sqlite3){ + 'use strict'; + if( sqlite3.config.disable?.vfs?.opfs && + sqlite3.config.disable.vfs['opfs-vfs'] ){ + return; + } + const toss = sqlite3.util.toss, + capi = sqlite3.capi, + util = sqlite3.util, + wasm = sqlite3.wasm; + + + const opfsUtil = sqlite3.opfs = Object.create(null); + + + opfsUtil.thisThreadHasOPFS = ()=>{ + return globalThis.FileSystemHandle && + globalThis.FileSystemDirectoryHandle && + globalThis.FileSystemFileHandle && + globalThis.FileSystemFileHandle.prototype.createSyncAccessHandle && + navigator?.storage?.getDirectory; + }; + + + opfsUtil.getRootDir = async function f(){ + return f.promise ??= navigator.storage.getDirectory().then(d=>{ + opfsUtil.rootDirectory = d; + return d; + }).catch(e=>{ + delete f.promise; + throw e; + }); + }; + + + opfsUtil.getResolvedPath = function(filename,splitIt){ + const p = new URL(filename, "file://irrelevant").pathname; + return splitIt ? p.split('/').filter((v)=>!!v) : p; + }; + + + opfsUtil.getDirForFilename = async function f(absFilename, createDirs = false){ + const path = opfsUtil.getResolvedPath(absFilename, true); + const filename = path.pop(); + let dh = await opfsUtil.getRootDir(); + for(const dirName of path){ + if(dirName){ + dh = await dh.getDirectoryHandle(dirName, {create: !!createDirs}); + } + } + return [dh, filename]; + }; + + + opfsUtil.mkdir = async function(absDirName){ + try { + await opfsUtil.getDirForFilename(absDirName+"/filepart", true); + return true; + }catch(e){ + + return false; + } + }; + + + opfsUtil.entryExists = async function(fsEntryName){ + try { + const [dh, fn] = await opfsUtil.getDirForFilename(fsEntryName); + await dh.getFileHandle(fn); + return true; + }catch(e){ + return false; + } + }; + + + opfsUtil.randomFilename = function f(len=16){ + if(!f._chars){ + f._chars = "abcdefghijklmnopqrstuvwxyz"+ + "ABCDEFGHIJKLMNOPQRSTUVWXYZ"+ + "012346789"; + f._n = f._chars.length; + } + const a = []; + let i = 0; + for( ; i < len; ++i){ + const ndx = Math.random() * (f._n * 64) % f._n | 0; + a[i] = f._chars[ndx]; + } + return a.join(""); + + }; + + + opfsUtil.treeList = async function(){ + const doDir = async function callee(dirHandle,tgt){ + tgt.name = dirHandle.name; + tgt.dirs = []; + tgt.files = []; + for await (const handle of dirHandle.values()){ + if('directory' === handle.kind){ + const subDir = Object.create(null); + tgt.dirs.push(subDir); + await callee(handle, subDir); + }else{ + tgt.files.push(handle.name); + } + } + }; + const root = Object.create(null); + const dir = await opfsUtil.getRootDir(); + await doDir(dir, root); + return root; + }; + + + opfsUtil.rmfr = async function(){ + const rd = await opfsUtil.getRootDir(); + const dir = rd, opt = {recurse: true}; + for await (const handle of dir.values()){ + dir.removeEntry(handle.name, opt); + } + }; + + + opfsUtil.unlink = async function(fsEntryName, recursive = false, + throwOnError = false){ + try { + const [hDir, filenamePart] = + await opfsUtil.getDirForFilename(fsEntryName, false); + await hDir.removeEntry(filenamePart, {recursive}); + return true; + }catch(e){ + if(throwOnError){ + throw new Error("unlink(",arguments[0],") failed: "+e.message,{ + cause: e + }); + } + return false; + } + }; + + + opfsUtil.traverse = async function(opt){ + const defaultOpt = { + recursive: true, + directory: await opfsUtil.getRootDir() + }; + if('function'===typeof opt){ + opt = {callback:opt}; + } + opt = Object.assign(defaultOpt, opt||{}); + const doDir = async function callee(dirHandle, depth){ + for await (const handle of dirHandle.values()){ + if(false === opt.callback(handle, dirHandle, depth)) return false; + else if(opt.recursive && 'directory' === handle.kind){ + if(false === await callee(handle, depth + 1)) break; + } + } + }; + doDir(opt.directory, 0); + }; + + + const importDbChunked = async function(filename, callback){ + const [hDir, fnamePart] = await opfsUtil.getDirForFilename(filename, true); + const hFile = await hDir.getFileHandle(fnamePart, {create:true}); + let sah = await hFile.createSyncAccessHandle(); + let nWrote = 0, chunk, checkedHeader = false, err = false; + try{ + sah.truncate(0); + while( undefined !== (chunk = await callback()) ){ + if(chunk instanceof ArrayBuffer) chunk = new Uint8Array(chunk); + if( !checkedHeader && 0===nWrote && chunk.byteLength>=15 ){ + util.affirmDbHeader(chunk); + checkedHeader = true; + } + sah.write(chunk, {at: nWrote}); + nWrote += chunk.byteLength; + } + if( nWrote < 512 || 0!==nWrote % 512 ){ + toss("Input size",nWrote,"is not correct for an SQLite database."); + } + if( !checkedHeader ){ + const header = new Uint8Array(20); + sah.read( header, {at: 0} ); + util.affirmDbHeader( header ); + } + sah.write(new Uint8Array([1,1]), {at: 18}); + return nWrote; + }catch(e){ + await sah.close(); + sah = undefined; + await hDir.removeEntry( fnamePart ).catch(()=>{}); + throw e; + }finally { + if( sah ) await sah.close(); + } + }; + + + opfsUtil.importDb = async function(filename, bytes){ + if( bytes instanceof Function ){ + return importDbChunked(filename, bytes); + } + if(bytes instanceof ArrayBuffer) bytes = new Uint8Array(bytes); + util.affirmIsDb(bytes); + const n = bytes.byteLength; + const [hDir, fnamePart] = await opfsUtil.getDirForFilename(filename, true); + let sah, err, nWrote = 0; + try { + const hFile = await hDir.getFileHandle(fnamePart, {create:true}); + sah = await hFile.createSyncAccessHandle(); + sah.truncate(0); + nWrote = sah.write(bytes, {at: 0}); + if(nWrote != n){ + toss("Expected to write "+n+" bytes but wrote "+nWrote+"."); + } + sah.write(new Uint8Array([1,1]), {at: 18}) ; + return nWrote; + }catch(e){ + if( sah ){ await sah.close(); sah = undefined; } + await hDir.removeEntry( fnamePart ).catch(()=>{}); + throw e; + }finally{ + if( sah ) await sah.close(); + } + }; + + + opfsUtil.vfsInstallationFeatureCheck = function(vfsName){ + if( !globalThis.SharedArrayBuffer || !globalThis.Atomics ){ + toss("Cannot install OPFS: Missing SharedArrayBuffer and/or Atomics.", + "The server must emit the COOP/COEP response headers to enable those.", + "See https://sqlite.org/wasm/doc/trunk/persistence.md#coop-coep"); + }else if( 'undefined'===typeof WorkerGlobalScope ){ + toss("The OPFS sqlite3_vfs cannot run in the main thread", + "because it requires Atomics.wait()."); + }else if( !globalThis.FileSystemHandle || + !globalThis.FileSystemDirectoryHandle || + !globalThis.FileSystemFileHandle?.prototype?.createSyncAccessHandle || + !navigator?.storage?.getDirectory ){ + toss("Missing required OPFS APIs."); + }else if( 'opfs-wl'===vfsName && !globalThis.Atomics.waitAsync ){ + toss('The',vfsName,'VFS requires Atomics.waitAsync(), which is not available.'); + } + }; + + + opfsUtil.initOptions = function callee(vfsName, options){ + const urlParams = new URL(globalThis.location.href).searchParams; + if( urlParams.has(vfsName+'-disable') ){ + + return; + } + try{ + opfsUtil.vfsInstallationFeatureCheck(vfsName); + }catch(e){ + return; + } + options = util.nu(options); + options.vfsName = vfsName; + options.verbose ??= urlParams.has('opfs-verbose') + ? +urlParams.get('opfs-verbose') : 1; + options.sanityChecks ??= urlParams.has('opfs-sanity-check'); + + if( !opfsUtil.proxyUri ){ + opfsUtil.proxyUri = "sqlite3-opfs-async-proxy.js"; + if( sqlite3.scriptInfo?.sqlite3Dir ){ + + opfsUtil.proxyUri = ( + sqlite3.scriptInfo.sqlite3Dir + opfsUtil.proxyUri + ); + } + } + options.proxyUri ??= opfsUtil.proxyUri; + if('function' === typeof options.proxyUri){ + options.proxyUri = options.proxyUri(); + } + + return opfsUtil.options = options; + }; + + + opfsUtil.createVfsState = function(){ + const state = util.nu(); + const options = opfsUtil.options; + state.verbose = options.verbose; + + const loggers = [ + sqlite3.config.error, + sqlite3.config.warn, + sqlite3.config.log + ]; + const vfsName = options.vfsName + || toss("Maintenance required: missing VFS name"); + const logImpl = (level,...args)=>{ + if(state.verbose>level) loggers[level](vfsName+":",...args); + }; + const log = (...args)=>logImpl(2, ...args), + warn = (...args)=>logImpl(1, ...args), + error = (...args)=>logImpl(0, ...args), + capi = sqlite3.capi, + wasm = sqlite3.wasm; + + const opfsVfs = state.vfs = new capi.sqlite3_vfs(); + const opfsIoMethods = opfsVfs.ioMethods = new capi.sqlite3_io_methods(); + + opfsIoMethods.$iVersion = 1; + opfsVfs.$iVersion = 2; + opfsVfs.$szOsFile = capi.sqlite3_file.structInfo.sizeof; + opfsVfs.$mxPathname = 1024; + opfsVfs.$zName = wasm.allocCString(vfsName); + opfsVfs.addOnDispose( + '$zName', opfsVfs.$zName, opfsIoMethods + + ); + + opfsVfs.metrics = util.nu({ + counters: util.nu(), + dump: function(){ + let k, n = 0, t = 0, w = 0; + for(k in state.opIds){ + const m = metrics[k]; + n += m.count; + t += m.time; + w += m.wait; + m.avgTime = (m.count && m.time) ? (m.time / m.count) : 0; + m.avgWait = (m.count && m.wait) ? (m.wait / m.count) : 0; + } + sqlite3.config.log(globalThis.location.href, + "metrics for",globalThis.location.href,":",metrics, + "\nTotal of",n,"op(s) for",t, + "ms (incl. "+w+" ms of waiting on the async side)"); + sqlite3.config.log("Serialization metrics:",opfsVfs.metrics.counters.s11n); + opfsVfs.worker?.postMessage?.({type:'opfs-async-metrics'}); + }, + reset: function(){ + let k; + const r = (m)=>(m.count = m.time = m.wait = 0); + const m = opfsVfs.metrics.counters; + for(k in state.opIds){ + r(m[k] = Object.create(null)); + } + let s = m.s11n = Object.create(null); + s = s.serialize = Object.create(null); + s.count = s.time = 0; + s = m.s11n.deserialize = Object.create(null); + s.count = s.time = 0; + } + }); + + + state.asyncIdleWaitTime = 150; + + + state.asyncS11nExceptions = 1; + + state.fileBufferSize = 1024 * 64; + state.sabS11nOffset = state.fileBufferSize; + + state.sabS11nSize = opfsVfs.$mxPathname * 2; + + state.sabIO = new SharedArrayBuffer( + state.fileBufferSize + + state.sabS11nSize + ); + + + state.opIds = Object.create(null); + { + + let i = 0; + + state.opIds.whichOp = i++; + + state.opIds.rc = i++; + + state.opIds.xAccess = i++; + state.opIds.xClose = i++; + state.opIds.xDelete = i++; + state.opIds.xDeleteNoWait = i++; + state.opIds.xFileSize = i++; + state.opIds.xLock = i++; + state.opIds.xOpen = i++; + state.opIds.xRead = i++; + state.opIds.xSleep = i++; + state.opIds.xSync = i++; + state.opIds.xTruncate = i++; + state.opIds.xUnlock = i++; + state.opIds.xWrite = i++; + state.opIds.mkdir = i++ ; + + state.opIds['opfs-async-metrics'] = i++; + state.opIds['opfs-async-shutdown'] = i++; + + state.opIds.retry = i++; + state.sabOP = new SharedArrayBuffer( + i * 4); + } + + state.sq3Codes = Object.create(null); + for(const k of [ + 'SQLITE_ACCESS_EXISTS', + 'SQLITE_ACCESS_READWRITE', + 'SQLITE_BUSY', + 'SQLITE_CANTOPEN', + 'SQLITE_ERROR', + 'SQLITE_IOERR', + 'SQLITE_IOERR_ACCESS', + 'SQLITE_IOERR_CLOSE', + 'SQLITE_IOERR_DELETE', + 'SQLITE_IOERR_FSYNC', + 'SQLITE_IOERR_LOCK', + 'SQLITE_IOERR_READ', + 'SQLITE_IOERR_SHORT_READ', + 'SQLITE_IOERR_TRUNCATE', + 'SQLITE_IOERR_UNLOCK', + 'SQLITE_IOERR_WRITE', + 'SQLITE_LOCK_EXCLUSIVE', + 'SQLITE_LOCK_NONE', + 'SQLITE_LOCK_PENDING', + 'SQLITE_LOCK_RESERVED', + 'SQLITE_LOCK_SHARED', + 'SQLITE_LOCKED', + 'SQLITE_MISUSE', + 'SQLITE_NOTFOUND', + 'SQLITE_OPEN_CREATE', + 'SQLITE_OPEN_DELETEONCLOSE', + 'SQLITE_OPEN_MAIN_DB', + 'SQLITE_OPEN_READONLY', + 'SQLITE_LOCK_NONE', + 'SQLITE_LOCK_SHARED', + 'SQLITE_LOCK_RESERVED', + 'SQLITE_LOCK_PENDING', + 'SQLITE_LOCK_EXCLUSIVE' + ]){ + state.sq3Codes[k] = + capi[k] ?? toss("Maintenance required: not found:",k); + } + + state.opfsFlags = Object.assign(Object.create(null),{ + + OPFS_UNLOCK_ASAP: 0x01, + + OPFS_UNLINK_BEFORE_OPEN: 0x02, + + defaultUnlockAsap: false + }); + + opfsVfs.metrics.reset(); + const metrics = opfsVfs.metrics.counters; + + + const opRun = opfsVfs.opRun = (op,...args)=>{ + const opNdx = state.opIds[op] || toss(opfsVfs.vfsName+": Invalid op ID:",op); + state.s11n.serialize(...args); + Atomics.store(state.sabOPView, state.opIds.rc, -1); + Atomics.store(state.sabOPView, state.opIds.whichOp, opNdx); + Atomics.notify(state.sabOPView, state.opIds.whichOp) + ; + const t = performance.now(); + while('not-equal'!==Atomics.wait(state.sabOPView, state.opIds.rc, -1)){ + + } + + const rc = Atomics.load(state.sabOPView, state.opIds.rc); + metrics[op].wait += performance.now() - t; + if(rc && state.asyncS11nExceptions){ + const err = state.s11n.deserialize(); + if(err) error(op+"() async error:",...err); + } + return rc; + }; + + const opTimer = Object.create(null); + opTimer.op = undefined; + opTimer.start = undefined; + const mTimeStart = opfsVfs.mTimeStart = (op)=>{ + opTimer.start = performance.now(); + opTimer.op = op; + ++metrics[op].count; + }; + const mTimeEnd = opfsVfs.mTimeEnd = ()=>( + metrics[opTimer.op].time += performance.now() - opTimer.start + ); + + + const __openFiles = opfsVfs.__openFiles = Object.create(null); + + + const ioSyncWrappers = opfsVfs.ioSyncWrappers = util.nu({ + xCheckReservedLock: function(pFile,pOut){ + + wasm.poke(pOut, 0, 'i32'); + return 0; + }, + xClose: function(pFile){ + mTimeStart('xClose'); + let rc = 0; + const f = __openFiles[pFile]; + if(f){ + delete __openFiles[pFile]; + rc = opRun('xClose', pFile); + if(f.sq3File) f.sq3File.dispose(); + } + mTimeEnd(); + return rc; + }, + xDeviceCharacteristics: function(pFile){ + return capi.SQLITE_IOCAP_UNDELETABLE_WHEN_OPEN; + }, + xFileControl: function(pFile, opId, pArg){ + + return capi.SQLITE_NOTFOUND; + }, + xFileSize: function(pFile,pSz64){ + mTimeStart('xFileSize'); + let rc = opRun('xFileSize', pFile); + if(0==rc){ + try { + const sz = state.s11n.deserialize()[0]; + wasm.poke(pSz64, sz, 'i64'); + }catch(e){ + error("Unexpected error reading xFileSize() result:",e); + rc = state.sq3Codes.SQLITE_IOERR; + } + } + mTimeEnd(); + return rc; + }, + xRead: function(pFile,pDest,n,offset64){ + mTimeStart('xRead'); + const f = __openFiles[pFile]; + let rc; + try { + rc = opRun('xRead',pFile, n, Number(offset64)); + if(0===rc || capi.SQLITE_IOERR_SHORT_READ===rc){ + + wasm.heap8u().set(f.sabView.subarray(0, n), Number(pDest)); + } + }catch(e){ + error("xRead(",arguments,") failed:",e,f); + rc = capi.SQLITE_IOERR_READ; + } + mTimeEnd(); + return rc; + }, + xSync: function(pFile,flags){ + mTimeStart('xSync'); + const rc = opRun('xSync', pFile, flags); + mTimeEnd(); + return rc; + }, + xTruncate: function(pFile,sz64){ + mTimeStart('xTruncate'); + const rc = opRun('xTruncate', pFile, Number(sz64)); + mTimeEnd(); + return rc; + }, + xWrite: function(pFile,pSrc,n,offset64){ + mTimeStart('xWrite'); + const f = __openFiles[pFile]; + let rc; + try { + f.sabView.set(wasm.heap8u().subarray( + Number(pSrc), Number(pSrc) + n + )); + rc = opRun('xWrite', pFile, n, Number(offset64)); + }catch(e){ + error("xWrite(",arguments,") failed:",e,f); + rc = capi.SQLITE_IOERR_WRITE; + } + mTimeEnd(); + return rc; + } + }); + + + const vfsSyncWrappers = opfsVfs.vfsSyncWrappers = { + xAccess: function(pVfs,zName,flags,pOut){ + mTimeStart('xAccess'); + const rc = opRun('xAccess', wasm.cstrToJs(zName)); + wasm.poke( pOut, (rc ? 0 : 1), 'i32' ); + mTimeEnd(); + return 0; + }, + xCurrentTime: function(pVfs,pOut){ + wasm.poke(pOut, 2440587.5 + (new Date().getTime()/86400000), + 'double'); + return 0; + }, + xCurrentTimeInt64: function(pVfs,pOut){ + wasm.poke(pOut, (2440587.5 * 86400000) + new Date().getTime(), + 'i64'); + return 0; + }, + xDelete: function(pVfs, zName, doSyncDir){ + mTimeStart('xDelete'); + const rc = opRun('xDelete', wasm.cstrToJs(zName), doSyncDir, false); + mTimeEnd(); + return rc; + }, + xFullPathname: function(pVfs,zName,nOut,pOut){ + + const i = wasm.cstrncpy(pOut, zName, nOut); + return i( + TypeIds[typeof v] + || toss("Maintenance required: this value type cannot be serialized.",v) + ); + const getTypeIdById = (tid)=>{ + switch(tid){ + case TypeIds.number.id: return TypeIds.number; + case TypeIds.bigint.id: return TypeIds.bigint; + case TypeIds.boolean.id: return TypeIds.boolean; + case TypeIds.string.id: return TypeIds.string; + default: toss("Invalid type ID:",tid); + } + }; + + + state.s11n.deserialize = function(clear=false){ + const t = performance.now(); + const argc = viewU8[0]; + const rc = argc ? [] : null; + if(argc){ + const typeIds = []; + let offset = 1, i, n, v; + for(i = 0; i < argc; ++i, ++offset){ + typeIds.push(getTypeIdById(viewU8[offset])); + } + for(i = 0; i < argc; ++i){ + const t = typeIds[i]; + if(t.getter){ + v = viewDV[t.getter](offset, state.littleEndian); + offset += t.size; + }else{ + n = viewDV.getInt32(offset, state.littleEndian); + offset += 4; + v = textDecoder.decode(viewU8.slice(offset, offset+n)); + offset += n; + } + rc.push(v); + } + } + if(clear) viewU8[0] = 0; + + return rc; + }; + + + state.s11n.serialize = function(...args){ + const t = performance.now(); + if(args.length){ + + const typeIds = []; + let i = 0, offset = 1; + viewU8[0] = args.length & 0xff ; + for(; i < args.length; ++i, ++offset){ + + typeIds.push(getTypeId(args[i])); + viewU8[offset] = typeIds[i].id; + } + for(i = 0; i < args.length; ++i) { + + const t = typeIds[i]; + if(t.setter){ + viewDV[t.setter](offset, args[i], state.littleEndian); + offset += t.size; + }else{ + const s = textEncoder.encode(args[i]); + viewDV.setInt32(offset, s.byteLength, state.littleEndian); + offset += 4; + viewU8.set(s, offset); + offset += s.byteLength; + } + } + + }else{ + viewU8[0] = 0; + } + }; + + + return state.s11n; +}; + opfsVfs.initS11n = initS11n; + + + opfsVfs.bindVfs = function(ioMethods, callback){ + Object.assign(opfsVfs.ioSyncWrappers, ioMethods); + const thePromise = new Promise(function(promiseResolve_, promiseReject_){ + let promiseWasRejected = undefined; + const promiseReject = (err)=>{ + promiseWasRejected = true; + opfsVfs.dispose(); + return promiseReject_(err); + }; + const promiseResolve = ()=>{ + try{ + callback(sqlite3, opfsVfs); + }catch(e){ + return promiseReject(e); + } + promiseWasRejected = false; + return promiseResolve_(sqlite3); + }; + const options = opfsUtil.options; + const proxyUri = options.proxyUri +( + (options.proxyUri.indexOf('?')<0) ? '?' : '&' + )+'vfs='+vfsName; + + const W = opfsVfs.worker = + new Worker(new URL(proxyUri, import.meta.url)); + let zombieTimer = setTimeout(()=>{ + + if(undefined===promiseWasRejected){ + promiseReject( + new Error("Timeout while waiting for OPFS async proxy worker.") + ); + } + }, 4000); + W._originalOnError = W.onerror ; + W.onerror = function(err){ + + + error("Error initializing OPFS asyncer:",err); + promiseReject(new Error("Loading OPFS async Worker failed for unknown reasons.")); + }; + + const opRun = opfsVfs.opRun; + + const sanityCheck = function(){ + const scope = wasm.scopedAllocPush(); + const sq3File = new capi.sqlite3_file(); + try{ + const fid = sq3File.pointer; + const openFlags = capi.SQLITE_OPEN_CREATE + | capi.SQLITE_OPEN_READWRITE + + | capi.SQLITE_OPEN_MAIN_DB; + const pOut = wasm.scopedAlloc(8); + const dbFile = "/sanity/check/file"+opfsUtil.randomFilename(8); + const zDbFile = wasm.scopedAllocCString(dbFile); + let rc; + state.s11n.serialize("This is ä string."); + rc = state.s11n.deserialize(); + log("deserialize() says:",rc); + if("This is ä string."!==rc[0]) toss("String d13n error."); + opfsVfs.vfsSyncWrappers.xAccess(opfsVfs.pointer, zDbFile, 0, pOut); + rc = wasm.peek(pOut,'i32'); + log("xAccess(",dbFile,") exists ?=",rc); + rc = opfsVfs.vfsSyncWrappers.xOpen(opfsVfs.pointer, zDbFile, + fid, openFlags, pOut); + log("open rc =",rc,"state.sabOPView[xOpen] =", + state.sabOPView[state.opIds.xOpen]); + if(0!==rc){ + error("open failed with code",rc); + return; + } + opfsVfs.vfsSyncWrappers.xAccess(opfsVfs.pointer, zDbFile, 0, pOut); + rc = wasm.peek(pOut,'i32'); + if(!rc) toss("xAccess() failed to detect file."); + rc = opfsVfs.ioSyncWrappers.xSync(sq3File.pointer, 0); + if(rc) toss('sync failed w/ rc',rc); + rc = opfsVfs.ioSyncWrappers.xTruncate(sq3File.pointer, 1024); + if(rc) toss('truncate failed w/ rc',rc); + wasm.poke(pOut,0,'i64'); + rc = opfsVfs.ioSyncWrappers.xFileSize(sq3File.pointer, pOut); + if(rc) toss('xFileSize failed w/ rc',rc); + log("xFileSize says:",wasm.peek(pOut, 'i64')); + rc = opfsVfs.ioSyncWrappers.xWrite(sq3File.pointer, zDbFile, 10, 1); + if(rc) toss("xWrite() failed!"); + const readBuf = wasm.scopedAlloc(16); + rc = opfsVfs.ioSyncWrappers.xRead(sq3File.pointer, readBuf, 6, 2); + wasm.poke(readBuf+6,0); + let jRead = wasm.cstrToJs(readBuf); + log("xRead() got:",jRead); + if("sanity"!==jRead) toss("Unexpected xRead() value."); + if(opfsVfs.vfsSyncWrappers.xSleep){ + log("xSleep()ing before close()ing..."); + opfsVfs.vfsSyncWrappers.xSleep(opfsVfs.pointer,2000); + log("waking up from xSleep()"); + } + rc = opfsVfs.ioSyncWrappers.xClose(fid); + log("xClose rc =",rc,"sabOPView =",state.sabOPView); + log("Deleting file:",dbFile); + opfsVfs.vfsSyncWrappers.xDelete(opfsVfs.pointer, zDbFile, 0x1234); + opfsVfs.vfsSyncWrappers.xAccess(opfsVfs.pointer, zDbFile, 0, pOut); + rc = wasm.peek(pOut,'i32'); + if(rc) toss("Expecting 0 from xAccess(",dbFile,") after xDelete()."); + warn("End of OPFS sanity checks."); + }finally{ + sq3File.dispose(); + wasm.scopedAllocPop(scope); + } + }; + + W.onmessage = function({data}){ + + switch(data.type){ + case 'opfs-unavailable': + + promiseReject(new Error(data.payload.join(' '))); + break; + case 'opfs-async-loaded': + + delete state.vfs; + W.postMessage({type: 'opfs-async-init', args: util.nu(state)}); + break; + case 'opfs-async-inited': { + + if(true===promiseWasRejected){ + break ; + } + clearTimeout(zombieTimer); + zombieTimer = null; + try { + sqlite3.vfs.installVfs({ + io: {struct: opfsVfs.ioMethods, methods: opfsVfs.ioSyncWrappers}, + vfs: {struct: opfsVfs, methods: opfsVfs.vfsSyncWrappers} + }); + state.sabOPView = new Int32Array(state.sabOP); + state.sabFileBufView = new Uint8Array(state.sabIO, 0, state.fileBufferSize); + state.sabS11nView = new Uint8Array(state.sabIO, state.sabS11nOffset, state.sabS11nSize); + opfsVfs.initS11n(); + opfsVfs.initS11n = null; + if(options.sanityChecks){ + warn("Running sanity checks because of opfs-sanity-check URL arg..."); + sanityCheck(); + } + if(opfsUtil.thisThreadHasOPFS()){ + opfsUtil.getRootDir().then((d)=>{ + W.onerror = W._originalOnError; + delete W._originalOnError; + log("End of OPFS sqlite3_vfs setup.", opfsVfs); + promiseResolve(); + }).catch(promiseReject); + }else{ + promiseResolve(); + } + }catch(e){ + error(e); + promiseReject(e); + } + break; + } + case 'debug': + warn("debug message from worker:",data); + break; + default: { + const errMsg = ( + "Unexpected message from the OPFS async worker: " + + JSON.stringify(data) + ); + error(errMsg); + promiseReject(new Error(errMsg)); + break; + } + } + }; + }); + return thePromise; + }; + + return state; + }; + +}); + +'use strict'; +globalThis.sqlite3ApiBootstrap.initializers.push(function(sqlite3){ + if( !sqlite3.opfs || sqlite3.config.disable?.vfs?.opfs ){ + return; + } + const util = sqlite3.util, + opfsUtil = sqlite3.opfs || sqlite3.util.toss("Missing sqlite3.opfs"); + +const installOpfsVfs = async function(options){ + options = opfsUtil.initOptions('opfs',options); + if( !options ) return sqlite3; + const capi = sqlite3.capi, + state = opfsUtil.createVfsState(), + opfsVfs = state.vfs, + metrics = opfsVfs.metrics.counters, + mTimeStart = opfsVfs.mTimeStart, + mTimeEnd = opfsVfs.mTimeEnd, + opRun = opfsVfs.opRun, + debug = (...args)=>sqlite3.config.debug("opfs:",...args), + warn = (...args)=>sqlite3.config.warn("opfs:",...args), + __openFiles = opfsVfs.__openFiles; + + + + return opfsVfs.bindVfs(util.nu({ + xLock: function(pFile,lockType){ + mTimeStart('xLock'); + ++metrics.xLock.count; + const f = __openFiles[pFile]; + let rc = 0; + + if( f.lockType ) { + f.lockType = lockType; + }else{ + rc = opRun('xLock', pFile, lockType); + if( 0===rc ) f.lockType = lockType; + } + mTimeEnd(); + return rc; + }, + xUnlock: function(pFile,lockType){ + mTimeStart('xUnlock'); + ++metrics.xUnlock.count; + const f = __openFiles[pFile]; + let rc = 0; + if( capi.SQLITE_LOCK_NONE === lockType + && f.lockType ){ + rc = opRun('xUnlock', pFile, lockType); + } + if( 0===rc ) f.lockType = lockType; + mTimeEnd(); + return rc; + } + }), function(sqlite3, vfs){ + + if(sqlite3.oo1){ + const OpfsDb = function(...args){ + const opt = sqlite3.oo1.DB.dbCtorHelper.normalizeArgs(...args); + opt.vfs = vfs.$zName; + sqlite3.oo1.DB.dbCtorHelper.call(this, opt); + }; + OpfsDb.prototype = Object.create(sqlite3.oo1.DB.prototype); + sqlite3.oo1.OpfsDb = OpfsDb; + OpfsDb.importDb = opfsUtil.importDb; + if( true ){ + + sqlite3.oo1.DB.dbCtorHelper.setVfsPostOpenCallback( + opfsVfs.pointer, + function(oo1Db, sqlite3){ + + sqlite3.capi.sqlite3_busy_timeout(oo1Db, 10000); + } + ); + } + } + }); +}; +globalThis.sqlite3ApiBootstrap.initializersAsync.push(async (sqlite3)=>{ + return installOpfsVfs().catch((e)=>{ + sqlite3.config.warn("Ignoring inability to install 'opfs' sqlite3_vfs:",e); + }) +}); +}); + +globalThis.sqlite3ApiBootstrap.initializers.push(function(sqlite3){ + 'use strict'; + if( sqlite3.config.disable?.vfs?.['opfs-sahpool'] ){ + return; + } + + const toss = sqlite3.util.toss; + const toss3 = sqlite3.util.toss3; + const initPromises = Object.create(null) ; + const capi = sqlite3.capi; + const util = sqlite3.util; + const wasm = sqlite3.wasm; + + const SECTOR_SIZE = 4096; + const HEADER_MAX_PATH_SIZE = 512; + const HEADER_FLAGS_SIZE = 4; + const HEADER_DIGEST_SIZE = 8; + const HEADER_CORPUS_SIZE = HEADER_MAX_PATH_SIZE + HEADER_FLAGS_SIZE; + const HEADER_OFFSET_FLAGS = HEADER_MAX_PATH_SIZE; + const HEADER_OFFSET_DIGEST = HEADER_CORPUS_SIZE; + const HEADER_OFFSET_DATA = SECTOR_SIZE; + + const PERSISTENT_FILE_TYPES = + capi.SQLITE_OPEN_MAIN_DB | + capi.SQLITE_OPEN_MAIN_JOURNAL | + capi.SQLITE_OPEN_SUPER_JOURNAL | + capi.SQLITE_OPEN_WAL; + const FLAG_COMPUTE_DIGEST_V2 = capi.SQLITE_OPEN_MEMORY + ; + + + const OPAQUE_DIR_NAME = ".opaque"; + + + const getRandomName = ()=>Math.random().toString(36).slice(2); + + const textDecoder = new TextDecoder(); + const textEncoder = new TextEncoder(); + + const optionDefaults = Object.assign(Object.create(null),{ + name: 'opfs-sahpool', + directory: undefined , + initialCapacity: 6, + clearOnInit: false, + + verbosity: 2, + forceReinitIfPreviouslyFailed: false, + preserveOnInitFailure: false + }); + + + const loggers = [ + sqlite3.config.error, + sqlite3.config.warn, + sqlite3.config.log + ]; + const log = sqlite3.config.log; + const warn = sqlite3.config.warn; + const error = sqlite3.config.error; + + + const __mapVfsToPool = new Map(); + const getPoolForVfs = (pVfs)=>__mapVfsToPool.get(pVfs); + const setPoolForVfs = (pVfs,pool)=>{ + if(pool) __mapVfsToPool.set(pVfs, pool); + else __mapVfsToPool.delete(pVfs); + }; + + const __mapSqlite3File = new Map(); + const getPoolForPFile = (pFile)=>__mapSqlite3File.get(pFile); + const setPoolForPFile = (pFile,pool)=>{ + if(pool) __mapSqlite3File.set(pFile, pool); + else __mapSqlite3File.delete(pFile); + }; + + + const ioMethods = { + xCheckReservedLock: function(pFile,pOut){ + const pool = getPoolForPFile(pFile); + pool.log('xCheckReservedLock'); + pool.storeErr(); + const file = pool.getOFileForS3File(pFile); + wasm.poke32( + pOut, + pool.hasReservedLock(file.path) ? 1 : 0 + + ); + return 0; + }, + xClose: function(pFile){ + const pool = getPoolForPFile(pFile); + pool.storeErr(); + const file = pool.getOFileForS3File(pFile); + if(file) { + try{ + pool.log(`xClose ${file.path}`); + pool.unlockFile(file, capi.SQLITE_LOCK_NONE); + pool.mapS3FileToOFile(pFile, false); + file.sah.flush(); + if(file.flags & capi.SQLITE_OPEN_DELETEONCLOSE){ + pool.deletePath(file.path); + } + }catch(e){ + return pool.storeErr(e, capi.SQLITE_IOERR); + } + } + return 0; + }, + xDeviceCharacteristics: function(pFile){ + return capi.SQLITE_IOCAP_UNDELETABLE_WHEN_OPEN; + }, + xFileControl: function(pFile, opId, pArg){ + return capi.SQLITE_NOTFOUND; + }, + xFileSize: function(pFile,pSz64){ + const pool = getPoolForPFile(pFile); + pool.log(`xFileSize`); + const file = pool.getOFileForS3File(pFile); + const size = file.sah.getSize() - HEADER_OFFSET_DATA; + + wasm.poke64(pSz64, BigInt(size)); + return 0; + }, + xLock: function(pFile,lockType){ + const pool = getPoolForPFile(pFile); + pool.log(`xLock ${lockType}`); + pool.storeErr(); + const file = pool.getOFileForS3File(pFile); + return pool.lockFile(file, lockType); + }, + xRead: function(pFile,pDest,n,offset64){ + const pool = getPoolForPFile(pFile); + pool.storeErr(); + const file = pool.getOFileForS3File(pFile); + pool.log(`xRead ${file.path} ${n} @ ${offset64}`); + try { + const nRead = file.sah.read( + wasm.heap8u().subarray(Number(pDest), Number(pDest)+n), + {at: HEADER_OFFSET_DATA + Number(offset64)} + ); + if(nRead < n){ + wasm.heap8u().fill(0, Number(pDest) + nRead, Number(pDest) + n); + return capi.SQLITE_IOERR_SHORT_READ; + } + return 0; + }catch(e){ + return pool.storeErr(e, capi.SQLITE_IOERR); + } + }, + xSectorSize: function(pFile){ + return SECTOR_SIZE; + }, + xSync: function(pFile,flags){ + const pool = getPoolForPFile(pFile); + pool.log(`xSync ${flags}`); + pool.storeErr(); + const file = pool.getOFileForS3File(pFile); + + try{ + file.sah.flush(); + return 0; + }catch(e){ + return pool.storeErr(e, capi.SQLITE_IOERR); + } + }, + xTruncate: function(pFile,sz64){ + const pool = getPoolForPFile(pFile); + pool.log(`xTruncate ${sz64}`); + pool.storeErr(); + const file = pool.getOFileForS3File(pFile); + + try{ + file.sah.truncate(HEADER_OFFSET_DATA + Number(sz64)); + return 0; + }catch(e){ + return pool.storeErr(e, capi.SQLITE_IOERR); + } + }, + xUnlock: function(pFile,lockType){ + const pool = getPoolForPFile(pFile); + pool.log('xUnlock'); + const file = pool.getOFileForS3File(pFile); + pool.unlockFile(file, lockType); + return 0; + }, + xWrite: function(pFile,pSrc,n,offset64){ + const pool = getPoolForPFile(pFile); + pool.storeErr(); + const file = pool.getOFileForS3File(pFile); + pool.log(`xWrite ${file.path} ${n} ${offset64}`); + try{ + const nBytes = file.sah.write( + wasm.heap8u().subarray(Number(pSrc), Number(pSrc)+n), + { at: HEADER_OFFSET_DATA + Number(offset64) } + ); + return n===nBytes ? 0 : toss("Unknown write() failure."); + }catch(e){ + return pool.storeErr(e, capi.SQLITE_IOERR); + } + } + }; + + const opfsIoMethods = new capi.sqlite3_io_methods(); + opfsIoMethods.$iVersion = 1; + sqlite3.vfs.installVfs({ + io: {struct: opfsIoMethods, methods: ioMethods} + }); + + + const vfsMethods = { + xAccess: function(pVfs,zName,flags,pOut){ + + const pool = getPoolForVfs(pVfs); + pool.storeErr(); + try{ + const name = pool.getPath(zName); + wasm.poke32(pOut, pool.hasFilename(name) ? 1 : 0); + }catch(e){ + + wasm.poke32(pOut, 0); + } + return 0; + }, + xCurrentTime: function(pVfs,pOut){ + wasm.poke(pOut, 2440587.5 + (new Date().getTime()/86400000), + 'double'); + return 0; + }, + xCurrentTimeInt64: function(pVfs,pOut){ + wasm.poke(pOut, (2440587.5 * 86400000) + new Date().getTime(), + 'i64'); + return 0; + }, + xDelete: function(pVfs, zName, doSyncDir){ + const pool = getPoolForVfs(pVfs); + pool.log(`xDelete ${wasm.cstrToJs(zName)}`); + pool.storeErr(); + try{ + pool.deletePath(pool.getPath(zName)); + return 0; + }catch(e){ + pool.storeErr(e); + return capi.SQLITE_IOERR_DELETE; + } + }, + xFullPathname: function(pVfs,zName,nOut,pOut){ + + + const i = wasm.cstrncpy(pOut, zName, nOut); + return i nOut) wasm.poke8(wasm.ptr.add(pOut,nOut,-1), 0); + }catch(e){ + return capi.SQLITE_NOMEM; + }finally{ + wasm.scopedAllocPop(scope); + } + } + return e ? (e.sqlite3Rc || capi.SQLITE_IOERR) : 0; + }, + + xOpen: function f(pVfs, zName, pFile, flags, pOutFlags){ + const pool = getPoolForVfs(pVfs); + try{ + flags &= ~FLAG_COMPUTE_DIGEST_V2; + pool.log(`xOpen ${wasm.cstrToJs(zName)} ${flags}`); + + const path = (zName && wasm.peek8(zName)) + ? pool.getPath(zName) + : getRandomName(); + let sah = pool.getSAHForPath(path); + if(sah && (flags & capi.SQLITE_OPEN_CREATE) + && (flags & capi.SQLITE_OPEN_EXCLUSIVE)){ + toss('file already exists:', path); + } + if(!sah && (flags & capi.SQLITE_OPEN_CREATE)) { + + if(pool.getFileCount() < pool.getCapacity()) { + + sah = pool.nextAvailableSAH(); + pool.setAssociatedPath(sah, path, flags); + }else{ + + toss('SAH pool is full. Cannot create file',path); + } + } + if(!sah){ + toss('file not found:',path); + } + + + const file = {path, flags, sah}; + pool.mapS3FileToOFile(pFile, file); + file.lockType = capi.SQLITE_LOCK_NONE; + const sq3File = new capi.sqlite3_file(pFile); + sq3File.$pMethods = opfsIoMethods.pointer; + sq3File.dispose(); + wasm.poke32(pOutFlags, flags); + return 0; + }catch(e){ + pool.storeErr(e); + return capi.SQLITE_CANTOPEN; + } + } + }; + + + const createOpfsVfs = function(vfsName){ + if( sqlite3.capi.sqlite3_vfs_find(vfsName)){ + toss3("VFS name is already registered:", vfsName); + } + const opfsVfs = new capi.sqlite3_vfs(); + + const pDVfs = capi.sqlite3_vfs_find(null); + const dVfs = pDVfs + ? new capi.sqlite3_vfs(pDVfs) + : null ; + opfsVfs.$iVersion = 2; + opfsVfs.$szOsFile = capi.sqlite3_file.structInfo.sizeof; + opfsVfs.$mxPathname = HEADER_MAX_PATH_SIZE; + opfsVfs.addOnDispose( + opfsVfs.$zName = wasm.allocCString(vfsName), + ()=>setPoolForVfs(opfsVfs.pointer, 0) + ); + + if(dVfs){ + + opfsVfs.$xRandomness = dVfs.$xRandomness; + dVfs.dispose(); + } + if(!opfsVfs.$xRandomness && !vfsMethods.xRandomness){ + + vfsMethods.xRandomness = function(pVfs, nOut, pOut){ + const heap = wasm.heap8u(); + let i = 0; + const npOut = Number(pOut); + for(; i < nOut; ++i) heap[npOut + i] = (Math.random()*255000) & 0xFF; + return i; + }; + } + + vfsMethods.xSleep = (pVfs,ms)=>0; + sqlite3.vfs.installVfs({ + vfs: {struct: opfsVfs, methods: vfsMethods} + }); + return opfsVfs; + }; + + + class OpfsSAHPool { + + vfsDir; + + #dhVfsRoot; + + #dhOpaque; + + #dhVfsParent; + + #mapSAHToName = new Map(); + + #mapFilenameToSAH = new Map(); + + #availableSAH = new Set(); + + #mapS3FileToOFile = new Map(); + + #mapPathToLock = new Map(); + + + + + #apBody = new Uint8Array(HEADER_CORPUS_SIZE); + + #dvBody; + + + #cVfs; + + + #verbosity; + + + constructor(options = Object.create(null)){ + this.#verbosity = options.verbosity ?? optionDefaults.verbosity; + this.vfsName = options.name || optionDefaults.name; + this.#cVfs = createOpfsVfs(this.vfsName); + setPoolForVfs(this.#cVfs.pointer, this); + this.vfsDir = options.directory || ("."+this.vfsName); + this.#dvBody = + new DataView(this.#apBody.buffer, this.#apBody.byteOffset); + this.isReady = this + .reset(!!(options.clearOnInit ?? optionDefaults.clearOnInit)) + .then(()=>{ + if(this.$error) throw this.$error; + return this.getCapacity() + ? Promise.resolve(undefined) + : this.addCapacity(options.initialCapacity + || optionDefaults.initialCapacity); + }); + } + + #logImpl(level,...args){ + if(this.#verbosity>level) loggers[level](this.vfsName+":",...args); + }; + log(...args){this.#logImpl(2, ...args)}; + warn(...args){this.#logImpl(1, ...args)}; + error(...args){this.#logImpl(0, ...args)}; + + + getVfs(){return this.#cVfs} + + + getCapacity(){return this.#mapSAHToName.size} + + + getFileCount(){return this.#mapFilenameToSAH.size} + + + getFileNames(){ + const rc = []; + for(const n of this.#mapFilenameToSAH.keys()) rc.push(n); + return rc; + } + + + async addCapacity(n){ + for(let i = 0; i < n; ++i){ + const name = getRandomName(); + const h = await this.#dhOpaque.getFileHandle(name, {create:true}); + const ah = await h.createSyncAccessHandle(); + this.#mapSAHToName.set(ah,name); + this.setAssociatedPath(ah, '', 0); + + } + return this.getCapacity(); + } + + + async reduceCapacity(n){ + if(!Number.isSafeInteger(n) || n < 0){ + toss('Invalid capacity reduction:', n); + } + let nRm = 0; + for(const ah of Array.from(this.#availableSAH)){ + if(nRm === n || this.getFileCount() === this.getCapacity()){ + break; + } + const name = this.#mapSAHToName.get(ah); + ah.close(); + this.#mapSAHToName.delete(ah); + this.#availableSAH.delete(ah); + await this.#dhOpaque.removeEntry(name); + ++nRm; + } + return nRm; + } + + + releaseAccessHandles(){ + for(const ah of this.#mapSAHToName.keys()) ah.close(); + this.#mapSAHToName.clear(); + this.#mapFilenameToSAH.clear(); + this.#availableSAH.clear(); + this.#mapS3FileToOFile.clear(); + this.#mapPathToLock.clear(); + } + + + async acquireAccessHandles(clearFiles=false){ + const files = []; + for await (const [name,h] of this.#dhOpaque){ + if('file'===h.kind){ + files.push([name,h]); + } + } + const results = await Promise.allSettled(files.map(async([name,h])=>{ + const ah = await h.createSyncAccessHandle() + this.#mapSAHToName.set(ah, name); + if(clearFiles){ + ah.truncate(HEADER_OFFSET_DATA); + this.setAssociatedPath(ah, '', 0); + }else{ + const path = this.getAssociatedPath(ah); + if(path){ + this.#mapFilenameToSAH.set(path, ah); + }else{ + this.#availableSAH.add(ah); + } + } + })); + const failure = results.find((r)=>'rejected'===r.status) + ; + if( failure ){ + this.storeErr(failure.reason); + this.releaseAccessHandles(); + throw failure.reason; + } + } + + + getAssociatedPath(sah){ + sah.read(this.#apBody, {at: 0}); + + + const flags = this.#dvBody.getUint32(HEADER_OFFSET_FLAGS); + if(this.#apBody[0] && + ((flags & capi.SQLITE_OPEN_DELETEONCLOSE) || + (flags & PERSISTENT_FILE_TYPES)===0)){ + warn(`Removing file with unexpected flags ${flags.toString(16)}`, + this.#apBody); + this.setAssociatedPath(sah, '', 0); + return ''; + } + + const fileDigest = new Uint32Array(HEADER_DIGEST_SIZE / 4); + sah.read(fileDigest, {at: HEADER_OFFSET_DIGEST}); + const compDigest = this.computeDigest(this.#apBody, flags); + + if(fileDigest.every((v,i) => v===compDigest[i])){ + + const pathBytes = this.#apBody.findIndex((v)=>0===v); + if(0===pathBytes){ + + + sah.truncate(HEADER_OFFSET_DATA); + } + + return pathBytes + ? textDecoder.decode(this.#apBody.subarray(0,pathBytes)) + : ''; + }else{ + + warn('Disassociating file with bad digest.'); + this.setAssociatedPath(sah, '', 0); + return ''; + } + } + + + setAssociatedPath(sah, path, flags){ + const enc = textEncoder.encodeInto(path, this.#apBody); + if(HEADER_MAX_PATH_SIZE <= enc.written + 1){ + toss("Path too long:",path); + } + if(path && flags){ + + flags |= FLAG_COMPUTE_DIGEST_V2; + } + this.#apBody.fill(0, enc.written, HEADER_MAX_PATH_SIZE); + this.#dvBody.setUint32(HEADER_OFFSET_FLAGS, flags); + const digest = this.computeDigest(this.#apBody, flags); + + sah.write(this.#apBody, {at: 0}); + sah.write(digest, {at: HEADER_OFFSET_DIGEST}); + sah.flush(); + + if(path){ + this.#mapFilenameToSAH.set(path, sah); + this.#availableSAH.delete(sah); + }else{ + + sah.truncate(HEADER_OFFSET_DATA); + this.#availableSAH.add(sah); + } + } + + + computeDigest(byteArray, fileFlags){ + if( fileFlags & FLAG_COMPUTE_DIGEST_V2 ){ + let h1 = 0xdeadbeef; + let h2 = 0x41c6ce57; + for(const v of byteArray){ + h1 = Math.imul(h1 ^ v, 2654435761); + h2 = Math.imul(h2 ^ v, 104729); + } + return new Uint32Array([h1>>>0, h2>>>0]); + }else{ + + return new Uint32Array([0,0]); + } + } + + + async reset(clearFiles){ + await this.isReady; + let h = await navigator.storage.getDirectory(); + let prev, prevName; + for(const d of this.vfsDir.split('/')){ + if(d){ + prev = h; + h = await h.getDirectoryHandle(d,{create:true}); + } + } + this.#dhVfsRoot = h; + this.#dhVfsParent = prev; + this.#dhOpaque = await this.#dhVfsRoot.getDirectoryHandle( + OPAQUE_DIR_NAME,{create:true} + ); + this.releaseAccessHandles(); + return this.acquireAccessHandles(clearFiles); + } + + + getPath(arg) { + if(wasm.isPtr(arg)) arg = wasm.cstrToJs(arg); + return ((arg instanceof URL) + ? arg + : new URL(arg, 'file://localhost/')).pathname; + } + + + deletePath(path) { + const sah = this.#mapFilenameToSAH.get(path); + if(sah) { + + this.#mapFilenameToSAH.delete(path); + this.setAssociatedPath(sah, '', 0); + } + return !!sah; + } + + + storeErr(e,code){ + if(e){ + e.sqlite3Rc = code || capi.SQLITE_IOERR; + this.error(e); + } + this.$error = e; + return code; + } + + popErr(){ + const rc = this.$error; + this.$error = undefined; + return rc; + } + + + nextAvailableSAH(){ + const [rc] = this.#availableSAH.keys(); + return rc; + } + + + getOFileForS3File(pFile){ + return this.#mapS3FileToOFile.get(pFile); + } + + mapS3FileToOFile(pFile,file){ + if(file){ + this.#mapS3FileToOFile.set(pFile, file); + setPoolForPFile(pFile, this); + }else{ + this.#mapS3FileToOFile.delete(pFile); + setPoolForPFile(pFile, false); + } + } + + + hasFilename(name){ + return this.#mapFilenameToSAH.has(name) + } + + + getSAHForPath(path){ + return this.#mapFilenameToSAH.get(path); + } + + + async removeVfs(){ + if(!this.#cVfs.pointer || !this.#dhOpaque) return false; + capi.sqlite3_vfs_unregister(this.#cVfs.pointer); + this.#cVfs.dispose(); + delete initPromises[this.vfsName]; + try{ + this.releaseAccessHandles(); + await this.#dhVfsRoot.removeEntry(OPAQUE_DIR_NAME, {recursive: true}); + this.#dhOpaque = undefined; + await this.#dhVfsParent.removeEntry( + this.#dhVfsRoot.name, {recursive: true} + ); + this.#dhVfsRoot = this.#dhVfsParent = undefined; + }catch(e){ + sqlite3.config.error(this.vfsName,"removeVfs() failed with no recovery strategy:",e); + + } + return true; + } + + + pauseVfs(){ + if(this.#mapS3FileToOFile.size>0){ + sqlite3.SQLite3Error.toss( + capi.SQLITE_MISUSE, "Cannot pause VFS", + this.vfsName,"because it has opened files." + ); + } + if(this.#mapSAHToName.size>0){ + capi.sqlite3_vfs_unregister(this.vfsName); + this.releaseAccessHandles(); + } + return this; + } + + + isPaused(){ + return 0===this.#mapSAHToName.size; + } + + + async unpauseVfs(){ + if(0===this.#mapSAHToName.size){ + return this.acquireAccessHandles(false). + then(()=>capi.sqlite3_vfs_register(this.#cVfs, 0),this); + } + return this; + } + + + exportFile(name){ + const sah = this.#mapFilenameToSAH.get(name) || toss("File not found:",name); + const n = sah.getSize() - HEADER_OFFSET_DATA; + const b = new Uint8Array(n>0 ? n : 0); + if(n>0){ + const nRead = sah.read(b, {at: HEADER_OFFSET_DATA}); + if(nRead != n){ + toss("Expected to read "+n+" bytes but read "+nRead+"."); + } + } + return b; + } + + + async importDbChunked(name, callback){ + const sah = this.#mapFilenameToSAH.get(name) + || this.nextAvailableSAH() + || toss("No available handles to import to."); + sah.truncate(0); + let nWrote = 0, chunk, checkedHeader = false, err = false; + try{ + while( undefined !== (chunk = await callback()) ){ + if(chunk instanceof ArrayBuffer) chunk = new Uint8Array(chunk); + if( !checkedHeader && 0===nWrote && chunk.byteLength>=15 ){ + util.affirmDbHeader(chunk); + checkedHeader = true; + } + const n = sah.write(chunk, {at: HEADER_OFFSET_DATA + nWrote}); + if( n!==chunk.byteLength ){ + toss("Expected to write "+chunk.byteLength+ + "bytes but wrote"+n+"."); + } + nWrote += chunk.byteLength; + } + if( nWrote < 512 || 0!==nWrote % 512 ){ + toss("Input size",nWrote,"is not correct for an SQLite database."); + } + if( !checkedHeader ){ + const header = new Uint8Array(20); + sah.read( header, {at: HEADER_OFFSET_DATA} ); + util.affirmDbHeader( header ); + } + sah.write(new Uint8Array([1,1]), { + at: HEADER_OFFSET_DATA + 18 + }); + }catch(e){ + this.setAssociatedPath(sah, '', 0); + throw e; + } + this.setAssociatedPath(sah, name, capi.SQLITE_OPEN_MAIN_DB); + return nWrote; + } + + + importDb(name, bytes){ + if( bytes instanceof ArrayBuffer ) bytes = new Uint8Array(bytes); + else if( bytes instanceof Function ) return this.importDbChunked(name, bytes); + util.affirmIsDb(bytes); + const sah = this.#mapFilenameToSAH.get(name) + || this.nextAvailableSAH() + || toss("No available handles to import to."); + const n = bytes.byteLength; + const nWrote = sah.write(bytes, {at: HEADER_OFFSET_DATA}); + if(nWrote != n){ + this.setAssociatedPath(sah, '', 0); + toss("Expected to write "+n+" bytes but wrote "+nWrote+"."); + }else{ + sah.truncate(HEADER_OFFSET_DATA + n + ); + sah.write(new Uint8Array([1,1]), {at: HEADER_OFFSET_DATA+18} + ); + this.setAssociatedPath(sah, name, capi.SQLITE_OPEN_MAIN_DB); + } + return nWrote; + } + + + hasReservedLock(filename){ + const lk = this.#mapPathToLock.get(filename); + return !!lk && lk.lockType>capi.SQLITE_LOCK_SHARED; + } + + + lockFile(file, lockType){ + if(file.lockType>=lockType) return 0; + let lk = this.#mapPathToLock.get(file.path); + if(!lk){ + lk = Object.assign(Object.create(null),{ + nShared: 0, lockType: capi.SQLITE_LOCK_NONE + }); + this.#mapPathToLock.set(file.path, lk); + } + if(file.lockType!==lk.lockType + && (lk.lockType>=capi.SQLITE_LOCK_PENDING + || lockType>capi.SQLITE_LOCK_SHARED)){ + return capi.SQLITE_BUSY; + } + if(lockType===capi.SQLITE_LOCK_SHARED){ + ++lk.nShared; + if(lk.lockType===capi.SQLITE_LOCK_NONE) lk.lockType = lockType; + }else if(lockType===capi.SQLITE_LOCK_EXCLUSIVE && lk.nShared>1){ + if(file.lockType===capi.SQLITE_LOCK_RESERVED){ + file.lockType = lk.lockType = capi.SQLITE_LOCK_PENDING; + } + return capi.SQLITE_BUSY; + }else{ + lk.lockType = lockType; + } + file.lockType = lockType; + return 0; + } + + + unlockFile(file, lockType){ + if(file.lockType<=lockType) return; + const lk = this.#mapPathToLock.get(file.path); + if(file.lockType>capi.SQLITE_LOCK_SHARED){ + lk.lockType = capi.SQLITE_LOCK_SHARED; + } + if(lockType===capi.SQLITE_LOCK_NONE && 0===--lk.nShared + ){ + this.#mapPathToLock.delete(file.path); + } + file.lockType = lockType; + } + + }; + + + class OpfsSAHPoolUtil { + + #p; + + constructor(sahPool){ + this.#p = sahPool; + this.vfsName = sahPool.vfsName; + } + + async addCapacity(n){ return this.#p.addCapacity(n) } + + async reduceCapacity(n){ return this.#p.reduceCapacity(n) } + + getCapacity(){ return this.#p.getCapacity(this.#p) } + + getFileCount(){ return this.#p.getFileCount() } + getFileNames(){ return this.#p.getFileNames() } + + async reserveMinimumCapacity(min){ + const c = this.#p.getCapacity(); + return (c < min) ? this.#p.addCapacity(min - c) : c; + } + + exportFile(name){ return this.#p.exportFile(name) } + + importDb(name, bytes){ return this.#p.importDb(name,bytes) } + + async wipeFiles(){ return this.#p.reset(true) } + + unlink(filename){ return this.#p.deletePath(filename) } + + async removeVfs(){ return this.#p.removeVfs() } + + pauseVfs(){ this.#p.pauseVfs(); return this; } + async unpauseVfs(){ return this.#p.unpauseVfs().then(()=>this); } + isPaused(){ return this.#p.isPaused() } + + }; + + + const apiVersionCheck = async ()=>{ + const dh = await navigator.storage.getDirectory(); + const fn = '.opfs-sahpool-sync-check-'+getRandomName(); + const fh = await dh.getFileHandle(fn, { create: true }); + const ah = await fh.createSyncAccessHandle(); + const close = ah.close(); + await close; + await dh.removeEntry(fn); + if(close?.then){ + toss("The local OPFS API is too old for opfs-sahpool:", + "it has an async FileSystemSyncAccessHandle.close() method."); + } + return true; + }; + + + sqlite3.installOpfsSAHPoolVfs = async function(options=Object.create(null)){ + options = Object.assign(Object.create(null), optionDefaults, (options||{})); + const vfsName = options.name; + if(options.$testThrowPhase1){ + throw options.$testThrowPhase1; + } + if(initPromises[vfsName]){ + try { + const p = await initPromises[vfsName]; + + return p; + }catch(e){ + + if( options.forceReinitIfPreviouslyFailed ){ + delete initPromises[vfsName]; + + }else{ + throw e; + } + } + } + if(!globalThis.FileSystemHandle || + !globalThis.FileSystemDirectoryHandle || + !globalThis.FileSystemFileHandle || + !globalThis.FileSystemFileHandle.prototype.createSyncAccessHandle || + !navigator?.storage?.getDirectory){ + return (initPromises[vfsName] = Promise.reject(new Error("Missing required OPFS APIs."))); + } + + + const unregisterVfs = (thePool)=>{ + const cVfs = thePool.getVfs(); + if( cVfs?.pointer ){ + capi.sqlite3_vfs_unregister(cVfs.pointer); + cVfs.dispose(); + thePool.releaseAccessHandles(); + } + }; + + + return initPromises[vfsName] = apiVersionCheck().then(async function(){ + if(options.$testThrowPhase2){ + throw options.$testThrowPhase2; + } + const thePool = new OpfsSAHPool(options); + return thePool.isReady.then(async()=>{ + + const poolUtil = new OpfsSAHPoolUtil(thePool); + if(sqlite3.oo1){ + const oo1 = sqlite3.oo1; + const theVfs = thePool.getVfs(); + const OpfsSAHPoolDb = function(...args){ + const opt = oo1.DB.dbCtorHelper.normalizeArgs(...args); + opt.vfs = theVfs.$zName; + oo1.DB.dbCtorHelper.call(this, opt); + }; + OpfsSAHPoolDb.prototype = Object.create(oo1.DB.prototype); + poolUtil.OpfsSAHPoolDb = OpfsSAHPoolDb; + } + thePool.log("VFS initialized."); + return poolUtil; + }).catch(async (e)=>{ + if( options.preserveOnInitFailure ){ + unregisterVfs(thePool); + }else{ + await thePool.removeVfs().catch(()=>{}); + } + throw e; + }); + }).catch((err)=>{ + + return initPromises[vfsName] = Promise.reject(err); + }); + }; +}); + +'use strict'; +globalThis.sqlite3ApiBootstrap.initializers.push(function(sqlite3){ + if( !sqlite3.opfs || sqlite3.config.disable?.vfs?.['opfs-wl'] ){ + return; + } + const util = sqlite3.util, + toss = sqlite3.util.toss; + const opfsUtil = sqlite3.opfs; + const vfsName = 'opfs-wl'; + +const installOpfsWlVfs = async function(options){ + options = opfsUtil.initOptions(vfsName,options); + if( !options ) return sqlite3; + const capi = sqlite3.capi, + state = opfsUtil.createVfsState(), + opfsVfs = state.vfs, + metrics = opfsVfs.metrics.counters, + mTimeStart = opfsVfs.mTimeStart, + mTimeEnd = opfsVfs.mTimeEnd, + opRun = opfsVfs.opRun, + debug = (...args)=>sqlite3.config.debug(vfsName+":",...args), + warn = (...args)=>sqlite3.config.warn(vfsName+":",...args), + __openFiles = opfsVfs.__openFiles; + + + + return opfsVfs.bindVfs(util.nu({ + xLock: function(pFile,lockType){ + mTimeStart('xLock'); + + const f = __openFiles[pFile]; + const rc = opRun('xLock', pFile, lockType); + if( !rc ) f.lockType = lockType; + mTimeEnd(); + return rc; + }, + xUnlock: function(pFile,lockType){ + mTimeStart('xUnlock'); + const f = __openFiles[pFile]; + const rc = opRun('xUnlock', pFile, lockType); + if( !rc ) f.lockType = lockType; + mTimeEnd(); + return rc; + } + }), function(sqlite3, vfs){ + + if(sqlite3.oo1){ + const OpfsWlDb = function(...args){ + const opt = sqlite3.oo1.DB.dbCtorHelper.normalizeArgs(...args); + opt.vfs = vfs.$zName; + sqlite3.oo1.DB.dbCtorHelper.call(this, opt); + }; + OpfsWlDb.prototype = Object.create(sqlite3.oo1.DB.prototype); + sqlite3.oo1.OpfsWlDb = OpfsWlDb; + OpfsWlDb.importDb = opfsUtil.importDb; + + } + }); +}; +globalThis.sqlite3ApiBootstrap.initializersAsync.push(async (sqlite3)=>{ + return installOpfsWlVfs().catch((e)=>{ + sqlite3.config.warn("Ignoring inability to install the",vfsName,"sqlite3_vfs:",e); + }); +}); +}); + +try{ + + + + const bootstrapConfig = Object.assign( + Object.create(null), + + { + memory: ('undefined'!==typeof wasmMemory) + ? wasmMemory + : EmscriptenModule['wasmMemory'], + exports: ('undefined'!==typeof wasmExports) + ? wasmExports + : (Object.prototype.hasOwnProperty.call(EmscriptenModule,'wasmExports') + ? EmscriptenModule['wasmExports'] + : EmscriptenModule['asm']) + }, + globalThis.sqlite3ApiBootstrap.defaultConfig, + globalThis.sqlite3ApiConfig || {} + ); + + sqlite3InitScriptInfo.debugModule("Bootstrapping lib config", bootstrapConfig); + + + const p = globalThis.sqlite3ApiBootstrap(bootstrapConfig); + delete globalThis.sqlite3ApiBootstrap; + return p ; +}catch(e){ + console.error("sqlite3ApiBootstrap() error:",e); + throw e; +} + + +}; + + + + + + + + + + + + return Module; +} + + + + + +const toExportForESM = +(function(){ + + + const originalInit = sqlite3InitModule; + if(!originalInit){ + throw new Error("Expecting sqlite3InitModule to be defined by the Emscripten build."); + } + + const sIMS = globalThis.sqlite3InitModuleState = Object.assign(Object.create(null),{ + moduleScript: globalThis?.document?.currentScript, + isWorker: ('undefined' !== typeof WorkerGlobalScope), + location: globalThis.location, + urlParams: globalThis?.location?.href + ? new URL(globalThis.location.href).searchParams + : new URLSearchParams(), + + wasmFilename: 'sqlite3.wasm' + }); + sIMS.debugModule = + sIMS.urlParams.has('sqlite3.debugModule') + ? (...args)=>console.warn('sqlite3.debugModule:',...args) + : ()=>{}; + + if(sIMS.urlParams.has('sqlite3.dir')){ + sIMS.sqlite3Dir = sIMS.urlParams.get('sqlite3.dir') +'/'; + }else if(sIMS.moduleScript){ + const li = sIMS.moduleScript.src.split('/'); + li.pop(); + sIMS.sqlite3Dir = li.join('/') + '/'; + } + + const sIM = globalThis.sqlite3InitModule = function ff(...args){ + + sIMS.emscriptenLocateFile = args[0]?.locateFile ; + sIMS.emscriptenInstantiateWasm = args[0]?.instantiateWasm ; + return originalInit(...args).then((EmscriptenModule)=>{ + sIMS.debugModule("sqlite3InitModule() sIMS =",sIMS); + sIMS.debugModule("sqlite3InitModule() EmscriptenModule =",EmscriptenModule); + const s = EmscriptenModule.runSQLite3PostLoadInit( + sIMS, + EmscriptenModule , + !!ff.__isUnderTest + ); + sIMS.debugModule("sqlite3InitModule() sqlite3 =",s); + + + return s; + }).catch((e)=>{ + console.error("Exception loading sqlite3 module:",e); + throw e; + }); + }; + sIM.ready = originalInit.ready; + + if(sIMS.moduleScript){ + let src = sIMS.moduleScript.src.split('/'); + src.pop(); + sIMS.scriptDir = src.join('/') + '/'; + } + sIMS.debugModule('extern-post-js.c-pp.js sqlite3InitModuleState =',sIMS); + return sIM; +})(); +sqlite3InitModule = toExportForESM; +export default sqlite3InitModule; diff --git a/vendor/sqlite3mc/2.6.1-3.54.0/sqlite3.wasm b/vendor/sqlite3mc/2.6.1-3.54.0/sqlite3.wasm new file mode 100644 index 000000000..970116279 Binary files /dev/null and b/vendor/sqlite3mc/2.6.1-3.54.0/sqlite3.wasm differ