- commit
- 2f9949303f132459618af77aa42d38f1ca7fafff
- parent
- 752d0f57ddf75c237e7cf4d8fbfc7b1501b91b4d
- author
- Dami <dami.etoile@gmail.com>
| M | README.md | 6 | +- |
| M | apps/ | 1 | + |
| M | deno.json | 1 | + |
| M | docs/ | 1 | + |
| M | docs/ | 2 | + |
| A | docs/ | 100 | +++++++++++++ |
| M | package/ | 9 | +- |
| M | package/ | 2 | + |
| M | package/ | 8 | +- |
| M | package/ | 9 | +- |
| A | package/ | 228 | ++++++++++++++++++++++++++++++ |
| A | package/ | 174 | +++++++++++++++++++++++ |
| M | package/ | 34 | ++++- |
| M | package/ | 4 | +- |
| M | package/ | 101 | +++++++++++++ |
| M | package/ | 47 | ++++++ |
| M | tools/ | 1 | + |
| M | tools/ | 193 | +++++++++++++++++++++++++- |
@@ -134,9 +134,9 @@ permission APIs.
134134 ## Optional installed-app recipes135135 136136 OS-facing capabilities stay out of the starter until the product opts in. The137-[installed-app recipe catalog](docs/recipes/README.md) begins with a tested inbound/outbound Web138-Share flow, including an isolated `@nzip/lofi/recipes/web-share` entrypoint, manifest validation,139-untrusted-input parsing, and ordinary-web fallbacks.137+[installed-app recipe catalog](docs/recipes/README.md) contains tested, isolated entrypoints for Web138+Share, installed-window launch handling, and validated file-import previews. Each keeps manifest139+changes opt-in and documents input validation, offline behavior, and ordinary-web fallbacks.140140 141141 ## Testing local-first behavior142142 @@ -12,6 +12,7 @@
1212 "@nzip/lofi/preview": "../../package/commands/preview.ts",1313 "@nzip/lofi/preact": "../../package/preact/mod.ts",1414 "@nzip/lofi/provision": "../../package/commands/provision.ts",15+ "@nzip/lofi/recipes/file-handler": "../../package/recipes/file-handler.ts",1516 "@nzip/lofi/recipes/launch-handler": "../../package/recipes/launch-handler.ts",1617 "@nzip/lofi/recipes/web-share": "../../package/recipes/web-share.ts",1718 "@nzip/lofi/test": "../../package/commands/run_tests.ts",@@ -14,6 +14,7 @@
1414 "./preview": "./package/commands/preview.ts",1515 "./preact": "./package/preact/mod.ts",1616 "./provision": "./package/commands/provision.ts",17+ "./recipes/file-handler": "./package/recipes/file-handler.ts",1718 "./recipes/launch-handler": "./package/recipes/launch-handler.ts",1819 "./recipes/web-share": "./package/recipes/web-share.ts",1920 "./testing": "./package/testing/mod.ts",@@ -68,6 +68,7 @@ The final package map is intentionally small:
6868 | `@nzip/lofi/access` | Private, direct-share, and fixed-role group helpers |6969 | `@nzip/lofi/preact` | Package-owned Preact hooks and diagnostic UI |7070 | `@nzip/lofi/astro` | Package-owned Astro/Vite integration |71+| `@nzip/lofi/recipes/file-handler` | Optional validated installed-app file import drafts |7172 | `@nzip/lofi/recipes/launch-handler` | Optional safe installed-window launch routing |7273 | `@nzip/lofi/recipes/web-share` | Optional inbound/outbound text and link sharing |7374 | `@nzip/lofi/testing` | Local-first Playwright helpers |@@ -18,6 +18,8 @@ flowchart LR
1818 receive an untrusted draft through an optional installed-app share target.1919 - [Launch handling and client reuse](launch-handler.md) — safely focus or navigate an existing2020 installed window while preserving ordinary launch behavior elsewhere.21+- [File handling and import previews](file-handler.md) — validate explicitly associated files into22+ transient drafts before the user confirms persistence.2123 2224 ## Contract for every recipe2325 @@ -0,0 +1,100 @@
1+# File handling and import previews2+3+Use this recipe when an installed desktop app should open a narrow, product-owned export format. The4+normal `<input type="file">` import remains the portable fallback.5+[Installed PWA file handling](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/How_to/Associate_files_with_your_PWA)6+is experimental and currently limited to Chromium-based browsers on desktop operating systems.7+8+The recipe deliberately ends at an import draft:9+10+```mermaid11+flowchart LR12+ OS["OS file launch"] --> Queue["launchQueue"]13+ Picker["Ordinary file picker"] --> Validate["Validate metadata and limits"]14+ Queue --> Validate15+ Validate --> Parse["Parse product-owned content"]16+ Parse --> Preview["Show preview"]17+ Preview --> Confirm{"User confirms?"}18+ Confirm -- "yes" --> Persist["Product persists data"]19+ Confirm -- "no" --> Discard["Discard transient draft"]20+```21+22+## 1. Add a narrow manifest association23+24+The generated starter has no `file_handlers` member. Add one only for a concrete import format:25+26+```json27+{28+ "file_handlers": [29+ {30+ "action": "./import/",31+ "accept": {32+ "application/json": [".field-notes.json"]33+ }34+ }35+ ]36+}37+```38+39+Use exact MIME types and extensions—no wildcards. The action must be a prerendered route inside the40+manifest scope so the import screen can open offline. Browsers read this association during install;41+after changing it, refresh or reinstall the PWA and verify the operating-system association.42+43+## 2. Use one validator for launches and the picker44+45+```ts46+import {47+ type FileImportDraft,48+ installFileLaunchConsumer,49+ prepareFileImportDrafts,50+} from "@nzip/lofi/recipes/file-handler";51+52+type ExportPreview = { title: string; rows: readonly unknown[] };53+54+const importOptions = {55+ accept: { "application/json": [".field-notes.json"] },56+ maxFiles: 1,57+ maxFileBytes: 256_000,58+ async parse(file: File): Promise<ExportPreview> {59+ const value: unknown = JSON.parse(await file.text());60+ if (!isFieldNotesExport(value)) throw new Error("invalid export");61+ return value;62+ },63+};64+65+let preview: readonly FileImportDraft<ExportPreview>[] = [];66+67+const launched = installFileLaunchConsumer({68+ ...importOptions,69+ onDrafts: (drafts) => {70+ preview = drafts;71+ renderImportPreview(drafts);72+ },73+ onRejected: () => renderGenericImportError(),74+});75+76+async function onPickerChange(files: FileList | null) {77+ const result = await prepareFileImportDrafts(files ? [...files] : [], importOptions);78+ if (result.ok) renderImportPreview(result.drafts);79+ else renderGenericImportError();80+}81+```82+83+Register one `launchQueue` consumer early on the action route. The recipe's shared ownership seam84+prevents an older development/HMR consumer from replacing a newer launch recipe. Do not register a85+second raw queue consumer in the same window.86+87+The helper treats handles, names, MIME types, extensions, sizes, access errors, and parsed content88+as untrusted. Rejections expose stable reason codes and an index, never file names or contents. A89+denied handle becomes `unreadable-file`; invalid and oversized inputs never produce a partial draft.90+91+## 3. Confirm before persistence92+93+Keep returned drafts in transient component state. Render a preview and use a separate button such94+as “Import these notes” for the application-owned database write. Neither `prepareFileImportDrafts`95+nor `installFileLaunchConsumer` writes to Jazz, OPFS, the service worker, or the source file.96+97+Test direct navigation to the action route, an installed file launch, an ordinary picker import,98+offline startup, denied access, malformed content, invalid metadata, oversized files, cancellation,99+and a browser without `launchQueue`. Removing `file_handlers` should leave the picker flow intact;100+reinstall or refresh the app before verifying that the OS association disappeared.@@ -61,7 +61,7 @@ const preactFiles = [
6161 "use-device-capabilities.ts",6262 ] as const;6363 64-const recipeFiles = ["launch-handler.ts", "web-share.ts"] as const;64+const recipeFiles = ["file-handler.ts", "launch-handler.ts", "web-share.ts"] as const;6565 6666 async function readPackageFile(path: string): Promise<string> {6767 const url = new URL(path, import.meta.url);@@ -77,6 +77,7 @@ function renderConfig(projectRoot: string): string {
7777 const runtimeEntry = join(projectRoot, ".lofi", "package", "runtime", "mod.ts");7878 const accessEntry = join(projectRoot, ".lofi", "package", "access", "mod.ts");7979 const preactEntry = join(projectRoot, ".lofi", "package", "preact", "mod.ts");80+ const fileHandlerRecipe = join(projectRoot, ".lofi", "package", "recipes", "file-handler.ts");8081 const launchHandlerRecipe = join(8182 projectRoot,8283 ".lofi",@@ -124,6 +125,9 @@ export default defineConfig({
124125 },125126 resolve: {126127 alias: [128+ { find: /^jsr:@nzip\\/lofi@[^/]+\\/recipes\\/file-handler$/, replacement: ${129+ JSON.stringify(fileHandlerRecipe)130+ } },127131 { find: /^jsr:@nzip\\/lofi@[^/]+\\/recipes\\/launch-handler$/, replacement: ${128132 JSON.stringify(launchHandlerRecipe)129133 } },@@ -142,6 +146,9 @@ export default defineConfig({
142146 { find: /^npm:jazz-tools@[^/]+$/, replacement: "jazz-tools" },143147 { find: "@nzip/lofi/recipes/launch-handler", replacement: ${144148 JSON.stringify(launchHandlerRecipe)149+ } },150+ { find: "@nzip/lofi/recipes/file-handler", replacement: ${151+ JSON.stringify(fileHandlerRecipe)145152 } },146153 { find: "@nzip/lofi/recipes/web-share", replacement: ${JSON.stringify(webShareRecipe)} },147154 { find: "@nzip/lofi/preact", replacement: ${JSON.stringify(preactEntry)} },@@ -15,6 +15,7 @@ Deno.test("prepareLofiAstroConfig materializes package-owned vendor integration"
1515 "base: deploymentBase",1616 'trailingSlash: "always"',1717 ".lofi/package/runtime/mod.ts",18+ ".lofi/package/recipes/file-handler.ts",1819 ".lofi/package/recipes/web-share.ts",1920 "^jsr:@nzip\\/lofi@[^/]+",2021 "^npm:preact@[^/]+",@@ -33,6 +34,7 @@ Deno.test("prepareLofiAstroConfig materializes package-owned vendor integration"
3334 await Deno.stat(`${root}/.lofi/package/runtime/startup-recovery.ts`);3435 await Deno.stat(`${root}/.lofi/package/runtime/table-mutations.ts`);3536 await Deno.stat(`${root}/.lofi/package/access/mod.ts`);37+ await Deno.stat(`${root}/.lofi/package/recipes/file-handler.ts`);3638 await Deno.stat(`${root}/.lofi/package/recipes/launch-handler.ts`);3739 await Deno.stat(`${root}/.lofi/package/recipes/web-share.ts`);3840 } finally {@@ -83,8 +83,8 @@ local-only mode. Start with the
8383 OS-facing capabilities remain absent until the product opts in. Start with the8484 [installed-app recipe catalog](https://github.com/FelineStateMachine/lofi/blob/main/docs/recipes/README.md)8585 for tested manifest patches, feature detection, input validation, offline behavior, and normal-web86-fallbacks. The first recipe covers inbound and outbound text/link sharing through the isolated87-\`@nzip/lofi/recipes/web-share\` entrypoint.86+fallbacks. Isolated entrypoints cover Web Share, installed-window launch handling, and validated87+file-import previews without adding any of them to the starter manifest.8888 8989 Public tasks: \`dev\`, \`doctor\`, \`test\`, \`build\`, and \`preview\`. Sync/backup and schema tasks:9090 \`jazz:provision\`, \`schema:validate\`, \`schema:deploy\`, \`migrations:create\`, and \`migrations:push\`.@@ -129,6 +129,10 @@ const lofiImportTargets: Record<string, { subpath: string; localPath: string }>
129129 "@nzip/lofi/preview": { subpath: "preview", localPath: "commands/preview.ts" },130130 "@nzip/lofi/preact": { subpath: "preact", localPath: "preact/mod.ts" },131131 "@nzip/lofi/provision": { subpath: "provision", localPath: "commands/provision.ts" },132+ "@nzip/lofi/recipes/file-handler": {133+ subpath: "recipes/file-handler",134+ localPath: "recipes/file-handler.ts",135+ },132136 "@nzip/lofi/recipes/launch-handler": {133137 subpath: "recipes/launch-handler",134138 localPath: "recipes/launch-handler.ts",@@ -106,8 +106,8 @@ Deno.test("createProject materializes the complete starter snapshot", async () =
106106 .filter(([name]) => name.startsWith("@nzip/lofi/"))107107 .map(([, specifier]) => String(specifier));108108 assert(109- lofiSpecifiers.length === 13,110- `expected one package prefix, access, two integrations, six commands, two recipes, and testing, received ${lofiSpecifiers.length}`,109+ lofiSpecifiers.length === 14,110+ `expected one package prefix, access, two integrations, six commands, three recipes, and testing, received ${lofiSpecifiers.length}`,111111 );112112 assert(113113 lofiSpecifiers.every((specifier) => specifier.startsWith("jsr:@nzip/lofi@0.3.3/")),@@ -151,6 +151,7 @@ Deno.test("package manifest and generated version stay coupled", () => {
151151 "./preact",152152 "./preview",153153 "./provision",154+ "./recipes/file-handler",154155 "./recipes/launch-handler",155156 "./recipes/web-share",156157 "./test",@@ -160,6 +161,10 @@ Deno.test("package manifest and generated version stay coupled", () => {
160161 assertEquals(packageManifest.exports["./access"], "./package/access/mod.ts");161162 assertEquals(packageManifest.exports["./astro"], "./package/astro/mod.ts");162163 assertEquals(packageManifest.exports["./preact"], "./package/preact/mod.ts");164+ assertEquals(165+ packageManifest.exports["./recipes/file-handler"],166+ "./package/recipes/file-handler.ts",167+ );163168 assertEquals(164169 packageManifest.exports["./recipes/launch-handler"],165170 "./package/recipes/launch-handler.ts",@@ -0,0 +1,228 @@
1+/**2+ * Opt-in helpers for turning file launches or ordinary picker files into3+ * bounded, validated import drafts.4+ *5+ * @module6+ */7+8+import {9+ type InstalledAppLaunchQueue,10+ type InstalledLaunchConsumer,11+ installInstalledAppLaunchQueueConsumer,12+} from "./launch-handler.ts";13+14+/** A minimal read-only file handle delivered by an installed-app launch. */15+export type InstalledAppFileHandle = {16+ readonly kind: "file";17+ readonly name: string;18+ getFile(): Promise<File>;19+};20+21+/** Explicit MIME-to-extension allow-list shared by the manifest and importer. */22+export type FileImportAccept = Readonly<Record<string, readonly string[]>>;23+24+/** Stable rejection that never contains a received file name or content. */25+export type FileImportIssue =26+ | "empty-selection"27+ | "too-many-files"28+ | "invalid-handle"29+ | "unreadable-file"30+ | "invalid-name"31+ | "unsupported-type"32+ | "unsupported-extension"33+ | "file-too-large"34+ | "selection-too-large"35+ | "invalid-content";36+37+/** One parsed file ready for product-owned preview, but not persistence. */38+export type FileImportDraft<Parsed> = {39+ readonly file: File;40+ readonly name: string;41+ readonly type: string;42+ readonly size: number;43+ readonly parsed: Parsed;44+};45+46+/** Accepted import drafts or a value-free rejection. */47+export type FileImportResult<Parsed> =48+ | { ok: true; drafts: readonly FileImportDraft<Parsed>[] }49+ | { ok: false; issue: FileImportIssue; index?: number };50+51+/** Limits and product-owned content parser for preparing import drafts. */52+export type PrepareFileImportOptions<Parsed> = {53+ /** Exact MIME types and lowercase extensions the product understands. */54+ accept: FileImportAccept;55+ /** Maximum number of files accepted in one user action. */56+ maxFiles: number;57+ /** Maximum bytes accepted for any one file. */58+ maxFileBytes: number;59+ /** Maximum bytes accepted across the selection. Defaults to maxFiles * maxFileBytes. */60+ maxTotalBytes?: number;61+ /** Parse and validate file content for preview. Throw to reject it. */62+ parse(file: File): Parsed | Promise<Parsed>;63+};64+65+/** Options for consuming installed file launches into preview-only drafts. */66+export type InstallFileLaunchConsumerOptions<Parsed> = PrepareFileImportOptions<Parsed> & {67+ /** Override the browser queue for tests. */68+ queue?: InstalledAppLaunchQueue;69+ /** Receives only fully validated drafts. The product must still ask before persistence. */70+ onDrafts(drafts: readonly FileImportDraft<Parsed>[]): void;71+ /** Receives a value-free reason for a rejected launch. */72+ onRejected?(issue: FileImportIssue, index?: number): void;73+};74+75+function isPositiveSafeInteger(value: number): boolean {76+ return Number.isSafeInteger(value) && value > 0;77+}78+79+function validateOptions<Parsed>(options: PrepareFileImportOptions<Parsed>): {80+ accept: Map<string, Set<string>>;81+ maxTotalBytes: number;82+} {83+ if (!isPositiveSafeInteger(options.maxFiles) || !isPositiveSafeInteger(options.maxFileBytes)) {84+ throw new TypeError("file import limits must be positive safe integers");85+ }86+ const maxTotalBytes = options.maxTotalBytes ?? options.maxFiles * options.maxFileBytes;87+ if (!isPositiveSafeInteger(maxTotalBytes)) {88+ throw new TypeError("file import total limit must be a positive safe integer");89+ }90+ const accept = new Map<string, Set<string>>();91+ for (const [type, extensions] of Object.entries(options.accept)) {92+ if (!/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/.test(type)) {93+ throw new TypeError("file import MIME types must be explicit lowercase types");94+ }95+ if (!Array.isArray(extensions) || extensions.length === 0) {96+ throw new TypeError("each file import MIME type needs extensions");97+ }98+ const normalized = new Set<string>();99+ for (const extension of extensions) {100+ if (!/^\.[a-z0-9][a-z0-9.+_-]*$/.test(extension)) {101+ throw new TypeError("file import extensions must be explicit lowercase dot extensions");102+ }103+ normalized.add(extension);104+ }105+ if (normalized.size !== extensions.length) {106+ throw new TypeError("file import extensions must not repeat within a MIME type");107+ }108+ accept.set(type, normalized);109+ }110+ if (accept.size === 0) throw new TypeError("file import accept list must not be empty");111+ return { accept, maxTotalBytes };112+}113+114+function validName(name: string): boolean {115+ return name.length > 0 && name.length <= 255 && name !== "." && name !== ".." &&116+ !/[\\/]/.test(name) &&117+ ![...name].some((character) => {118+ const code = character.codePointAt(0)!;119+ return code <= 0x1f || code === 0x7f;120+ });121+}122+123+function hasAcceptedExtension(name: string, extensions: ReadonlySet<string>): boolean {124+ const normalized = name.toLowerCase();125+ return [...extensions].some((extension) =>126+ normalized.length > extension.length && normalized.endsWith(extension)127+ );128+}129+130+function isInstalledFileHandle(value: unknown): value is InstalledAppFileHandle {131+ return value !== null && typeof value === "object" &&132+ (value as { kind?: unknown }).kind === "file" &&133+ typeof (value as { name?: unknown }).name === "string" &&134+ typeof (value as { getFile?: unknown }).getFile === "function";135+}136+137+async function readInput(input: File | InstalledAppFileHandle): Promise<138+ | { ok: true; file: File }139+ | { ok: false; issue: FileImportIssue }140+> {141+ if (input instanceof File) return { ok: true, file: input };142+ if (!isInstalledFileHandle(input)) return { ok: false, issue: "invalid-handle" };143+ if (!validName(input.name)) return { ok: false, issue: "invalid-name" };144+ try {145+ const file = await input.getFile();146+ if (!(file instanceof File)) return { ok: false, issue: "invalid-handle" };147+ if (file.name !== input.name) return { ok: false, issue: "invalid-name" };148+ return { ok: true, file };149+ } catch {150+ return { ok: false, issue: "unreadable-file" };151+ }152+}153+154+/**155+ * Validate picker files or installed-app handles and parse them into drafts.156+ *157+ * The function performs no writes. Keep the returned drafts in transient UI158+ * state, show a preview, and persist only after an explicit user confirmation.159+ */160+export async function prepareFileImportDrafts<Parsed>(161+ inputs: readonly (File | InstalledAppFileHandle)[],162+ options: PrepareFileImportOptions<Parsed>,163+): Promise<FileImportResult<Parsed>> {164+ const configured = validateOptions(options);165+ if (inputs.length === 0) return { ok: false, issue: "empty-selection" };166+ if (inputs.length > options.maxFiles) return { ok: false, issue: "too-many-files" };167+168+ const drafts: FileImportDraft<Parsed>[] = [];169+ let totalBytes = 0;170+ for (const [index, input] of inputs.entries()) {171+ const read = await readInput(input);172+ if (!read.ok) return { ok: false, issue: read.issue, index };173+ const file = read.file;174+ if (!validName(file.name)) return { ok: false, issue: "invalid-name", index };175+ const extensions = configured.accept.get(file.type);176+ if (!extensions) return { ok: false, issue: "unsupported-type", index };177+ if (!hasAcceptedExtension(file.name, extensions)) {178+ return { ok: false, issue: "unsupported-extension", index };179+ }180+ if (file.size > options.maxFileBytes) {181+ return { ok: false, issue: "file-too-large", index };182+ }183+ totalBytes += file.size;184+ if (totalBytes > configured.maxTotalBytes) {185+ return { ok: false, issue: "selection-too-large", index };186+ }187+ let parsed: Parsed;188+ try {189+ parsed = await options.parse(file);190+ } catch {191+ return { ok: false, issue: "invalid-content", index };192+ }193+ drafts.push({ file, name: file.name, type: file.type, size: file.size, parsed });194+ }195+ return { ok: true, drafts };196+}197+198+/** Feature-detect `launchQueue` and turn file launches into validated drafts. */199+export function installFileLaunchConsumer<Parsed>(200+ options: InstallFileLaunchConsumerOptions<Parsed>,201+): InstalledLaunchConsumer {202+ validateOptions(options);203+ let generation = 0;204+ const consumer = installInstalledAppLaunchQueueConsumer({205+ queue: options.queue,206+ onLaunch(parameters) {207+ const current = ++generation;208+ const inputs = parameters.files ?? [];209+ void prepareFileImportDrafts(210+ inputs as unknown as readonly InstalledAppFileHandle[],211+ options,212+ ).then(213+ (result) => {214+ if (current !== generation) return;215+ if (result.ok) options.onDrafts(result.drafts);216+ else options.onRejected?.(result.issue, result.index);217+ },218+ );219+ },220+ });221+ return {222+ supported: consumer.supported,223+ dispose() {224+ generation++;225+ consumer.dispose();226+ },227+ };228+}@@ -0,0 +1,174 @@
1+import {2+ type FileImportIssue,3+ installFileLaunchConsumer,4+ prepareFileImportDrafts,5+} from "./file-handler.ts";6+import type { InstalledAppLaunchParameters, InstalledAppLaunchQueue } from "./launch-handler.ts";7+8+function assert(condition: unknown, message: string): asserts condition {9+ if (!condition) throw new Error(message);10+}11+12+const options = {13+ accept: { "application/json": [".json"] },14+ maxFiles: 2,15+ maxFileBytes: 64,16+ maxTotalBytes: 96,17+ async parse(file: File) {18+ const value = JSON.parse(await file.text());19+ if (!value || typeof value !== "object" || typeof value.title !== "string") {20+ throw new Error("invalid export");21+ }22+ return { title: value.title as string };23+ },24+} as const;25+26+Deno.test("file import prepares parsed picker and handle drafts without persistence", async () => {27+ const picker = new File(['{"title":"Picker"}'], "picker.json", {28+ type: "application/json",29+ });30+ const handledFile = new File(['{"title":"Opened"}'], "opened.json", {31+ type: "application/json",32+ });33+ const result = await prepareFileImportDrafts([34+ picker,35+ { kind: "file", name: "opened.json", getFile: () => Promise.resolve(handledFile) },36+ ], options);37+ assert(result.ok, `valid files failed: ${JSON.stringify(result)}`);38+ assert(result.drafts.length === 2, "draft count drifted");39+ assert(result.drafts[1].parsed.title === "Opened", "file content was not parsed");40+41+ const compound = await prepareFileImportDrafts(42+ [43+ new File(['{"title":"Compound"}'], "notes.FIELD-NOTES.JSON", {44+ type: "application/json",45+ }),46+ ],47+ { ...options, accept: { "application/json": [".field-notes.json"] } },48+ );49+ assert(compound.ok, "compound case-insensitive extension was rejected");50+});51+52+Deno.test("file import rejects untrusted metadata, limits, access, and content", async () => {53+ const cases: Array<[string, Parameters<typeof prepareFileImportDrafts>[0], FileImportIssue]> = [54+ ["empty", [], "empty-selection"],55+ [56+ "count",57+ [58+ new File(["{}"], "a.json", { type: "application/json" }),59+ new File(["{}"], "b.json", { type: "application/json" }),60+ new File(["{}"], "c.json", { type: "application/json" }),61+ ],62+ "too-many-files",63+ ],64+ [65+ "name",66+ [new File(["{}"], "../private.json", { type: "application/json" })],67+ "invalid-name",68+ ],69+ [70+ "type",71+ [new File(["{}"], "note.json", { type: "text/plain" })],72+ "unsupported-type",73+ ],74+ [75+ "extension",76+ [new File(["{}"], "note.txt", { type: "application/json" })],77+ "unsupported-extension",78+ ],79+ [80+ "size",81+ [new File(["x".repeat(65)], "note.json", { type: "application/json" })],82+ "file-too-large",83+ ],84+ [85+ "content",86+ [new File(["{}"], "note.json", { type: "application/json" })],87+ "invalid-content",88+ ],89+ [90+ "denied",91+ [{92+ kind: "file",93+ name: "note.json",94+ getFile: () => Promise.reject(new DOMException("denied", "NotAllowedError")),95+ }],96+ "unreadable-file",97+ ],98+ ];99+ for (const [label, files, issue] of cases) {100+ const result = await prepareFileImportDrafts(files, options);101+ assert(!result.ok && result.issue === issue, `${label} produced ${JSON.stringify(result)}`);102+ }103+104+ const total = await prepareFileImportDrafts(105+ [106+ new File(['{"title":"One"}'], "one.json", { type: "application/json" }),107+ new File(['{"title":"Two"}'], "two.json", { type: "application/json" }),108+ ],109+ { ...options, maxTotalBytes: 20 },110+ );111+ assert(!total.ok && total.issue === "selection-too-large", "total size bound was not enforced");112+});113+114+Deno.test("file import rejects unsafe allow-list configuration", async () => {115+ const unsafe: Array<Record<string, readonly string[]>> = [116+ { "application/*": [".json"] },117+ { "application/json": ["json"] },118+ ];119+ for (const accept of unsafe) {120+ let rejected = false;121+ try {122+ await prepareFileImportDrafts(123+ [new File(["{}"], "note.json", { type: "application/json" })],124+ { ...options, accept },125+ );126+ } catch (error) {127+ rejected = error instanceof TypeError;128+ }129+ assert(rejected, "unsafe file allow-list passed");130+ }131+});132+133+Deno.test("file launch consumer feature-detects, validates, and ignores stale async launches", async () => {134+ const unsupported = installFileLaunchConsumer({135+ ...options,136+ onDrafts: () => {137+ throw new Error("unsupported consumer ran");138+ },139+ });140+ assert(!unsupported.supported, "missing launchQueue was reported as supported");141+142+ let current: (parameters: InstalledAppLaunchParameters) => void = () => {};143+ const queue: InstalledAppLaunchQueue = {144+ setConsumer(next) {145+ current = next;146+ },147+ };148+ const received: string[] = [];149+ const rejected: FileImportIssue[] = [];150+ const consumer = installFileLaunchConsumer({151+ ...options,152+ queue,153+ onDrafts: (drafts) => received.push(drafts[0].parsed.title),154+ onRejected: (issue) => rejected.push(issue),155+ });156+ current({157+ files: [{158+ kind: "file",159+ name: "opened.json",160+ getFile: () =>161+ Promise.resolve(162+ new File(['{"title":"Opened"}'], "opened.json", {163+ type: "application/json",164+ }),165+ ),166+ }] as unknown as FileSystemHandle[],167+ });168+ await new Promise((resolve) => setTimeout(resolve, 0));169+ assert(received.join() === "Opened", `launch draft was not delivered: ${received}`);170+ current({ files: [] });171+ await new Promise((resolve) => setTimeout(resolve, 0));172+ assert(rejected.join() === "empty-selection", `empty launch produced ${rejected}`);173+ consumer.dispose();174+});@@ -51,6 +51,14 @@ export type InstalledLaunchConsumer = {
5151 dispose(): void;5252 };5353 54+/** Options for registering one raw browser launch-queue consumer. */55+export type InstallLaunchQueueConsumerOptions = {56+ /** Override the browser queue for tests. */57+ queue?: InstalledAppLaunchQueue;58+ /** Receives browser launch parameters without trusting or interpreting them. */59+ onLaunch(parameters: InstalledAppLaunchParameters): void;60+};61+5462 const owners = new WeakMap<InstalledAppLaunchQueue, symbol>();5563 5664 function configuredScope(value: string | URL): URL {@@ -101,18 +109,36 @@ export function parseInstalledAppLaunchTarget(
101109 */102110 export function installInstalledAppLaunchConsumer(103111 options: InstallLaunchConsumerOptions,112+): InstalledLaunchConsumer {113+ const scope = configuredScope(options.scope);114+ return installInstalledAppLaunchQueueConsumer({115+ queue: options.queue,116+ onLaunch(parameters) {117+ const result = parseInstalledAppLaunchTarget(parameters.targetURL, scope);118+ if (result.ok) options.onLaunch(result.target);119+ else options.onRejected?.(result.issue);120+ },121+ });122+}123+124+/**125+ * Feature-detect and install one raw launch-queue consumer.126+ *127+ * This is the composition seam used by recipes such as file handling. The most128+ * recently installed consumer owns the queue, including across different lofi129+ * launch recipes. Treat every delivered parameter as untrusted input.130+ */131+export function installInstalledAppLaunchQueueConsumer(132+ options: InstallLaunchQueueConsumerOptions,104133 ): InstalledLaunchConsumer {105134 const queue = options.queue ?? browserLaunchQueue();106135 if (!queue) return { supported: false, dispose() {} };107- const scope = configuredScope(options.scope);108136 const owner = Symbol("lofi-launch-consumer");109137 let active = true;110138 owners.set(queue, owner);111139 queue.setConsumer((parameters) => {112140 if (!active || owners.get(queue) !== owner) return;113- const result = parseInstalledAppLaunchTarget(parameters.targetURL, scope);114- if (result.ok) options.onLaunch(result.target);115- else options.onRejected?.(result.issue);141+ options.onLaunch(parameters);116142 });117143 return {118144 supported: true,@@ -1,7 +1,7 @@
11 {22 ".env.example": "0cfad81a3ba6442ac15d7cbe44a00374bf4dbf8af68ca412e54fb100241ddeab",33 ".gitignore": "9b9fa4b95f8f728ce80e93fc224d430328786b620096b80f067a3190c410a00e",4- "deno.json": "6a5ec31baf61b57b997b738e633e1f649cb272eb6a121ba9104ec82c0b4e4a79",4+ "deno.json": "e5c834268efb574c1409bae762c2ae8d907faff0e55e37a5b6b21281d789817f",55 "public/apple-touch-icon.png": "be2b77066d9681709210388413ddcd94318906799ddd6645934b789a04bd4a61",66 "public/favicon.svg": "35b92ad48231c470b348eaebe6d6fe14acf2ec4a86e6726a03ccb130328a0d85",77 "public/icon-192.png": "6ee711f9ec990ec55f789d7943813c5193973bf2ca9199078ee85d285317e07c",@@ -11,7 +11,7 @@
1111 "public/manifest.webmanifest": "c76d1d487e86fca4ce4f8c71a5c3b22a15a2d0e3370583ae0fb40aafd4513560",1212 "public/screenshot-narrow.png": "8d11e3779e2e7437ef9e44d07bd382d161570846663338b3ad3f1cdb065b018f",1313 "public/screenshot-wide.png": "812786db28f6803d12adc8bd3af5fffefa8e4e20851b61de8a256fcc538c114e",14- "README.md": "962117ca61d07ba8d5de689dfd6697b40e9e97f0af8c45e10202306cbdb3d13c",14+ "README.md": "a18ea936a0fbe6ae22e6ef0f395f0a5cdfa4ae26a4566f242ad93e8d3c9c9c5d",1515 "src/app.ts": "4c4c6c3f6ad4123f6cffc7a480965c4ea38442676c2af662d1bbfe082d40194f",1616 "src/env.d.ts": "b44daed05ec5cdfacfd8d8acf7866974b5f6b8db923ab53bd244419093c719da",1717 "src/islands/AccountGate.tsx": "ef541886720e984f989c2054266be8d2272b6c43fea722bde8ce615352cc0210",@@ -472,6 +472,88 @@ async function parseAndValidateManifest(
472472 }473473 }474474 475+ if (manifest.file_handlers !== undefined) {476+ if (!Array.isArray(manifest.file_handlers) || manifest.file_handlers.length === 0) {477+ issues.push(issue(`${manifestFile}: file_handlers must be a non-empty array`, remediation));478+ } else {479+ for (const [index, raw] of manifest.file_handlers.entries()) {480+ const label = `${manifestFile}: file_handlers[${index}]`;481+ if (!isObject(raw)) {482+ issues.push(issue(`${label} must be an object`, remediation));483+ continue;484+ }485+ const unknown = Object.keys(raw).filter((member) =>486+ !["action", "accept", "icons"].includes(member)487+ );488+ if (unknown.length > 0) {489+ issues.push(issue(`${label} contains unsupported members`, remediation));490+ }491+ if (!nonEmptyString(raw.action)) {492+ issues.push(issue(`${label}.action must be a non-empty URL`, remediation));493+ } else {494+ try {495+ const action = new URL(raw.action, manifestUrl);496+ if (!withinScope(action, scope)) {497+ issues.push(issue(`${label}.action must stay inside manifest scope`, remediation));498+ }499+ if (action.search || action.hash) {500+ issues.push(501+ issue(`${label}.action must not contain a query or fragment`, remediation),502+ );503+ }504+ } catch {505+ issues.push(issue(`${label}.action is not a valid URL`, remediation));506+ }507+ }508+ if (!isObject(raw.accept) || Object.keys(raw.accept).length === 0) {509+ issues.push(issue(`${label}.accept must be a non-empty object`, remediation));510+ } else {511+ for (const [type, extensions] of Object.entries(raw.accept)) {512+ if (!/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/.test(type)) {513+ issues.push(514+ issue(`${label}.accept keys must be explicit lowercase MIME types`, remediation),515+ );516+ }517+ if (518+ !Array.isArray(extensions) || extensions.length === 0 ||519+ !extensions.every((extension) =>520+ typeof extension === "string" && /^\.[a-z0-9][a-z0-9.+_-]*$/.test(extension)521+ )522+ ) {523+ issues.push(524+ issue(525+ `${label}.accept values must be non-empty lowercase dot-extension arrays`,526+ remediation,527+ ),528+ );529+ continue;530+ }531+ if (new Set(extensions).size !== extensions.length) {532+ issues.push(533+ issue(534+ `${label}.accept extensions must not repeat within a MIME type`,535+ remediation,536+ ),537+ );538+ }539+ }540+ }541+ if (raw.icons !== undefined) {542+ const handlerIcons = await validateImages(543+ raw.icons,544+ `file_handlers[${index}].icons`,545+ join(root, location),546+ manifestUrl,547+ scope,548+ basePath,549+ remediation,550+ );551+ issues.push(...handlerIcons.issues);552+ }553+ }554+ }555+ }556+475557 if (manifest.share_target !== undefined) {476558 const label = `${manifestFile}: share_target`;477559 const target = manifest.share_target;@@ -719,6 +801,25 @@ export async function productionPwaIssues(root: string, deploymentBase = "/") {
719801 }720802 }721803 }804+ if (Array.isArray(result.manifest?.file_handlers)) {805+ const manifestUrl = new URL(`${syntheticOrigin}${basePath}${manifestFile}`);806+ for (const [index, raw] of result.manifest.file_handlers.entries()) {807+ if (!isObject(raw) || !nonEmptyString(raw.action)) continue;808+ try {809+ const route = emittedRoutePath(new URL(raw.action, manifestUrl), basePath);810+ if (!route || !htmlFiles.includes(route)) {811+ issues.push(812+ issue(813+ `dist/${manifestFile}: file_handlers[${index}].action has no emitted offline route`,814+ remediation,815+ ),816+ );817+ }818+ } catch {819+ // Source validation already reports malformed action URLs.820+ }821+ }822+ }722823 const expectedManifest = new URL(`${syntheticOrigin}${basePath}${manifestFile}`).href;723824 const appleIcons = new Set<string>();724825 for (const path of htmlFiles) {@@ -197,6 +197,45 @@ Deno.test("source PWA validation checks an opt-in launch handler", async () => {
197197 }198198 });199199 200+Deno.test("source PWA validation checks opt-in file handlers", async () => {201+ const { root, project } = await makeProject();202+ try {203+ const manifest = await readManifest(project);204+ manifest.file_handlers = [{205+ action: "../outside?unsafe=1",206+ accept: {207+ "application/*": ["json"],208+ "application/json": [".json", ".json"],209+ },210+ icons: [{211+ src: "./missing-file-icon.png",212+ sizes: "64x64",213+ type: "image/png",214+ }],215+ extra: true,216+ }];217+ await writeManifest(project, manifest);218+ const issues = await sourcePwaIssues(project, "/field-notes/");219+ assert(hasIssue(issues, "action must stay inside"), "escaped file action was accepted");220+ assert(hasIssue(issues, "must not contain a query"), "file action query was accepted");221+ assert(hasIssue(issues, "explicit lowercase MIME"), "wildcard MIME type was accepted");222+ assert(hasIssue(issues, "lowercase dot-extension"), "bare extension was accepted");223+ assert(hasIssue(issues, "must not repeat"), "duplicate extension was accepted");224+ assert(hasIssue(issues, "missing asset missing-file-icon.png"), "missing file icon passed");225+ assert(hasIssue(issues, "contains unsupported members"), "unknown file member was accepted");226+227+ manifest.file_handlers = [{228+ action: "./import/",229+ accept: { "application/json": [".json"] },230+ }];231+ await writeManifest(project, manifest);232+ const valid = await sourcePwaIssues(project, "/field-notes/");233+ assert(valid.length === 0, `valid file handler was rejected: ${JSON.stringify(valid)}`);234+ } finally {235+ await Deno.remove(root, { recursive: true });236+ }237+});238+200239 Deno.test("source PWA validation permits replacement branding and optional members", async () => {201240 const { root, project } = await makeProject();202241 try {@@ -299,6 +338,10 @@ Deno.test("production PWA validation catches nested links, precache drift, and w
299338 enctype: "application/x-www-form-urlencoded",300339 params: { title: "title", text: "text", url: "url" },301340 };341+ manifest.file_handlers = [{342+ action: "./import/",343+ accept: { "application/json": [".json"] },344+ }];302345 await Deno.writeTextFile(303346 join(dist, "manifest.webmanifest"),304347 `${JSON.stringify(manifest, null, 2)}\n`,@@ -312,6 +355,10 @@ Deno.test("production PWA validation catches nested links, precache drift, and w
312355 hasIssue(issues, "share_target.action has no emitted offline route"),313356 "missing share-target route passed",314357 );358+ assert(359+ hasIssue(issues, "file_handlers[0].action has no emitted offline route"),360+ "missing file-handler route passed",361+ );315362 } finally {316363 await Deno.remove(root, { recursive: true });317364 }@@ -392,7 +392,7 @@ async function assertGeneratedBoundary(
392392 const lofiImports = Object.entries(config.imports).filter(([name]) =>393393 name === "@nzip/lofi" || name.startsWith("@nzip/lofi/")394394 );395- assert(lofiImports.length === 14, `expected 14 lofi imports, received ${lofiImports.length}`);395+ assert(lofiImports.length === 15, `expected 15 lofi imports, received ${lofiImports.length}`);396396 const expected = source === "registry"397397 ? `jsr:@nzip/lofi@${version}`398398 : pathToFileURL(join(repositoryRoot, "package")).href;@@ -759,6 +759,100 @@ export default function LaunchReceiver() {
759759 );760760 }761761 762+async function installFileHandlerRecipe(projectRoot: string) {763+ const manifestPath = join(projectRoot, "public", "manifest.webmanifest");764+ const manifest = JSON.parse(await Deno.readTextFile(manifestPath));765+ manifest.file_handlers = [{766+ action: "./import/",767+ accept: { "application/json": [".json"] },768+ }];769+ await Deno.writeTextFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);770+ await Deno.writeTextFile(771+ join(projectRoot, "src", "pages", "import.astro"),772+ `---773+import FileImport from "../islands/FileImport.tsx";774+import Shell from "../layouts/Shell.astro";775+import "../styles/global.css";776+---777+778+<Shell title="Import field notes">779+ <FileImport client:load />780+</Shell>781+`,782+ );783+ await Deno.writeTextFile(784+ join(projectRoot, "src", "islands", "FileImport.tsx"),785+ `import { useEffect, useState } from "preact/hooks";786+import {787+ installFileLaunchConsumer,788+ prepareFileImportDrafts,789+ type FileImportDraft,790+ type FileImportIssue,791+} from "@nzip/lofi/recipes/file-handler";792+793+type Preview = { title: string };794+const options = {795+ accept: { "application/json": [".json"] },796+ maxFiles: 1,797+ maxFileBytes: 128,798+ async parse(file: File): Promise<Preview> {799+ const value: unknown = JSON.parse(await file.text());800+ if (!value || typeof value !== "object" || typeof (value as { title?: unknown }).title !== "string") {801+ throw new Error("invalid field notes export");802+ }803+ return { title: (value as { title: string }).title };804+ },805+} as const;806+807+export default function FileImport() {808+ const [supported, setSupported] = useState<boolean>();809+ const [drafts, setDrafts] = useState<readonly FileImportDraft<Preview>[]>([]);810+ const [issue, setIssue] = useState<FileImportIssue>();811+ const [confirmed, setConfirmed] = useState(false);812+813+ const receive = (next: readonly FileImportDraft<Preview>[]) => {814+ setConfirmed(false);815+ setIssue(undefined);816+ setDrafts(next);817+ };818+ useEffect(() => {819+ const consumer = installFileLaunchConsumer({820+ ...options,821+ onDrafts: receive,822+ onRejected: (next) => {823+ setDrafts([]);824+ setIssue(next);825+ },826+ });827+ setSupported(consumer.supported);828+ return consumer.dispose;829+ }, []);830+831+ async function pick(files: FileList | null) {832+ const result = await prepareFileImportDrafts(files ? [...files] : [], options);833+ if (result.ok) receive(result.drafts);834+ else {835+ setDrafts([]);836+ setIssue(result.issue);837+ }838+ }839+840+ return (841+ <section class="island">842+ <h1>Import field notes</h1>843+ <output data-file-support>{supported === undefined ? "checking" : supported ? "supported" : "unsupported"}</output>844+ <label>Choose an export <input type="file" accept="application/json,.json" onChange={(event) => void pick(event.currentTarget.files)} /></label>845+ {drafts.map((draft) => <p data-file-title>{draft.parsed.title}</p>)}846+ {drafts.length > 0 && <button type="button" onClick={() => setConfirmed(true)}>Import these notes</button>}847+ {confirmed && <p role="status">Import confirmed.</p>}848+ {issue && <p role="alert">This file cannot be imported.</p>}849+ </section>850+ );851+}852+`,853+ );854+}855+762856 async function main() {763857 const { source, version } = parseArgs(Deno.args);764858 await Deno.remove(artifactsRoot, { recursive: true }).catch((error) => {@@ -1004,6 +1098,7 @@ async function main() {
10041098 10051099 await stage("install web-share recipe", () => installWebShareRecipe(projectRoot!));10061100 await stage("install launch-handler recipe", () => installLaunchHandlerRecipe(projectRoot!));1101+ await stage("install file-handler recipe", () => installFileHandlerRecipe(projectRoot!));10071102 10081103 await stage(10091104 "build",@@ -1223,10 +1318,11 @@ async function main() {
12231318 await context!.setOffline(true);12241319 try {12251320 await page!.goto(`${origin}launch/`, { waitUntil: "domcontentloaded" });1226- await page!.locator("[data-launch-support]").waitFor({ state: "visible" });1227- assert(1228- await page!.locator("[data-launch-support]").textContent() === "supported",1229- "mock launchQueue was not consumed",1321+ await waitForReady(1322+ page!,1323+ () => document.querySelector("[data-launch-support]")?.textContent === "supported",1324+ undefined,1325+ { description: "mock launchQueue consumed" },12301326 );12311327 await page!.evaluate((targetURL) => {12321328 const deliver = (globalThis as typeof globalThis & {@@ -1257,6 +1353,93 @@ async function main() {
12571353 description: "task app after launch-handler exercise",12581354 });12591355 });1356+ await stage("file-handler recipe browser", async () => {1357+ const unsupportedPage = await context!.newPage();1358+ try {1359+ await unsupportedPage.addInitScript(() => {1360+ Object.defineProperty(globalThis, "launchQueue", {1361+ configurable: true,1362+ value: undefined,1363+ });1364+ });1365+ await unsupportedPage.goto(`${origin}import/`, { waitUntil: "domcontentloaded" });1366+ await unsupportedPage.locator("[data-file-support]").waitFor({ state: "visible" });1367+ assert(1368+ await unsupportedPage.locator("[data-file-support]").textContent() === "unsupported",1369+ "missing launchQueue was not reported as unsupported for file import",1370+ );1371+ await unsupportedPage.locator('input[type="file"]').setInputFiles({1372+ name: "picker.json",1373+ mimeType: "application/json",1374+ buffer: Buffer.from('{"title":"Picker import"}'),1375+ });1376+ await unsupportedPage.getByText("Picker import").waitFor({ state: "visible" });1377+ } finally {1378+ await unsupportedPage.close();1379+ }1380+1381+ await page!.addInitScript(() => {1382+ let consumer: (parameters: { files?: unknown[] }) => void = () => {};1383+ Object.defineProperty(globalThis, "launchQueue", {1384+ configurable: true,1385+ value: {1386+ setConsumer(next: typeof consumer) {1387+ consumer = next;1388+ },1389+ },1390+ });1391+ Object.defineProperty(globalThis, "__LOFI_DELIVER_FILE__", {1392+ configurable: true,1393+ value: (name: string, type: string, content: string) =>1394+ consumer({1395+ files: [{1396+ kind: "file",1397+ name,1398+ getFile: () => Promise.resolve(new File([content], name, { type })),1399+ }],1400+ }),1401+ });1402+ });1403+ await context!.setOffline(true);1404+ try {1405+ await page!.goto(`${origin}import/`, { waitUntil: "domcontentloaded" });1406+ await waitForReady(1407+ page!,1408+ () => document.querySelector("[data-file-support]")?.textContent === "supported",1409+ undefined,1410+ { description: "mock launchQueue consumed by file import" },1411+ );1412+ await page!.evaluate(() => {1413+ const deliver = (globalThis as typeof globalThis & {1414+ __LOFI_DELIVER_FILE__?: (name: string, type: string, content: string) => void;1415+ }).__LOFI_DELIVER_FILE__;1416+ deliver?.("opened.json", "application/json", '{"title":"Opened import"}');1417+ });1418+ await page!.getByText("Opened import").waitFor({ state: "visible" });1419+ assert(1420+ await page!.getByText("Import confirmed.").count() === 0,1421+ "received file was persisted before explicit confirmation",1422+ );1423+ await page!.getByRole("button", { name: "Import these notes" }).click();1424+ await page!.getByText("Import confirmed.").waitFor({ state: "visible" });1425+ await page!.evaluate(() => {1426+ const deliver = (globalThis as typeof globalThis & {1427+ __LOFI_DELIVER_FILE__?: (name: string, type: string, content: string) => void;1428+ }).__LOFI_DELIVER_FILE__;1429+ deliver?.("private.txt", "text/plain", "private-value");1430+ });1431+ await page!.getByRole("alert").waitFor({ state: "visible" });1432+ const rejected = await page!.locator("body").innerText();1433+ assert(!rejected.includes("private-value"), "rejected file content leaked into the UI");1434+ assert(!rejected.includes("private.txt"), "rejected file name leaked into the UI");1435+ } finally {1436+ await context!.setOffline(false);1437+ }1438+ await page!.goto(origin, { waitUntil: "domcontentloaded" });1439+ await waitForReady(page!, taskAppReady, undefined, {1440+ description: "task app after file-handler exercise",1441+ });1442+ });12601443 await stage("waiting worker update browser", async () => {12611444 const workerPath = join(projectRoot!, "dist", "sw.js");12621445 const worker = await Deno.readTextFile(workerPath);